Compare commits

...

12 commits

Author SHA1 Message Date
6aa63cf2f0 chore: bump version to 0.3.0
Some checks failed
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-16 14:24:16 -07:00
e745ce4375 feat: wire meal planner slot editor and meal type picker
Slot click now opens an inline editor panel:
- Pick from saved recipes via dropdown (pre-loaded on mount)
- Or type a custom label
- Clear slot button when a slot is already filled
- Save/Cancel with loading state

Add meal type opens a chip picker showing the types not yet active
(breakfast / lunch / snack minus whatever is already on the plan).
Selecting one calls the new PATCH /meal-plans/{plan_id} endpoint.

Backend:
- PATCH /meal-plans/{plan_id} with UpdatePlanRequest(meal_types)
- store.update_meal_plan_types() UPDATE ... RETURNING *
- 409 on IntegrityError in create_plan (already in place)
2026-04-16 14:23:38 -07:00
de0008f5c7 fix: meal planner auto-selects current week on load, + New week idempotent
- Add autoSelectPlan() to the store: after loadPlans() resolves, set
  activePlan to the current week's plan (or most recent) without a second
  API round-trip -- list already returns full PlanSummary with slots
- Call autoSelectPlan(mondayOfCurrentWeek()) in onMounted so the grid
  populates immediately without the user touching the dropdown
- Make onNewPlan idempotent: if a 409 comes back, activate the existing
  plan for that week instead of surfacing an error to the user
2026-04-16 10:50:34 -07:00
dbaf2b6ac8 fix: meal planner week add button crashing on r.name / add duplicate guard
- Fix sqlite3.OperationalError: the recipes table uses `title` not `name`;
  get_plan_slots JOIN was crashing every list_plans call with a 500,
  making the + New week button appear broken (plans were being created
  silently but the selector refresh always failed)
- Add migration 032 to add UNIQUE INDEX on meal_plans(week_start)
  to prevent duplicate plans accumulating while the button was broken
- Raise HTTP 409 on IntegrityError in create_plan so duplicates produce
  a clear error instead of a 500
- Fix mondayOfCurrentWeek to build the date string from local date parts
  instead of toISOString(), which converts through UTC and can produce the
  wrong calendar day for UTC+ timezones
- Add planCreating/planError state to MealPlanView so button shows
  "Creating..." during the request and displays errors inline
2026-04-16 10:46:28 -07:00
9a277f9b42 fix: barcode scan performance + timeout + success message
- Refactor _lookup_in_database to accept a shared httpx.AsyncClient so
  all three Open*Facts database attempts reuse one TLS connection instead
  of opening a new one per call; restores pre-fallback scan speed
- Increase recipe suggest timeout to 120s (was 30s) to survive cf-orch
  model cold-start on first request of a session
- Include product brand in barcode scan success message so the user can
  clearly see what was found (e.g. "Added: Cheerios (General Mills) to pantry")
2026-04-16 09:57:53 -07:00
200a6ef87b feat(recipes): complexity badges, time hints, Surprise Me, Just Pick One
#55 — Complexity rating on recipe cards:
  - Derived from direction text via _classify_method_complexity()
  - Badge displayed on every card: easy (green), moderate (amber), involved (red)
  - Filterable via complexity filter chips in the results bar

#58 — Cooking time + difficulty as filter domains:
  - estimated_time_min derived from step count + complexity
  - Time hint (~Nm) shown on every card
  - complexity_filter and max_time_min fields in RecipeRequest
  - Both applied in the engine before suggestions are built

#53 — Surprise Me: picks a random suggestion from the filtered pool,
  avoids repeating the last pick. Shown in a spotlight card.

#57 — Just Pick One: surfaces the top-matched suggestion in the same
  spotlight card. One tap to commit to cooking it.

Closes #55, #58, #53, #57
2026-04-16 09:27:34 -07:00
c8fdc21c29 feat(export): JSON full-backup download (pantry + saved recipes)
Adds GET /export/json that bundles inventory and saved recipes into a
single timestamped JSON file for data portability. The export envelope
includes schema version and export timestamp so future import logic can
handle version differences.

Frontend: new primary-styled JSON download button in the Export card with
a short description of what is included.

Closes #62
2026-04-16 09:16:33 -07:00
2ad71f2636 feat(recipes): pantry match floor filter — 'can make now' toggle
Adds pantry_match_only flag to RecipeRequest. When enabled, any recipe
with one or more missing ingredients (after swaps) is excluded from
results. Swapped ingredients count as covered.

Frontend: toggle checkbox in recipe settings panel, disabled when
shopping mode is active (the two modes are mutually exclusive). Hides
the max-missing input when pantry-match-only is on (irrelevant there).

Closes #63
2026-04-16 09:12:24 -07:00
0de6182f48 feat(scan): barcode miss fallback chain — Open Beauty Facts + Open Products Facts
When a barcode is not found in Open Food Facts, the service now tries
Open Beauty Facts and Open Products Facts before giving up. All three
share the same API format; only the host URL differs.

