Adds max_active_min request field and backend filter. Active time uses parse_time_effort().active_min (passive waits excluded). Recipes with no parsed active time signal are not excluded (avoid hiding unlabelled results). Total and active limits are AND'd when both set. UI: two pill rows — "Hands-on time" (15/30/45/1hr) and "Total time" (30m/1hr/90m/2hr/3hr/4+hr). Replaces single row capped at 90 min.
1281 lines
34 KiB
TypeScript
1281 lines
34 KiB
TypeScript
/**
|
||
* API Service for Kiwi Backend
|
||
*
|
||
* VITE_API_BASE is baked in at build time:
|
||
* dev: '' (empty — proxy in vite.config.ts handles /api/)
|
||
* cloud: '/kiwi' (Caddy strips /kiwi and forwards to nginx, which proxies /api/ → api container)
|
||
*/
|
||
|
||
import axios, { type AxiosInstance } from 'axios'
|
||
|
||
// API Configuration
|
||
const API_BASE_URL = (import.meta.env.VITE_API_BASE ?? '') + '/api/v1'
|
||
|
||
// Create axios instance
|
||
const api: AxiosInstance = axios.create({
|
||
baseURL: API_BASE_URL,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
timeout: 30000, // 30 seconds
|
||
})
|
||
|
||
// Request interceptor for logging
|
||
api.interceptors.request.use(
|
||
(config) => {
|
||
console.log(`[API] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`, {
|
||
params: config.params,
|
||
data: config.data instanceof FormData ? '<FormData>' : config.data,
|
||
})
|
||
return config
|
||
},
|
||
(error) => {
|
||
console.error('[API Request Error]', error)
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
// Response interceptor for error handling
|
||
api.interceptors.response.use(
|
||
(response) => {
|
||
console.log(`[API] ✓ ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`)
|
||
return response
|
||
},
|
||
(error) => {
|
||
console.error('[API Error]', {
|
||
message: error.message,
|
||
url: error.config?.url,
|
||
method: error.config?.method?.toUpperCase(),
|
||
status: error.response?.status,
|
||
statusText: error.response?.statusText,
|
||
data: error.response?.data,
|
||
baseURL: error.config?.baseURL,
|
||
})
|
||
return Promise.reject(error)
|
||
}
|
||
)
|
||
|
||
// ========== Types ==========
|
||
|
||
export interface Product {
|
||
id: string
|
||
barcode: string | null
|
||
name: string
|
||
brand: string | null
|
||
category: string | null
|
||
description: string | null
|
||
image_url: string | null
|
||
nutrition_data: Record<string, any>
|
||
source: string
|
||
tags: Tag[]
|
||
}
|
||
|
||
export interface Tag {
|
||
id: string
|
||
name: string
|
||
slug: string
|
||
description: string | null
|
||
color: string | null
|
||
category: string | null
|
||
}
|
||
|
||
export interface InventoryItem {
|
||
id: number
|
||
product_id: number
|
||
product_name: string | null
|
||
barcode: string | null
|
||
category: string | null
|
||
quantity: number
|
||
unit: string
|
||
location: string
|
||
sublocation: string | null
|
||
purchase_date: string | null
|
||
expiration_date: string | null
|
||
opened_date: string | null
|
||
opened_expiry_date: string | null
|
||
secondary_state: string | null
|
||
secondary_uses: string[] | null
|
||
secondary_warning: string | null
|
||
status: string
|
||
source: string
|
||
notes: string | null
|
||
disposal_reason: string | null
|
||
created_at: string
|
||
updated_at: string
|
||
}
|
||
|
||
export interface BarcodeScanResult {
|
||
barcode: string
|
||
barcode_type: string
|
||
product: Product | null
|
||
inventory_item: InventoryItem | null
|
||
added_to_inventory: boolean
|
||
needs_manual_entry: boolean
|
||
needs_visual_capture: boolean
|
||
message: string
|
||
}
|
||
|
||
export interface LabelCaptureResult {
|
||
barcode: string
|
||
product_name: string | null
|
||
brand: string | null
|
||
serving_size_g: number | null
|
||
calories: number | null
|
||
fat_g: number | null
|
||
saturated_fat_g: number | null
|
||
carbs_g: number | null
|
||
sugar_g: number | null
|
||
fiber_g: number | null
|
||
protein_g: number | null
|
||
sodium_mg: number | null
|
||
ingredient_names: string[]
|
||
allergens: string[]
|
||
confidence: number
|
||
needs_review: boolean
|
||
}
|
||
|
||
export interface LabelConfirmRequest {
|
||
barcode: string
|
||
product_name?: string | null
|
||
brand?: string | null
|
||
serving_size_g?: number | null
|
||
calories?: number | null
|
||
fat_g?: number | null
|
||
saturated_fat_g?: number | null
|
||
carbs_g?: number | null
|
||
sugar_g?: number | null
|
||
fiber_g?: number | null
|
||
protein_g?: number | null
|
||
sodium_mg?: number | null
|
||
ingredient_names?: string[]
|
||
allergens?: string[]
|
||
confidence?: number
|
||
location?: string
|
||
quantity?: number
|
||
auto_add?: boolean
|
||
}
|
||
|
||
export interface BarcodeScanResponse {
|
||
success: boolean
|
||
barcodes_found: number
|
||
results: BarcodeScanResult[]
|
||
message: string
|
||
}
|
||
|
||
export interface InventoryItemUpdate {
|
||
quantity?: number
|
||
unit?: string
|
||
location?: string
|
||
sublocation?: string | null
|
||
purchase_date?: string | null
|
||
expiration_date?: string | null
|
||
status?: string
|
||
notes?: string | null
|
||
}
|
||
|
||
export interface InventoryStats {
|
||
total_items: number
|
||
available_items: number
|
||
expiring_soon: number
|
||
expired_items: number
|
||
locations: Record<string, number>
|
||
}
|
||
|
||
export interface Receipt {
|
||
id: string
|
||
filename: string
|
||
status: string
|
||
metadata: Record<string, any>
|
||
quality_score: number | null
|
||
}
|
||
|
||
export interface ReceiptOCRData {
|
||
id: string
|
||
receipt_id: string
|
||
merchant: {
|
||
name: string | null
|
||
address: string | null
|
||
phone: string | null
|
||
}
|
||
transaction: {
|
||
date: string | null
|
||
time: string | null
|
||
receipt_number: string | null
|
||
register: string | null
|
||
cashier: string | null
|
||
}
|
||
items: Array<{
|
||
name: string
|
||
quantity: number
|
||
unit_price: number | null
|
||
total_price: number
|
||
category: string | null
|
||
}>
|
||
totals: {
|
||
subtotal: number | null
|
||
tax: number | null
|
||
total: number | null
|
||
payment_method: string | null
|
||
}
|
||
confidence: Record<string, number>
|
||
warnings: string[]
|
||
processing_time: number | null
|
||
created_at: string
|
||
}
|
||
|
||
// ========== Inventory API ==========
|
||
|
||
export const inventoryAPI = {
|
||
/**
|
||
* List all inventory items
|
||
*/
|
||
async listItems(params?: {
|
||
location?: string
|
||
item_status?: string
|
||
limit?: number
|
||
offset?: number
|
||
}): Promise<InventoryItem[]> {
|
||
const response = await api.get('/inventory/items', { params })
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Get a single inventory item
|
||
*/
|
||
async getItem(itemId: string): Promise<InventoryItem> {
|
||
const response = await api.get(`/inventory/items/${itemId}`)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Update an inventory item
|
||
*/
|
||
async updateItem(itemId: number, update: InventoryItemUpdate): Promise<InventoryItem> {
|
||
const response = await api.patch(`/inventory/items/${itemId}`, update)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Delete an inventory item
|
||
*/
|
||
async deleteItem(itemId: number): Promise<void> {
|
||
await api.delete(`/inventory/items/${itemId}`)
|
||
},
|
||
|
||
/**
|
||
* Get inventory statistics
|
||
*/
|
||
async getStats(): Promise<InventoryStats> {
|
||
const response = await api.get('/inventory/stats')
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Get items expiring soon
|
||
*/
|
||
async getExpiring(days: number = 7): Promise<any[]> {
|
||
const response = await api.get(`/inventory/expiring?days=${days}`)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Scan barcode from text
|
||
*/
|
||
async scanBarcodeText(
|
||
barcode: string,
|
||
location: string = 'pantry',
|
||
quantity: number = 1.0,
|
||
autoAdd: boolean = true
|
||
): Promise<BarcodeScanResponse> {
|
||
const response = await api.post('/inventory/scan/text', {
|
||
barcode,
|
||
location,
|
||
quantity,
|
||
auto_add_to_inventory: autoAdd,
|
||
})
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Mark item as consumed fully or partially.
|
||
* Pass quantity to decrement; omit to consume all.
|
||
*/
|
||
async consumeItem(itemId: number, quantity?: number): Promise<InventoryItem> {
|
||
const body = quantity !== undefined ? { quantity } : undefined
|
||
const response = await api.post(`/inventory/items/${itemId}/consume`, body)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Mark item as discarded (not used, spoiled, etc).
|
||
*/
|
||
async discardItem(itemId: number, reason?: string): Promise<InventoryItem> {
|
||
const response = await api.post(`/inventory/items/${itemId}/discard`, { reason: reason ?? null })
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Mark item as opened today — starts secondary shelf-life tracking
|
||
*/
|
||
async openItem(itemId: number): Promise<InventoryItem> {
|
||
const response = await api.post(`/inventory/items/${itemId}/open`)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Create a new product
|
||
*/
|
||
async createProduct(data: {
|
||
name: string
|
||
brand?: string
|
||
source?: string
|
||
}): Promise<Product> {
|
||
const response = await api.post('/inventory/products', data)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Create a new inventory item
|
||
*/
|
||
async createItem(data: {
|
||
product_id: string
|
||
quantity: number
|
||
unit?: string
|
||
location: string
|
||
expiration_date?: string
|
||
source?: string
|
||
}): Promise<InventoryItem> {
|
||
const response = await api.post('/inventory/items', data)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Bulk-add items by ingredient name (no barcode required).
|
||
* Idempotent: re-adding an existing product just creates a new inventory entry.
|
||
*/
|
||
async bulkAddByName(items: Array<{
|
||
name: string
|
||
quantity?: number
|
||
unit?: string
|
||
location?: string
|
||
}>): Promise<{ added: number; failed: number; results: Array<{ name: string; ok: boolean; item_id?: number; error?: string }> }> {
|
||
const response = await api.post('/inventory/items/bulk-add-by-name', { items })
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Scan barcode from image
|
||
*/
|
||
async scanBarcodeImage(
|
||
file: File,
|
||
location: string = 'pantry',
|
||
quantity: number = 1.0,
|
||
autoAdd: boolean = true
|
||
): Promise<any> {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('location', location)
|
||
formData.append('quantity', quantity.toString())
|
||
formData.append('auto_add_to_inventory', autoAdd.toString())
|
||
|
||
const response = await api.post('/inventory/scan', formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
})
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Upload a nutrition label photo for an unenriched barcode (paid tier).
|
||
* Returns extracted fields + confidence score for user review.
|
||
*/
|
||
async captureLabelPhoto(
|
||
file: File,
|
||
barcode: string
|
||
): Promise<LabelCaptureResult> {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
formData.append('barcode', barcode)
|
||
const response = await api.post('/inventory/scan/label-capture', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
timeout: 60000, // vision inference can take ~5–10s
|
||
})
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Confirm a user-reviewed label extraction and save to the local cache.
|
||
*/
|
||
async confirmLabelCapture(data: LabelConfirmRequest): Promise<{ ok: boolean; product_id?: number; inventory_item_id?: number; message: string }> {
|
||
const response = await api.post('/inventory/scan/label-confirm', data)
|
||
return response.data
|
||
},
|
||
}
|
||
|
||
// ========== Receipts API ==========
|
||
|
||
export const receiptsAPI = {
|
||
/**
|
||
* List all receipts
|
||
*/
|
||
async listReceipts(): Promise<Receipt[]> {
|
||
const response = await api.get('/receipts/')
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Get a single receipt
|
||
*/
|
||
async getReceipt(receiptId: string): Promise<Receipt> {
|
||
const response = await api.get(`/receipts/${receiptId}`)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Upload a receipt
|
||
*/
|
||
async upload(file: File): Promise<Receipt> {
|
||
const formData = new FormData()
|
||
formData.append('file', file)
|
||
|
||
const response = await api.post('/receipts/', formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
})
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Get receipt statistics
|
||
*/
|
||
async getStats(): Promise<any> {
|
||
const response = await api.get('/export/stats')
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Get OCR data for a receipt
|
||
*/
|
||
async getOCRData(receiptId: string): Promise<ReceiptOCRData> {
|
||
const response = await api.get(`/receipts/${receiptId}/ocr/data`)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Get OCR status for a receipt
|
||
*/
|
||
async getOCRStatus(receiptId: string): Promise<any> {
|
||
const response = await api.get(`/receipts/${receiptId}/ocr/status`)
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Trigger OCR processing
|
||
*/
|
||
async triggerOCR(receiptId: string, forceReprocess: boolean = false): Promise<any> {
|
||
const response = await api.post(`/receipts/${receiptId}/ocr/trigger`, {
|
||
force_reprocess: forceReprocess,
|
||
})
|
||
return response.data
|
||
},
|
||
}
|
||
|
||
// ========== Export API ==========
|
||
|
||
export const exportAPI = {
|
||
/**
|
||
* Get export statistics
|
||
*/
|
||
async getStats(): Promise<any> {
|
||
const response = await api.get('/export/stats')
|
||
return response.data
|
||
},
|
||
|
||
/**
|
||
* Download inventory CSV
|
||
*/
|
||
getInventoryCSVUrl(location?: string, status: string = 'available'): string {
|
||
const params = new URLSearchParams()
|
||
if (location) params.append('location', location)
|
||
params.append('status', status)
|
||
return `${API_BASE_URL}/export/inventory/csv?${params.toString()}`
|
||
},
|
||
|
||
/**
|
||
* Download inventory Excel
|
||
*/
|
||
getInventoryExcelUrl(location?: string, status: string = 'available'): string {
|
||
const params = new URLSearchParams()
|
||
if (location) params.append('location', location)
|
||
params.append('status', status)
|
||
return `${API_BASE_URL}/export/inventory/excel?${params.toString()}`
|
||
},
|
||
|
||
/**
|
||
* Download receipts CSV
|
||
*/
|
||
getReceiptsCSVUrl(): string {
|
||
return `${API_BASE_URL}/export/csv`
|
||
},
|
||
|
||
/**
|
||
* Download receipts Excel
|
||
*/
|
||
getReceiptsExcelUrl(): string {
|
||
return `${API_BASE_URL}/export/excel`
|
||
},
|
||
}
|
||
|
||
// ========== Recipes & Settings Types ==========
|
||
|
||
export interface SwapCandidate {
|
||
original_name: string
|
||
substitute_name: string
|
||
constraint_label: string
|
||
explanation: string
|
||
compensation_hints: Record<string, string>[]
|
||
}
|
||
|
||
export interface NutritionPanel {
|
||
calories: number | null
|
||
fat_g: number | null
|
||
protein_g: number | null
|
||
carbs_g: number | null
|
||
fiber_g: number | null
|
||
sugar_g: number | null
|
||
sodium_mg: number | null
|
||
servings: number | null
|
||
estimated: boolean
|
||
}
|
||
|
||
export interface RecipeSuggestion {
|
||
id: number
|
||
title: string
|
||
match_count: number
|
||
element_coverage: Record<string, number>
|
||
swap_candidates: SwapCandidate[]
|
||
matched_ingredients: string[]
|
||
missing_ingredients: string[]
|
||
directions: string[]
|
||
prep_notes: string[]
|
||
notes: string
|
||
level: number
|
||
is_wildcard: boolean
|
||
nutrition: NutritionPanel | null
|
||
source_url: string | null
|
||
complexity: 'easy' | 'moderate' | 'involved' | null
|
||
estimated_time_min: number | null
|
||
time_effort: TimeEffortProfile | null
|
||
}
|
||
|
||
export interface NutritionFilters {
|
||
max_calories: number | null
|
||
max_sugar_g: number | null
|
||
max_carbs_g: number | null
|
||
max_sodium_mg: number | null
|
||
}
|
||
|
||
export interface GroceryLink {
|
||
ingredient: string
|
||
retailer: string
|
||
url: string
|
||
}
|
||
|
||
export interface RecipeResult {
|
||
suggestions: RecipeSuggestion[]
|
||
element_gaps: string[]
|
||
grocery_list: string[]
|
||
grocery_links: GroceryLink[]
|
||
rate_limited: boolean
|
||
rate_limit_count: number
|
||
}
|
||
|
||
export interface StreamTokenResponse {
|
||
stream_url: string
|
||
token: string
|
||
expires_in_s: number
|
||
}
|
||
|
||
export type RecipeJobStatusValue = 'queued' | 'running' | 'done' | 'failed'
|
||
|
||
export interface RecipeJobStatus {
|
||
job_id: string
|
||
status: RecipeJobStatusValue
|
||
result: RecipeResult | null
|
||
error: string | null
|
||
}
|
||
|
||
export interface RecipeRequest {
|
||
pantry_items: string[]
|
||
secondary_pantry_items: Record<string, string>
|
||
level: number
|
||
constraints: string[]
|
||
allergies: string[]
|
||
expiry_first: boolean
|
||
hard_day_mode: boolean
|
||
max_missing: number | null
|
||
style_id: string | null
|
||
category: string | null
|
||
wildcard_confirmed: boolean
|
||
nutrition_filters: NutritionFilters
|
||
excluded_ids: number[]
|
||
exclude_ingredients: string[]
|
||
shopping_mode: boolean
|
||
pantry_match_only: boolean
|
||
complexity_filter: string | null
|
||
max_time_min: number | null
|
||
max_total_min: number | null
|
||
max_active_min: number | null
|
||
}
|
||
|
||
export interface Staple {
|
||
slug: string
|
||
name: string
|
||
category: string
|
||
dietary_tags: string[]
|
||
}
|
||
|
||
// ── Build Your Own types ──────────────────────────────────────────────────
|
||
|
||
export interface AssemblyRoleOut {
|
||
display: string
|
||
required: boolean
|
||
keywords: string[]
|
||
hint: string
|
||
}
|
||
|
||
export interface AssemblyTemplateOut {
|
||
id: string
|
||
title: string
|
||
icon: string
|
||
descriptor: string
|
||
role_sequence: AssemblyRoleOut[]
|
||
}
|
||
|
||
export interface RoleCandidateItem {
|
||
name: string
|
||
in_pantry: boolean
|
||
tags: string[]
|
||
}
|
||
|
||
export interface RoleCandidatesResponse {
|
||
compatible: RoleCandidateItem[]
|
||
other: RoleCandidateItem[]
|
||
available_tags: string[]
|
||
}
|
||
|
||
export interface BuildRequest {
|
||
template_id: string
|
||
role_overrides: Record<string, string>
|
||
}
|
||
|
||
// ========== Recipes API ==========
|
||
|
||
export const recipesAPI = {
|
||
async suggest(req: RecipeRequest): Promise<RecipeResult> {
|
||
// Allow up to 120s — cf-orch model cold-start can take 60+ seconds on first request
|
||
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
|
||
return response.data
|
||
},
|
||
|
||
/** Submit an async job for L3/L4 generation. Returns job_id + initial status. */
|
||
async suggestAsync(req: RecipeRequest): Promise<{ job_id: string; status: string }> {
|
||
const response = await api.post('/recipes/suggest', req, { params: { async: 'true' }, timeout: 15000 })
|
||
return response.data
|
||
},
|
||
|
||
/** Poll an async job. Returns the full status including result once done. */
|
||
async pollJob(jobId: string): Promise<RecipeJobStatus> {
|
||
const response = await api.get(`/recipes/jobs/${jobId}`, { timeout: 10000 })
|
||
return response.data
|
||
},
|
||
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
||
const response = await api.get(`/recipes/${id}`)
|
||
return response.data
|
||
},
|
||
async getLeftovers(id: number): Promise<{ fridge_days: number; freeze_days: number | null; freeze_by_day: number | null; storage_advice: string }> {
|
||
const response = await api.post(`/recipes/${id}/leftovers`)
|
||
return response.data
|
||
},
|
||
async listStaples(dietary?: string): Promise<Staple[]> {
|
||
const response = await api.get('/staples/', { params: dietary ? { dietary } : undefined })
|
||
return response.data
|
||
},
|
||
async getTemplates(): Promise<AssemblyTemplateOut[]> {
|
||
const response = await api.get('/recipes/templates')
|
||
return response.data
|
||
},
|
||
async getRoleCandidates(
|
||
templateId: string,
|
||
role: string,
|
||
priorPicks: string[] = [],
|
||
): Promise<RoleCandidatesResponse> {
|
||
const response = await api.get('/recipes/template-candidates', {
|
||
params: {
|
||
template_id: templateId,
|
||
role,
|
||
prior_picks: priorPicks.join(','),
|
||
},
|
||
})
|
||
return response.data
|
||
},
|
||
async buildRecipe(req: BuildRequest): Promise<RecipeSuggestion> {
|
||
const response = await api.post('/recipes/build', req)
|
||
return response.data
|
||
},
|
||
|
||
/** Issue a one-time stream token for LLM recipe generation (Paid tier / BYOK only). */
|
||
async getRecipeStreamToken(params: {
|
||
level: 3 | 4
|
||
wildcard_confirmed?: boolean
|
||
}): Promise<StreamTokenResponse> {
|
||
const response = await api.post('/recipes/stream-token', {
|
||
level: params.level,
|
||
wildcard_confirmed: params.wildcard_confirmed ?? false,
|
||
})
|
||
return response.data
|
||
},
|
||
}
|
||
|
||
// ========== Settings API ==========
|
||
|
||
export const settingsAPI = {
|
||
async getSetting(key: string): Promise<string | null> {
|
||
try {
|
||
const response = await api.get(`/settings/${key}`)
|
||
return response.data.value
|
||
} catch {
|
||
return null
|
||
}
|
||
},
|
||
async setSetting(key: string, value: string): Promise<void> {
|
||
await api.put(`/settings/${key}`, { value })
|
||
},
|
||
}
|
||
|
||
// ========== Household Types ==========
|
||
|
||
export interface HouseholdMember {
|
||
user_id: string
|
||
joined_at: string
|
||
is_owner: boolean
|
||
}
|
||
|
||
export interface HouseholdStatus {
|
||
in_household: boolean
|
||
household_id: string | null
|
||
is_owner: boolean
|
||
members: HouseholdMember[]
|
||
max_seats: number
|
||
}
|
||
|
||
export interface HouseholdInvite {
|
||
invite_url: string
|
||
token: string
|
||
expires_at: string
|
||
}
|
||
|
||
// ========== Household API ==========
|
||
|
||
export const householdAPI = {
|
||
async create(): Promise<{ household_id: string; message: string }> {
|
||
const response = await api.post('/household/create')
|
||
return response.data
|
||
},
|
||
async status(): Promise<HouseholdStatus> {
|
||
const response = await api.get('/household/status')
|
||
return response.data
|
||
},
|
||
async invite(): Promise<HouseholdInvite> {
|
||
const response = await api.post('/household/invite')
|
||
return response.data
|
||
},
|
||
async accept(householdId: string, token: string): Promise<{ message: string; household_id: string }> {
|
||
const response = await api.post('/household/accept', { household_id: householdId, token })
|
||
return response.data
|
||
},
|
||
async leave(): Promise<{ message: string }> {
|
||
const response = await api.post('/household/leave')
|
||
return response.data
|
||
},
|
||
async removeMember(userId: string): Promise<{ message: string }> {
|
||
const response = await api.post('/household/remove-member', { user_id: userId })
|
||
return response.data
|
||
},
|
||
}
|
||
|
||
// ========== Saved Recipes Types ==========
|
||
|
||
export interface SavedRecipe {
|
||
id: number
|
||
recipe_id: number
|
||
title: string
|
||
saved_at: string
|
||
notes: string | null
|
||
rating: number | null
|
||
style_tags: string[]
|
||
collection_ids: number[]
|
||
}
|
||
|
||
export interface RecipeCollection {
|
||
id: number
|
||
name: string
|
||
description: string | null
|
||
member_count: number
|
||
created_at: string
|
||
}
|
||
|
||
// ========== Saved Recipes API ==========
|
||
|
||
export const savedRecipesAPI = {
|
||
async save(recipe_id: number, notes?: string, rating?: number): Promise<SavedRecipe> {
|
||
const response = await api.post('/recipes/saved', { recipe_id, notes, rating })
|
||
return response.data
|
||
},
|
||
async unsave(recipe_id: number): Promise<void> {
|
||
await api.delete(`/recipes/saved/${recipe_id}`)
|
||
},
|
||
async update(recipe_id: number, data: { notes?: string | null; rating?: number | null; style_tags?: string[] }): Promise<SavedRecipe> {
|
||
const response = await api.patch(`/recipes/saved/${recipe_id}`, data)
|
||
return response.data
|
||
},
|
||
async list(params?: { sort_by?: string; collection_id?: number }): Promise<SavedRecipe[]> {
|
||
const response = await api.get('/recipes/saved', { params })
|
||
return response.data
|
||
},
|
||
async listCollections(): Promise<RecipeCollection[]> {
|
||
const response = await api.get('/recipes/saved/collections')
|
||
return response.data
|
||
},
|
||
async createCollection(name: string, description?: string): Promise<RecipeCollection> {
|
||
const response = await api.post('/recipes/saved/collections', { name, description })
|
||
return response.data
|
||
},
|
||
async deleteCollection(id: number): Promise<void> {
|
||
await api.delete(`/recipes/saved/collections/${id}`)
|
||
},
|
||
async addToCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
||
await api.post(`/recipes/saved/collections/${collection_id}/members`, { saved_recipe_id })
|
||
},
|
||
async removeFromCollection(collection_id: number, saved_recipe_id: number): Promise<void> {
|
||
await api.delete(`/recipes/saved/collections/${collection_id}/members/${saved_recipe_id}`)
|
||
},
|
||
async classifyStyle(recipe_id: number): Promise<string[]> {
|
||
const response = await api.post(`/recipes/saved/${recipe_id}/classify-style`)
|
||
return response.data.suggested_tags
|
||
},
|
||
}
|
||
|
||
// --- Meal Plan types ---
|
||
|
||
export interface MealPlanSlot {
|
||
id: number
|
||
plan_id: number
|
||
day_of_week: number // 0 = Monday
|
||
meal_type: string
|
||
recipe_id: number | null
|
||
recipe_title: string | null
|
||
servings: number
|
||
custom_label: string | null
|
||
}
|
||
|
||
export interface MealPlan {
|
||
id: number
|
||
week_start: string // ISO date, e.g. "2026-04-13"
|
||
meal_types: string[]
|
||
slots: MealPlanSlot[]
|
||
created_at: string
|
||
}
|
||
|
||
export interface RetailerLink {
|
||
retailer: string
|
||
label: string
|
||
url: string
|
||
}
|
||
|
||
export interface GapItem {
|
||
ingredient_name: string
|
||
needed_raw: string | null
|
||
have_quantity: number | null
|
||
have_unit: string | null
|
||
covered: boolean
|
||
retailer_links: RetailerLink[]
|
||
}
|
||
|
||
export interface ShoppingList {
|
||
plan_id: number
|
||
gap_items: GapItem[]
|
||
covered_items: GapItem[]
|
||
disclosure: string | null
|
||
}
|
||
|
||
export interface PrepTask {
|
||
id: number
|
||
recipe_id: number | null
|
||
task_label: string
|
||
duration_minutes: number | null
|
||
sequence_order: number
|
||
equipment: string | null
|
||
is_parallel: boolean
|
||
notes: string | null
|
||
user_edited: boolean
|
||
}
|
||
|
||
export interface PrepSession {
|
||
id: number
|
||
plan_id: number
|
||
scheduled_date: string
|
||
status: 'draft' | 'reviewed' | 'done'
|
||
tasks: PrepTask[]
|
||
}
|
||
|
||
// --- Meal Plan API ---
|
||
|
||
export const mealPlanAPI = {
|
||
async list(): Promise<MealPlan[]> {
|
||
const resp = await api.get<MealPlan[]>('/meal-plans/')
|
||
return resp.data
|
||
},
|
||
|
||
async create(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
|
||
const resp = await api.post<MealPlan>('/meal-plans/', { week_start: weekStart, meal_types: mealTypes })
|
||
return resp.data
|
||
},
|
||
|
||
async get(planId: number): Promise<MealPlan> {
|
||
const resp = await api.get<MealPlan>(`/meal-plans/${planId}`)
|
||
return resp.data
|
||
},
|
||
|
||
async updateMealTypes(planId: number, mealTypes: string[]): Promise<MealPlan> {
|
||
const resp = await api.patch<MealPlan>(`/meal-plans/${planId}`, { meal_types: mealTypes })
|
||
return resp.data
|
||
},
|
||
|
||
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
|
||
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
|
||
return resp.data
|
||
},
|
||
|
||
async deleteSlot(planId: number, slotId: number): Promise<void> {
|
||
await api.delete(`/meal-plans/${planId}/slots/${slotId}`)
|
||
},
|
||
|
||
async getShoppingList(planId: number): Promise<ShoppingList> {
|
||
const resp = await api.get<ShoppingList>(`/meal-plans/${planId}/shopping-list`)
|
||
return resp.data
|
||
},
|
||
|
||
async getPrepSession(planId: number): Promise<PrepSession> {
|
||
const resp = await api.get<PrepSession>(`/meal-plans/${planId}/prep-session`)
|
||
return resp.data
|
||
},
|
||
|
||
async updatePrepTask(planId: number, taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<PrepTask> {
|
||
const resp = await api.patch<PrepTask>(`/meal-plans/${planId}/prep-session/tasks/${taskId}`, data)
|
||
return resp.data
|
||
},
|
||
}
|
||
|
||
// ========== Orch Usage Types ==========
|
||
|
||
export interface OrchUsage {
|
||
calls_used: number
|
||
topup_calls: number
|
||
calls_total: number
|
||
period_start: string // ISO date YYYY-MM-DD
|
||
resets_on: string // ISO date YYYY-MM-DD
|
||
}
|
||
|
||
// ========== Browser Types ==========
|
||
|
||
export interface BrowserDomain {
|
||
id: string
|
||
label: string
|
||
}
|
||
|
||
export interface BrowserCategory {
|
||
category: string
|
||
recipe_count: number
|
||
has_subcategories: boolean
|
||
}
|
||
|
||
export interface BrowserSubcategory {
|
||
subcategory: string
|
||
recipe_count: number
|
||
}
|
||
|
||
// ── Time & Effort types ───────────────────────────────────────────────────
|
||
|
||
export interface StepAnalysis {
|
||
is_passive: boolean
|
||
detected_minutes: number | null
|
||
}
|
||
|
||
export interface TimeEffortProfile {
|
||
active_min: number
|
||
passive_min: number
|
||
total_min: number
|
||
effort_label: 'quick' | 'moderate' | 'involved'
|
||
equipment: string[]
|
||
step_analyses: StepAnalysis[]
|
||
}
|
||
|
||
export interface BrowserRecipe {
|
||
id: number
|
||
title: string
|
||
category: string | null
|
||
match_pct: number | null
|
||
active_min: number | null
|
||
passive_min: number | null
|
||
}
|
||
|
||
export interface BrowserResult {
|
||
recipes: BrowserRecipe[]
|
||
total: number
|
||
page: number
|
||
}
|
||
|
||
// ========== Browser API ==========
|
||
|
||
export const browserAPI = {
|
||
async listDomains(): Promise<BrowserDomain[]> {
|
||
const response = await api.get('/recipes/browse/domains')
|
||
return response.data
|
||
},
|
||
async listCategories(domain: string): Promise<BrowserCategory[]> {
|
||
const response = await api.get(`/recipes/browse/${domain}`)
|
||
return response.data
|
||
},
|
||
async listSubcategories(domain: string, category: string): Promise<BrowserSubcategory[]> {
|
||
const response = await api.get(
|
||
`/recipes/browse/${domain}/${encodeURIComponent(category)}/subcategories`
|
||
)
|
||
return response.data
|
||
},
|
||
async browse(domain: string, category: string, params?: {
|
||
page?: number
|
||
page_size?: number
|
||
pantry_items?: string
|
||
subcategory?: string
|
||
q?: string
|
||
sort?: string
|
||
required_ingredient?: string
|
||
}): Promise<BrowserResult> {
|
||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||
return response.data
|
||
},
|
||
|
||
async submitRecipeTag(body: {
|
||
recipe_id: number
|
||
domain: string
|
||
category: string
|
||
subcategory: string | null
|
||
pseudonym: string
|
||
}): Promise<void> {
|
||
await api.post('/recipes/community-tags', body)
|
||
},
|
||
|
||
async upvoteRecipeTag(tagId: number, pseudonym: string): Promise<void> {
|
||
await api.post(`/recipes/community-tags/${tagId}/upvote`, null, { params: { pseudonym } })
|
||
},
|
||
|
||
async listRecipeTags(recipeId: number): Promise<Array<{
|
||
id: number; domain: string; category: string; subcategory: string | null;
|
||
pseudonym: string; upvotes: number; accepted: boolean
|
||
}>> {
|
||
const response = await api.get(`/recipes/community-tags/${recipeId}`)
|
||
return response.data
|
||
},
|
||
}
|
||
|
||
// ── Shopping List ─────────────────────────────────────────────────────────────
|
||
|
||
export interface GroceryLink {
|
||
ingredient: string
|
||
retailer: string
|
||
url: string
|
||
}
|
||
|
||
export interface ShoppingItem {
|
||
id: number
|
||
name: string
|
||
quantity: number | null
|
||
unit: string | null
|
||
category: string | null
|
||
checked: boolean
|
||
notes: string | null
|
||
source: string
|
||
recipe_id: number | null
|
||
sort_order: number
|
||
created_at: string
|
||
updated_at: string
|
||
grocery_links: GroceryLink[]
|
||
}
|
||
|
||
export interface ShoppingItemCreate {
|
||
name: string
|
||
quantity?: number
|
||
unit?: string
|
||
category?: string
|
||
notes?: string
|
||
source?: string
|
||
recipe_id?: number
|
||
sort_order?: number
|
||
}
|
||
|
||
export interface ShoppingItemUpdate {
|
||
name?: string
|
||
quantity?: number
|
||
unit?: string
|
||
category?: string
|
||
checked?: boolean
|
||
notes?: string
|
||
sort_order?: number
|
||
}
|
||
|
||
export const shoppingAPI = {
|
||
list: (includeChecked = true) =>
|
||
api.get<ShoppingItem[]>('/shopping', { params: { include_checked: includeChecked } }).then(r => r.data),
|
||
|
||
add: (item: ShoppingItemCreate) =>
|
||
api.post<ShoppingItem>('/shopping', item).then(r => r.data),
|
||
|
||
addFromRecipe: (recipeId: number, includeCovered = false) =>
|
||
api.post<ShoppingItem[]>('/shopping/from-recipe', { recipe_id: recipeId, include_covered: includeCovered }).then(r => r.data),
|
||
|
||
update: (id: number, update: ShoppingItemUpdate) =>
|
||
api.patch<ShoppingItem>(`/shopping/${id}`, update).then(r => r.data),
|
||
|
||
remove: (id: number) =>
|
||
api.delete(`/shopping/${id}`),
|
||
|
||
clearChecked: () =>
|
||
api.delete('/shopping/checked'),
|
||
|
||
clearAll: () =>
|
||
api.delete('/shopping/all'),
|
||
|
||
confirmPurchase: (id: number, location = 'pantry', quantity?: number, unit?: string) =>
|
||
api.post(`/shopping/${id}/confirm`, { location, quantity, unit }).then(r => r.data),
|
||
}
|
||
|
||
// ── Orch Usage ────────────────────────────────────────────────────────────────
|
||
|
||
export async function getOrchUsage(): Promise<OrchUsage | null> {
|
||
const resp = await api.get<OrchUsage | null>('/orch-usage')
|
||
return resp.data
|
||
}
|
||
|
||
// ── Session Bootstrap ─────────────────────────────────────────────────────────
|
||
|
||
export interface SessionInfo {
|
||
auth: 'local' | 'anon' | 'authed'
|
||
tier: string
|
||
has_byok: boolean
|
||
}
|
||
|
||
/** Call once on app load. Logs auth= + tier= server-side for analytics. */
|
||
export async function bootstrapSession(): Promise<SessionInfo | null> {
|
||
try {
|
||
const resp = await api.get<SessionInfo>('/session/bootstrap')
|
||
return resp.data
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
// ========== Sensory Preferences Types ==========
|
||
|
||
export type TextureTag = 'mushy' | 'slimy' | 'crunchy' | 'chewy' | 'creamy' | 'chunky'
|
||
export type SmellLevel = 'mild' | 'aromatic' | 'pungent' | 'fermented' | null
|
||
export type NoiseLevel = 'quiet' | 'moderate' | 'loud' | 'very_loud' | null
|
||
|
||
export interface SensoryPreferences {
|
||
avoid_textures: TextureTag[]
|
||
max_smell: SmellLevel
|
||
max_noise: NoiseLevel
|
||
}
|
||
|
||
export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
|
||
avoid_textures: [],
|
||
max_smell: null,
|
||
max_noise: null,
|
||
}
|
||
|
||
// ── Recipe Scanner (kiwi#9) ───────────────────────────────────────────────────
|
||
|
||
export interface ScannedIngredient {
|
||
name: string
|
||
qty: string | null
|
||
unit: string | null
|
||
raw: string | null
|
||
in_pantry: boolean
|
||
}
|
||
|
||
export interface ScannedRecipe {
|
||
title: string | null
|
||
subtitle: string | null
|
||
servings: string | null
|
||
cook_time: string | null
|
||
source_note: string | null
|
||
ingredients: ScannedIngredient[]
|
||
steps: string[]
|
||
notes: string | null
|
||
tags: string[]
|
||
pantry_match_pct: number
|
||
confidence: 'high' | 'medium' | 'low'
|
||
warnings: string[]
|
||
}
|
||
|
||
export interface UserRecipe {
|
||
id: number
|
||
title: string
|
||
subtitle: string | null
|
||
servings: string | null
|
||
cook_time: string | null
|
||
source_note: string | null
|
||
ingredients: ScannedIngredient[]
|
||
steps: string[]
|
||
notes: string | null
|
||
tags: string[]
|
||
source: string
|
||
pantry_match_pct: number | null
|
||
created_at: string
|
||
}
|
||
|
||
export const recipeScanAPI = {
|
||
/** Scan 1-4 recipe photos. Returns structured recipe for review (not saved). */
|
||
scan(files: File[]): Promise<ScannedRecipe> {
|
||
const form = new FormData()
|
||
files.forEach((f) => form.append('files', f))
|
||
return api.post('/recipes/scan', form, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
timeout: 120_000, // VLM can be slow on first call
|
||
}).then((r) => r.data)
|
||
},
|
||
|
||
/** Save a reviewed/edited scanned recipe to user_recipes. */
|
||
saveScanned(recipe: Omit<ScannedRecipe, 'pantry_match_pct' | 'confidence' | 'warnings'> & { source?: string }): Promise<UserRecipe> {
|
||
return api.post('/recipes/scan/save', recipe).then((r) => r.data)
|
||
},
|
||
|
||
/** List all user-created recipes (scan + manual). */
|
||
listUserRecipes(): Promise<UserRecipe[]> {
|
||
return api.get('/recipes/user').then((r) => r.data)
|
||
},
|
||
|
||
/** Get a single user recipe by ID. */
|
||
getUserRecipe(id: number): Promise<UserRecipe> {
|
||
return api.get(`/recipes/user/${id}`).then((r) => r.data)
|
||
},
|
||
|
||
/** Delete a user recipe. */
|
||
deleteUserRecipe(id: number): Promise<void> {
|
||
return api.delete(`/recipes/user/${id}`).then(() => undefined)
|
||
},
|
||
}
|
||
|
||
export default api
|