Compare commits
12 commits
4423373750
...
6aa63cf2f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6aa63cf2f0 | |||
| e745ce4375 | |||
| de0008f5c7 | |||
| dbaf2b6ac8 | |||
| 9a277f9b42 | |||
| 200a6ef87b | |||
| c8fdc21c29 | |||
| 2ad71f2636 | |||
| 0de6182f48 | |||
| fb18a9c78c | |||
| 443e68ba3f | |||
| 64a0abebe3 |
25 changed files with 1435 additions and 122 deletions
|
|
@ -1,9 +1,11 @@
|
|||
"""Export endpoints — CSV/Excel of receipt and inventory data."""
|
||||
"""Export endpoints — CSV and JSON export of user data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
|
@ -45,3 +47,33 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
|
|||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/json")
|
||||
async def export_full_json(store: Store = Depends(get_store)):
|
||||
"""Export full pantry inventory + saved recipes as a single JSON file.
|
||||
|
||||
Intended for data portability — users can import this into another
|
||||
Kiwi instance or keep it as an offline backup.
|
||||
"""
|
||||
inventory, saved = await asyncio.gather(
|
||||
asyncio.to_thread(store.list_inventory),
|
||||
asyncio.to_thread(store.get_saved_recipes, 1000, 0),
|
||||
)
|
||||
|
||||
export_doc = {
|
||||
"kiwi_export": {
|
||||
"version": "1.0",
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"inventory": [dict(row) for row in inventory],
|
||||
"saved_recipes": [dict(row) for row in saved],
|
||||
}
|
||||
}
|
||||
|
||||
body = json.dumps(export_doc, default=str, indent=2)
|
||||
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
|
||||
return StreamingResponse(
|
||||
iter([body]),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,16 +13,21 @@ from pydantic import BaseModel
|
|||
|
||||
from app.cloud_session import CloudUser, get_session
|
||||
from app.db.session import get_store
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
|
||||
_predictor = ExpirationPredictor()
|
||||
from app.db.store import Store
|
||||
from app.models.schemas.inventory import (
|
||||
BarcodeScanResponse,
|
||||
BulkAddByNameRequest,
|
||||
BulkAddByNameResponse,
|
||||
BulkAddItemResult,
|
||||
DiscardRequest,
|
||||
InventoryItemCreate,
|
||||
InventoryItemResponse,
|
||||
InventoryItemUpdate,
|
||||
InventoryStats,
|
||||
PartialConsumeRequest,
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductUpdate,
|
||||
|
|
@ -33,6 +38,25 @@ from app.models.schemas.inventory import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _enrich_item(item: dict) -> dict:
|
||||
"""Attach computed opened_expiry_date when opened_date is set."""
|
||||
from datetime import date, timedelta
|
||||
opened = item.get("opened_date")
|
||||
if opened:
|
||||
days = _predictor.days_after_opening(item.get("category"))
|
||||
if days is not None:
|
||||
try:
|
||||
opened_expiry = date.fromisoformat(opened) + timedelta(days=days)
|
||||
item = {**item, "opened_expiry_date": str(opened_expiry)}
|
||||
except ValueError:
|
||||
pass
|
||||
if "opened_expiry_date" not in item:
|
||||
item = {**item, "opened_expiry_date": None}
|
||||
return item
|
||||
|
||||
|
||||
# ── Products ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
|
@ -168,13 +192,13 @@ async def list_inventory_items(
|
|||
store: Store = Depends(get_store),
|
||||
):
|
||||
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
||||
return [InventoryItemResponse.model_validate(i) for i in items]
|
||||
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||
|
||||
|
||||
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
||||
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
||||
items = await asyncio.to_thread(store.expiring_soon, days)
|
||||
return [InventoryItemResponse.model_validate(i) for i in items]
|
||||
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
|
|
@ -182,7 +206,7 @@ async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
|
|||
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(item)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
|
|
@ -194,24 +218,79 @@ async def update_inventory_item(
|
|||
updates["purchase_date"] = str(updates["purchase_date"])
|
||||
if "expiration_date" in updates and updates["expiration_date"]:
|
||||
updates["expiration_date"] = str(updates["expiration_date"])
|
||||
if "opened_date" in updates and updates["opened_date"]:
|
||||
updates["opened_date"] = str(updates["opened_date"])
|
||||
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(item)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
||||
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
||||
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
|
||||
from datetime import date
|
||||
item = await asyncio.to_thread(
|
||||
store.update_inventory_item,
|
||||
item_id,
|
||||
opened_date=str(date.today()),
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||
async def consume_item(item_id: int, store: Store = Depends(get_store)):
|
||||
async def consume_item(
|
||||
item_id: int,
|
||||
body: Optional[PartialConsumeRequest] = None,
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
"""Consume an inventory item fully or partially.
|
||||
|
||||
When body.quantity is provided, decrements by that amount and only marks
|
||||
status=consumed when quantity reaches zero. Omit body to consume all.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
if body is not None:
|
||||
item = await asyncio.to_thread(
|
||||
store.partial_consume_item, item_id, body.quantity, now
|
||||
)
|
||||
else:
|
||||
item = await asyncio.to_thread(
|
||||
store.update_inventory_item,
|
||||
item_id,
|
||||
status="consumed",
|
||||
consumed_at=datetime.now(timezone.utc).isoformat(),
|
||||
consumed_at=now,
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(item)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
||||
async def discard_item(
|
||||
item_id: int,
|
||||
body: DiscardRequest = DiscardRequest(),
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
"""Mark an item as discarded (not used, spoiled, etc).
|
||||
|
||||
Optional reason field accepts free text or a preset label
|
||||
('not used', 'spoiled', 'excess', 'other').
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
item = await asyncio.to_thread(
|
||||
store.update_inventory_item,
|
||||
item_id,
|
||||
status="discarded",
|
||||
consumed_at=datetime.now(timezone.utc).isoformat(),
|
||||
disposal_reason=body.reason,
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -267,10 +346,14 @@ async def scan_barcode_text(
|
|||
tier=session.tier,
|
||||
has_byok=session.has_byok,
|
||||
)
|
||||
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
|
||||
resolved_qty = product_info.get("pack_quantity") or body.quantity
|
||||
resolved_unit = product_info.get("pack_unit") or "count"
|
||||
inventory_item = await asyncio.to_thread(
|
||||
store.add_inventory_item,
|
||||
product["id"], body.location,
|
||||
quantity=body.quantity,
|
||||
quantity=resolved_qty,
|
||||
unit=resolved_unit,
|
||||
expiration_date=str(exp) if exp else None,
|
||||
source="barcode_scan",
|
||||
)
|
||||
|
|
@ -278,6 +361,7 @@ async def scan_barcode_text(
|
|||
else:
|
||||
result_product = None
|
||||
|
||||
product_found = product_info is not None
|
||||
return BarcodeScanResponse(
|
||||
success=True,
|
||||
barcodes_found=1,
|
||||
|
|
@ -287,7 +371,8 @@ async def scan_barcode_text(
|
|||
"product": result_product,
|
||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||
"added_to_inventory": inventory_item is not None,
|
||||
"message": "Added to inventory" if inventory_item else "Product not found in database",
|
||||
"needs_manual_entry": not product_found,
|
||||
"message": "Added to inventory" if inventory_item else "Not found in any product database — add manually",
|
||||
}],
|
||||
message="Barcode processed",
|
||||
)
|
||||
|
|
@ -345,10 +430,13 @@ async def scan_barcode_image(
|
|||
tier=session.tier,
|
||||
has_byok=session.has_byok,
|
||||
)
|
||||
resolved_qty = product_info.get("pack_quantity") or quantity
|
||||
resolved_unit = product_info.get("pack_unit") or "count"
|
||||
inventory_item = await asyncio.to_thread(
|
||||
store.add_inventory_item,
|
||||
product["id"], location,
|
||||
quantity=quantity,
|
||||
quantity=resolved_qty,
|
||||
unit=resolved_unit,
|
||||
expiration_date=str(exp) if exp else None,
|
||||
source="barcode_scan",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from app.models.schemas.meal_plan import (
|
|||
PrepTaskSummary,
|
||||
ShoppingListResponse,
|
||||
SlotSummary,
|
||||
UpdatePlanRequest,
|
||||
UpdatePrepTaskRequest,
|
||||
UpsertSlotRequest,
|
||||
VALID_MEAL_TYPES,
|
||||
|
|
@ -81,13 +82,21 @@ async def create_plan(
|
|||
session: CloudUser = Depends(get_session),
|
||||
store: Store = Depends(get_store),
|
||||
) -> PlanSummary:
|
||||
import sqlite3
|
||||
|
||||
# Free tier is locked to dinner-only; paid+ may configure meal types
|
||||
if can_use("meal_plan_config", session.tier):
|
||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
||||
else:
|
||||
meal_types = ["dinner"]
|
||||
|
||||
try:
|
||||
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"A meal plan for the week of {req.week_start} already exists.",
|
||||
)
|
||||
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
||||
return _plan_summary(plan, slots)
|
||||
|
||||
|
|
@ -105,6 +114,28 @@ async def list_plans(
|
|||
return result
|
||||
|
||||
|
||||
@router.patch("/{plan_id}", response_model=PlanSummary)
|
||||
async def update_plan(
|
||||
plan_id: int,
|
||||
req: UpdatePlanRequest,
|
||||
session: CloudUser = Depends(get_session),
|
||||
store: Store = Depends(get_store),
|
||||
) -> PlanSummary:
|
||||
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
||||
# Free tier stays dinner-only; paid+ may add meal types
|
||||
if can_use("meal_plan_config", session.tier):
|
||||
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
||||
else:
|
||||
meal_types = ["dinner"]
|
||||
updated = await asyncio.to_thread(store.update_meal_plan_types, plan_id, meal_types)
|
||||
if updated is None:
|
||||
raise HTTPException(status_code=404, detail="Plan not found.")
|
||||
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
|
||||
return _plan_summary(updated, slots)
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=PlanSummary)
|
||||
async def get_plan(
|
||||
plan_id: int,
|
||||
|
|
|
|||
5
app/db/migrations/030_opened_date.sql
Normal file
5
app/db/migrations/030_opened_date.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Migration 030: open-package tracking
|
||||
-- Adds opened_date to track when a multi-use item was first opened,
|
||||
-- enabling secondary shelf-life windows (e.g. salsa: 1 year sealed → 2 weeks opened).
|
||||
|
||||
ALTER TABLE inventory_items ADD COLUMN opened_date TEXT;
|
||||
4
app/db/migrations/031_disposal_reason.sql
Normal file
4
app/db/migrations/031_disposal_reason.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- Migration 031: add disposal_reason for waste logging (#60)
|
||||
-- status='discarded' already exists in the CHECK constraint from migration 002.
|
||||
-- This column stores free-text reason (optional) and calm-framing presets.
|
||||
ALTER TABLE inventory_items ADD COLUMN disposal_reason TEXT;
|
||||
4
app/db/migrations/032_meal_plan_unique_week.sql
Normal file
4
app/db/migrations/032_meal_plan_unique_week.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- 032_meal_plan_unique_week.sql
|
||||
-- Prevent duplicate plans for the same week.
|
||||
-- Existing duplicates must be resolved before applying (keep MIN(id) per week_start).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_meal_plans_week_start ON meal_plans (week_start);
|
||||
|
|
@ -218,7 +218,8 @@ class Store:
|
|||
|
||||
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
||||
allowed = {"quantity", "unit", "location", "sublocation",
|
||||
"expiration_date", "status", "notes", "consumed_at"}
|
||||
"expiration_date", "opened_date", "status", "notes", "consumed_at",
|
||||
"disposal_reason"}
|
||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
if not updates:
|
||||
return self.get_inventory_item(item_id)
|
||||
|
|
@ -231,6 +232,32 @@ class Store:
|
|||
self.conn.commit()
|
||||
return self.get_inventory_item(item_id)
|
||||
|
||||
def partial_consume_item(
|
||||
self,
|
||||
item_id: int,
|
||||
consume_qty: float,
|
||||
consumed_at: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Decrement quantity by consume_qty. Mark consumed when quantity reaches 0."""
|
||||
row = self.get_inventory_item(item_id)
|
||||
if row is None:
|
||||
return None
|
||||
remaining = max(0.0, round(row["quantity"] - consume_qty, 6))
|
||||
if remaining <= 0:
|
||||
self.conn.execute(
|
||||
"UPDATE inventory_items SET quantity = 0, status = 'consumed',"
|
||||
" consumed_at = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(consumed_at, item_id),
|
||||
)
|
||||
else:
|
||||
self.conn.execute(
|
||||
"UPDATE inventory_items SET quantity = ?, updated_at = datetime('now')"
|
||||
" WHERE id = ?",
|
||||
(remaining, item_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
return self.get_inventory_item(item_id)
|
||||
|
||||
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
|
||||
return self._fetch_all(
|
||||
"""SELECT i.*, p.name as product_name, p.category
|
||||
|
|
@ -1092,6 +1119,12 @@ class Store:
|
|||
def get_meal_plan(self, plan_id: int) -> dict | None:
|
||||
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
|
||||
|
||||
def update_meal_plan_types(self, plan_id: int, meal_types: list[str]) -> dict | None:
|
||||
return self._fetch_one(
|
||||
"UPDATE meal_plans SET meal_types = ? WHERE id = ? RETURNING *",
|
||||
(json.dumps(meal_types), plan_id),
|
||||
)
|
||||
|
||||
def list_meal_plans(self) -> list[dict]:
|
||||
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
|
||||
|
||||
|
|
@ -1122,7 +1155,7 @@ class Store:
|
|||
|
||||
def get_plan_slots(self, plan_id: int) -> list[dict]:
|
||||
return self._fetch_all(
|
||||
"""SELECT s.*, r.name AS recipe_title
|
||||
"""SELECT s.*, r.title AS recipe_title
|
||||
FROM meal_plan_slots s
|
||||
LEFT JOIN recipes r ON r.id = s.recipe_id
|
||||
WHERE s.plan_id = ?
|
||||
|
|
|
|||
|
|
@ -90,8 +90,18 @@ class InventoryItemUpdate(BaseModel):
|
|||
location: Optional[str] = None
|
||||
sublocation: Optional[str] = None
|
||||
expiration_date: Optional[date] = None
|
||||
opened_date: Optional[date] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
disposal_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PartialConsumeRequest(BaseModel):
|
||||
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
|
||||
|
||||
|
||||
class DiscardRequest(BaseModel):
|
||||
reason: Optional[str] = Field(None, max_length=200)
|
||||
|
||||
|
||||
class InventoryItemResponse(BaseModel):
|
||||
|
|
@ -106,8 +116,11 @@ class InventoryItemResponse(BaseModel):
|
|||
sublocation: Optional[str]
|
||||
purchase_date: Optional[str]
|
||||
expiration_date: Optional[str]
|
||||
opened_date: Optional[str] = None
|
||||
opened_expiry_date: Optional[str] = None
|
||||
status: str
|
||||
notes: Optional[str]
|
||||
disposal_reason: Optional[str] = None
|
||||
source: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
|
@ -123,6 +136,7 @@ class BarcodeScanResult(BaseModel):
|
|||
product: Optional[ProductResponse]
|
||||
inventory_item: Optional[InventoryItemResponse]
|
||||
added_to_inventory: bool
|
||||
needs_manual_entry: bool = False
|
||||
message: str
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ class CreatePlanRequest(BaseModel):
|
|||
return v
|
||||
|
||||
|
||||
class UpdatePlanRequest(BaseModel):
|
||||
meal_types: list[str]
|
||||
|
||||
|
||||
class UpsertSlotRequest(BaseModel):
|
||||
recipe_id: int | None = None
|
||||
servings: float = Field(2.0, gt=0)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ class RecipeSuggestion(BaseModel):
|
|||
is_wildcard: bool = False
|
||||
nutrition: NutritionPanel | None = None
|
||||
source_url: str | None = None
|
||||
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
||||
estimated_time_min: int | None = None # derived from step count + method signals
|
||||
|
||||
|
||||
class GroceryLink(BaseModel):
|
||||
|
|
@ -83,6 +85,9 @@ class RecipeRequest(BaseModel):
|
|||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||
excluded_ids: list[int] = Field(default_factory=list)
|
||||
shopping_mode: bool = False
|
||||
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
||||
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
||||
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
||||
unit_system: str = "metric" # "metric" | "imperial"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -116,6 +116,53 @@ class ExpirationPredictor:
|
|||
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
||||
}
|
||||
|
||||
# Secondary shelf life in days after a package is opened.
|
||||
# Sources: USDA FoodKeeper app, FDA consumer guides.
|
||||
# Only categories where opening significantly shortens shelf life are listed.
|
||||
# Items not listed default to None (no secondary window tracked).
|
||||
SHELF_LIFE_AFTER_OPENING: dict[str, int] = {
|
||||
# Dairy — once opened, clock ticks fast
|
||||
'dairy': 5,
|
||||
'milk': 5,
|
||||
'cream': 3,
|
||||
'yogurt': 7,
|
||||
'cheese': 14,
|
||||
'butter': 30,
|
||||
# Condiments — refrigerated after opening
|
||||
'condiments': 30,
|
||||
'ketchup': 30,
|
||||
'mustard': 30,
|
||||
'mayo': 14,
|
||||
'salad_dressing': 30,
|
||||
'soy_sauce': 90,
|
||||
# Canned goods — once opened, very short
|
||||
'canned_goods': 4,
|
||||
# Beverages
|
||||
'juice': 7,
|
||||
'soda': 4,
|
||||
# Bread / Bakery
|
||||
'bread': 5,
|
||||
'bakery': 3,
|
||||
# Produce
|
||||
'leafy_greens': 3,
|
||||
'berries': 3,
|
||||
# Pantry staples (open bag)
|
||||
'chips': 14,
|
||||
'cookies': 14,
|
||||
'cereal': 30,
|
||||
'flour': 90,
|
||||
}
|
||||
|
||||
def days_after_opening(self, category: str | None) -> int | None:
|
||||
"""Return days of shelf life remaining once a package is opened.
|
||||
|
||||
Returns None if the category is unknown or not tracked after opening
|
||||
(e.g. frozen items, raw meat — category check irrelevant once opened).
|
||||
"""
|
||||
if not category:
|
||||
return None
|
||||
return self.SHELF_LIFE_AFTER_OPENING.get(category.lower())
|
||||
|
||||
# Keyword lists are checked in declaration order — most specific first.
|
||||
# Rules:
|
||||
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
||||
|
|
|
|||
|
|
@ -15,63 +15,72 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class OpenFoodFactsService:
|
||||
"""
|
||||
Service for interacting with the OpenFoodFacts API.
|
||||
Service for interacting with the Open*Facts family of databases.
|
||||
|
||||
OpenFoodFacts is a free, open database of food products with
|
||||
ingredients, allergens, and nutrition facts.
|
||||
Primary: OpenFoodFacts (food products).
|
||||
Fallback chain: Open Beauty Facts (personal care) → Open Products Facts (household).
|
||||
All three databases share the same API path and JSON format.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://world.openfoodfacts.org/api/v2"
|
||||
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)"
|
||||
|
||||
# Fallback databases tried in order when OFFs returns no match.
|
||||
# Same API format as OFFs — only the host differs.
|
||||
_FALLBACK_DATABASES = [
|
||||
"https://world.openbeautyfacts.org/api/v2",
|
||||
"https://world.openproductsfacts.org/api/v2",
|
||||
]
|
||||
|
||||
async def _lookup_in_database(
|
||||
self, barcode: str, base_url: str, client: httpx.AsyncClient
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Try one Open*Facts database using an existing client. Returns parsed product dict or None."""
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{base_url}/product/{barcode}.json",
|
||||
headers={"User-Agent": self.USER_AGENT},
|
||||
timeout=10.0,
|
||||
)
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("status") != 1:
|
||||
return None
|
||||
return self._parse_product_data(data, barcode)
|
||||
except httpx.HTTPError as e:
|
||||
logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("Lookup failed for %s at %s: %s", barcode, base_url, e)
|
||||
return None
|
||||
|
||||
async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Look up a product by barcode in the OpenFoodFacts database.
|
||||
Look up a product by barcode, trying OFFs then fallback databases.
|
||||
|
||||
A single httpx.AsyncClient is created for the whole lookup chain so that
|
||||
connection pooling and TLS session reuse apply across all database attempts.
|
||||
|
||||
Args:
|
||||
barcode: UPC/EAN barcode (8-13 digits)
|
||||
|
||||
Returns:
|
||||
Dictionary with product information, or None if not found
|
||||
|
||||
Example response:
|
||||
{
|
||||
"name": "Organic Milk",
|
||||
"brand": "Horizon",
|
||||
"categories": ["Dairy", "Milk"],
|
||||
"image_url": "https://...",
|
||||
"nutrition_data": {...},
|
||||
"raw_data": {...} # Full API response
|
||||
}
|
||||
Dictionary with product information, or None if not found in any database.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.BASE_URL}/product/{barcode}.json"
|
||||
result = await self._lookup_in_database(barcode, self.BASE_URL, client)
|
||||
if result:
|
||||
return result
|
||||
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"User-Agent": self.USER_AGENT},
|
||||
timeout=10.0,
|
||||
)
|
||||
for db_url in self._FALLBACK_DATABASES:
|
||||
result = await self._lookup_in_database(barcode, db_url, client)
|
||||
if result:
|
||||
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
|
||||
return result
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
|
||||
return None
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") != 1:
|
||||
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
|
||||
return None
|
||||
|
||||
return self._parse_product_data(data, barcode)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"HTTP error looking up barcode {barcode}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error looking up barcode {barcode}: {e}")
|
||||
logger.info("Barcode %s not found in any Open*Facts database", barcode)
|
||||
return None
|
||||
|
||||
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
|
||||
|
|
@ -114,6 +123,9 @@ class OpenFoodFactsService:
|
|||
allergens = product.get("allergens_tags", [])
|
||||
labels = product.get("labels_tags", [])
|
||||
|
||||
# Pack size detection: prefer explicit unit_count, fall back to serving count
|
||||
pack_quantity, pack_unit = self._extract_pack_size(product)
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
|
|
@ -124,9 +136,47 @@ class OpenFoodFactsService:
|
|||
"nutrition_data": nutrition_data,
|
||||
"allergens": allergens,
|
||||
"labels": labels,
|
||||
"pack_quantity": pack_quantity,
|
||||
"pack_unit": pack_unit,
|
||||
"raw_data": product, # Store full response for debugging
|
||||
}
|
||||
|
||||
def _extract_pack_size(self, product: Dict[str, Any]) -> tuple[float | None, str | None]:
|
||||
"""Return (quantity, unit) for multi-pack products, or (None, None).
|
||||
|
||||
OFFs fields tried in order:
|
||||
1. `number_of_units` (explicit count, highest confidence)
|
||||
2. `serving_quantity` + `product_quantity_unit` (e.g. 6 x 150g yoghurt)
|
||||
3. Parse `quantity` string like "4 x 113 g" or "6 pack"
|
||||
|
||||
Returns None, None when data is absent, ambiguous, or single-unit.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Field 1: explicit unit count
|
||||
unit_count = product.get("number_of_units")
|
||||
if unit_count:
|
||||
try:
|
||||
n = float(unit_count)
|
||||
if n > 1:
|
||||
return n, product.get("serving_size_unit") or "unit"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Field 2: parse quantity string for "N x ..." pattern
|
||||
qty_str = product.get("quantity", "")
|
||||
if qty_str:
|
||||
m = re.match(r"^(\d+(?:\.\d+)?)\s*[xX×]\s*", qty_str.strip())
|
||||
if m:
|
||||
n = float(m.group(1))
|
||||
if n > 1:
|
||||
# Try to get a sensible sub-unit label from the rest
|
||||
rest = qty_str[m.end():].strip()
|
||||
unit_label = re.sub(r"[\d.,\s]+", "", rest).strip()[:20] or "unit"
|
||||
return n, unit_label
|
||||
|
||||
return None, None
|
||||
|
||||
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract nutrition facts from product data.
|
||||
|
|
|
|||
|
|
@ -143,12 +143,14 @@ class LLMRecipeGenerator:
|
|||
|
||||
return "\n".join(lines)
|
||||
|
||||
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
|
||||
_SERVICE_TYPE = "cf-text"
|
||||
_TTL_S = 300.0
|
||||
_CALLER = "kiwi-recipe"
|
||||
|
||||
def _get_llm_context(self):
|
||||
"""Return a sync context manager that yields an Allocation or None.
|
||||
|
||||
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
|
||||
When CF_ORCH_URL is set, uses CFOrchClient to acquire a cf-text allocation
|
||||
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
|
||||
when the env var is absent or CFOrchClient raises on construction.
|
||||
"""
|
||||
|
|
@ -158,10 +160,9 @@ class LLMRecipeGenerator:
|
|||
from circuitforge_orch.client import CFOrchClient
|
||||
client = CFOrchClient(cf_orch_url)
|
||||
return client.allocate(
|
||||
service="vllm",
|
||||
model_candidates=self._MODEL_CANDIDATES,
|
||||
ttl_s=300.0,
|
||||
caller="kiwi-recipe",
|
||||
service=self._SERVICE_TYPE,
|
||||
ttl_s=self._TTL_S,
|
||||
caller=self._CALLER,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
||||
|
|
|
|||
|
|
@ -562,6 +562,19 @@ def _hard_day_sort_tier(
|
|||
return 2
|
||||
|
||||
|
||||
def _estimate_time_min(directions: list[str], complexity: str) -> int:
|
||||
"""Rough cooking time estimate from step count and method complexity.
|
||||
|
||||
Not precise — intended for filtering and display hints only.
|
||||
"""
|
||||
steps = len(directions)
|
||||
if complexity == "easy":
|
||||
return max(5, 10 + steps * 3)
|
||||
if complexity == "involved":
|
||||
return max(20, 30 + steps * 6)
|
||||
return max(10, 20 + steps * 4) # moderate
|
||||
|
||||
|
||||
def _classify_method_complexity(
|
||||
directions: list[str],
|
||||
available_equipment: list[str] | None = None,
|
||||
|
|
@ -699,6 +712,11 @@ class RecipeEngine:
|
|||
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
|
||||
continue
|
||||
|
||||
# "Can make now" toggle: drop any recipe that still has missing ingredients
|
||||
# after swaps are applied. Swapped items count as covered.
|
||||
if req.pantry_match_only and missing:
|
||||
continue
|
||||
|
||||
# L1 match ratio gate: drop results where less than 60% of the recipe's
|
||||
# ingredients are in the pantry. Prevents low-signal results like a
|
||||
# 10-ingredient recipe matching on only one common item.
|
||||
|
|
@ -707,16 +725,21 @@ class RecipeEngine:
|
|||
if match_ratio < _L1_MIN_MATCH_RATIO:
|
||||
continue
|
||||
|
||||
# Filter and tier-rank by hard_day_mode
|
||||
if req.hard_day_mode:
|
||||
# Parse directions — needed for complexity, hard_day_mode, and time estimate.
|
||||
directions: list[str] = row.get("directions") or []
|
||||
if isinstance(directions, str):
|
||||
try:
|
||||
directions = json.loads(directions)
|
||||
except Exception:
|
||||
directions = [directions]
|
||||
complexity = _classify_method_complexity(directions, available_equipment)
|
||||
if complexity == "involved":
|
||||
|
||||
# Compute complexity for every suggestion (used for badge + filter).
|
||||
row_complexity = _classify_method_complexity(directions, available_equipment)
|
||||
row_time_min = _estimate_time_min(directions, row_complexity)
|
||||
|
||||
# Filter and tier-rank by hard_day_mode
|
||||
if req.hard_day_mode:
|
||||
if row_complexity == "involved":
|
||||
continue
|
||||
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
|
||||
title=row.get("title", ""),
|
||||
|
|
@ -724,6 +747,14 @@ class RecipeEngine:
|
|||
directions=directions,
|
||||
)
|
||||
|
||||
# Complexity filter (#58)
|
||||
if req.complexity_filter and row_complexity != req.complexity_filter:
|
||||
continue
|
||||
|
||||
# Max time filter (#58)
|
||||
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
||||
continue
|
||||
|
||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||
if req.level == 2 and req.constraints:
|
||||
for ing in ingredient_names:
|
||||
|
|
@ -773,6 +804,8 @@ class RecipeEngine:
|
|||
level=req.level,
|
||||
nutrition=nutrition if has_nutrition else None,
|
||||
source_url=_build_source_url(row),
|
||||
complexity=row_complexity,
|
||||
estimated_time_min=row_time_min,
|
||||
))
|
||||
|
||||
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
||||
|
|
|
|||
275
frontend/src/components/ActionDialog.vue
Normal file
275
frontend/src/components/ActionDialog.vue
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
<template>
|
||||
<Transition name="modal">
|
||||
<div v-if="show" class="modal-overlay" @click="handleCancel">
|
||||
<div class="modal-container" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>{{ message }}</p>
|
||||
|
||||
<!-- Partial quantity input -->
|
||||
<div v-if="inputType === 'quantity'" class="action-input-row">
|
||||
<label class="action-input-label">{{ inputLabel }}</label>
|
||||
<div class="qty-input-group">
|
||||
<input
|
||||
v-model.number="inputNumber"
|
||||
type="number"
|
||||
:min="0.01"
|
||||
:max="inputMax"
|
||||
step="0.5"
|
||||
class="action-number-input"
|
||||
:aria-label="inputLabel"
|
||||
/>
|
||||
<span class="qty-unit">{{ inputUnit }}</span>
|
||||
</div>
|
||||
<button class="btn-use-all" @click="inputNumber = inputMax">
|
||||
Use all ({{ inputMax }} {{ inputUnit }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reason select -->
|
||||
<div v-if="inputType === 'select'" class="action-input-row">
|
||||
<label class="action-input-label">{{ inputLabel }}</label>
|
||||
<select v-model="inputSelect" class="action-select" :aria-label="inputLabel">
|
||||
<option value="">— skip —</option>
|
||||
<option v-for="opt in inputOptions" :key="opt" :value="opt">{{ opt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="handleCancel">Cancel</button>
|
||||
<button :class="['btn', `btn-${type}`]" @click="handleConfirm">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
title?: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
type?: 'primary' | 'danger' | 'warning' | 'secondary'
|
||||
inputType?: 'quantity' | 'select' | null
|
||||
inputLabel?: string
|
||||
inputMax?: number
|
||||
inputUnit?: string
|
||||
inputOptions?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: 'Confirm',
|
||||
confirmText: 'Confirm',
|
||||
type: 'primary',
|
||||
inputType: null,
|
||||
inputLabel: '',
|
||||
inputMax: 1,
|
||||
inputUnit: '',
|
||||
inputOptions: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [value: number | string | undefined]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const inputNumber = ref<number>(props.inputMax)
|
||||
const inputSelect = ref<string>('')
|
||||
|
||||
watch(() => props.inputMax, (v) => { inputNumber.value = v })
|
||||
watch(() => props.show, (v) => {
|
||||
if (v) {
|
||||
inputNumber.value = props.inputMax
|
||||
inputSelect.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleConfirm() {
|
||||
if (props.inputType === 'quantity') {
|
||||
const qty = Math.min(Math.max(0.01, inputNumber.value || props.inputMax), props.inputMax)
|
||||
emit('confirm', qty)
|
||||
} else if (props.inputType === 'select') {
|
||||
emit('confirm', inputSelect.value || undefined)
|
||||
} else {
|
||||
emit('confirm', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.modal-body p {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.action-input-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.action-input-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qty-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.action-number-input {
|
||||
width: 90px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.qty-unit {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-use-all {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-select {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover { background: var(--color-bg-primary); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gradient-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--color-error);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--color-error-dark);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--color-warning);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active { transition: opacity 0.3s ease; }
|
||||
.modal-enter-active .modal-container,
|
||||
.modal-leave-active .modal-container { transition: transform 0.3s ease; }
|
||||
.modal-enter-from,
|
||||
.modal-leave-to { opacity: 0; }
|
||||
.modal-enter-from .modal-container,
|
||||
.modal-leave-to .modal-container { transform: scale(0.9) translateY(-20px); }
|
||||
</style>
|
||||
|
|
@ -323,9 +323,16 @@
|
|||
<div class="inv-row-right">
|
||||
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
||||
|
||||
<!-- Opened expiry takes priority over sell-by date -->
|
||||
<span
|
||||
v-if="item.expiration_date"
|
||||
v-if="item.opened_expiry_date"
|
||||
:class="['expiry-badge', 'expiry-opened', getExpiryBadgeClass(item.opened_expiry_date)]"
|
||||
:title="`Opened · ${formatDateFull(item.opened_expiry_date)}`"
|
||||
>📂 {{ formatDateShort(item.opened_expiry_date) }}</span>
|
||||
<span
|
||||
v-else-if="item.expiration_date"
|
||||
:class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
|
||||
:title="formatDateFull(item.expiration_date)"
|
||||
>{{ formatDateShort(item.expiration_date) }}</span>
|
||||
|
||||
<div class="inv-actions">
|
||||
|
|
@ -334,11 +341,41 @@
|
|||
<path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
|
||||
<button
|
||||
v-if="!item.opened_date && item.status === 'available'"
|
||||
@click="markAsOpened(item)"
|
||||
class="btn-icon btn-icon-open"
|
||||
aria-label="Mark as opened today"
|
||||
title="I opened this today"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<path d="M5 8V6a7 7 0 0114 0v2"/>
|
||||
<rect x="3" y="8" width="14" height="10" rx="2"/>
|
||||
<circle cx="10" cy="13" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'available'"
|
||||
@click="markAsConsumed(item)"
|
||||
class="btn-icon btn-icon-success"
|
||||
:aria-label="item.quantity > 1 ? `Use some (${item.quantity} ${item.unit})` : 'Mark as used'"
|
||||
:title="item.quantity > 1 ? `Use some or all (${item.quantity} ${item.unit})` : 'Mark as used'"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<polyline points="4 10 8 14 16 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status === 'available'"
|
||||
@click="markAsDiscarded(item)"
|
||||
class="btn-icon btn-icon-discard"
|
||||
aria-label="Mark as not used"
|
||||
title="I didn't use this (went bad, too much, etc)"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<path d="M4 4l12 12M4 16L16 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(item)" class="btn-icon btn-icon-danger" aria-label="Delete">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||
<polyline points="3 6 5 6 17 6"/>
|
||||
|
|
@ -355,10 +392,14 @@
|
|||
<!-- Export -->
|
||||
<div class="card export-card">
|
||||
<h2 class="section-title">Export</h2>
|
||||
<div class="flex gap-sm" style="margin-top: var(--spacing-sm)">
|
||||
<div class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
|
||||
<button @click="exportJSON" class="btn btn-primary">Download JSON (full backup)</button>
|
||||
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
|
||||
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
|
||||
</div>
|
||||
<p class="text-sm text-secondary" style="margin-top: var(--spacing-xs)">
|
||||
JSON includes pantry + saved recipes. Import it into another Kiwi instance any time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
|
|
@ -380,6 +421,22 @@
|
|||
@cancel="confirmDialog.show = false"
|
||||
/>
|
||||
|
||||
<!-- Action Dialog (partial consume / discard reason) -->
|
||||
<ActionDialog
|
||||
:show="actionDialog.show"
|
||||
:title="actionDialog.title"
|
||||
:message="actionDialog.message"
|
||||
:type="actionDialog.type"
|
||||
:confirm-text="actionDialog.confirmText"
|
||||
:input-type="actionDialog.inputType"
|
||||
:input-label="actionDialog.inputLabel"
|
||||
:input-max="actionDialog.inputMax"
|
||||
:input-unit="actionDialog.inputUnit"
|
||||
:input-options="actionDialog.inputOptions"
|
||||
@confirm="(v) => { actionDialog.onConfirm(v); actionDialog.show = false }"
|
||||
@cancel="actionDialog.show = false"
|
||||
/>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<ToastNotification
|
||||
:show="toast.show"
|
||||
|
|
@ -401,6 +458,7 @@ import type { InventoryItem } from '../services/api'
|
|||
import { formatQuantity } from '../utils/units'
|
||||
import EditItemModal from './EditItemModal.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ActionDialog from './ActionDialog.vue'
|
||||
import ToastNotification from './ToastNotification.vue'
|
||||
|
||||
const store = useInventoryStore()
|
||||
|
|
@ -446,6 +504,20 @@ const confirmDialog = reactive({
|
|||
onConfirm: () => {},
|
||||
})
|
||||
|
||||
const actionDialog = reactive({
|
||||
show: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'primary' as 'primary' | 'danger' | 'warning' | 'secondary',
|
||||
confirmText: 'Confirm',
|
||||
inputType: null as 'quantity' | 'select' | null,
|
||||
inputLabel: '',
|
||||
inputMax: 1,
|
||||
inputUnit: '',
|
||||
inputOptions: [] as string[],
|
||||
onConfirm: (_v: number | string | undefined) => {},
|
||||
})
|
||||
|
||||
// Toast Notification
|
||||
const toast = reactive({
|
||||
show: false,
|
||||
|
|
@ -555,24 +627,75 @@ async function confirmDelete(item: InventoryItem) {
|
|||
)
|
||||
}
|
||||
|
||||
async function markAsConsumed(item: InventoryItem) {
|
||||
showConfirm(
|
||||
`Mark ${item.product_name || 'item'} as consumed?`,
|
||||
async () => {
|
||||
async function markAsOpened(item: InventoryItem) {
|
||||
try {
|
||||
await inventoryAPI.consumeItem(item.id)
|
||||
await inventoryAPI.openItem(item.id)
|
||||
await refreshItems()
|
||||
showToast(`${item.product_name || 'item'} marked as consumed`, 'success')
|
||||
} catch (err) {
|
||||
showToast('Failed to mark item as consumed', 'error')
|
||||
showToast(`${item.product_name || 'Item'} marked as opened — tracking freshness`, 'info')
|
||||
} catch {
|
||||
showToast('Could not mark item as opened', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function markAsConsumed(item: InventoryItem) {
|
||||
const isMulti = item.quantity > 1
|
||||
const label = item.product_name || 'item'
|
||||
Object.assign(actionDialog, {
|
||||
show: true,
|
||||
title: 'Mark as Used',
|
||||
message: isMulti
|
||||
? `How much of ${label} did you use?`
|
||||
: `Mark ${label} as used?`,
|
||||
type: 'primary',
|
||||
confirmText: isMulti ? 'Use' : 'Mark as Used',
|
||||
inputType: isMulti ? 'quantity' : null,
|
||||
inputLabel: 'Amount used:',
|
||||
inputMax: item.quantity,
|
||||
inputUnit: item.unit,
|
||||
inputOptions: [],
|
||||
onConfirm: async (val: number | string | undefined) => {
|
||||
const qty = isMulti ? (val as number) : undefined
|
||||
try {
|
||||
await inventoryAPI.consumeItem(item.id, qty)
|
||||
await refreshItems()
|
||||
const verb = qty !== undefined && qty < item.quantity ? 'partially used' : 'marked as used'
|
||||
showToast(`${label} ${verb}`, 'success')
|
||||
} catch {
|
||||
showToast('Could not update item', 'error')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Mark as Consumed',
|
||||
type: 'primary',
|
||||
confirmText: 'Mark as Consumed',
|
||||
})
|
||||
}
|
||||
|
||||
function markAsDiscarded(item: InventoryItem) {
|
||||
const label = item.product_name || 'item'
|
||||
Object.assign(actionDialog, {
|
||||
show: true,
|
||||
title: 'Item Not Used',
|
||||
message: `${label} — what happened to it?`,
|
||||
type: 'secondary',
|
||||
confirmText: 'Log It',
|
||||
inputType: 'select',
|
||||
inputLabel: 'Reason (optional):',
|
||||
inputMax: 1,
|
||||
inputUnit: '',
|
||||
inputOptions: [
|
||||
'went bad before I could use it',
|
||||
'too much — had excess',
|
||||
'changed my mind',
|
||||
'other',
|
||||
],
|
||||
onConfirm: async (val: number | string | undefined) => {
|
||||
const reason = typeof val === 'string' && val ? val : undefined
|
||||
try {
|
||||
await inventoryAPI.discardItem(item.id, reason)
|
||||
await refreshItems()
|
||||
showToast(`${label} logged as not used`, 'info')
|
||||
} catch {
|
||||
showToast('Could not update item', 'error')
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Scanner Gun Functions
|
||||
|
|
@ -591,13 +714,22 @@ async function handleScannerGunInput() {
|
|||
true
|
||||
)
|
||||
|
||||
if (result.success && result.barcodes_found > 0) {
|
||||
const item = result.results[0]
|
||||
if (item?.added_to_inventory) {
|
||||
const productName = item.product?.name || 'item'
|
||||
const productBrand = item.product?.brand ? ` (${item.product.brand})` : ''
|
||||
scannerResults.value.push({
|
||||
type: 'success',
|
||||
message: `Added: ${item.product_name || 'item'} to ${scannerLocation.value}`,
|
||||
message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
|
||||
})
|
||||
await refreshItems()
|
||||
} else if (item?.needs_manual_entry) {
|
||||
// Barcode not found in any database — guide user to manual entry
|
||||
scannerResults.value.push({
|
||||
type: 'warning',
|
||||
message: `Barcode ${barcode} not found. Fill in the details below.`,
|
||||
})
|
||||
scanMode.value = 'manual'
|
||||
} else {
|
||||
scannerResults.value.push({
|
||||
type: 'error',
|
||||
|
|
@ -612,7 +744,7 @@ async function handleScannerGunInput() {
|
|||
} finally {
|
||||
scannerLoading.value = false
|
||||
scannerBarcode.value = ''
|
||||
scannerGunInput.value?.focus()
|
||||
if (scanMode.value === 'gun') scannerGunInput.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -721,20 +853,40 @@ function exportExcel() {
|
|||
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
|
||||
}
|
||||
|
||||
// Short date for compact row display
|
||||
function formatDateShort(dateStr: string): string {
|
||||
function exportJSON() {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
window.open(`${apiUrl}/export/json`, '_blank')
|
||||
}
|
||||
|
||||
// Full date string for tooltip (accessible label)
|
||||
function formatDateFull(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const expiry = new Date(dateStr)
|
||||
expiry.setHours(0, 0, 0, 0)
|
||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const cal = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
if (diffDays < 0) return `Expired ${cal}`
|
||||
if (diffDays === 0) return `Expires today (${cal})`
|
||||
if (diffDays === 1) return `Expires tomorrow (${cal})`
|
||||
return `Expires in ${diffDays} days (${cal})`
|
||||
}
|
||||
|
||||
// Short date for compact row display
|
||||
function formatDateShort(dateStr: string): string {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const expiry = new Date(dateStr)
|
||||
expiry.setHours(0, 0, 0, 0)
|
||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const cal = new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
|
||||
if (diffDays < 0) return `${Math.abs(diffDays)}d ago`
|
||||
if (diffDays === 0) return 'today'
|
||||
if (diffDays === 1) return 'tmrw'
|
||||
if (diffDays <= 14) return `${diffDays}d`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
if (diffDays === 1) return `tmrw · ${cal}`
|
||||
if (diffDays <= 14) return `${diffDays}d · ${cal}`
|
||||
return cal
|
||||
}
|
||||
|
||||
function getExpiryBadgeClass(expiryStr: string): string {
|
||||
|
|
@ -1147,6 +1299,30 @@ function getItemClass(item: InventoryItem): string {
|
|||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* "I opened this today" button */
|
||||
.btn-icon-open {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.btn-icon-open:hover {
|
||||
background: var(--color-warning-bg);
|
||||
}
|
||||
|
||||
/* "Item not used" discard button — muted, not alarming */
|
||||
.btn-icon-discard {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.btn-icon-discard:hover {
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
/* Opened badge — distinct icon prefix signals this is after-open expiry */
|
||||
.expiry-opened {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Action icons inline */
|
||||
.inv-actions {
|
||||
display: flex;
|
||||
|
|
@ -1220,6 +1396,12 @@ function getItemClass(item: InventoryItem): string {
|
|||
border: 1px solid var(--color-info-border);
|
||||
}
|
||||
|
||||
.result-warning {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
color: var(--color-warning-dark, #92400e);
|
||||
border: 1px solid var(--color-warning-border, #fcd34d);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPORT CARD
|
||||
============================================ */
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@
|
|||
Week of {{ p.week_start }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
|
||||
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
|
||||
{{ planCreating ? 'Creating…' : '+ New week' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="planError" class="plan-error">{{ planError }}</p>
|
||||
|
||||
<template v-if="activePlan">
|
||||
<!-- Compact expandable week grid (always visible) -->
|
||||
|
|
@ -26,6 +29,70 @@
|
|||
@add-meal-type="onAddMealType"
|
||||
/>
|
||||
|
||||
<!-- Slot editor panel -->
|
||||
<div v-if="slotEditing" class="slot-editor card">
|
||||
<div class="slot-editor-header">
|
||||
<span class="slot-editor-title">
|
||||
{{ DAY_LABELS[slotEditing.dayOfWeek] }} · {{ slotEditing.mealType }}
|
||||
</span>
|
||||
<button class="close-btn" @click="slotEditing = null" aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom label -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Custom label</label>
|
||||
<input
|
||||
v-model="slotCustomLabel"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="e.g. Taco night, Leftovers…"
|
||||
maxlength="80"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pick from saved recipes -->
|
||||
<div v-if="savedStore.saved.length" class="form-group">
|
||||
<label class="form-label">Or pick a saved recipe</label>
|
||||
<select class="week-select" v-model="slotRecipeId">
|
||||
<option :value="null">— None —</option>
|
||||
<option v-for="r in savedStore.saved" :key="r.recipe_id" :value="r.recipe_id">
|
||||
{{ r.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-else class="slot-hint">Save recipes from the Recipes tab to pick them here.</p>
|
||||
|
||||
<div class="slot-editor-actions">
|
||||
<button class="btn-secondary" @click="slotEditing = null">Cancel</button>
|
||||
<button
|
||||
v-if="currentSlot"
|
||||
class="btn-danger-subtle"
|
||||
@click="onClearSlot"
|
||||
:disabled="slotSaving"
|
||||
>Clear slot</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
@click="onSaveSlot"
|
||||
:disabled="slotSaving"
|
||||
>{{ slotSaving ? 'Saving…' : 'Save' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meal type picker -->
|
||||
<div v-if="addingMealType" class="meal-type-picker card">
|
||||
<span class="slot-editor-title">Add meal type</span>
|
||||
<div class="chip-row">
|
||||
<button
|
||||
v-for="t in availableMealTypes"
|
||||
:key="t"
|
||||
class="btn-chip"
|
||||
:disabled="mealTypeAdding"
|
||||
@click="onPickMealType(t)"
|
||||
>{{ t }}</button>
|
||||
</div>
|
||||
<button class="close-link" @click="addingMealType = false">Cancel</button>
|
||||
</div>
|
||||
|
||||
<!-- Panel tabs: Shopping List | Prep Schedule -->
|
||||
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
|
||||
<button
|
||||
|
|
@ -64,7 +131,9 @@
|
|||
|
||||
<div v-else-if="!loading" class="empty-plan-state">
|
||||
<p>No meal plan yet for this week.</p>
|
||||
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
|
||||
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
|
||||
{{ planCreating ? 'Creating…' : 'Start planning' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -73,49 +142,136 @@
|
|||
import { ref, computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMealPlanStore } from '../stores/mealPlan'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import MealPlanGrid from './MealPlanGrid.vue'
|
||||
import ShoppingListPanel from './ShoppingListPanel.vue'
|
||||
import PrepSessionView from './PrepSessionView.vue'
|
||||
import type { MealPlanSlot } from '../services/api'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'shopping', label: 'Shopping List' },
|
||||
{ id: 'prep', label: 'Prep Schedule' },
|
||||
] as const
|
||||
|
||||
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const ALL_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack']
|
||||
|
||||
type TabId = typeof TABS[number]['id']
|
||||
|
||||
const store = useMealPlanStore()
|
||||
const savedStore = useSavedRecipesStore()
|
||||
const { plans, activePlan, loading } = storeToRefs(store)
|
||||
|
||||
const activeTab = ref<TabId>('shopping')
|
||||
const planError = ref<string | null>(null)
|
||||
const planCreating = ref(false)
|
||||
|
||||
// ── slot editor ───────────────────────────────────────────────────────────────
|
||||
const slotEditing = ref<{ dayOfWeek: number; mealType: string } | null>(null)
|
||||
const slotCustomLabel = ref('')
|
||||
const slotRecipeId = ref<number | null>(null)
|
||||
const slotSaving = ref(false)
|
||||
|
||||
const currentSlot = computed((): MealPlanSlot | undefined => {
|
||||
if (!slotEditing.value) return undefined
|
||||
return store.getSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
|
||||
})
|
||||
|
||||
// ── meal type picker ──────────────────────────────────────────────────────────
|
||||
const addingMealType = ref(false)
|
||||
const mealTypeAdding = ref(false)
|
||||
|
||||
const availableMealTypes = computed(() =>
|
||||
ALL_MEAL_TYPES.filter(t => !activePlan.value?.meal_types.includes(t))
|
||||
)
|
||||
|
||||
// canAddMealType is a UI hint — backend enforces the paid gate authoritatively
|
||||
const canAddMealType = computed(() =>
|
||||
(activePlan.value?.meal_types.length ?? 0) < 4
|
||||
)
|
||||
|
||||
onMounted(() => store.loadPlans())
|
||||
onMounted(async () => {
|
||||
await Promise.all([store.loadPlans(), savedStore.load()])
|
||||
store.autoSelectPlan(mondayOfCurrentWeek())
|
||||
})
|
||||
|
||||
function mondayOfCurrentWeek(): string {
|
||||
const today = new Date()
|
||||
const day = today.getDay() // 0=Sun, 1=Mon...
|
||||
// Build date string from local parts to avoid UTC-offset day drift
|
||||
const d = new Date(today)
|
||||
d.setDate(today.getDate() - ((day + 6) % 7))
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
async function onNewPlan() {
|
||||
const today = new Date()
|
||||
const day = today.getDay()
|
||||
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
|
||||
const monday = new Date(today)
|
||||
monday.setDate(today.getDate() - ((day + 6) % 7))
|
||||
const weekStart = monday.toISOString().split('T')[0] ?? monday.toISOString().slice(0, 10)
|
||||
planError.value = null
|
||||
planCreating.value = true
|
||||
const weekStart = mondayOfCurrentWeek()
|
||||
try {
|
||||
await store.createPlan(weekStart, ['dinner'])
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes('409') || msg.toLowerCase().includes('already exists')) {
|
||||
const existing = plans.value.find(p => p.week_start === weekStart)
|
||||
if (existing) await store.setActivePlan(existing.id)
|
||||
} else {
|
||||
planError.value = `Couldn't create plan: ${msg}`
|
||||
}
|
||||
} finally {
|
||||
planCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectPlan(planId: number) {
|
||||
if (planId) await store.setActivePlan(planId)
|
||||
}
|
||||
|
||||
function onSlotClick(_: { dayOfWeek: number; mealType: string }) {
|
||||
// Recipe picker integration filed as follow-up
|
||||
function onSlotClick(payload: { dayOfWeek: number; mealType: string }) {
|
||||
slotEditing.value = payload
|
||||
const existing = store.getSlot(payload.dayOfWeek, payload.mealType)
|
||||
slotCustomLabel.value = existing?.custom_label ?? ''
|
||||
slotRecipeId.value = existing?.recipe_id ?? null
|
||||
}
|
||||
|
||||
async function onSaveSlot() {
|
||||
if (!slotEditing.value) return
|
||||
slotSaving.value = true
|
||||
try {
|
||||
await store.upsertSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType, {
|
||||
recipe_id: slotRecipeId.value,
|
||||
custom_label: slotCustomLabel.value.trim() || null,
|
||||
})
|
||||
slotEditing.value = null
|
||||
} finally {
|
||||
slotSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onClearSlot() {
|
||||
if (!slotEditing.value) return
|
||||
slotSaving.value = true
|
||||
try {
|
||||
await store.clearSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
|
||||
slotEditing.value = null
|
||||
} finally {
|
||||
slotSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onAddMealType() {
|
||||
// Add meal type picker — Paid gate enforced by backend
|
||||
addingMealType.value = true
|
||||
}
|
||||
|
||||
async function onPickMealType(mealType: string) {
|
||||
mealTypeAdding.value = true
|
||||
try {
|
||||
await store.addMealType(mealType)
|
||||
addingMealType.value = false
|
||||
} finally {
|
||||
mealTypeAdding.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -135,6 +291,29 @@ function onAddMealType() {
|
|||
}
|
||||
.new-plan-btn:hover { background: var(--color-accent); color: white; }
|
||||
|
||||
/* Slot editor */
|
||||
.slot-editor, .meal-type-picker {
|
||||
padding: 1rem; border-radius: 8px;
|
||||
border: 1px solid var(--color-border); background: var(--color-surface);
|
||||
display: flex; flex-direction: column; gap: 0.75rem;
|
||||
}
|
||||
.slot-editor-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.slot-editor-title { font-size: 0.85rem; font-weight: 600; }
|
||||
.close-btn {
|
||||
background: none; border: none; cursor: pointer; font-size: 0.9rem;
|
||||
color: var(--color-text-secondary); padding: 0.1rem 0.3rem; border-radius: 4px;
|
||||
}
|
||||
.close-btn:hover { background: var(--color-surface-2); }
|
||||
.slot-hint { font-size: 0.8rem; opacity: 0.55; margin: 0; }
|
||||
.slot-editor-actions { display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap; }
|
||||
|
||||
.chip-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.close-link {
|
||||
background: none; border: none; cursor: pointer; font-size: 0.8rem;
|
||||
color: var(--color-text-secondary); align-self: flex-start; padding: 0;
|
||||
}
|
||||
.close-link:hover { text-decoration: underline; }
|
||||
|
||||
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
|
||||
.panel-tab {
|
||||
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
|
||||
|
|
@ -150,4 +329,11 @@ function onAddMealType() {
|
|||
.tab-panel { padding-top: 0.75rem; }
|
||||
|
||||
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
|
||||
|
||||
.plan-error {
|
||||
font-size: 0.82rem; color: var(--color-error, #e05252);
|
||||
background: var(--color-error-subtle, #fef2f2);
|
||||
border: 1px solid var(--color-error, #e05252); border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem; margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,20 @@
|
|||
<!-- Scrollable body -->
|
||||
<div class="detail-body">
|
||||
|
||||
<!-- Serving multiplier -->
|
||||
<div class="serving-scale-row">
|
||||
<span class="serving-scale-label text-sm text-muted">Scale:</span>
|
||||
<div class="serving-scale-btns" role="group" aria-label="Serving multiplier">
|
||||
<button
|
||||
v-for="n in [1, 2, 3, 4]"
|
||||
:key="n"
|
||||
:class="['scale-btn', { active: servingScale === n }]"
|
||||
:aria-pressed="servingScale === n"
|
||||
@click="servingScale = n"
|
||||
>{{ n }}×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients: have vs. need in a two-column layout -->
|
||||
<div class="ingredients-grid">
|
||||
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
||||
|
|
@ -43,7 +57,7 @@
|
|||
<ul class="ingredient-list">
|
||||
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
|
||||
<span class="ing-icon ing-icon-have">✓</span>
|
||||
<span>{{ ing }}</span>
|
||||
<span>{{ scaleIngredient(ing, servingScale) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -66,7 +80,7 @@
|
|||
:checked="checkedIngredients.has(ing)"
|
||||
@change="toggleIngredient(ing)"
|
||||
/>
|
||||
<span class="ing-name">{{ ing }}</span>
|
||||
<span class="ing-name">{{ scaleIngredient(ing, servingScale) }}</span>
|
||||
</label>
|
||||
<a
|
||||
v-if="groceryLinkFor(ing)"
|
||||
|
|
@ -248,6 +262,69 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
|||
const cookDone = ref(false)
|
||||
const shareCopied = ref(false)
|
||||
|
||||
// Serving scale multiplier: 1×, 2×, 3×, 4×
|
||||
const servingScale = ref(1)
|
||||
|
||||
/**
|
||||
* Scale a freeform ingredient string by a multiplier.
|
||||
* Handles integers, decimals, and simple fractions (1/2, 1/4, 3/4, etc.).
|
||||
* Ranges like "2-3" are scaled on both ends.
|
||||
* Returns the original string unchanged if no leading number is found.
|
||||
*/
|
||||
function scaleIngredient(ing: string, scale: number): string {
|
||||
if (scale === 1) return ing
|
||||
|
||||
// Match an optional leading fraction OR decimal OR integer,
|
||||
// optionally followed by a space and another fraction (mixed number like "1 1/2")
|
||||
const numPat = String.raw`(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)`
|
||||
const rangePat = new RegExp(`^${numPat}(?:\\s*-\\s*${numPat})?`)
|
||||
|
||||
const m = ing.match(rangePat)
|
||||
if (!m) return ing
|
||||
|
||||
function parseFrac(s: string): number {
|
||||
const mixed = s.match(/^(\d+)\s+(\d+)\/(\d+)$/)
|
||||
if (mixed) return parseInt(mixed[1]) + parseInt(mixed[2]) / parseInt(mixed[3])
|
||||
const frac = s.match(/^(\d+)\/(\d+)$/)
|
||||
if (frac) return parseInt(frac[1]) / parseInt(frac[2])
|
||||
return parseFloat(s)
|
||||
}
|
||||
|
||||
function fmtNum(n: number): string {
|
||||
// Try to express as a simple fraction for common baking values
|
||||
const fracs: [number, string][] = [
|
||||
[0.125, '1/8'], [0.25, '1/4'], [0.333, '1/3'], [0.5, '1/2'],
|
||||
[0.667, '2/3'], [0.75, '3/4'],
|
||||
]
|
||||
for (const [val, str] of fracs) {
|
||||
if (Math.abs(n - Math.round(n / val) * val) < 0.01 && n < 1) return str
|
||||
}
|
||||
// Mixed numbers
|
||||
const whole = Math.floor(n)
|
||||
const remainder = n - whole
|
||||
if (whole > 0 && remainder > 0.05) {
|
||||
for (const [val, str] of fracs) {
|
||||
if (Math.abs(remainder - val) < 0.05) return `${whole} ${str}`
|
||||
}
|
||||
}
|
||||
// Round to reasonable precision
|
||||
return whole > 0 && remainder < 0.05 ? `${whole}` : n.toFixed(1).replace(/\.0$/, '')
|
||||
}
|
||||
|
||||
const low = parseFrac(m[1])
|
||||
const scaledLow = fmtNum(low * scale)
|
||||
|
||||
let scaled: string
|
||||
if (m[2] !== undefined) {
|
||||
const high = parseFrac(m[2])
|
||||
scaled = `${scaledLow}-${fmtNum(high * scale)}`
|
||||
} else {
|
||||
scaled = scaledLow
|
||||
}
|
||||
|
||||
return scaled + ing.slice(m[0].length)
|
||||
}
|
||||
|
||||
// Shopping: add purchased ingredients to pantry
|
||||
const checkedIngredients = ref<Set<string>>(new Set())
|
||||
const addingToPantry = ref(false)
|
||||
|
|
@ -327,6 +404,7 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
|||
}
|
||||
|
||||
function handleCook() {
|
||||
recipesStore.logCook(props.recipe.id, props.recipe.title)
|
||||
cookDone.value = true
|
||||
emit('cooked', props.recipe)
|
||||
}
|
||||
|
|
@ -445,6 +523,40 @@ function handleCook() {
|
|||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* ── Serving scale row ──────────────────────────────────── */
|
||||
.serving-scale-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.serving-scale-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.serving-scale-btns {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.scale-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.scale-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary, #fff);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Ingredients grid ───────────────────────────────────── */
|
||||
.ingredients-grid {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -169,6 +169,17 @@
|
|||
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
|
||||
</div>
|
||||
|
||||
<!-- Can Make Now toggle -->
|
||||
<div class="form-group">
|
||||
<label class="flex-start gap-sm shopping-toggle">
|
||||
<input type="checkbox" v-model="recipesStore.pantryMatchOnly" :disabled="recipesStore.shoppingMode" />
|
||||
<span class="form-label" style="margin-bottom: 0;">Can make now (no missing ingredients)</span>
|
||||
</label>
|
||||
<p v-if="recipesStore.pantryMatchOnly && !recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
|
||||
Only recipes where every ingredient is in your pantry — no substitutions, no shopping.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shopping Mode (temporary home — moves to Shopping tab in #71) -->
|
||||
<div class="form-group">
|
||||
<label class="flex-start gap-sm shopping-toggle">
|
||||
|
|
@ -180,8 +191,8 @@
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Max Missing — hidden in shopping mode -->
|
||||
<div v-if="!recipesStore.shoppingMode" class="form-group">
|
||||
<!-- Max Missing — hidden in shopping mode or pantry-match-only mode -->
|
||||
<div v-if="!recipesStore.shoppingMode && !recipesStore.pantryMatchOnly" class="form-group">
|
||||
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
|
||||
<input
|
||||
id="max-missing"
|
||||
|
|
@ -359,6 +370,14 @@
|
|||
:aria-pressed="filterMissing === 2"
|
||||
@click="filterMissing = filterMissing === 2 ? null : 2"
|
||||
>≤2 missing</button>
|
||||
<!-- Complexity filter chips (#55 / #58) -->
|
||||
<button
|
||||
v-for="cx in ['easy', 'moderate', 'involved']"
|
||||
:key="cx"
|
||||
:class="['filter-chip', { active: filterComplexity === cx }]"
|
||||
:aria-pressed="filterComplexity === cx"
|
||||
@click="filterComplexity = filterComplexity === cx ? null : cx"
|
||||
>{{ cx }}</button>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
class="filter-chip filter-chip-clear"
|
||||
|
|
@ -366,6 +385,33 @@
|
|||
@click="clearFilters"
|
||||
><span aria-hidden="true">✕</span> Clear</button>
|
||||
</div>
|
||||
|
||||
<!-- Zero-decision picks (#53 Surprise Me / #57 Just Pick One) -->
|
||||
<div v-if="filteredSuggestions.length > 0" class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
|
||||
<button class="btn btn-secondary btn-sm" @click="pickSurprise" :disabled="filteredSuggestions.length === 0">
|
||||
🎲 Surprise me
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="pickBest" :disabled="filteredSuggestions.length === 0">
|
||||
⚡ Just pick one
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spotlight (Surprise Me / Just Pick One result) -->
|
||||
<div v-if="spotlightRecipe" class="card spotlight-card slide-up mb-md">
|
||||
<div class="flex-between mb-sm">
|
||||
<h3 class="text-lg font-bold">{{ spotlightRecipe.title }}</h3>
|
||||
<div class="flex gap-xs" style="align-items:center">
|
||||
<span v-if="spotlightRecipe.complexity" :class="['status-badge', `complexity-${spotlightRecipe.complexity}`]">{{ spotlightRecipe.complexity }}</span>
|
||||
<span v-if="spotlightRecipe.estimated_time_min" class="status-badge status-neutral">~{{ spotlightRecipe.estimated_time_min }}m</span>
|
||||
<button class="btn-icon" @click="spotlightRecipe = null" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-secondary mb-xs">{{ spotlightRecipe.match_count }} ingredients matched from your pantry</p>
|
||||
<button class="btn btn-primary btn-sm" @click="selectedRecipe = spotlightRecipe; spotlightRecipe = null">
|
||||
Cook this
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm ml-sm" @click="pickSurprise">Try another</button>
|
||||
</div>
|
||||
|
||||
<!-- No suggestions -->
|
||||
|
|
@ -392,6 +438,8 @@
|
|||
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
|
||||
<div class="flex flex-wrap gap-xs" style="align-items:center">
|
||||
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
|
||||
<span v-if="recipe.complexity" :class="['status-badge', `complexity-${recipe.complexity}`]">{{ recipe.complexity }}</span>
|
||||
<span v-if="recipe.estimated_time_min" class="status-badge status-neutral">~{{ recipe.estimated_time_min }}m</span>
|
||||
<span class="status-badge status-info">Level {{ recipe.level }}</span>
|
||||
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
|
||||
<button
|
||||
|
|
@ -728,6 +776,8 @@ const selectedRecipe = ref<RecipeSuggestion | null>(null)
|
|||
const filterText = ref('')
|
||||
const filterLevel = ref<number | null>(null)
|
||||
const filterMissing = ref<number | null>(null)
|
||||
const filterComplexity = ref<string | null>(null)
|
||||
const spotlightRecipe = ref<RecipeSuggestion | null>(null)
|
||||
|
||||
const availableLevels = computed(() => {
|
||||
if (!recipesStore.result) return []
|
||||
|
|
@ -751,17 +801,35 @@ const filteredSuggestions = computed(() => {
|
|||
if (filterMissing.value !== null) {
|
||||
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
|
||||
}
|
||||
if (filterComplexity.value !== null) {
|
||||
items = items.filter((r) => r.complexity === filterComplexity.value)
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
|
||||
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null || filterComplexity.value !== null
|
||||
)
|
||||
|
||||
function clearFilters() {
|
||||
filterText.value = ''
|
||||
filterLevel.value = null
|
||||
filterMissing.value = null
|
||||
filterComplexity.value = null
|
||||
}
|
||||
|
||||
function pickSurprise() {
|
||||
const pool = filteredSuggestions.value
|
||||
if (!pool.length) return
|
||||
const exclude = spotlightRecipe.value?.id
|
||||
const candidates = pool.length > 1 ? pool.filter((r) => r.id !== exclude) : pool
|
||||
spotlightRecipe.value = candidates[Math.floor(Math.random() * candidates.length)]
|
||||
}
|
||||
|
||||
function pickBest() {
|
||||
const pool = filteredSuggestions.value
|
||||
if (!pool.length) return
|
||||
spotlightRecipe.value = pool[0]
|
||||
}
|
||||
|
||||
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
|
||||
|
|
@ -1461,6 +1529,11 @@ details[open] .collapsible-summary::before {
|
|||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
}
|
||||
|
||||
.spotlight-card {
|
||||
border: 2px solid var(--color-primary);
|
||||
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, rgba(232, 168, 32, 0.06) 100%);
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@
|
|||
>{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Last cooked hint -->
|
||||
<div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-hint text-xs text-muted mt-xs">
|
||||
{{ lastCookedLabel(recipe.recipe_id) }}
|
||||
</div>
|
||||
|
||||
<!-- Notes preview with expand/collapse -->
|
||||
<div v-if="recipe.notes" class="mt-xs">
|
||||
<div
|
||||
|
|
@ -146,6 +151,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import type { SavedRecipe } from '../services/api'
|
||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||
|
||||
|
|
@ -155,7 +161,24 @@ const emit = defineEmits<{
|
|||
}>()
|
||||
|
||||
const store = useSavedRecipesStore()
|
||||
const recipesStore = useRecipesStore()
|
||||
const editingRecipe = ref<SavedRecipe | null>(null)
|
||||
|
||||
function lastCookedLabel(recipeId: number): string | null {
|
||||
const entries = recipesStore.cookLog.filter((e) => e.id === recipeId)
|
||||
if (entries.length === 0) return null
|
||||
const latestMs = Math.max(...entries.map((e) => e.cookedAt))
|
||||
const diffMs = Date.now() - latestMs
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
if (diffDays === 0) return 'Last made: today'
|
||||
if (diffDays === 1) return 'Last made: yesterday'
|
||||
if (diffDays < 7) return `Last made: ${diffDays} days ago`
|
||||
if (diffDays < 14) return 'Last made: 1 week ago'
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
if (diffDays < 60) return `Last made: ${diffWeeks} weeks ago`
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
return `Last made: ${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`
|
||||
}
|
||||
const showNewCollection = ref(false)
|
||||
|
||||
// #44: two-step remove confirmation
|
||||
|
|
@ -340,6 +363,11 @@ async function createCollection() {
|
|||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.last-cooked-hint {
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
|
|
|||
|
|
@ -91,13 +91,33 @@ export interface InventoryItem {
|
|||
sublocation: string | null
|
||||
purchase_date: string | null
|
||||
expiration_date: string | null
|
||||
opened_date: string | null
|
||||
opened_expiry_date: 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
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface BarcodeScanResponse {
|
||||
success: boolean
|
||||
barcodes_found: number
|
||||
results: BarcodeScanResult[]
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface InventoryItemUpdate {
|
||||
quantity?: number
|
||||
unit?: string
|
||||
|
|
@ -222,7 +242,7 @@ export const inventoryAPI = {
|
|||
location: string = 'pantry',
|
||||
quantity: number = 1.0,
|
||||
autoAdd: boolean = true
|
||||
): Promise<any> {
|
||||
): Promise<BarcodeScanResponse> {
|
||||
const response = await api.post('/inventory/scan/text', {
|
||||
barcode,
|
||||
location,
|
||||
|
|
@ -233,10 +253,29 @@ export const inventoryAPI = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Mark item as consumed
|
||||
* Mark item as consumed fully or partially.
|
||||
* Pass quantity to decrement; omit to consume all.
|
||||
*/
|
||||
async consumeItem(itemId: number): Promise<void> {
|
||||
await api.post(`/inventory/items/${itemId}/consume`)
|
||||
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
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
@ -456,6 +495,8 @@ export interface RecipeSuggestion {
|
|||
is_wildcard: boolean
|
||||
nutrition: NutritionPanel | null
|
||||
source_url: string | null
|
||||
complexity: 'easy' | 'moderate' | 'involved' | null
|
||||
estimated_time_min: number | null
|
||||
}
|
||||
|
||||
export interface NutritionFilters {
|
||||
|
|
@ -494,6 +535,9 @@ export interface RecipeRequest {
|
|||
nutrition_filters: NutritionFilters
|
||||
excluded_ids: number[]
|
||||
shopping_mode: boolean
|
||||
pantry_match_only: boolean
|
||||
complexity_filter: string | null
|
||||
max_time_min: number | null
|
||||
}
|
||||
|
||||
export interface Staple {
|
||||
|
|
@ -541,7 +585,8 @@ export interface BuildRequest {
|
|||
|
||||
export const recipesAPI = {
|
||||
async suggest(req: RecipeRequest): Promise<RecipeResult> {
|
||||
const response = await api.post('/recipes/suggest', req)
|
||||
// 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
|
||||
},
|
||||
async getRecipe(id: number): Promise<RecipeSuggestion> {
|
||||
|
|
@ -782,6 +827,11 @@ export const mealPlanAPI = {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,20 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-select the best available plan without a round-trip.
|
||||
* Prefers the plan whose week_start matches preferredWeekStart (current week's Monday).
|
||||
* Falls back to the first plan in the list (most recent, since list is DESC).
|
||||
* No-ops if a plan is already active or no plans exist.
|
||||
*/
|
||||
function autoSelectPlan(preferredWeekStart?: string) {
|
||||
if (activePlan.value || plans.value.length === 0) return
|
||||
const match = preferredWeekStart
|
||||
? (plans.value.find(p => p.week_start === preferredWeekStart) ?? plans.value[0])
|
||||
: plans.value[0]
|
||||
if (match) activePlan.value = match ?? null
|
||||
}
|
||||
|
||||
async function createPlan(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
|
||||
const plan = await mealPlanAPI.create(weekStart, mealTypes)
|
||||
plans.value = [plan, ...plans.value]
|
||||
|
|
@ -110,6 +124,14 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function addMealType(mealType: string): Promise<void> {
|
||||
if (!activePlan.value) return
|
||||
const current = activePlan.value.meal_types
|
||||
if (current.includes(mealType)) return
|
||||
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, [...current, mealType])
|
||||
activePlan.value = updated
|
||||
}
|
||||
|
||||
async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
|
||||
if (!activePlan.value || !prepSession.value) return
|
||||
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
|
||||
|
|
@ -129,7 +151,7 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
|||
return {
|
||||
plans, activePlan, shoppingList, prepSession,
|
||||
loading, shoppingListLoading, prepLoading, slots,
|
||||
getSlot, loadPlans, createPlan, setActivePlan,
|
||||
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
||||
getSlot, loadPlans, autoSelectPlan, createPlan, setActivePlan,
|
||||
addMealType, upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -132,6 +132,9 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
const category = ref<string | null>(null)
|
||||
const wildcardConfirmed = ref(false)
|
||||
const shoppingMode = ref(false)
|
||||
const pantryMatchOnly = ref(false)
|
||||
const complexityFilter = ref<string | null>(null)
|
||||
const maxTimeMin = ref<number | null>(null)
|
||||
const nutritionFilters = ref<NutritionFilters>({
|
||||
max_calories: null,
|
||||
max_sugar_g: null,
|
||||
|
|
@ -176,6 +179,9 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
nutrition_filters: nutritionFilters.value,
|
||||
excluded_ids: [...excluded],
|
||||
shopping_mode: shoppingMode.value,
|
||||
pantry_match_only: pantryMatchOnly.value,
|
||||
complexity_filter: complexityFilter.value,
|
||||
max_time_min: maxTimeMin.value,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,6 +312,9 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
category,
|
||||
wildcardConfirmed,
|
||||
shoppingMode,
|
||||
pantryMatchOnly,
|
||||
complexityFilter,
|
||||
maxTimeMin,
|
||||
nutritionFilters,
|
||||
dismissedIds,
|
||||
dismissedCount,
|
||||
|
|
|
|||
|
|
@ -649,6 +649,31 @@
|
|||
border: 1px solid var(--color-info-border);
|
||||
}
|
||||
|
||||
.status-neutral {
|
||||
background: rgba(255, 248, 235, 0.06);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Recipe complexity badges */
|
||||
.complexity-easy {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success-light);
|
||||
border: 1px solid var(--color-success-border);
|
||||
}
|
||||
|
||||
.complexity-moderate {
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-light);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
}
|
||||
|
||||
.complexity-involved {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error-light);
|
||||
border: 1px solid var(--color-error-border);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ANIMATION UTILITIES
|
||||
============================================ */
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "kiwi"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Pantry tracking + leftover recipe suggestions"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
Loading…
Reference in a new issue