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
import asyncio
import csv
import io
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
@ -45,3 +47,33 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
)
@router.get("/json")
async def export_full_json(store: Store = Depends(get_store)):
"""Export full pantry inventory + saved recipes as a single JSON file.
Intended for data portability users can import this into another
Kiwi instance or keep it as an offline backup.
"""
inventory, saved = await asyncio.gather(
asyncio.to_thread(store.list_inventory),
asyncio.to_thread(store.get_saved_recipes, 1000, 0),
)
export_doc = {
"kiwi_export": {
"version": "1.0",
"exported_at": datetime.now(timezone.utc).isoformat(),
"inventory": [dict(row) for row in inventory],
"saved_recipes": [dict(row) for row in saved],
}
}
body = json.dumps(export_doc, default=str, indent=2)
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
return StreamingResponse(
iter([body]),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)

View file

@ -13,16 +13,21 @@ from pydantic import BaseModel
from app.cloud_session import CloudUser, get_session
from app.db.session import get_store
from app.services.expiration_predictor import ExpirationPredictor
_predictor = ExpirationPredictor()
from app.db.store import Store
from app.models.schemas.inventory import (
BarcodeScanResponse,
BulkAddByNameRequest,
BulkAddByNameResponse,
BulkAddItemResult,
DiscardRequest,
InventoryItemCreate,
InventoryItemResponse,
InventoryItemUpdate,
InventoryStats,
PartialConsumeRequest,
ProductCreate,
ProductResponse,
ProductUpdate,
@ -33,6 +38,25 @@ from app.models.schemas.inventory import (
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _enrich_item(item: dict) -> dict:
"""Attach computed opened_expiry_date when opened_date is set."""
from datetime import date, timedelta
opened = item.get("opened_date")
if opened:
days = _predictor.days_after_opening(item.get("category"))
if days is not None:
try:
opened_expiry = date.fromisoformat(opened) + timedelta(days=days)
item = {**item, "opened_expiry_date": str(opened_expiry)}
except ValueError:
pass
if "opened_expiry_date" not in item:
item = {**item, "opened_expiry_date": None}
return item
# ── Products ──────────────────────────────────────────────────────────────────
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
@ -168,13 +192,13 @@ async def list_inventory_items(
store: Store = Depends(get_store),
):
items = await asyncio.to_thread(store.list_inventory, location, item_status)
return [InventoryItemResponse.model_validate(i) for i in items]
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
items = await asyncio.to_thread(store.expiring_soon, days)
return [InventoryItemResponse.model_validate(i) for i in items]
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
@ -182,7 +206,7 @@ async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
item = await asyncio.to_thread(store.get_inventory_item, item_id)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
return InventoryItemResponse.model_validate(item)
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
@ -194,24 +218,79 @@ async def update_inventory_item(
updates["purchase_date"] = str(updates["purchase_date"])
if "expiration_date" in updates and updates["expiration_date"]:
updates["expiration_date"] = str(updates["expiration_date"])
if "opened_date" in updates and updates["opened_date"]:
updates["opened_date"] = str(updates["opened_date"])
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
return InventoryItemResponse.model_validate(item)
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
from datetime import date
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
opened_date=str(date.today()),
)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
async def consume_item(item_id: int, store: Store = Depends(get_store)):
async def consume_item(
item_id: int,
body: Optional[PartialConsumeRequest] = None,
store: Store = Depends(get_store),
):
"""Consume an inventory item fully or partially.
When body.quantity is provided, decrements by that amount and only marks
status=consumed when quantity reaches zero. Omit body to consume all.
"""
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
if body is not None:
item = await asyncio.to_thread(
store.partial_consume_item, item_id, body.quantity, now
)
else:
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
status="consumed",
consumed_at=now,
)
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}/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="consumed",
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(item)
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
@ -267,10 +346,14 @@ async def scan_barcode_text(
tier=session.tier,
has_byok=session.has_byok,
)
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
resolved_qty = product_info.get("pack_quantity") or body.quantity
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
product["id"], body.location,
quantity=body.quantity,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)
@ -278,6 +361,7 @@ async def scan_barcode_text(
else:
result_product = None
product_found = product_info is not None
return BarcodeScanResponse(
success=True,
barcodes_found=1,
@ -287,7 +371,8 @@ async def scan_barcode_text(
"product": result_product,
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
"added_to_inventory": inventory_item is not None,
"message": "Added to inventory" if inventory_item else "Product not found in database",
"needs_manual_entry": not product_found,
"message": "Added to inventory" if inventory_item else "Not found in any product database — add manually",
}],
message="Barcode processed",
)
@ -345,10 +430,13 @@ async def scan_barcode_image(
tier=session.tier,
has_byok=session.has_byok,
)
resolved_qty = product_info.get("pack_quantity") or quantity
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
product["id"], location,
quantity=quantity,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)

View file