When all databases miss, the scan endpoint sets needs_manual_entry=true
in the result. The frontend detects this, shows a calm informational
message, and switches to manual entry mode automatically.

Also fixes a latent bug where not-found scans showed 'Added: item to
pantry' due to the success condition checking barcodes_found (always 1)
instead of added_to_inventory.

Closes #65
2026-04-16 08:30:49 -07:00
fb18a9c78c feat: partial consumption tracking and waste/disposal logging (#12 #60)
#12 — partial consume:
- POST /inventory/items/{id}/consume now accepts optional {quantity}
  body; decrements by that amount and only marks status=consumed when
  quantity reaches zero (store.partial_consume_item)
- OFFs barcode scan pre-fills sub-unit quantity when product data
  includes a pack size (number_of_units or 'N x ...' quantity string)
- Consume button shows quantity-aware label and opens ActionDialog
  with number input for multi-unit items ('use some or all')
- consumeItem() in api.ts now returns InventoryItem and accepts
  optional quantity param

#60 — disposal logging:
- Migration 031: adds disposal_reason TEXT column to inventory_items
  (status='discarded' was already in the CHECK constraint)
- POST /inventory/items/{id}/discard endpoint with optional DiscardRequest
  body (free text or preset reason)
- Calm framing: 'item not used' not 'wasted'; reason presets avoid
  blame language ('went bad before I could use it', 'too much — had excess')
- Muted discard button (X icon, tertiary color) — not alarming

Shared:
- New ActionDialog.vue component for dialogs with inline inputs
  (quantity stepper or reason dropdown); keeps ConfirmDialog simple
- disposal_reason field added to InventoryItemResponse

Closes #12
Closes #60
2026-04-16 07:28:21 -07:00
443e68ba3f fix: wire recipe engine to cf-text service instead of vllm
Aligns llm_recipe.py with the pattern already used by the meal plan
service. cf-text routes through a lighter GGUF/llama.cpp path and
shares VRAM budget with other products via cf-orch, rather than
requiring a dedicated vLLM process. Also drops model_candidates
(not applicable to cf-text allocation).

Closes #70
2026-04-16 06:25:46 -07:00
64a0abebe3 feat: pantry intel cluster — #61 expiry display, #64 cook log, #66 scaling, #59 open-package tracking
#61: expiry badge now shows relative + calendar date ("5d · Apr 15") with
tooltip "Expires in 5 days (Apr 15)"; traffic-light colors already in place

#64: RecipeDetailPanel.handleCook() calls recipesStore.logCook(); SavedRecipesPanel
shows "Last made: X ago" below each card using cookLog entries

#66: Serving multiplier (1x/2x/3x/4x) in RecipeDetailPanel scales ingredient
quantities using regex; handles integers, decimals, fractions (1/2, 3/4),
mixed numbers (1 1/2), and ranges (2-3); leaves unrecognised strings unchanged

#59: migration 030 adds opened_date column; ExpirationPredictor gains
SHELF_LIFE_AFTER_OPENING table + days_after_opening(); POST /inventory/items/{id}/open
sets opened_date=today and returns computed opened_expiry_date; InventoryList
shows lock-open button for unopened items and an "📂 5d · Apr 15" badge once opened
2026-04-16 06:01:25 -07:00
25 changed files with 1435 additions and 122 deletions

View file

@ -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 from __future__ import annotations
import asyncio import asyncio
import csv import csv
import io import io
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -45,3 +47,33 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
media_type="text/csv", media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=inventory.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}"},
)

View file

@ -13,16 +13,21 @@ from pydantic import BaseModel
from app.cloud_session import CloudUser, get_session from app.cloud_session import CloudUser, get_session
from app.db.session import get_store from app.db.session import get_store
from app.services.expiration_predictor import ExpirationPredictor
_predictor = ExpirationPredictor()
from app.db.store import Store from app.db.store import Store
from app.models.schemas.inventory import ( from app.models.schemas.inventory import (
BarcodeScanResponse, BarcodeScanResponse,
BulkAddByNameRequest, BulkAddByNameRequest,
BulkAddByNameResponse, BulkAddByNameResponse,
BulkAddItemResult, BulkAddItemResult,
DiscardRequest,
InventoryItemCreate, InventoryItemCreate,
InventoryItemResponse, InventoryItemResponse,
InventoryItemUpdate, InventoryItemUpdate,
InventoryStats, InventoryStats,
PartialConsumeRequest,
ProductCreate, ProductCreate,
ProductResponse, ProductResponse,
ProductUpdate, ProductUpdate,
@ -33,6 +38,25 @@ from app.models.schemas.inventory import (
router = APIRouter() 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 ────────────────────────────────────────────────────────────────── # ── Products ──────────────────────────────────────────────────────────────────
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED) @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), store: Store = Depends(get_store),
): ):
items = await asyncio.to_thread(store.list_inventory, location, item_status) 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]) @router.get("/items/expiring", response_model=List[InventoryItemResponse])
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)): async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
items = await asyncio.to_thread(store.expiring_soon, days) 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) @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) item = await asyncio.to_thread(store.get_inventory_item, item_id)
if not item: if not item:
raise HTTPException(status_code=404, detail="Inventory item not found") 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) @router.patch("/items/{item_id}", response_model=InventoryItemResponse)
@ -194,24 +218,79 @@ async def update_inventory_item(
updates["purchase_date"] = str(updates["purchase_date"]) updates["purchase_date"] = str(updates["purchase_date"])
if "expiration_date" in updates and updates["expiration_date"]: if "expiration_date" in updates and updates["expiration_date"]:
updates["expiration_date"] = str(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) item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
if not item: if not item:
raise HTTPException(status_code=404, detail="Inventory item not found") 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) @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 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( item = await asyncio.to_thread(
store.update_inventory_item, store.update_inventory_item,
item_id, item_id,
status="consumed", status="consumed",
consumed_at=datetime.now(timezone.utc).isoformat(), consumed_at=now,
) )
if not item: if not item:
raise HTTPException(status_code=404, detail="Inventory item not found") 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) @router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
@ -267,10 +346,14 @@ async def scan_barcode_text(
tier=session.tier, tier=session.tier,
has_byok=session.has_byok, 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( inventory_item = await asyncio.to_thread(
store.add_inventory_item, store.add_inventory_item,
product["id"], body.location, product["id"], body.location,
quantity=body.quantity, quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None, expiration_date=str(exp) if exp else None,
source="barcode_scan", source="barcode_scan",
) )
@ -278,6 +361,7 @@ async def scan_barcode_text(
else: else:
result_product = None result_product = None
product_found = product_info is not None
return BarcodeScanResponse( return BarcodeScanResponse(
success=True, success=True,
barcodes_found=1, barcodes_found=1,
@ -287,7 +371,8 @@ async def scan_barcode_text(
"product": result_product, "product": result_product,
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None, "inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
"added_to_inventory": inventory_item is not 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", message="Barcode processed",
) )
@ -345,10 +430,13 @@ async def scan_barcode_image(
tier=session.tier, tier=session.tier,
has_byok=session.has_byok, 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( inventory_item = await asyncio.to_thread(
store.add_inventory_item, store.add_inventory_item,
product["id"], location, product["id"], location,
quantity=quantity, quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None, expiration_date=str(exp) if exp else None,
source="barcode_scan", source="barcode_scan",
) )

View file

@ -19,6 +19,7 @@ from app.models.schemas.meal_plan import (
PrepTaskSummary, PrepTaskSummary,
ShoppingListResponse, ShoppingListResponse,
SlotSummary, SlotSummary,
UpdatePlanRequest,
UpdatePrepTaskRequest, UpdatePrepTaskRequest,
UpsertSlotRequest, UpsertSlotRequest,
VALID_MEAL_TYPES, VALID_MEAL_TYPES,
@ -81,13 +82,21 @@ async def create_plan(
session: CloudUser = Depends(get_session), session: CloudUser = Depends(get_session),
store: Store = Depends(get_store), store: Store = Depends(get_store),
) -> PlanSummary: ) -> PlanSummary:
import sqlite3
# Free tier is locked to dinner-only; paid+ may configure meal types # Free tier is locked to dinner-only; paid+ may configure meal types
if can_use("meal_plan_config", session.tier): 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"] meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
else: else:
meal_types = ["dinner"] meal_types = ["dinner"]
try:
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types) 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"]) slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
return _plan_summary(plan, slots) return _plan_summary(plan, slots)
@ -105,6 +114,28 @@ async def list_plans(
return result 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) @router.get("/{plan_id}", response_model=PlanSummary)
async def get_plan( async def get_plan(
plan_id: int, plan_id: int,

View 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;

View 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;

View 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);

View file

@ -218,7 +218,8 @@ class Store:
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None: def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
allowed = {"quantity", "unit", "location", "sublocation", 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} updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates: if not updates:
return self.get_inventory_item(item_id) return self.get_inventory_item(item_id)
@ -231,6 +232,32 @@ class Store:
self.conn.commit() self.conn.commit()
return self.get_inventory_item(item_id) 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]]: def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
return self._fetch_all( return self._fetch_all(
"""SELECT i.*, p.name as product_name, p.category """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: def get_meal_plan(self, plan_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,)) 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]: def list_meal_plans(self) -> list[dict]:
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC") 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]: def get_plan_slots(self, plan_id: int) -> list[dict]:
return self._fetch_all( return self._fetch_all(
"""SELECT s.*, r.name AS recipe_title """SELECT s.*, r.title AS recipe_title
FROM meal_plan_slots s FROM meal_plan_slots s
LEFT JOIN recipes r ON r.id = s.recipe_id LEFT JOIN recipes r ON r.id = s.recipe_id
WHERE s.plan_id = ? WHERE s.plan_id = ?

View file

@ -90,8 +90,18 @@ class InventoryItemUpdate(BaseModel):
location: Optional[str] = None location: Optional[str] = None
sublocation: Optional[str] = None sublocation: Optional[str] = None
expiration_date: Optional[date] = None expiration_date: Optional[date] = None
opened_date: Optional[date] = None
status: Optional[str] = None status: Optional[str] = None
notes: 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): class InventoryItemResponse(BaseModel):
@ -106,8 +116,11 @@ class InventoryItemResponse(BaseModel):
sublocation: Optional[str] sublocation: Optional[str]
purchase_date: Optional[str] purchase_date: Optional[str]
expiration_date: Optional[str] expiration_date: Optional[str]
opened_date: Optional[str] = None
opened_expiry_date: Optional[str] = None
status: str status: str
notes: Optional[str] notes: Optional[str]
disposal_reason: Optional[str] = None
source: str source: str
created_at: str created_at: str
updated_at: str updated_at: str
@ -123,6 +136,7 @@ class BarcodeScanResult(BaseModel):
product: Optional[ProductResponse] product: Optional[ProductResponse]
inventory_item: Optional[InventoryItemResponse] inventory_item: Optional[InventoryItemResponse]
added_to_inventory: bool added_to_inventory: bool
needs_manual_entry: bool = False
message: str message: str

View file

@ -22,6 +22,10 @@ class CreatePlanRequest(BaseModel):
return v return v
class UpdatePlanRequest(BaseModel):
meal_types: list[str]
class UpsertSlotRequest(BaseModel): class UpsertSlotRequest(BaseModel):
recipe_id: int | None = None recipe_id: int | None = None
servings: float = Field(2.0, gt=0) servings: float = Field(2.0, gt=0)

View file

@ -41,6 +41,8 @@ class RecipeSuggestion(BaseModel):
is_wildcard: bool = False is_wildcard: bool = False
nutrition: NutritionPanel | None = None nutrition: NutritionPanel | None = None
source_url: str | 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): class GroceryLink(BaseModel):
@ -83,6 +85,9 @@ class RecipeRequest(BaseModel):
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters) nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = Field(default_factory=list) excluded_ids: list[int] = Field(default_factory=list)
shopping_mode: bool = False 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" unit_system: str = "metric" # "metric" | "imperial"

View file

@ -116,6 +116,53 @@ class ExpirationPredictor:
'prepared_foods': {'fridge': 4, 'freezer': 90}, '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. # Keyword lists are checked in declaration order — most specific first.
# Rules: # Rules:
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken) # - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)