@ -19,6 +19,7 @@ from app.models.schemas.meal_plan import (
PrepTaskSummary,
ShoppingListResponse,
SlotSummary,
UpdatePlanRequest,
UpdatePrepTaskRequest,
UpsertSlotRequest,
VALID_MEAL_TYPES,
@ -81,13 +82,21 @@ async def create_plan(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PlanSummary:
import sqlite3
# Free tier is locked to dinner-only; paid+ may configure meal types
if can_use("meal_plan_config", session.tier):
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
else:
meal_types = ["dinner"]
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
try:
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
except sqlite3.IntegrityError:
raise HTTPException(
status_code=409,
detail=f"A meal plan for the week of {req.week_start} already exists.",
)
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
return _plan_summary(plan, slots)
@ -105,6 +114,28 @@ async def list_plans(
return result
@router.patch("/{plan_id}", response_model=PlanSummary)
async def update_plan(
plan_id: int,
req: UpdatePlanRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PlanSummary:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
# Free tier stays dinner-only; paid+ may add meal types
if can_use("meal_plan_config", session.tier):
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
else:
meal_types = ["dinner"]
updated = await asyncio.to_thread(store.update_meal_plan_types, plan_id, meal_types)
if updated is None:
raise HTTPException(status_code=404, detail="Plan not found.")
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
return _plan_summary(updated, slots)
@router.get("/{plan_id}", response_model=PlanSummary)
async def get_plan(
plan_id: int,

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:
allowed = {"quantity", "unit", "location", "sublocation",
"expiration_date", "status", "notes", "consumed_at"}
"expiration_date", "opened_date", "status", "notes", "consumed_at",
"disposal_reason"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return self.get_inventory_item(item_id)
@ -231,6 +232,32 @@ class Store:
self.conn.commit()
return self.get_inventory_item(item_id)
def partial_consume_item(
self,
item_id: int,
consume_qty: float,
consumed_at: str,
) -> dict[str, Any] | None:
"""Decrement quantity by consume_qty. Mark consumed when quantity reaches 0."""
row = self.get_inventory_item(item_id)
if row is None:
return None
remaining = max(0.0, round(row["quantity"] - consume_qty, 6))
if remaining <= 0:
self.conn.execute(
"UPDATE inventory_items SET quantity = 0, status = 'consumed',"
" consumed_at = ?, updated_at = datetime('now') WHERE id = ?",
(consumed_at, item_id),
)
else:
self.conn.execute(
"UPDATE inventory_items SET quantity = ?, updated_at = datetime('now')"
" WHERE id = ?",
(remaining, item_id),
)
self.conn.commit()
return self.get_inventory_item(item_id)
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
return self._fetch_all(
"""SELECT i.*, p.name as product_name, p.category
@ -1092,6 +1119,12 @@ class Store:
def get_meal_plan(self, plan_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
def update_meal_plan_types(self, plan_id: int, meal_types: list[str]) -> dict | None:
return self._fetch_one(
"UPDATE meal_plans SET meal_types = ? WHERE id = ? RETURNING *",
(json.dumps(meal_types), plan_id),
)
def list_meal_plans(self) -> list[dict]:
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
@ -1122,7 +1155,7 @@ class Store:
def get_plan_slots(self, plan_id: int) -> list[dict]:
return self._fetch_all(
"""SELECT s.*, r.name AS recipe_title
"""SELECT s.*, r.title AS recipe_title
FROM meal_plan_slots s
LEFT JOIN recipes r ON r.id = s.recipe_id
WHERE s.plan_id = ?

View file

@ -90,8 +90,18 @@ class InventoryItemUpdate(BaseModel):
location: Optional[str] = None
sublocation: Optional[str] = None
expiration_date: Optional[date] = None
opened_date: Optional[date] = None
status: Optional[str] = None
notes: Optional[str] = None
disposal_reason: Optional[str] = None
class PartialConsumeRequest(BaseModel):
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
class DiscardRequest(BaseModel):
reason: Optional[str] = Field(None, max_length=200)
class InventoryItemResponse(BaseModel):
@ -106,8 +116,11 @@ class InventoryItemResponse(BaseModel):
sublocation: Optional[str]
purchase_date: Optional[str]
expiration_date: Optional[str]
opened_date: Optional[str] = None
opened_expiry_date: Optional[str] = None
status: str
notes: Optional[str]
disposal_reason: Optional[str] = None
source: str
created_at: str
updated_at: str
@ -123,6 +136,7 @@ class BarcodeScanResult(BaseModel):
product: Optional[ProductResponse]
inventory_item: Optional[InventoryItemResponse]
added_to_inventory: bool
needs_manual_entry: bool = False
message: str

View file

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

View file

@ -41,6 +41,8 @@ class RecipeSuggestion(BaseModel):
is_wildcard: bool = False
nutrition: NutritionPanel | None = None
source_url: str | None = None
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
estimated_time_min: int | None = None # derived from step count + method signals
class GroceryLink(BaseModel):
@ -83,6 +85,9 @@ class RecipeRequest(BaseModel):
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = Field(default_factory=list)
shopping_mode: bool = False
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
max_time_min: int | None = None # filter by estimated cooking time ceiling
unit_system: str = "metric" # "metric" | "imperial"

View file

@ -116,6 +116,53 @@ class ExpirationPredictor:
'prepared_foods': {'fridge': 4, 'freezer': 90},
}
# Secondary shelf life in days after a package is opened.
# Sources: USDA FoodKeeper app, FDA consumer guides.
# Only categories where opening significantly shortens shelf life are listed.
# Items not listed default to None (no secondary window tracked).
SHELF_LIFE_AFTER_OPENING: dict[str, int] = {
# Dairy — once opened, clock ticks fast
'dairy': 5,
'milk': 5,
'cream': 3,
'yogurt': 7,
'cheese': 14,
'butter': 30,
# Condiments — refrigerated after opening
'condiments': 30,
'ketchup': 30,
'mustard': 30,
'mayo': 14,
'salad_dressing': 30,
'soy_sauce': 90,
# Canned goods — once opened, very short
'canned_goods': 4,
# Beverages
'juice': 7,
'soda': 4,
# Bread / Bakery
'bread': 5,
'bakery': 3,
# Produce
'leafy_greens': 3,
'berries': 3,
# Pantry staples (open bag)
'chips': 14,
'cookies': 14,
'cereal': 30,
'flour': 90,
}
def days_after_opening(self, category: str | None) -> int | None:
"""Return days of shelf life remaining once a package is opened.
Returns None if the category is unknown or not tracked after opening
(e.g. frozen items, raw meat category check irrelevant once opened).
"""
if not category:
return None
return self.SHELF_LIFE_AFTER_OPENING.get(category.lower())
# Keyword lists are checked in declaration order — most specific first.
# Rules:
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)

View file

@ -15,64 +15,73 @@ logger = logging.getLogger(__name__)
class OpenFoodFactsService:
"""
Service for interacting with the OpenFoodFacts API.
Service for interacting with the Open*Facts family of databases.
OpenFoodFacts is a free, open database of food products with
ingredients, allergens, and nutrition facts.
Primary: OpenFoodFacts (food products).
Fallback chain: Open Beauty Facts (personal care) Open Products Facts (household).
All three databases share the same API path and JSON format.
"""
BASE_URL = "https://world.openfoodfacts.org/api/v2"
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)"
# Fallback databases tried in order when OFFs returns no match.
# Same API format as OFFs — only the host differs.
_FALLBACK_DATABASES = [
"https://world.openbeautyfacts.org/api/v2",
"https://world.openproductsfacts.org/api/v2",
]
async def _lookup_in_database(
self, barcode: str, base_url: str, client: httpx.AsyncClient
) -> Optional[Dict[str, Any]]:
"""Try one Open*Facts database using an existing client. Returns parsed product dict or None."""
try:
response = await client.get(
f"{base_url}/product/{barcode}.json",
headers={"User-Agent": self.USER_AGENT},
timeout=10.0,
)
if response.status_code == 404:
return None
response.raise_for_status()
data = response.json()
if data.get("status") != 1:
return None
return self._parse_product_data(data, barcode)
except httpx.HTTPError as e:
logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e)
return None
except Exception as e:
logger.debug("Lookup failed for %s at %s: %s", barcode, base_url, e)
return None
async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]:
"""
Look up a product by barcode in the OpenFoodFacts database.
Look up a product by barcode, trying OFFs then fallback databases.
A single httpx.AsyncClient is created for the whole lookup chain so that
connection pooling and TLS session reuse apply across all database attempts.
Args:
barcode: UPC/EAN barcode (8-13 digits)
Returns:
Dictionary with product information, or None if not found
Example response:
{
"name": "Organic Milk",
"brand": "Horizon",
"categories": ["Dairy", "Milk"],
"image_url": "https://...",
"nutrition_data": {...},
"raw_data": {...} # Full API response
}
Dictionary with product information, or None if not found in any database.
"""
try:
async with httpx.AsyncClient() as client:
url = f"{self.BASE_URL}/product/{barcode}.json"
async with httpx.AsyncClient() as client:
result = await self._lookup_in_database(barcode, self.BASE_URL, client)
if result:
return result
response = await client.get(
url,
headers={"User-Agent": self.USER_AGENT},
timeout=10.0,
)
for db_url in self._FALLBACK_DATABASES:
result = await self._lookup_in_database(barcode, db_url, client)
if result:
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
return result
if response.status_code == 404:
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
return None
response.raise_for_status()
data = response.json()
if data.get("status") != 1:
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
return None
return self._parse_product_data(data, barcode)
except httpx.HTTPError as e:
logger.error(f"HTTP error looking up barcode {barcode}: {e}")
return None
except Exception as e:
logger.error(f"Error looking up barcode {barcode}: {e}")
return None
logger.info("Barcode %s not found in any Open*Facts database", barcode)
return None
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
"""
@ -114,6 +123,9 @@ class OpenFoodFactsService:
allergens = product.get("allergens_tags", [])
labels = product.get("labels_tags", [])
# Pack size detection: prefer explicit unit_count, fall back to serving count
pack_quantity, pack_unit = self._extract_pack_size(product)
return {
"name": name,
"brand": brand,
@ -124,9 +136,47 @@ class OpenFoodFactsService:
"nutrition_data": nutrition_data,
"allergens": allergens,
"labels": labels,
"pack_quantity": pack_quantity,
"pack_unit": pack_unit,
"raw_data": product, # Store full response for debugging
}
def _extract_pack_size(self, product: Dict[str, Any]) -> tuple[float | None, str | None]:
"""Return (quantity, unit) for multi-pack products, or (None, None).
OFFs fields tried in order:
1. `number_of_units` (explicit count, highest confidence)
2. `serving_quantity` + `product_quantity_unit` (e.g. 6 x 150g yoghurt)
3. Parse `quantity` string like "4 x 113 g" or "6 pack"
Returns None, None when data is absent, ambiguous, or single-unit.
"""
import re
# Field 1: explicit unit count
unit_count = product.get("number_of_units")
if unit_count:
try:
n = float(unit_count)
if n > 1:
return n, product.get("serving_size_unit") or "unit"
except (ValueError, TypeError):
pass
# Field 2: parse quantity string for "N x ..." pattern
qty_str = product.get("quantity", "")
if qty_str:
m = re.match(r"^(\d+(?:\.\d+)?)\s*[xX×]\s*", qty_str.strip())
if m:
n = float(m.group(1))
if n > 1:
# Try to get a sensible sub-unit label from the rest
rest = qty_str[m.end():].strip()
unit_label = re.sub(r"[\d.,\s]+", "", rest).strip()[:20] or "unit"
return n, unit_label
return None, None
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract nutrition facts from product data.

View file

@ -143,12 +143,14 @@ class LLMRecipeGenerator:
return "\n".join(lines)
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
_SERVICE_TYPE = "cf-text"
_TTL_S = 300.0
_CALLER = "kiwi-recipe"
def _get_llm_context(self):
"""Return a sync context manager that yields an Allocation or None.
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
When CF_ORCH_URL is set, uses CFOrchClient to acquire a cf-text allocation
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
when the env var is absent or CFOrchClient raises on construction.
"""
@ -158,10 +160,9 @@ class LLMRecipeGenerator:
from circuitforge_orch.client import CFOrchClient
client = CFOrchClient(cf_orch_url)
return client.allocate(
service="vllm",
model_candidates=self._MODEL_CANDIDATES,
ttl_s=300.0,
caller="kiwi-recipe",
service=self._SERVICE_TYPE,
ttl_s=self._TTL_S,
caller=self._CALLER,
)
except Exception as exc:
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)

View file

@ -562,6 +562,19 @@ def _hard_day_sort_tier(
return 2
def _estimate_time_min(directions: list[str], complexity: str) -> int:
"""Rough cooking time estimate from step count and method complexity.
Not precise intended for filtering and display hints only.
"""
steps = len(directions)
if complexity == "easy":
return max(5, 10 + steps * 3)
if complexity == "involved":
return max(20, 30 + steps * 6)
return max(10, 20 + steps * 4) # moderate
def _classify_method_complexity(
directions: list[str],
available_equipment: list[str] | None = None,
@ -699,6 +712,11 @@ class RecipeEngine:
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
continue
# "Can make now" toggle: drop any recipe that still has missing ingredients
# after swaps are applied. Swapped items count as covered.
if req.pantry_match_only and missing:
continue
# L1 match ratio gate: drop results where less than 60% of the recipe's
# ingredients are in the pantry. Prevents low-signal results like a
# 10-ingredient recipe matching on only one common item.
@ -707,16 +725,21 @@ class RecipeEngine:
if match_ratio < _L1_MIN_MATCH_RATIO:
continue
# Parse directions — needed for complexity, hard_day_mode, and time estimate.
directions: list[str] = row.get("directions") or []
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = [directions]
# 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:
directions: list[str] = row.get("directions") or []
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = [directions]
complexity = _classify_method_complexity(directions, available_equipment)
if complexity == "involved":
if row_complexity == "involved":
continue
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
title=row.get("title", ""),
@ -724,6 +747,14 @@ class RecipeEngine:
directions=directions,
)
# Complexity filter (#58)
if req.complexity_filter and row_complexity != req.complexity_filter:
continue
# Max time filter (#58)
if req.max_time_min is not None and row_time_min > req.max_time_min:
continue
# Level 2: also add dietary constraint swaps from substitution_pairs
if req.level == 2 and req.constraints:
for ing in ingredient_names:
@ -773,6 +804,8 @@ class RecipeEngine:
level=req.level,
nutrition=nutrition if has_nutrition else None,
source_url=_build_source_url(row),
complexity=row_complexity,
estimated_time_min=row_time_min,
))
# Sort corpus results — assembly templates are now served from a dedicated tab.

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">
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<!-- Opened expiry takes priority over sell-by date -->
<span
v-if="item.expiration_date"
v-if="item.opened_expiry_date"
:class="['expiry-badge', 'expiry-opened', getExpiryBadgeClass(item.opened_expiry_date)]"
:title="`Opened · ${formatDateFull(item.opened_expiry_date)}`"
>📂 {{ formatDateShort(item.opened_expiry_date) }}</span>
<span
v-else-if="item.expiration_date"
:class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
:title="formatDateFull(item.expiration_date)"
>{{ formatDateShort(item.expiration_date) }}</span>
<div class="inv-actions">
@ -334,11 +341,41 @@
<path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
</svg>
</button>
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
<button
v-if="!item.opened_date && item.status === 'available'"
@click="markAsOpened(item)"
class="btn-icon btn-icon-open"
aria-label="Mark as opened today"
title="I opened this today"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M5 8V6a7 7 0 0114 0v2"/>
<rect x="3" y="8" width="14" height="10" rx="2"/>
<circle cx="10" cy="13" r="1.5" fill="currentColor"/>
</svg>
</button>
<button
v-if="item.status === 'available'"
@click="markAsConsumed(item)"
class="btn-icon btn-icon-success"
:aria-label="item.quantity > 1 ? `Use some (${item.quantity} ${item.unit})` : 'Mark as used'"
:title="item.quantity > 1 ? `Use some or all (${item.quantity} ${item.unit})` : 'Mark as used'"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="4 10 8 14 16 6"/>
</svg>
</button>
<button
v-if="item.status === 'available'"
@click="markAsDiscarded(item)"
class="btn-icon btn-icon-discard"
aria-label="Mark as not used"
title="I didn't use this (went bad, too much, etc)"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M4 4l12 12M4 16L16 4"/>
</svg>
</button>
<button @click="confirmDelete(item)" class="btn-icon btn-icon-danger" aria-label="Delete">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="3 6 5 6 17 6"/>
@ -355,10 +392,14 @@
<!-- Export -->
<div class="card export-card">
<h2 class="section-title">Export</h2>
<div class="flex gap-sm" style="margin-top: var(--spacing-sm)">
<div class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
<button @click="exportJSON" class="btn btn-primary">Download JSON (full backup)</button>
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
</div>
<p class="text-sm text-secondary" style="margin-top: var(--spacing-xs)">
JSON includes pantry + saved recipes. Import it into another Kiwi instance any time.
</p>
</div>
<!-- Edit Modal -->
@ -380,6 +421,22 @@
@cancel="confirmDialog.show = false"
/>
<!-- Action Dialog (partial consume / discard reason) -->
<ActionDialog
:show="actionDialog.show"
:title="actionDialog.title"
:message="actionDialog.message"
:type="actionDialog.type"
:confirm-text="actionDialog.confirmText"
:input-type="actionDialog.inputType"
:input-label="actionDialog.inputLabel"
:input-max="actionDialog.inputMax"
:input-unit="actionDialog.inputUnit"
:input-options="actionDialog.inputOptions"
@confirm="(v) => { actionDialog.onConfirm(v); actionDialog.show = false }"
@cancel="actionDialog.show = false"
/>
<!-- Toast Notification -->
<ToastNotification
:show="toast.show"
@ -401,6 +458,7 @@ import type { InventoryItem } from '../services/api'
import { formatQuantity } from '../utils/units'
import EditItemModal from './EditItemModal.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import ActionDialog from './ActionDialog.vue'
import ToastNotification from './ToastNotification.vue'
const store = useInventoryStore()
@ -446,6 +504,20 @@ const confirmDialog = reactive({
onConfirm: () => {},
})
const actionDialog = reactive({
show: false,
title: '',
message: '',
type: 'primary' as 'primary' | 'danger' | 'warning' | 'secondary',
confirmText: 'Confirm',
inputType: null as 'quantity' | 'select' | null,
inputLabel: '',
inputMax: 1,
inputUnit: '',
inputOptions: [] as string[],
onConfirm: (_v: number | string | undefined) => {},
})
// Toast Notification
const toast = reactive({
show: false,
@ -555,24 +627,75 @@ async function confirmDelete(item: InventoryItem) {
)
}
async function markAsConsumed(item: InventoryItem) {
showConfirm(
`Mark ${item.product_name || 'item'} as consumed?`,
async () => {
async function markAsOpened(item: InventoryItem) {
try {
await inventoryAPI.openItem(item.id)
await refreshItems()
showToast(`${item.product_name || 'Item'} marked as opened — tracking freshness`, 'info')
} catch {
showToast('Could not mark item as opened', 'error')
}
}
function markAsConsumed(item: InventoryItem) {
const isMulti = item.quantity > 1
const label = item.product_name || 'item'
Object.assign(actionDialog, {
show: true,
title: 'Mark as Used',
message: isMulti
? `How much of ${label} did you use?`
: `Mark ${label} as used?`,
type: 'primary',
confirmText: isMulti ? 'Use' : 'Mark as Used',
inputType: isMulti ? 'quantity' : null,
inputLabel: 'Amount used:',
inputMax: item.quantity,
inputUnit: item.unit,
inputOptions: [],
onConfirm: async (val: number | string | undefined) => {
const qty = isMulti ? (val as number) : undefined
try {
await inventoryAPI.consumeItem(item.id)
await inventoryAPI.consumeItem(item.id, qty)
await refreshItems()
showToast(`${item.product_name || 'item'} marked as consumed`, 'success')
} catch (err) {
showToast('Failed to mark item as consumed', 'error')
const verb = qty !== undefined && qty < item.quantity ? 'partially used' : 'marked as used'
showToast(`${label} ${verb}`, 'success')
} catch {
showToast('Could not update item', 'error')
}
},
{
title: 'Mark as Consumed',
type: 'primary',
confirmText: 'Mark as Consumed',
}
)
})
}
function markAsDiscarded(item: InventoryItem) {
const label = item.product_name || 'item'
Object.assign(actionDialog, {
show: true,
title: 'Item Not Used',
message: `${label} — what happened to it?`,
type: 'secondary',
confirmText: 'Log It',
inputType: 'select',
inputLabel: 'Reason (optional):',
inputMax: 1,
inputUnit: '',
inputOptions: [
'went bad before I could use it',
'too much — had excess',
'changed my mind',
'other',
],
onConfirm: async (val: number | string | undefined) => {
const reason = typeof val === 'string' && val ? val : undefined
try {
await inventoryAPI.discardItem(item.id, reason)
await refreshItems()
showToast(`${label} logged as not used`, 'info')
} catch {
showToast('Could not update item', 'error')
}
},
})
}
// Scanner Gun Functions
@ -591,13 +714,22 @@ async function handleScannerGunInput() {
true
)
if (result.success && result.barcodes_found > 0) {
const item = result.results[0]
const item = result.results[0]
if (item?.added_to_inventory) {
const productName = item.product?.name || 'item'
const productBrand = item.product?.brand ? ` (${item.product.brand})` : ''
scannerResults.value.push({
type: 'success',
message: `Added: ${item.product_name || 'item'} to ${scannerLocation.value}`,
message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
})
await refreshItems()
} else if (item?.needs_manual_entry) {
// Barcode not found in any database guide user to manual entry
scannerResults.value.push({
type: 'warning',
message: `Barcode ${barcode} not found. Fill in the details below.`,
})
scanMode.value = 'manual'
} else {
scannerResults.value.push({
type: 'error',
@ -612,7 +744,7 @@ async function handleScannerGunInput() {
} finally {
scannerLoading.value = false
scannerBarcode.value = ''
scannerGunInput.value?.focus()
if (scanMode.value === 'gun') scannerGunInput.value?.focus()
}
}
@ -721,20 +853,40 @@ function exportExcel() {
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
}
// Short date for compact row display
function formatDateShort(dateStr: string): string {
function exportJSON() {
const apiUrl = import.meta.env.VITE_API_URL || '/api/v1'
window.open(`${apiUrl}/export/json`, '_blank')
}
// Full date string for tooltip (accessible label)
function formatDateFull(dateStr: string): string {
const date = new Date(dateStr)
const today = new Date()
today.setHours(0, 0, 0, 0)
const expiry = new Date(dateStr)
expiry.setHours(0, 0, 0, 0)
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const cal = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
if (diffDays < 0) return `Expired ${cal}`
if (diffDays === 0) return `Expires today (${cal})`
if (diffDays === 1) return `Expires tomorrow (${cal})`
return `Expires in ${diffDays} days (${cal})`
}
// Short date for compact row display
function formatDateShort(dateStr: string): string {
const today = new Date()
today.setHours(0, 0, 0, 0)
const expiry = new Date(dateStr)
expiry.setHours(0, 0, 0, 0)
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const cal = new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
if (diffDays < 0) return `${Math.abs(diffDays)}d ago`
if (diffDays === 0) return 'today'
if (diffDays === 1) return 'tmrw'
if (diffDays <= 14) return `${diffDays}d`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
if (diffDays === 1) return `tmrw · ${cal}`
if (diffDays <= 14) return `${diffDays}d · ${cal}`
return cal
}
function getExpiryBadgeClass(expiryStr: string): string {
@ -1147,6 +1299,30 @@ function getItemClass(item: InventoryItem): string {
text-decoration: line-through;
}
/* "I opened this today" button */
.btn-icon-open {
color: var(--color-warning);
}
.btn-icon-open:hover {
background: var(--color-warning-bg);
}
/* "Item not used" discard button — muted, not alarming */
.btn-icon-discard {
color: var(--color-text-tertiary);
}
.btn-icon-discard:hover {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
}
/* Opened badge — distinct icon prefix signals this is after-open expiry */
.expiry-opened {
letter-spacing: 0;
}
/* Action icons inline */
.inv-actions {
display: flex;
@ -1220,6 +1396,12 @@ function getItemClass(item: InventoryItem): string {
border: 1px solid var(--color-info-border);
}
.result-warning {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning-dark, #92400e);
border: 1px solid var(--color-warning-border, #fcd34d);
}
/* ============================================
EXPORT CARD
============================================ */

View file

@ -14,8 +14,11 @@
Week of {{ p.week_start }}
</option>
</select>
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
{{ planCreating ? 'Creating…' : '+ New week' }}
</button>
</div>
<p v-if="planError" class="plan-error">{{ planError }}</p>
<template v-if="activePlan">
<!-- Compact expandable week grid (always visible) -->
@ -26,6 +29,70 @@
@add-meal-type="onAddMealType"
/>
<!-- Slot editor panel -->
<div v-if="slotEditing" class="slot-editor card">
<div class="slot-editor-header">
<span class="slot-editor-title">
{{ DAY_LABELS[slotEditing.dayOfWeek] }} · {{ slotEditing.mealType }}
</span>
<button class="close-btn" @click="slotEditing = null" aria-label="Close"></button>
</div>
<!-- Custom label -->
<div class="form-group">
<label class="form-label">Custom label</label>
<input
v-model="slotCustomLabel"
class="form-input"
type="text"
placeholder="e.g. Taco night, Leftovers…"
maxlength="80"
/>
</div>
<!-- Pick from saved recipes -->
<div v-if="savedStore.saved.length" class="form-group">
<label class="form-label">Or pick a saved recipe</label>
<select class="week-select" v-model="slotRecipeId">
<option :value="null"> None </option>
<option v-for="r in savedStore.saved" :key="r.recipe_id" :value="r.recipe_id">
{{ r.title }}
</option>
</select>
</div>
<p v-else class="slot-hint">Save recipes from the Recipes tab to pick them here.</p>
<div class="slot-editor-actions">
<button class="btn-secondary" @click="slotEditing = null">Cancel</button>
<button
v-if="currentSlot"
class="btn-danger-subtle"
@click="onClearSlot"
:disabled="slotSaving"
>Clear slot</button>
<button
class="btn-primary"
@click="onSaveSlot"
:disabled="slotSaving"
>{{ slotSaving ? 'Saving…' : 'Save' }}</button>
</div>
</div>
<!-- Meal type picker -->
<div v-if="addingMealType" class="meal-type-picker card">
<span class="slot-editor-title">Add meal type</span>
<div class="chip-row">
<button
v-for="t in availableMealTypes"
:key="t"
class="btn-chip"
:disabled="mealTypeAdding"
@click="onPickMealType(t)"
>{{ t }}</button>
</div>
<button class="close-link" @click="addingMealType = false">Cancel</button>
</div>
<!-- Panel tabs: Shopping List | Prep Schedule -->
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
<button
@ -64,7 +131,9 @@
<div v-else-if="!loading" class="empty-plan-state">
<p>No meal plan yet for this week.</p>
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
{{ planCreating ? 'Creating…' : 'Start planning' }}
</button>
</div>
</div>
</template>
@ -73,49 +142,136 @@
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useMealPlanStore } from '../stores/mealPlan'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import MealPlanGrid from './MealPlanGrid.vue'
import ShoppingListPanel from './ShoppingListPanel.vue'
import PrepSessionView from './PrepSessionView.vue'
import type { MealPlanSlot } from '../services/api'
const TABS = [
{ id: 'shopping', label: 'Shopping List' },
{ id: 'prep', label: 'Prep Schedule' },
] as const
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const ALL_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack']
type TabId = typeof TABS[number]['id']
const store = useMealPlanStore()
const savedStore = useSavedRecipesStore()
const { plans, activePlan, loading } = storeToRefs(store)
const activeTab = ref<TabId>('shopping')
const planError = ref<string | null>(null)
const planCreating = ref(false)
// slot editor
const slotEditing = ref<{ dayOfWeek: number; mealType: string } | null>(null)
const slotCustomLabel = ref('')
const slotRecipeId = ref<number | null>(null)
const slotSaving = ref(false)
const currentSlot = computed((): MealPlanSlot | undefined => {
if (!slotEditing.value) return undefined
return store.getSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
})
// meal type picker
const addingMealType = ref(false)
const mealTypeAdding = ref(false)
const availableMealTypes = computed(() =>
ALL_MEAL_TYPES.filter(t => !activePlan.value?.meal_types.includes(t))
)
// canAddMealType is a UI hint backend enforces the paid gate authoritatively
const canAddMealType = computed(() =>
(activePlan.value?.meal_types.length ?? 0) < 4
)
onMounted(() => store.loadPlans())
onMounted(async () => {
await Promise.all([store.loadPlans(), savedStore.load()])
store.autoSelectPlan(mondayOfCurrentWeek())
})
function mondayOfCurrentWeek(): string {
const today = new Date()
const day = today.getDay() // 0=Sun, 1=Mon...
// Build date string from local parts to avoid UTC-offset day drift
const d = new Date(today)
d.setDate(today.getDate() - ((day + 6) % 7))
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
async function onNewPlan() {
const today = new Date()
const day = today.getDay()
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
const monday = new Date(today)
monday.setDate(today.getDate() - ((day + 6) % 7))
const weekStart = monday.toISOString().split('T')[0] ?? monday.toISOString().slice(0, 10)
await store.createPlan(weekStart, ['dinner'])
planError.value = null
planCreating.value = true
const weekStart = mondayOfCurrentWeek()
try {
await store.createPlan(weekStart, ['dinner'])
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('409') || msg.toLowerCase().includes('already exists')) {
const existing = plans.value.find(p => p.week_start === weekStart)
if (existing) await store.setActivePlan(existing.id)
} else {
planError.value = `Couldn't create plan: ${msg}`
}
} finally {
planCreating.value = false
}
}
async function onSelectPlan(planId: number) {
if (planId) await store.setActivePlan(planId)
}
function onSlotClick(_: { dayOfWeek: number; mealType: string }) {
// Recipe picker integration filed as follow-up
function onSlotClick(payload: { dayOfWeek: number; mealType: string }) {
slotEditing.value = payload
const existing = store.getSlot(payload.dayOfWeek, payload.mealType)
slotCustomLabel.value = existing?.custom_label ?? ''
slotRecipeId.value = existing?.recipe_id ?? null
}
async function onSaveSlot() {
if (!slotEditing.value) return
slotSaving.value = true
try {
await store.upsertSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType, {
recipe_id: slotRecipeId.value,
custom_label: slotCustomLabel.value.trim() || null,
})
slotEditing.value = null
} finally {
slotSaving.value = false
}
}
async function onClearSlot() {
if (!slotEditing.value) return
slotSaving.value = true
try {
await store.clearSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
slotEditing.value = null
} finally {
slotSaving.value = false
}
}
function onAddMealType() {
// Add meal type picker Paid gate enforced by backend
addingMealType.value = true
}
async function onPickMealType(mealType: string) {
mealTypeAdding.value = true
try {
await store.addMealType(mealType)
addingMealType.value = false
} finally {
mealTypeAdding.value = false
}
}
</script>
@ -135,6 +291,29 @@ function onAddMealType() {
}
.new-plan-btn:hover { background: var(--color-accent); color: white; }
/* Slot editor */
.slot-editor, .meal-type-picker {
padding: 1rem; border-radius: 8px;
border: 1px solid var(--color-border); background: var(--color-surface);
display: flex; flex-direction: column; gap: 0.75rem;
}
.slot-editor-header { display: flex; align-items: center; justify-content: space-between; }
.slot-editor-title { font-size: 0.85rem; font-weight: 600; }
.close-btn {
background: none; border: none; cursor: pointer; font-size: 0.9rem;
color: var(--color-text-secondary); padding: 0.1rem 0.3rem; border-radius: 4px;
}
.close-btn:hover { background: var(--color-surface-2); }
.slot-hint { font-size: 0.8rem; opacity: 0.55; margin: 0; }
.slot-editor-actions { display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap; }
.chip-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.close-link {
background: none; border: none; cursor: pointer; font-size: 0.8rem;
color: var(--color-text-secondary); align-self: flex-start; padding: 0;
}
.close-link:hover { text-decoration: underline; }
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
.panel-tab {
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
@ -150,4 +329,11 @@ function onAddMealType() {
.tab-panel { padding-top: 0.75rem; }
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
.plan-error {
font-size: 0.82rem; color: var(--color-error, #e05252);
background: var(--color-error-subtle, #fef2f2);
border: 1px solid var(--color-error, #e05252); border-radius: 6px;
padding: 0.4rem 0.75rem; margin: 0;
}
</style>

View file

@ -36,6 +36,20 @@
<!-- Scrollable body -->
<div class="detail-body">
<!-- Serving multiplier -->
<div class="serving-scale-row">
<span class="serving-scale-label text-sm text-muted">Scale:</span>
<div class="serving-scale-btns" role="group" aria-label="Serving multiplier">
<button
v-for="n in [1, 2, 3, 4]"
:key="n"
:class="['scale-btn', { active: servingScale === n }]"
:aria-pressed="servingScale === n"
@click="servingScale = n"
>{{ n }}×</button>
</div>
</div>
<!-- Ingredients: have vs. need in a two-column layout -->
<div class="ingredients-grid">
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
@ -43,7 +57,7 @@
<ul class="ingredient-list">
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
<span class="ing-icon ing-icon-have"></span>
<span>{{ ing }}</span>
<span>{{ scaleIngredient(ing, servingScale) }}</span>
</li>
</ul>
</div>
@ -66,7 +80,7 @@
:checked="checkedIngredients.has(ing)"
@change="toggleIngredient(ing)"
/>
<span class="ing-name">{{ ing }}</span>
<span class="ing-name">{{ scaleIngredient(ing, servingScale) }}</span>
</label>
<a
v-if="groceryLinkFor(ing)"
@ -248,6 +262,69 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
const cookDone = ref(false)
const shareCopied = ref(false)
// Serving scale multiplier: 1×, 2×, 3×, 4×
const servingScale = ref(1)
/**
* Scale a freeform ingredient string by a multiplier.
* Handles integers, decimals, and simple fractions (1/2, 1/4, 3/4, etc.).
* Ranges like "2-3" are scaled on both ends.
* Returns the original string unchanged if no leading number is found.
*/
function scaleIngredient(ing: string, scale: number): string {
if (scale === 1) return ing
// Match an optional leading fraction OR decimal OR integer,
// optionally followed by a space and another fraction (mixed number like "1 1/2")
const numPat = String.raw`(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)`
const rangePat = new RegExp(`^${numPat}(?:\\s*-\\s*${numPat})?`)
const m = ing.match(rangePat)
if (!m) return ing
function parseFrac(s: string): number {
const mixed = s.match(/^(\d+)\s+(\d+)\/(\d+)$/)
if (mixed) return parseInt(mixed[1]) + parseInt(mixed[2]) / parseInt(mixed[3])
const frac = s.match(/^(\d+)\/(\d+)$/)
if (frac) return parseInt(frac[1]) / parseInt(frac[2])
return parseFloat(s)
}
function fmtNum(n: number): string {
// Try to express as a simple fraction for common baking values
const fracs: [number, string][] = [
[0.125, '1/8'], [0.25, '1/4'], [0.333, '1/3'], [0.5, '1/2'],
[0.667, '2/3'], [0.75, '3/4'],
]
for (const [val, str] of fracs) {
if (Math.abs(n - Math.round(n / val) * val) < 0.01 && n < 1) return str
}
// Mixed numbers
const whole = Math.floor(n)
const remainder = n - whole
if (whole > 0 && remainder > 0.05) {
for (const [val, str] of fracs) {
if (Math.abs(remainder - val) < 0.05) return `${whole} ${str}`
}
}
// Round to reasonable precision
return whole > 0 && remainder < 0.05 ? `${whole}` : n.toFixed(1).replace(/\.0$/, '')
}
const low = parseFrac(m[1])
const scaledLow = fmtNum(low * scale)
let scaled: string
if (m[2] !== undefined) {
const high = parseFrac(m[2])
scaled = `${scaledLow}-${fmtNum(high * scale)}`
} else {
scaled = scaledLow
}
return scaled + ing.slice(m[0].length)
}
// Shopping: add purchased ingredients to pantry
const checkedIngredients = ref<Set<string>>(new Set())
const addingToPantry = ref(false)
@ -327,6 +404,7 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined {
}
function handleCook() {
recipesStore.logCook(props.recipe.id, props.recipe.title)
cookDone.value = true
emit('cooked', props.recipe)
}
@ -445,6 +523,40 @@ function handleCook() {
-webkit-overflow-scrolling: touch;
}
/* ── Serving scale row ──────────────────────────────────── */
.serving-scale-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.serving-scale-label {
white-space: nowrap;
}
.serving-scale-btns {
display: flex;
gap: var(--spacing-xs);
}
.scale-btn {
padding: 2px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.scale-btn.active {
background: var(--color-primary);
color: var(--color-on-primary, #fff);
border-color: var(--color-primary);
}
/* ── Ingredients grid ───────────────────────────────────── */
.ingredients-grid {
display: grid;

View file

@ -169,6 +169,17 @@
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
</div>
<!-- Can Make Now toggle -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
<input type="checkbox" v-model="recipesStore.pantryMatchOnly" :disabled="recipesStore.shoppingMode" />
<span class="form-label" style="margin-bottom: 0;">Can make now (no missing ingredients)</span>
</label>
<p v-if="recipesStore.pantryMatchOnly && !recipesStore.shoppingMode" class="text-sm text-secondary mt-xs">
Only recipes where every ingredient is in your pantry no substitutions, no shopping.
</p>
</div>
<!-- Shopping Mode (temporary home moves to Shopping tab in #71) -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
@ -180,8 +191,8 @@
</p>
</div>
<!-- Max Missing hidden in shopping mode -->
<div v-if="!recipesStore.shoppingMode" class="form-group">
<!-- Max Missing hidden in shopping mode or pantry-match-only mode -->
<div v-if="!recipesStore.shoppingMode && !recipesStore.pantryMatchOnly" class="form-group">
<label class="form-label" for="max-missing">Max Missing Ingredients <span class="text-muted text-xs">(optional)</span></label>
<input
id="max-missing"
@ -359,6 +370,14 @@
:aria-pressed="filterMissing === 2"
@click="filterMissing = filterMissing === 2 ? null : 2"
>2 missing</button>
<!-- Complexity filter chips (#55 / #58) -->
<button
v-for="cx in ['easy', 'moderate', 'involved']"
:key="cx"
:class="['filter-chip', { active: filterComplexity === cx }]"
:aria-pressed="filterComplexity === cx"
@click="filterComplexity = filterComplexity === cx ? null : cx"
>{{ cx }}</button>
<button
v-if="hasActiveFilters"
class="filter-chip filter-chip-clear"
@ -366,6 +385,33 @@
@click="clearFilters"
><span aria-hidden="true"></span> Clear</button>
</div>
<!-- Zero-decision picks (#53 Surprise Me / #57 Just Pick One) -->
<div v-if="filteredSuggestions.length > 0" class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
<button class="btn btn-secondary btn-sm" @click="pickSurprise" :disabled="filteredSuggestions.length === 0">
🎲 Surprise me
</button>
<button class="btn btn-secondary btn-sm" @click="pickBest" :disabled="filteredSuggestions.length === 0">
Just pick one
</button>
</div>
</div>
<!-- Spotlight (Surprise Me / Just Pick One result) -->
<div v-if="spotlightRecipe" class="card spotlight-card slide-up mb-md">
<div class="flex-between mb-sm">
<h3 class="text-lg font-bold">{{ spotlightRecipe.title }}</h3>
<div class="flex gap-xs" style="align-items:center">
<span v-if="spotlightRecipe.complexity" :class="['status-badge', `complexity-${spotlightRecipe.complexity}`]">{{ spotlightRecipe.complexity }}</span>
<span v-if="spotlightRecipe.estimated_time_min" class="status-badge status-neutral">~{{ spotlightRecipe.estimated_time_min }}m</span>
<button class="btn-icon" @click="spotlightRecipe = null" aria-label="Dismiss"></button>
</div>
</div>
<p class="text-sm text-secondary mb-xs">{{ spotlightRecipe.match_count }} ingredients matched from your pantry</p>
<button class="btn btn-primary btn-sm" @click="selectedRecipe = spotlightRecipe; spotlightRecipe = null">
Cook this
</button>
<button class="btn btn-ghost btn-sm ml-sm" @click="pickSurprise">Try another</button>
</div>
<!-- No suggestions -->
@ -392,6 +438,8 @@
<h3 class="text-lg font-bold recipe-title">{{ recipe.title }}</h3>
<div class="flex flex-wrap gap-xs" style="align-items:center">
<span class="status-badge status-success">{{ recipe.match_count }} matched</span>
<span v-if="recipe.complexity" :class="['status-badge', `complexity-${recipe.complexity}`]">{{ recipe.complexity }}</span>
<span v-if="recipe.estimated_time_min" class="status-badge status-neutral">~{{ recipe.estimated_time_min }}m</span>
<span class="status-badge status-info">Level {{ recipe.level }}</span>
<span v-if="recipe.is_wildcard" class="status-badge status-info">Wildcard</span>
<button
@ -728,6 +776,8 @@ const selectedRecipe = ref<RecipeSuggestion | null>(null)
const filterText = ref('')
const filterLevel = ref<number | null>(null)
const filterMissing = ref<number | null>(null)
const filterComplexity = ref<string | null>(null)
const spotlightRecipe = ref<RecipeSuggestion | null>(null)
const availableLevels = computed(() => {
if (!recipesStore.result) return []
@ -751,17 +801,35 @@ const filteredSuggestions = computed(() => {
if (filterMissing.value !== null) {
items = items.filter((r) => r.missing_ingredients.length <= filterMissing.value!)
}
if (filterComplexity.value !== null) {
items = items.filter((r) => r.complexity === filterComplexity.value)
}
return items
})
const hasActiveFilters = computed(
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null
() => filterText.value.trim() !== '' || filterLevel.value !== null || filterMissing.value !== null || filterComplexity.value !== null
)
function clearFilters() {
filterText.value = ''
filterLevel.value = null
filterMissing.value = null
filterComplexity.value = null
}
function pickSurprise() {
const pool = filteredSuggestions.value
if (!pool.length) return
const exclude = spotlightRecipe.value?.id
const candidates = pool.length > 1 ? pool.filter((r) => r.id !== exclude) : pool
spotlightRecipe.value = candidates[Math.floor(Math.random() * candidates.length)]
}
function pickBest() {
const pool = filteredSuggestions.value
if (!pool.length) return
spotlightRecipe.value = pool[0]
}
const selectedGroceryLinks = computed<GroceryLink[]>(() => {
@ -1461,6 +1529,11 @@ details[open] .collapsible-summary::before {
padding: var(--spacing-xs) var(--spacing-md);
}
.spotlight-card {
border: 2px solid var(--color-primary);
background: linear-gradient(135deg, var(--color-bg-elevated) 0%, rgba(232, 168, 32, 0.06) 100%);
}
.results-section {
margin-top: var(--spacing-md);
}

View file

@ -79,6 +79,11 @@
>{{ tag }}</span>
</div>
<!-- Last cooked hint -->
<div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-hint text-xs text-muted mt-xs">
{{ lastCookedLabel(recipe.recipe_id) }}
</div>
<!-- Notes preview with expand/collapse -->
<div v-if="recipe.notes" class="mt-xs">
<div
@ -146,6 +151,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { useRecipesStore } from '../stores/recipes'
import type { SavedRecipe } from '../services/api'
import SaveRecipeModal from './SaveRecipeModal.vue'
@ -155,7 +161,24 @@ const emit = defineEmits<{
}>()
const store = useSavedRecipesStore()
const recipesStore = useRecipesStore()
const editingRecipe = ref<SavedRecipe | null>(null)
function lastCookedLabel(recipeId: number): string | null {
const entries = recipesStore.cookLog.filter((e) => e.id === recipeId)
if (entries.length === 0) return null
const latestMs = Math.max(...entries.map((e) => e.cookedAt))
const diffMs = Date.now() - latestMs
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffDays === 0) return 'Last made: today'
if (diffDays === 1) return 'Last made: yesterday'
if (diffDays < 7) return `Last made: ${diffDays} days ago`
if (diffDays < 14) return 'Last made: 1 week ago'
const diffWeeks = Math.floor(diffDays / 7)
if (diffDays < 60) return `Last made: ${diffWeeks} weeks ago`
const diffMonths = Math.floor(diffDays / 30)
return `Last made: ${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`
}
const showNewCollection = ref(false)
// #44: two-step remove confirmation
@ -340,6 +363,11 @@ async function createCollection() {
padding: var(--spacing-xl);
}
.last-cooked-hint {
font-style: italic;
opacity: 0.75;
}
.modal-overlay {
position: fixed;
inset: 0;

View file

@ -91,13 +91,33 @@ export interface InventoryItem {
sublocation: string | null
purchase_date: string | null
expiration_date: string | null
opened_date: string | null
opened_expiry_date: string | null
status: string
source: string
notes: string | null
disposal_reason: string | null
created_at: string
updated_at: string
}
export interface BarcodeScanResult {
barcode: string
barcode_type: string
product: Product | null
inventory_item: InventoryItem | null
added_to_inventory: boolean
needs_manual_entry: boolean
message: string
}
export interface BarcodeScanResponse {
success: boolean
barcodes_found: number
results: BarcodeScanResult[]
message: string
}
export interface InventoryItemUpdate {
quantity?: number
unit?: string
@ -222,7 +242,7 @@ export const inventoryAPI = {
location: string = 'pantry',
quantity: number = 1.0,
autoAdd: boolean = true
): Promise<any> {
): Promise<BarcodeScanResponse> {
const response = await api.post('/inventory/scan/text', {
barcode,
location,
@ -233,10 +253,29 @@ export const inventoryAPI = {
},
/**
* Mark item as consumed
* Mark item as consumed fully or partially.
* Pass quantity to decrement; omit to consume all.
*/
async consumeItem(itemId: number): Promise<void> {
await api.post(`/inventory/items/${itemId}/consume`)
async consumeItem(itemId: number, quantity?: number): Promise<InventoryItem> {
const body = quantity !== undefined ? { quantity } : undefined
const response = await api.post(`/inventory/items/${itemId}/consume`, body)
return response.data
},
/**
* Mark item as discarded (not used, spoiled, etc).
*/
async discardItem(itemId: number, reason?: string): Promise<InventoryItem> {
const response = await api.post(`/inventory/items/${itemId}/discard`, { reason: reason ?? null })
return response.data
},
/**
* Mark item as opened today starts secondary shelf-life tracking
*/
async openItem(itemId: number): Promise<InventoryItem> {
const response = await api.post(`/inventory/items/${itemId}/open`)
return response.data
},
/**
@ -456,6 +495,8 @@ export interface RecipeSuggestion {
is_wildcard: boolean
nutrition: NutritionPanel | null
source_url: string | null
complexity: 'easy' | 'moderate' | 'involved' | null
estimated_time_min: number | null
}
export interface NutritionFilters {
@ -494,6 +535,9 @@ export interface RecipeRequest {
nutrition_filters: NutritionFilters
excluded_ids: number[]
shopping_mode: boolean
pantry_match_only: boolean
complexity_filter: string | null
max_time_min: number | null
}
export interface Staple {
@ -541,7 +585,8 @@ export interface BuildRequest {
export const recipesAPI = {
async suggest(req: RecipeRequest): Promise<RecipeResult> {
const response = await api.post('/recipes/suggest', req)
// Allow up to 120s — cf-orch model cold-start can take 60+ seconds on first request
const response = await api.post('/recipes/suggest', req, { timeout: 120000 })
return response.data
},
async getRecipe(id: number): Promise<RecipeSuggestion> {
@ -782,6 +827,11 @@ export const mealPlanAPI = {
return resp.data
},
async updateMealTypes(planId: number, mealTypes: string[]): Promise<MealPlan> {
const resp = await api.patch<MealPlan>(`/meal-plans/${planId}`, { meal_types: mealTypes })
return resp.data
},
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
return resp.data

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

View file

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

View file

@ -649,6 +649,31 @@
border: 1px solid var(--color-info-border);
}
.status-neutral {
background: rgba(255, 248, 235, 0.06);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
/* Recipe complexity badges */
.complexity-easy {
background: var(--color-success-bg);
color: var(--color-success-light);
border: 1px solid var(--color-success-border);
}
.complexity-moderate {
background: var(--color-warning-bg);
color: var(--color-warning-light);
border: 1px solid var(--color-warning-border);
}
.complexity-involved {
background: var(--color-error-bg);
color: var(--color-error-light);
border: 1px solid var(--color-error-border);
}
/* ============================================
ANIMATION UTILITIES
============================================ */

View file

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