View file

@ -15,63 +15,72 @@ logger = logging.getLogger(__name__)
class OpenFoodFactsService: 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 Primary: OpenFoodFacts (food products).
ingredients, allergens, and nutrition facts. 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" BASE_URL = "https://world.openfoodfacts.org/api/v2"
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)" 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]]: 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: Args:
barcode: UPC/EAN barcode (8-13 digits) barcode: UPC/EAN barcode (8-13 digits)
Returns: Returns:
Dictionary with product information, or None if not found Dictionary with product information, or None if not found in any database.
Example response:
{
"name": "Organic Milk",
"brand": "Horizon",
"categories": ["Dairy", "Milk"],
"image_url": "https://...",
"nutrition_data": {...},
"raw_data": {...} # Full API response
}
""" """
try:
async with httpx.AsyncClient() as client: 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( for db_url in self._FALLBACK_DATABASES:
url, result = await self._lookup_in_database(barcode, db_url, client)
headers={"User-Agent": self.USER_AGENT}, if result:
timeout=10.0, logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
) return result
if response.status_code == 404: logger.info("Barcode %s not found in any Open*Facts database", barcode)
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}")
return None return None
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]: 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", []) allergens = product.get("allergens_tags", [])
labels = product.get("labels_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 { return {
"name": name, "name": name,
"brand": brand, "brand": brand,
@ -124,9 +136,47 @@ class OpenFoodFactsService:
"nutrition_data": nutrition_data, "nutrition_data": nutrition_data,
"allergens": allergens, "allergens": allergens,
"labels": labels, "labels": labels,
"pack_quantity": pack_quantity,
"pack_unit": pack_unit,
"raw_data": product, # Store full response for debugging "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]: def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
""" """
Extract nutrition facts from product data. Extract nutrition facts from product data.

View file

@ -143,12 +143,14 @@ class LLMRecipeGenerator:
return "\n".join(lines) 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): def _get_llm_context(self):
"""Return a sync context manager that yields an Allocation or None. """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) (which handles service lifecycle and VRAM). Falls back to nullcontext(None)
when the env var is absent or CFOrchClient raises on construction. when the env var is absent or CFOrchClient raises on construction.
""" """
@ -158,10 +160,9 @@ class LLMRecipeGenerator:
from circuitforge_orch.client import CFOrchClient from circuitforge_orch.client import CFOrchClient
client = CFOrchClient(cf_orch_url) client = CFOrchClient(cf_orch_url)
return client.allocate( return client.allocate(
service="vllm", service=self._SERVICE_TYPE,
model_candidates=self._MODEL_CANDIDATES, ttl_s=self._TTL_S,
ttl_s=300.0, caller=self._CALLER,
caller="kiwi-recipe",
) )
except Exception as exc: except Exception as exc:
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc) logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)

View file

@ -562,6 +562,19 @@ def _hard_day_sort_tier(
return 2 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( def _classify_method_complexity(
directions: list[str], directions: list[str],
available_equipment: list[str] | None = None, 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: if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
continue 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 # 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 # ingredients are in the pantry. Prevents low-signal results like a
# 10-ingredient recipe matching on only one common item. # 10-ingredient recipe matching on only one common item.
@ -707,16 +725,21 @@ class RecipeEngine:
if match_ratio < _L1_MIN_MATCH_RATIO: if match_ratio < _L1_MIN_MATCH_RATIO:
continue continue
# Filter and tier-rank by hard_day_mode # Parse directions — needed for complexity, hard_day_mode, and time estimate.
if req.hard_day_mode:
directions: list[str] = row.get("directions") or [] directions: list[str] = row.get("directions") or []
if isinstance(directions, str): if isinstance(directions, str):
try: try:
directions = json.loads(directions) directions = json.loads(directions)
except Exception: except Exception:
directions = [directions] 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 continue
hard_day_tier_map[row["id"]] = _hard_day_sort_tier( hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
title=row.get("title", ""), title=row.get("title", ""),
@ -724,6 +747,14 @@ class RecipeEngine:
directions=directions, 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 # Level 2: also add dietary constraint swaps from substitution_pairs
if req.level == 2 and req.constraints: if req.level == 2 and req.constraints:
for ing in ingredient_names: for ing in ingredient_names:
@ -773,6 +804,8 @@ class RecipeEngine:
level=req.level, level=req.level,
nutrition=nutrition if has_nutrition else None, nutrition=nutrition if has_nutrition else None,
source_url=_build_source_url(row), 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. # Sort corpus results — assembly templates are now served from a dedicated tab.

View 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>

View file

@ -323,9 +323,16 @@
<div class="inv-row-right"> <div class="inv-row-right">
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span> <span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<!-- Opened expiry takes priority over sell-by date -->
<span <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)]" :class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
:title="formatDateFull(item.expiration_date)"
>{{ formatDateShort(item.expiration_date) }}</span> >{{ formatDateShort(item.expiration_date) }}</span>
<div class="inv-actions"> <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"/> <path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
</svg> </svg>
</button> </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"> <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"/> <polyline points="4 10 8 14 16 6"/>
</svg> </svg>
</button> </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"> <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"> <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"/> <polyline points="3 6 5 6 17 6"/>
@ -355,10 +392,14 @@
<!-- Export --> <!-- Export -->
<div class="card export-card"> <div class="card export-card">
<h2 class="section-title">Export</h2> <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="exportCSV" class="btn btn-secondary">Download CSV</button>
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button> <button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
</div> </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> </div>
<!-- Edit Modal --> <!-- Edit Modal -->
@ -380,6 +421,22 @@
@cancel="confirmDialog.show = false" @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 --> <!-- Toast Notification -->
<ToastNotification <ToastNotification
:show="toast.show" :show="toast.show"
@ -401,6 +458,7 @@ import type { InventoryItem } from '../services/api'
import { formatQuantity } from '../utils/units' import { formatQuantity } from '../utils/units'
import EditItemModal from './EditItemModal.vue' import EditItemModal from './EditItemModal.vue'
import ConfirmDialog from './ConfirmDialog.vue' import ConfirmDialog from './ConfirmDialog.vue'
import ActionDialog from './ActionDialog.vue'
import ToastNotification from './ToastNotification.vue' import ToastNotification from './ToastNotification.vue'
const store = useInventoryStore() const store = useInventoryStore()
@ -446,6 +504,20 @@ const confirmDialog = reactive({
onConfirm: () => {}, 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 // Toast Notification
const toast = reactive({ const toast = reactive({
show: false, show: false,
@ -555,24 +627,75 @@ async function confirmDelete(item: InventoryItem) {
) )
} }
async function markAsConsumed(item: InventoryItem) { async function markAsOpened(item: InventoryItem) {
showConfirm(
`Mark ${item.product_name || 'item'} as consumed?`,
async () => {
try { try {
await inventoryAPI.consumeItem(item.id) await inventoryAPI.openItem(item.id)
await refreshItems() await refreshItems()
showToast(`${item.product_name || 'item'} marked as consumed`, 'success') showToast(`${item.product_name || 'Item'} marked as opened — tracking freshness`, 'info')
} catch (err) { } catch {
showToast('Failed to mark item as consumed', 'error') 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 // Scanner Gun Functions
@ -591,13 +714,22 @@ async function handleScannerGunInput() {
true true
) )
if (result.success && result.barcodes_found > 0) {
const item = result.results[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({ scannerResults.value.push({
type: 'success', type: 'success',
message: `Added: ${item.product_name || 'item'} to ${scannerLocation.value}`, message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
}) })
await refreshItems() 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 { } else {
scannerResults.value.push({ scannerResults.value.push({
type: 'error', type: 'error',
@ -612,7 +744,7 @@ async function handleScannerGunInput() {
} finally { } finally {
scannerLoading.value = false scannerLoading.value = false
scannerBarcode.value = '' 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') window.open(`${apiUrl}/export/inventory/excel`, '_blank')
} }
// Short date for compact row display function exportJSON() {
function formatDateShort(dateStr: string): string { 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 date = new Date(dateStr)
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
const expiry = new Date(dateStr) const expiry = new Date(dateStr)
expiry.setHours(0, 0, 0, 0) expiry.setHours(0, 0, 0, 0)
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) 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 `${Math.abs(diffDays)}d ago`
if (diffDays === 0) return 'today' if (diffDays === 0) return 'today'
if (diffDays === 1) return 'tmrw' if (diffDays === 1) return `tmrw · ${cal}`
if (diffDays <= 14) return `${diffDays}d` if (diffDays <= 14) return `${diffDays}d · ${cal}`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) return cal
} }
function getExpiryBadgeClass(expiryStr: string): string { function getExpiryBadgeClass(expiryStr: string): string {
@ -1147,6 +1299,30 @@ function getItemClass(item: InventoryItem): string {
text-decoration: line-through; 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 */ /* Action icons inline */
.inv-actions { .inv-actions {
display: flex; display: flex;
@ -1220,6 +1396,12 @@ function getItemClass(item: InventoryItem): string {
border: 1px solid var(--color-info-border); 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 EXPORT CARD
============================================ */ ============================================ */

View file

@ -14,8 +14,11 @@
Week of {{ p.week_start }} Week of {{ p.week_start }}
</option> </option>
</select> </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> </div>
<p v-if="planError" class="plan-error">{{ planError }}</p>
<template v-if="activePlan"> <template v-if="activePlan">
<!-- Compact expandable week grid (always visible) --> <!-- Compact expandable week grid (always visible) -->
@ -26,6 +29,70 @@
@add-meal-type="onAddMealType" @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 --> <!-- Panel tabs: Shopping List | Prep Schedule -->
<div class="panel-tabs" role="tablist" aria-label="Plan outputs"> <div class="panel-tabs" role="tablist" aria-label="Plan outputs">
<button <button
@ -64,7 +131,9 @@
<div v-else-if="!loading" class="empty-plan-state"> <div v-else-if="!loading" class="empty-plan-state">
<p>No meal plan yet for this week.</p> <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>
</div> </div>
</template> </template>
@ -73,49 +142,136 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useMealPlanStore } from '../stores/mealPlan' import { useMealPlanStore } from '../stores/mealPlan'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import MealPlanGrid from './MealPlanGrid.vue' import MealPlanGrid from './MealPlanGrid.vue'
import ShoppingListPanel from './ShoppingListPanel.vue' import ShoppingListPanel from './ShoppingListPanel.vue'
import PrepSessionView from './PrepSessionView.vue' import PrepSessionView from './PrepSessionView.vue'
import type { MealPlanSlot } from '../services/api'
const TABS = [ const TABS = [
{ id: 'shopping', label: 'Shopping List' }, { id: 'shopping', label: 'Shopping List' },
{ id: 'prep', label: 'Prep Schedule' }, { id: 'prep', label: 'Prep Schedule' },
] as const ] 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'] type TabId = typeof TABS[number]['id']
const store = useMealPlanStore() const store = useMealPlanStore()
const savedStore = useSavedRecipesStore()
const { plans, activePlan, loading } = storeToRefs(store) const { plans, activePlan, loading } = storeToRefs(store)
const activeTab = ref<TabId>('shopping') 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 // canAddMealType is a UI hint backend enforces the paid gate authoritatively
const canAddMealType = computed(() => const canAddMealType = computed(() =>
(activePlan.value?.meal_types.length ?? 0) < 4 (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() { async function onNewPlan() {
const today = new Date() planError.value = null
const day = today.getDay() planCreating.value = true
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...) const weekStart = mondayOfCurrentWeek()
const monday = new Date(today) try {
monday.setDate(today.getDate() - ((day + 6) % 7))
const weekStart = monday.toISOString().split('T')[0] ?? monday.toISOString().slice(0, 10)
await store.createPlan(weekStart, ['dinner']) 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) { async function onSelectPlan(planId: number) {
if (planId) await store.setActivePlan(planId) if (planId) await store.setActivePlan(planId)
} }
function onSlotClick(_: { dayOfWeek: number; mealType: string }) { function onSlotClick(payload: { dayOfWeek: number; mealType: string }) {
// Recipe picker integration filed as follow-up 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() { 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> </script>
@ -135,6 +291,29 @@ function onAddMealType() {
} }
.new-plan-btn:hover { background: var(--color-accent); color: white; } .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-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
.panel-tab { .panel-tab {
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0; 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; } .tab-panel { padding-top: 0.75rem; }
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; } .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> </style>

View file

@ -36,6 +36,20 @@
<!-- Scrollable body --> <!-- Scrollable body -->
<div class="detail-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 --> <!-- Ingredients: have vs. need in a two-column layout -->
<div class="ingredients-grid"> <div class="ingredients-grid">
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have"> <div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
@ -43,7 +57,7 @@
<ul class="ingredient-list"> <ul class="ingredient-list">
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row"> <li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
<span class="ing-icon ing-icon-have"></span> <span class="ing-icon ing-icon-have"></span>
<span>{{ ing }}</span> <span>{{ scaleIngredient(ing, servingScale) }}</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -66,7 +80,7 @@
:checked="checkedIngredients.has(ing)" :checked="checkedIngredients.has(ing)"
@change="toggleIngredient(ing)" @change="toggleIngredient(ing)"
/> />
<span class="ing-name">{{ ing }}</span> <span class="ing-name">{{ scaleIngredient(ing, servingScale) }}</span>
</label> </label>
<a <a
v-if="groceryLinkFor(ing)" v-if="groceryLinkFor(ing)"
@ -248,6 +262,69 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
const cookDone = ref(false) const cookDone = ref(false)
const shareCopied = 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 // Shopping: add purchased ingredients to pantry
const checkedIngredients = ref<Set<string>>(new Set()) const checkedIngredients = ref<Set<string>>(new Set())
const addingToPantry = ref(false) const addingToPantry = ref(false)
@ -327,6 +404,7 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined {
} }
function handleCook() { function handleCook() {
recipesStore.logCook(props.recipe.id, props.recipe.title)
cookDone.value = true cookDone.value = true
emit('cooked', props.recipe) emit('cooked', props.recipe)
} }
@ -445,6 +523,40 @@ function handleCook() {
-webkit-overflow-scrolling: touch; -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 ───────────────────────────────────── */
.ingredients-grid { .ingredients-grid {
display: grid; display: grid;

View file

@ -169,6 +169,17 @@
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span> <span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
</div> </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) --> <!-- Shopping Mode (temporary home moves to Shopping tab in #71) -->
<div class="form-group"> <div class="form-group">
<label class="flex-start gap-sm shopping-toggle"> <label class="flex-start gap-sm shopping-toggle">
@ -180,8 +191,8 @@
</p> </p>
</div> </div>
<!-- Max Missing hidden in shopping mode --> <!-- Max Missing hidden in shopping mode or pantry-match-only mode -->
<div v-if="!recipesStore.shoppingMode" class="form-group"> <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> <label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
<input <input
id="max-missing" id="max-missing"
@ -359,6 +370,14 @@
:aria-pressed="filterMissing === 2" :aria-pressed="filterMissing === 2"
@click="filterMissing = filterMissing === 2 ? null : 2" @click="filterMissing = filterMissing === 2 ? null : 2"
>2 missing</button> >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 <button
v-if="hasActiveFilters" v-if="hasActiveFilters"
class="filter-chip filter-chip-clear" class="filter-chip filter-chip-clear"
@ -366,6 +385,33 @@
@click="clearFilters" @click="clearFilters"
><span aria-hidden="true"></span> Clear</button> ><span aria-hidden="true"></span> Clear</button>
</div> </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> </div>
<!-- No suggestions --> <!-- No suggestions -->
@ -392,6 +438,8 @@
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3> <h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs" style="align-items:center"> <div class="flex flex-wrap gap-xs" style="align-items:center">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span> <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 class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span> <span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
<button <button
@ -728,6 +776,8 @@ const selectedRecipe = ref<RecipeSuggestion | null>(null)
const filterText = ref('') const filterText = ref('')
const filterLevel = ref<number | null>(null) const filterLevel = ref<number | null>(null)
const filterMissing = 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(() => { const availableLevels = computed(() => {
if (!recipesStore.result) return [] if (!recipesStore.result) return []
@ -751,17 +801,35 @@ const filteredSuggestions = computed(() => {
if (filterMissing.value !== null) { if (filterMissing.value !== null) {
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!) items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
} }
if (filterComplexity.value !== null) {
items = items.filter((r) => r.complexity === filterComplexity.value)
}
return items return items
}) })
const hasActiveFilters = computed( 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() { function clearFilters() {
filterText.value = '' filterText.value = ''
filterLevel.value = null filterLevel.value = null
filterMissing.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[]>(() => { const selectedGroceryLinks = computed<GroceryLink[]>(() => {
@ -1461,6 +1529,11 @@ details[open] .collapsible-summary::before {
padding: var(--spacing-xs) var(--spacing-md); 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 { .results-section {
margin-top: var(--spacing-md); margin-top: var(--spacing-md);
} }

View file

@ -79,6 +79,11 @@
>{{ tag }}</span> >{{ tag }}</span>
</div> </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 --> <!-- Notes preview with expand/collapse -->
<div v-if="recipe.notes" class="mt-xs"> <div v-if="recipe.notes" class="mt-xs">
<div <div
@ -146,6 +151,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useSavedRecipesStore } from '../stores/savedRecipes' import { useSavedRecipesStore } from '../stores/savedRecipes'
import { useRecipesStore } from '../stores/recipes'
import type { SavedRecipe } from '../services/api' import type { SavedRecipe } from '../services/api'
import SaveRecipeModal from './SaveRecipeModal.vue' import SaveRecipeModal from './SaveRecipeModal.vue'
@ -155,7 +161,24 @@ const emit = defineEmits<{
}>() }>()
const store = useSavedRecipesStore() const store = useSavedRecipesStore()
const recipesStore = useRecipesStore()
const editingRecipe = ref<SavedRecipe | null>(null) 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) const showNewCollection = ref(false)
// #44: two-step remove confirmation // #44: two-step remove confirmation
@ -340,6 +363,11 @@ async function createCollection() {
padding: var(--spacing-xl); padding: var(--spacing-xl);
} }
.last-cooked-hint {
font-style: italic;
opacity: 0.75;
}
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;

View file

@ -91,13 +91,33 @@ export interface InventoryItem {
sublocation: string | null sublocation: string | null
purchase_date: string | null purchase_date: string | null
expiration_date: string | null expiration_date: string | null
opened_date: string | null
opened_expiry_date: string | null
status: string status: string
source: string source: string
notes: string | null notes: string | null
disposal_reason: string | null
created_at: string created_at: string
updated_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 { export interface InventoryItemUpdate {
quantity?: number quantity?: number
unit?: string unit?: string
@ -222,7 +242,7 @@ export const inventoryAPI = {
location: string = 'pantry', location: string = 'pantry',
quantity: number = 1.0, quantity: number = 1.0,
autoAdd: boolean = true autoAdd: boolean = true
): Promise<any> { ): Promise<BarcodeScanResponse> {
const response = await api.post('/inventory/scan/text', { const response = await api.post('/inventory/scan/text', {
barcode, barcode,
location, 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> { async consumeItem(itemId: number, quantity?: number): Promise<InventoryItem> {
await api.post(`/inventory/items/${itemId}/consume`) 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 is_wildcard: boolean
nutrition: NutritionPanel | null nutrition: NutritionPanel | null
source_url: string | null source_url: string | null
complexity: 'easy' | 'moderate' | 'involved' | null
estimated_time_min: number | null
} }
export interface NutritionFilters { export interface NutritionFilters {
@ -494,6 +535,9 @@ export interface RecipeRequest {
nutrition_filters: NutritionFilters nutrition_filters: NutritionFilters
excluded_ids: number[] excluded_ids: number[]
shopping_mode: boolean shopping_mode: boolean
pantry_match_only: boolean
complexity_filter: string | null
max_time_min: number | null
} }
export interface Staple { export interface Staple {
@ -541,7 +585,8 @@ export interface BuildRequest {
export const recipesAPI = { export const recipesAPI = {
async suggest(req: RecipeRequest): Promise<RecipeResult> { 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 return response.data
}, },
async getRecipe(id: number): Promise<RecipeSuggestion> { async getRecipe(id: number): Promise<RecipeSuggestion> {
@ -782,6 +827,11 @@ export const mealPlanAPI = {
return resp.data 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> { 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) const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
return resp.data return resp.data

View file

@ -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> { async function createPlan(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
const plan = await mealPlanAPI.create(weekStart, mealTypes) const plan = await mealPlanAPI.create(weekStart, mealTypes)
plans.value = [plan, ...plans.value] 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> { async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
if (!activePlan.value || !prepSession.value) return if (!activePlan.value || !prepSession.value) return
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data) const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
@ -129,7 +151,7 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
return { return {
plans, activePlan, shoppingList, prepSession, plans, activePlan, shoppingList, prepSession,
loading, shoppingListLoading, prepLoading, slots, loading, shoppingListLoading, prepLoading, slots,
getSlot, loadPlans, createPlan, setActivePlan, getSlot, loadPlans, autoSelectPlan, createPlan, setActivePlan,
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask, addMealType, upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
} }
}) })

View file

@ -132,6 +132,9 @@ export const useRecipesStore = defineStore('recipes', () => {
const category = ref<string | null>(null) const category = ref<string | null>(null)
const wildcardConfirmed = ref(false) const wildcardConfirmed = ref(false)
const shoppingMode = 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>({ const nutritionFilters = ref<NutritionFilters>({
max_calories: null, max_calories: null,
max_sugar_g: null, max_sugar_g: null,
@ -176,6 +179,9 @@ export const useRecipesStore = defineStore('recipes', () => {
nutrition_filters: nutritionFilters.value, nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded], excluded_ids: [...excluded],
shopping_mode: shoppingMode.value, 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, category,
wildcardConfirmed, wildcardConfirmed,
shoppingMode, shoppingMode,
pantryMatchOnly,
complexityFilter,
maxTimeMin,
nutritionFilters, nutritionFilters,
dismissedIds, dismissedIds,
dismissedCount, dismissedCount,

View file

@ -649,6 +649,31 @@
border: 1px solid var(--color-info-border); 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 ANIMATION UTILITIES
============================================ */ ============================================ */

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "kiwi" name = "kiwi"
version = "0.2.0" version = "0.3.0"
description = "Pantry tracking + leftover recipe suggestions" description = "Pantry tracking + leftover recipe suggestions"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"