diff --git a/app/api/endpoints/meal_plans.py b/app/api/endpoints/meal_plans.py new file mode 100644 index 0000000..e1d6cbc --- /dev/null +++ b/app/api/endpoints/meal_plans.py @@ -0,0 +1,294 @@ +# app/api/endpoints/meal_plans.py +"""Meal plan CRUD, shopping list, and prep session endpoints.""" +from __future__ import annotations + +import asyncio +import json +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException + +from app.cloud_session import CloudUser, get_session +from app.db.session import get_store +from app.db.store import Store +from app.models.schemas.meal_plan import ( + CreatePlanRequest, + GapItem, + PlanSummary, + PrepSessionSummary, + PrepTaskSummary, + ShoppingListResponse, + SlotSummary, + UpdatePrepTaskRequest, + UpsertSlotRequest, + VALID_MEAL_TYPES, +) +from app.services.meal_plan.affiliates import get_retailer_links +from app.services.meal_plan.prep_scheduler import build_prep_tasks +from app.services.meal_plan.shopping_list import compute_shopping_list +from app.tiers import can_use + +router = APIRouter() + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _slot_summary(row: dict) -> SlotSummary: + return SlotSummary( + id=row["id"], + plan_id=row["plan_id"], + day_of_week=row["day_of_week"], + meal_type=row["meal_type"], + recipe_id=row.get("recipe_id"), + recipe_title=row.get("recipe_title"), + servings=row["servings"], + custom_label=row.get("custom_label"), + ) + + +def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary: + meal_types = plan.get("meal_types") or ["dinner"] + if isinstance(meal_types, str): + meal_types = json.loads(meal_types) + return PlanSummary( + id=plan["id"], + week_start=plan["week_start"], + meal_types=meal_types, + slots=[_slot_summary(s) for s in slots], + created_at=plan["created_at"], + ) + + +def _prep_task_summary(row: dict) -> PrepTaskSummary: + return PrepTaskSummary( + id=row["id"], + recipe_id=row.get("recipe_id"), + task_label=row["task_label"], + duration_minutes=row.get("duration_minutes"), + sequence_order=row["sequence_order"], + equipment=row.get("equipment"), + is_parallel=bool(row.get("is_parallel", False)), + notes=row.get("notes"), + user_edited=bool(row.get("user_edited", False)), + ) + + +# ── plan CRUD ───────────────────────────────────────────────────────────────── + +@router.post("/", response_model=PlanSummary) +async def create_plan( + req: CreatePlanRequest, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PlanSummary: + # 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) + slots = await asyncio.to_thread(store.get_plan_slots, plan["id"]) + return _plan_summary(plan, slots) + + +@router.get("/", response_model=list[PlanSummary]) +async def list_plans( + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> list[PlanSummary]: + plans = await asyncio.to_thread(store.list_meal_plans) + result = [] + for p in plans: + slots = await asyncio.to_thread(store.get_plan_slots, p["id"]) + result.append(_plan_summary(p, slots)) + return result + + +@router.get("/{plan_id}", response_model=PlanSummary) +async def get_plan( + plan_id: int, + 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.") + slots = await asyncio.to_thread(store.get_plan_slots, plan_id) + return _plan_summary(plan, slots) + + +# ── slots ───────────────────────────────────────────────────────────────────── + +@router.put("/{plan_id}/slots/{day_of_week}/{meal_type}", response_model=SlotSummary) +async def upsert_slot( + plan_id: int, + day_of_week: int, + meal_type: str, + req: UpsertSlotRequest, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> SlotSummary: + if day_of_week < 0 or day_of_week > 6: + raise HTTPException(status_code=422, detail="day_of_week must be 0-6.") + if meal_type not in VALID_MEAL_TYPES: + raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.") + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + row = await asyncio.to_thread( + store.upsert_slot, + plan_id, day_of_week, meal_type, + req.recipe_id, req.servings, req.custom_label, + ) + return _slot_summary(row) + + +@router.delete("/{plan_id}/slots/{slot_id}", status_code=204) +async def delete_slot( + plan_id: int, + slot_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> None: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + await asyncio.to_thread(store.delete_slot, slot_id) + + +# ── shopping list ───────────────────────────────────────────────────────────── + +@router.get("/{plan_id}/shopping-list", response_model=ShoppingListResponse) +async def get_shopping_list( + plan_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> ShoppingListResponse: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + + recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id) + inventory = await asyncio.to_thread(store.list_inventory) + + gaps, covered = compute_shopping_list(recipes, inventory) + + # Enrich gap items with retailer links + def _to_schema(item, enrich: bool) -> GapItem: + links = get_retailer_links(item.ingredient_name) if enrich else [] + return GapItem( + ingredient_name=item.ingredient_name, + needed_raw=item.needed_raw, + have_quantity=item.have_quantity, + have_unit=item.have_unit, + covered=item.covered, + retailer_links=links, + ) + + gap_items = [_to_schema(g, enrich=True) for g in gaps] + covered_items = [_to_schema(c, enrich=False) for c in covered] + + disclosure = ( + "Some links may be affiliate links. Purchases through them support Kiwi development." + if gap_items else None + ) + + return ShoppingListResponse( + plan_id=plan_id, + gap_items=gap_items, + covered_items=covered_items, + disclosure=disclosure, + ) + + +# ── prep session ────────────────────────────────────────────────────────────── + +@router.get("/{plan_id}/prep-session", response_model=PrepSessionSummary) +async def get_prep_session( + plan_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PrepSessionSummary: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + prep_session = await asyncio.to_thread(store.get_prep_session_for_plan, plan_id) + if prep_session is None: + raise HTTPException(status_code=404, detail="No prep session for this plan.") + raw_tasks = await asyncio.to_thread(store.get_prep_tasks, prep_session["id"]) + return PrepSessionSummary( + id=prep_session["id"], + plan_id=plan_id, + scheduled_date=prep_session["scheduled_date"], + status=prep_session["status"], + tasks=[_prep_task_summary(t) for t in raw_tasks], + ) + + +@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary) +async def create_prep_session( + plan_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PrepSessionSummary: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + + slots = await asyncio.to_thread(store.get_plan_slots, plan_id) + recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id) + prep_tasks = build_prep_tasks(slots=slots, recipes=recipes) + + scheduled_date = date.today().isoformat() + prep_session = await asyncio.to_thread( + store.create_prep_session, plan_id, scheduled_date + ) + session_id = prep_session["id"] + + task_dicts = [ + { + "recipe_id": t.recipe_id, + "slot_id": t.slot_id, + "task_label": t.task_label, + "duration_minutes": t.duration_minutes, + "sequence_order": t.sequence_order, + "equipment": t.equipment, + "is_parallel": t.is_parallel, + "notes": t.notes, + } + for t in prep_tasks + ] + inserted = await asyncio.to_thread(store.bulk_insert_prep_tasks, session_id, task_dicts) + + return PrepSessionSummary( + id=prep_session["id"], + plan_id=prep_session["plan_id"], + scheduled_date=prep_session["scheduled_date"], + status=prep_session["status"], + tasks=[_prep_task_summary(r) for r in inserted], + ) + + +@router.patch( + "/{plan_id}/prep-session/tasks/{task_id}", + response_model=PrepTaskSummary, +) +async def update_prep_task( + plan_id: int, + task_id: int, + req: UpdatePrepTaskRequest, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PrepTaskSummary: + updated = await asyncio.to_thread( + store.update_prep_task, + task_id, + duration_minutes=req.duration_minutes, + sequence_order=req.sequence_order, + notes=req.notes, + equipment=req.equipment, + ) + if updated is None: + raise HTTPException(status_code=404, detail="Task not found.") + return _prep_task_summary(updated) diff --git a/app/api/routes.py b/app/api/routes.py index c19770c..69b5190 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, imitate, meal_plans from app.api.endpoints.community import router as community_router api_router = APIRouter() @@ -16,4 +16,5 @@ api_router.include_router(staples.router, prefix="/staples", tags= api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) api_router.include_router(household.router, prefix="/household", tags=["household"]) api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"]) -api_router.include_router(community_router) \ No newline at end of file +api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"]) +api_router.include_router(community_router) diff --git a/app/db/migrations/022_meal_plans.sql b/app/db/migrations/022_meal_plans.sql new file mode 100644 index 0000000..79c019c --- /dev/null +++ b/app/db/migrations/022_meal_plans.sql @@ -0,0 +1,8 @@ +-- 022_meal_plans.sql +CREATE TABLE meal_plans ( + id INTEGER PRIMARY KEY, + week_start TEXT NOT NULL, + meal_types TEXT NOT NULL DEFAULT '["dinner"]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/app/db/migrations/023_meal_plan_slots.sql b/app/db/migrations/023_meal_plan_slots.sql new file mode 100644 index 0000000..f2926fa --- /dev/null +++ b/app/db/migrations/023_meal_plan_slots.sql @@ -0,0 +1,11 @@ +-- 023_meal_plan_slots.sql +CREATE TABLE meal_plan_slots ( + id INTEGER PRIMARY KEY, + plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, + day_of_week INTEGER NOT NULL CHECK(day_of_week BETWEEN 0 AND 6), + meal_type TEXT NOT NULL, + recipe_id INTEGER REFERENCES recipes(id), + servings REAL NOT NULL DEFAULT 2.0, + custom_label TEXT, + UNIQUE(plan_id, day_of_week, meal_type) +); diff --git a/app/db/migrations/024_prep_sessions.sql b/app/db/migrations/024_prep_sessions.sql new file mode 100644 index 0000000..bb313a2 --- /dev/null +++ b/app/db/migrations/024_prep_sessions.sql @@ -0,0 +1,10 @@ +-- 024_prep_sessions.sql +CREATE TABLE prep_sessions ( + id INTEGER PRIMARY KEY, + plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, + scheduled_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' + CHECK(status IN ('draft','reviewed','done')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/app/db/migrations/025_prep_tasks.sql b/app/db/migrations/025_prep_tasks.sql new file mode 100644 index 0000000..d9541e3 --- /dev/null +++ b/app/db/migrations/025_prep_tasks.sql @@ -0,0 +1,15 @@ +-- 025_prep_tasks.sql +CREATE TABLE prep_tasks ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES prep_sessions(id) ON DELETE CASCADE, + recipe_id INTEGER REFERENCES recipes(id), + slot_id INTEGER REFERENCES meal_plan_slots(id), + task_label TEXT NOT NULL, + duration_minutes INTEGER, + sequence_order INTEGER NOT NULL, + equipment TEXT, + is_parallel INTEGER NOT NULL DEFAULT 0, + notes TEXT, + user_edited INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/app/db/store.py b/app/db/store.py index 0e6546f..de838c0 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -44,7 +44,9 @@ class Store: "ingredients", "ingredient_names", "directions", "keywords", "element_coverage", # saved recipe columns - "style_tags"): + "style_tags", + # meal plan columns + "meal_types"): if key in d and isinstance(d[key], str): try: d[key] = json.loads(d[key]) @@ -1079,6 +1081,123 @@ class Store: ) self.conn.commit() + # ── meal plans ──────────────────────────────────────────────────────── + + def create_meal_plan(self, week_start: str, meal_types: list[str]) -> dict: + return self._insert_returning( + "INSERT INTO meal_plans (week_start, meal_types) VALUES (?, ?) RETURNING *", + (week_start, json.dumps(meal_types)), + ) + + def get_meal_plan(self, plan_id: int) -> dict | None: + return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,)) + + def list_meal_plans(self) -> list[dict]: + return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC") + + def upsert_slot( + self, + plan_id: int, + day_of_week: int, + meal_type: str, + recipe_id: int | None, + servings: float, + custom_label: str | None, + ) -> dict: + return self._insert_returning( + """INSERT INTO meal_plan_slots + (plan_id, day_of_week, meal_type, recipe_id, servings, custom_label) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(plan_id, day_of_week, meal_type) DO UPDATE SET + recipe_id = excluded.recipe_id, + servings = excluded.servings, + custom_label = excluded.custom_label + RETURNING *""", + (plan_id, day_of_week, meal_type, recipe_id, servings, custom_label), + ) + + def delete_slot(self, slot_id: int) -> None: + self.conn.execute("DELETE FROM meal_plan_slots WHERE id = ?", (slot_id,)) + self.conn.commit() + + def get_plan_slots(self, plan_id: int) -> list[dict]: + return self._fetch_all( + """SELECT s.*, r.name AS recipe_title + FROM meal_plan_slots s + LEFT JOIN recipes r ON r.id = s.recipe_id + WHERE s.plan_id = ? + ORDER BY s.day_of_week, s.meal_type""", + (plan_id,), + ) + + def get_plan_recipes(self, plan_id: int) -> list[dict]: + """Return full recipe rows for all recipes assigned to a plan.""" + return self._fetch_all( + """SELECT DISTINCT r.* + FROM meal_plan_slots s + JOIN recipes r ON r.id = s.recipe_id + WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""", + (plan_id,), + ) + + # ── prep sessions ───────────────────────────────────────────────────── + + def create_prep_session(self, plan_id: int, scheduled_date: str) -> dict: + return self._insert_returning( + "INSERT INTO prep_sessions (plan_id, scheduled_date) VALUES (?, ?) RETURNING *", + (plan_id, scheduled_date), + ) + + def get_prep_session_for_plan(self, plan_id: int) -> dict | None: + return self._fetch_one( + "SELECT * FROM prep_sessions WHERE plan_id = ? ORDER BY id DESC LIMIT 1", + (plan_id,), + ) + + def bulk_insert_prep_tasks(self, session_id: int, tasks: list[dict]) -> list[dict]: + """Insert multiple prep tasks and return them all.""" + inserted = [] + for t in tasks: + row = self._insert_returning( + """INSERT INTO prep_tasks + (session_id, recipe_id, slot_id, task_label, duration_minutes, + sequence_order, equipment, is_parallel, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""", + ( + session_id, t.get("recipe_id"), t.get("slot_id"), + t["task_label"], t.get("duration_minutes"), + t["sequence_order"], t.get("equipment"), + int(t.get("is_parallel", False)), t.get("notes"), + ), + ) + inserted.append(row) + return inserted + + def get_prep_tasks(self, session_id: int) -> list[dict]: + return self._fetch_all( + "SELECT * FROM prep_tasks WHERE session_id = ? ORDER BY sequence_order", + (session_id,), + ) + + def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None: + allowed = {"duration_minutes", "sequence_order", "notes", "equipment"} + invalid = set(kwargs) - allowed # check raw kwargs BEFORE filtering + if invalid: + raise ValueError(f"Unexpected column(s) in update_prep_task: {invalid}") + updates = {k: v for k, v in kwargs.items() if v is not None} + if not updates: + return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [1, task_id] + self.conn.execute( + f"UPDATE prep_tasks SET {set_clause}, user_edited = ? WHERE id = ?", + values, + ) + self.conn.commit() + return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) + + # ── community ───────────────────────────────────────────────────────── + def get_current_pseudonym(self, directus_user_id: str) -> str | None: """Return the current community pseudonym for this user, or None if not set.""" cur = self.conn.execute( diff --git a/app/main.py b/app/main.py index 5783585..0e79e63 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.routes import api_router from app.core.config import settings +from app.services.meal_plan.affiliates import register_kiwi_programs logger = logging.getLogger(__name__) @@ -17,6 +18,7 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): logger.info("Starting Kiwi API...") settings.ensure_dirs() + register_kiwi_programs() # Start LLM background task scheduler from app.tasks.scheduler import get_scheduler diff --git a/app/models/schemas/meal_plan.py b/app/models/schemas/meal_plan.py new file mode 100644 index 0000000..6e1a678 --- /dev/null +++ b/app/models/schemas/meal_plan.py @@ -0,0 +1,96 @@ +# app/models/schemas/meal_plan.py +"""Pydantic schemas for meal planning endpoints.""" +from __future__ import annotations + +from datetime import date as _date + +from pydantic import BaseModel, Field, field_validator + + +VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"} + + +class CreatePlanRequest(BaseModel): + week_start: _date + meal_types: list[str] = Field(default_factory=lambda: ["dinner"]) + + @field_validator("week_start") + @classmethod + def must_be_monday(cls, v: _date) -> _date: + if v.weekday() != 0: + raise ValueError("week_start must be a Monday (weekday 0)") + return v + + +class UpsertSlotRequest(BaseModel): + recipe_id: int | None = None + servings: float = Field(2.0, gt=0) + custom_label: str | None = None + + +class SlotSummary(BaseModel): + id: int + plan_id: int + day_of_week: int + meal_type: str + recipe_id: int | None + recipe_title: str | None + servings: float + custom_label: str | None + + +class PlanSummary(BaseModel): + id: int + week_start: str + meal_types: list[str] + slots: list[SlotSummary] + created_at: str + + +class RetailerLink(BaseModel): + retailer: str + label: str + url: str + + +class GapItem(BaseModel): + ingredient_name: str + needed_raw: str | None # e.g. "2 cups" from recipe text + have_quantity: float | None # from pantry + have_unit: str | None + covered: bool # True = pantry has it + retailer_links: list[RetailerLink] = Field(default_factory=list) + + +class ShoppingListResponse(BaseModel): + plan_id: int + gap_items: list[GapItem] + covered_items: list[GapItem] + disclosure: str | None = None # affiliate disclosure text when links present + + +class PrepTaskSummary(BaseModel): + id: int + recipe_id: int | None + task_label: str + duration_minutes: int | None + sequence_order: int + equipment: str | None + is_parallel: bool + notes: str | None + user_edited: bool + + +class PrepSessionSummary(BaseModel): + id: int + plan_id: int + scheduled_date: str + status: str + tasks: list[PrepTaskSummary] + + +class UpdatePrepTaskRequest(BaseModel): + duration_minutes: int | None = None + sequence_order: int | None = None + notes: str | None = None + equipment: str | None = None diff --git a/app/services/meal_plan/__init__.py b/app/services/meal_plan/__init__.py new file mode 100644 index 0000000..245ab0b --- /dev/null +++ b/app/services/meal_plan/__init__.py @@ -0,0 +1 @@ +"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core).""" diff --git a/app/services/meal_plan/affiliates.py b/app/services/meal_plan/affiliates.py new file mode 100644 index 0000000..b8085f7 --- /dev/null +++ b/app/services/meal_plan/affiliates.py @@ -0,0 +1,108 @@ +# app/services/meal_plan/affiliates.py +"""Register Kiwi-specific affiliate programs and provide search URL builders. + +Called once at API startup. Programs not yet in core.affiliates are registered +here. The actual affiliate IDs are read from environment variables at call +time, so the process can start before accounts are approved (plain URLs +returned when env vars are absent). +""" +from __future__ import annotations + +from urllib.parse import quote_plus + +from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url + + +# ── URL builders ────────────────────────────────────────────────────────────── + +def _walmart_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}affil=apa&affiliateId={affiliate_id}" + + +def _target_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}afid={affiliate_id}" + + +def _thrive_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}raf={affiliate_id}" + + +def _misfits_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}ref={affiliate_id}" + + +# ── Registration ────────────────────────────────────────────────────────────── + +def register_kiwi_programs() -> None: + """Register Kiwi retailer programs. Safe to call multiple times (idempotent).""" + register_program(AffiliateProgram( + name="Walmart", + retailer_key="walmart", + env_var="WALMART_AFFILIATE_ID", + build_url=_walmart_search, + )) + register_program(AffiliateProgram( + name="Target", + retailer_key="target", + env_var="TARGET_AFFILIATE_ID", + build_url=_target_search, + )) + register_program(AffiliateProgram( + name="Thrive Market", + retailer_key="thrive", + env_var="THRIVE_AFFILIATE_ID", + build_url=_thrive_search, + )) + register_program(AffiliateProgram( + name="Misfits Market", + retailer_key="misfits", + env_var="MISFITS_AFFILIATE_ID", + build_url=_misfits_search, + )) + + +# ── Search URL helpers ───────────────────────────────────────────────────────── + +_SEARCH_TEMPLATES: dict[str, str] = { + "amazon": "https://www.amazon.com/s?k={q}", + "instacart": "https://www.instacart.com/store/search_v3/term?term={q}", + "walmart": "https://www.walmart.com/search?q={q}", + "target": "https://www.target.com/s?searchTerm={q}", + "thrive": "https://thrivemarket.com/search?q={q}", + "misfits": "https://www.misfitsmarket.com/shop?search={q}", +} + +KIWI_RETAILERS = list(_SEARCH_TEMPLATES.keys()) + + +def get_retailer_links(ingredient_name: str) -> list[dict]: + """Return affiliate-wrapped search links for *ingredient_name*. + + Returns a list of dicts: {"retailer": str, "label": str, "url": str}. + Falls back to plain search URL when no affiliate ID is configured. + """ + q = quote_plus(ingredient_name) + links = [] + for key, template in _SEARCH_TEMPLATES.items(): + plain_url = template.format(q=q) + try: + affiliate_url = wrap_url(plain_url, retailer=key) + except Exception: + affiliate_url = plain_url + links.append({"retailer": key, "label": _label(key), "url": affiliate_url}) + return links + + +def _label(key: str) -> str: + return { + "amazon": "Amazon", + "instacart": "Instacart", + "walmart": "Walmart", + "target": "Target", + "thrive": "Thrive Market", + "misfits": "Misfits Market", + }.get(key, key.title()) diff --git a/app/services/meal_plan/llm_planner.py b/app/services/meal_plan/llm_planner.py new file mode 100644 index 0000000..11859b4 --- /dev/null +++ b/app/services/meal_plan/llm_planner.py @@ -0,0 +1,91 @@ +# app/services/meal_plan/llm_planner.py +# BSL 1.1 — LLM feature +"""LLM-assisted full-week meal plan generation. + +Returns suggestions for human review — never writes to the DB directly. +The API endpoint presents the suggestions and waits for user approval +before calling store.upsert_slot(). + +Routing: pass a router from get_meal_plan_router() in llm_router.py. +Cloud: cf-text via cf-orch (3B-7B GGUF, ~2GB VRAM). +Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml). +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +_PLAN_SYSTEM = """\ +You are a practical meal planning assistant. Given a pantry inventory and +dietary preferences, suggest a week of dinners (or other configured meals). + +Prioritise ingredients that are expiring soon. Prefer variety across the week. +Respect all dietary restrictions. + +Respond with a JSON array only — no prose, no markdown fences. +Each item: {"day": 0-6, "meal_type": "dinner", "recipe_id": , "suggestion": ""} + +day 0 = Monday, day 6 = Sunday. +If you cannot match a known recipe_id, set recipe_id to null and provide a suggestion name. +""" + + +@dataclass(frozen=True) +class PlanSuggestion: + day: int # 0 = Monday + meal_type: str + recipe_id: int | None + suggestion: str # human-readable name + + +def generate_plan( + pantry_items: list[str], + meal_types: list[str], + dietary_notes: str, + router, +) -> list[PlanSuggestion]: + """Return a list of PlanSuggestion for user review. + + Never writes to DB — caller must upsert slots after user approves. + Returns an empty list if router is None or response is unparseable. + """ + if router is None: + return [] + + pantry_text = "\n".join(f"- {item}" for item in pantry_items[:50]) + meal_text = ", ".join(meal_types) + user_msg = ( + f"Meal types: {meal_text}\n" + f"Dietary notes: {dietary_notes or 'none'}\n\n" + f"Pantry (partial):\n{pantry_text}" + ) + + try: + response = router.complete( + system=_PLAN_SYSTEM, + user=user_msg, + max_tokens=512, + temperature=0.7, + ) + items = json.loads(response.strip()) + suggestions = [] + for item in items: + if not isinstance(item, dict): + continue + day = item.get("day") + meal_type = item.get("meal_type", "dinner") + if not isinstance(day, int) or day < 0 or day > 6: + continue + suggestions.append(PlanSuggestion( + day=day, + meal_type=meal_type, + recipe_id=item.get("recipe_id"), + suggestion=str(item.get("suggestion", "")), + )) + return suggestions + except Exception as exc: + logger.debug("LLM plan generation failed: %s", exc) + return [] diff --git a/app/services/meal_plan/llm_router.py b/app/services/meal_plan/llm_router.py new file mode 100644 index 0000000..4475b52 --- /dev/null +++ b/app/services/meal_plan/llm_router.py @@ -0,0 +1,96 @@ +# app/services/meal_plan/llm_router.py +# BSL 1.1 — LLM feature +"""Provide a router-compatible LLM client for meal plan generation tasks. + +Cloud (CF_ORCH_URL set): + Allocates a cf-text service via cf-orch (3B-7B GGUF, ~2GB VRAM). + Returns an _OrchTextRouter that wraps the cf-text HTTP endpoint + with a .complete(system, user, **kwargs) interface. + +Local / self-hosted (no CF_ORCH_URL): + Returns an LLMRouter instance which tries ollama, vllm, or any + backend configured in ~/.config/circuitforge/llm.yaml. + +Both paths expose the same interface so llm_timing.py and llm_planner.py +need no knowledge of the backend. +""" +from __future__ import annotations + +import logging +import os +from contextlib import nullcontext + +logger = logging.getLogger(__name__) + +# cf-orch service name and VRAM budget for meal plan LLM tasks. +# These are lighter than recipe_llm (4.0 GB) — cf-text handles them. +_SERVICE_TYPE = "cf-text" +_TTL_S = 120.0 +_CALLER = "kiwi-meal-plan" + + +class _OrchTextRouter: + """Thin adapter that makes a cf-text HTTP endpoint look like LLMRouter.""" + + def __init__(self, base_url: str) -> None: + self._base_url = base_url.rstrip("/") + + def complete( + self, + system: str = "", + user: str = "", + max_tokens: int = 512, + temperature: float = 0.7, + **_kwargs, + ) -> str: + from openai import OpenAI + client = OpenAI(base_url=self._base_url + "/v1", api_key="any") + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": user}) + try: + model = client.models.list().data[0].id + except Exception: + model = "local" + resp = client.chat.completions.create( + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + ) + return resp.choices[0].message.content or "" + + +def get_meal_plan_router(): + """Return an LLM client for meal plan tasks. + + Tries cf-orch cf-text allocation first (cloud); falls back to LLMRouter + (local ollama/vllm). Returns None if no backend is available. + """ + cf_orch_url = os.environ.get("CF_ORCH_URL") + if cf_orch_url: + try: + from circuitforge_orch.client import CFOrchClient + client = CFOrchClient(cf_orch_url) + ctx = client.allocate( + service=_SERVICE_TYPE, + ttl_s=_TTL_S, + caller=_CALLER, + ) + alloc = ctx.__enter__() + if alloc is not None: + return _OrchTextRouter(alloc.url), ctx + except Exception as exc: + logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc) + + # Local fallback: LLMRouter (ollama / vllm / openai-compat) + try: + from circuitforge_core.llm.router import LLMRouter + return LLMRouter(), nullcontext(None) + except FileNotFoundError: + logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled") + return None, nullcontext(None) + except Exception as exc: + logger.debug("LLMRouter init failed: %s", exc) + return None, nullcontext(None) diff --git a/app/services/meal_plan/llm_timing.py b/app/services/meal_plan/llm_timing.py new file mode 100644 index 0000000..8918b8b --- /dev/null +++ b/app/services/meal_plan/llm_timing.py @@ -0,0 +1,65 @@ +# app/services/meal_plan/llm_timing.py +# BSL 1.1 — LLM feature +"""Estimate cook times for recipes missing corpus prep/cook time fields. + +Used only when tier allows `meal_plan_llm_timing`. Falls back gracefully +when no LLM backend is available. + +Routing: pass a router from get_meal_plan_router() in llm_router.py. +Cloud: cf-text via cf-orch (3B GGUF, ~2GB VRAM). +Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml). +""" +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +_TIMING_PROMPT = """\ +You are a practical cook. Given a recipe name and its ingredients, estimate: +1. prep_time: minutes of active prep work (chopping, mixing, etc.) +2. cook_time: minutes of cooking (oven, stovetop, etc.) + +Respond with ONLY two integers on separate lines: +prep_time +cook_time + +If you cannot estimate, respond with: +0 +0 +""" + + +def estimate_timing(recipe_name: str, ingredients: list[str], router) -> tuple[int | None, int | None]: + """Return (prep_minutes, cook_minutes) for a recipe using LLMRouter. + + Returns (None, None) if the router is unavailable or the response is + unparseable. Never raises. + + Args: + recipe_name: Name of the recipe. + ingredients: List of raw ingredient strings from the corpus. + router: An LLMRouter instance (from circuitforge_core.llm). + """ + if router is None: + return None, None + + ingredient_list = "\n".join(f"- {i}" for i in (ingredients or [])[:15]) + prompt = f"Recipe: {recipe_name}\n\nIngredients:\n{ingredient_list}" + + try: + response = router.complete( + system=_TIMING_PROMPT, + user=prompt, + max_tokens=16, + temperature=0.0, + ) + lines = response.strip().splitlines() + prep = int(lines[0].strip()) if lines else 0 + cook = int(lines[1].strip()) if len(lines) > 1 else 0 + if prep == 0 and cook == 0: + return None, None + return prep or None, cook or None + except Exception as exc: + logger.debug("LLM timing estimation failed for %r: %s", recipe_name, exc) + return None, None diff --git a/app/services/meal_plan/planner.py b/app/services/meal_plan/planner.py new file mode 100644 index 0000000..ca1fdea --- /dev/null +++ b/app/services/meal_plan/planner.py @@ -0,0 +1,26 @@ +# app/services/meal_plan/planner.py +"""Plan and slot orchestration — thin layer over Store. + +No FastAPI imports. Provides helpers used by the API endpoint. +""" +from __future__ import annotations + +from app.db.store import Store +from app.models.schemas.meal_plan import VALID_MEAL_TYPES + + +def create_plan(store: Store, week_start: str, meal_types: list[str]) -> dict: + """Create a plan, filtering meal_types to valid values only.""" + valid = [t for t in meal_types if t in VALID_MEAL_TYPES] + if not valid: + valid = ["dinner"] + return store.create_meal_plan(week_start, valid) + + +def get_plan_with_slots(store: Store, plan_id: int) -> dict | None: + """Return a plan row with its slots list attached, or None.""" + plan = store.get_meal_plan(plan_id) + if plan is None: + return None + slots = store.get_plan_slots(plan_id) + return {**plan, "slots": slots} diff --git a/app/services/meal_plan/prep_scheduler.py b/app/services/meal_plan/prep_scheduler.py new file mode 100644 index 0000000..e627f65 --- /dev/null +++ b/app/services/meal_plan/prep_scheduler.py @@ -0,0 +1,91 @@ +# app/services/meal_plan/prep_scheduler.py +"""Sequence prep tasks for a batch cooking session. + +Pure function — no DB or network calls. Sorts tasks by equipment priority +(oven first to maximise oven utilisation) then assigns sequence_order. +""" +from __future__ import annotations + +from dataclasses import dataclass + +_EQUIPMENT_PRIORITY = {"oven": 0, "stovetop": 1, "cold": 2, "no-heat": 3} +_DEFAULT_PRIORITY = 4 + + +@dataclass(frozen=True) +class PrepTask: + recipe_id: int | None + slot_id: int | None + task_label: str + duration_minutes: int | None + sequence_order: int + equipment: str | None + is_parallel: bool = False + notes: str | None = None + user_edited: bool = False + + +def _total_minutes(recipe: dict) -> int | None: + prep = recipe.get("prep_time") + cook = recipe.get("cook_time") + if prep is None and cook is None: + return None + return (prep or 0) + (cook or 0) + + +def _equipment(recipe: dict) -> str | None: + # Corpus recipes don't have an explicit equipment field; use test helper + # field if present, otherwise infer from cook_time (long = oven heuristic). + if "_equipment" in recipe: + return recipe["_equipment"] + minutes = _total_minutes(recipe) + if minutes and minutes >= 45: + return "oven" + return "stovetop" + + +def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]: + """Return a sequenced list of PrepTask objects from plan slots + recipe rows. + + Algorithm: + 1. Build a recipe_id → recipe dict lookup. + 2. Create one task per slot that has a recipe assigned. + 3. Sort by equipment priority (oven first). + 4. Assign contiguous sequence_order starting at 1. + """ + if not slots or not recipes: + return [] + + recipe_map: dict[int, dict] = {r["id"]: r for r in recipes} + raw_tasks: list[tuple[int, dict]] = [] # (priority, kwargs) + + for slot in slots: + recipe_id = slot.get("recipe_id") + if not recipe_id: + continue + recipe = recipe_map.get(recipe_id) + if not recipe: + continue + + eq = _equipment(recipe) + priority = _EQUIPMENT_PRIORITY.get(eq or "", _DEFAULT_PRIORITY) + raw_tasks.append((priority, { + "recipe_id": recipe_id, + "slot_id": slot.get("id"), + "task_label": recipe.get("name", f"Recipe {recipe_id}"), + "duration_minutes": _total_minutes(recipe), + "equipment": eq, + })) + + raw_tasks.sort(key=lambda t: t[0]) + return [ + PrepTask( + recipe_id=kw["recipe_id"], + slot_id=kw["slot_id"], + task_label=kw["task_label"], + duration_minutes=kw["duration_minutes"], + sequence_order=i, + equipment=kw["equipment"], + ) + for i, (_, kw) in enumerate(raw_tasks, 1) + ] diff --git a/app/services/meal_plan/shopping_list.py b/app/services/meal_plan/shopping_list.py new file mode 100644 index 0000000..ea441ad --- /dev/null +++ b/app/services/meal_plan/shopping_list.py @@ -0,0 +1,88 @@ +# app/services/meal_plan/shopping_list.py +"""Compute a shopping list from a meal plan and current pantry inventory. + +Pure function — no DB or network calls. Takes plain dicts from the Store +and returns GapItem dataclasses. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class GapItem: + ingredient_name: str + needed_raw: str | None # first quantity token from recipe text, e.g. "300g" + have_quantity: float | None # pantry quantity when partial match + have_unit: str | None + covered: bool + retailer_links: list = field(default_factory=list) # filled by API layer + + +_QUANTITY_RE = re.compile(r"^(\d+[\d./]*\s*(?:g|kg|ml|l|oz|lb|cup|cups|tsp|tbsp|tbsps|tsps)?)\b", re.I) + + +def _extract_quantity(ingredient_text: str) -> str | None: + """Pull the leading quantity string from a raw ingredient line.""" + m = _QUANTITY_RE.match(ingredient_text.strip()) + return m.group(1).strip() if m else None + + +def _normalise(name: str) -> str: + """Lowercase, strip possessives and plural -s for fuzzy matching.""" + return name.lower().strip().rstrip("s") + + +def compute_shopping_list( + recipes: list[dict], + inventory: list[dict], +) -> tuple[list[GapItem], list[GapItem]]: + """Return (gap_items, covered_items) for a list of recipe dicts + inventory dicts. + + Deduplicates by normalised ingredient name — the first recipe's quantity + string wins when the same ingredient appears in multiple recipes. + """ + if not recipes: + return [], [] + + # Build pantry lookup: normalised_name → inventory row + pantry: dict[str, dict] = {} + for item in inventory: + pantry[_normalise(item["name"])] = item + + # Collect unique ingredients with their first quantity token + seen: dict[str, str | None] = {} # normalised_name → needed_raw + for recipe in recipes: + names: list[str] = recipe.get("ingredient_names") or [] + raw_lines: list[str] = recipe.get("ingredients") or [] + for i, name in enumerate(names): + key = _normalise(name) + if key in seen: + continue + raw = raw_lines[i] if i < len(raw_lines) else "" + seen[key] = _extract_quantity(raw) + + gaps: list[GapItem] = [] + covered: list[GapItem] = [] + + for norm_name, needed_raw in seen.items(): + pantry_row = pantry.get(norm_name) + if pantry_row: + covered.append(GapItem( + ingredient_name=norm_name, + needed_raw=needed_raw, + have_quantity=pantry_row.get("quantity"), + have_unit=pantry_row.get("unit"), + covered=True, + )) + else: + gaps.append(GapItem( + ingredient_name=norm_name, + needed_raw=needed_raw, + have_quantity=None, + have_unit=None, + covered=False, + )) + + return gaps, covered diff --git a/app/tiers.py b/app/tiers.py index 1341999..1ce348c 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -16,6 +16,8 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({ "expiry_llm_matching", "receipt_ocr", "style_classifier", + "meal_plan_llm", + "meal_plan_llm_timing", "community_fork_adapt", }) @@ -44,7 +46,10 @@ KIWI_FEATURES: dict[str, str] = { "receipt_ocr": "paid", # BYOK-unlockable "recipe_suggestions": "paid", # BYOK-unlockable "expiry_llm_matching": "paid", # BYOK-unlockable - "meal_planning": "paid", + "meal_planning": "free", + "meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack) + "meal_plan_llm": "paid", # LLM-assisted full-week plan generation; BYOK-unlockable + "meal_plan_llm_timing": "paid", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable "dietary_profiles": "paid", "style_picker": "paid", "recipe_collections": "paid", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 066830e..cde0d78 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -46,6 +46,18 @@ Receipts + + + @@ -163,12 +189,13 @@ import InventoryList from './components/InventoryList.vue' import ReceiptsView from './components/ReceiptsView.vue' import RecipesView from './components/RecipesView.vue' import SettingsView from './components/SettingsView.vue' +import MealPlanView from './components/MealPlanView.vue' import FeedbackButton from './components/FeedbackButton.vue' import { useInventoryStore } from './stores/inventory' import { useEasterEggs } from './composables/useEasterEggs' import { householdAPI } from './services/api' -type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' +type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan' const currentTab = ref('recipes') const sidebarCollapsed = ref(false) diff --git a/frontend/src/components/MealPlanGrid.vue b/frontend/src/components/MealPlanGrid.vue new file mode 100644 index 0000000..6fdf0e6 --- /dev/null +++ b/frontend/src/components/MealPlanGrid.vue @@ -0,0 +1,126 @@ + + + + + + diff --git a/frontend/src/components/MealPlanView.vue b/frontend/src/components/MealPlanView.vue new file mode 100644 index 0000000..7f6264e --- /dev/null +++ b/frontend/src/components/MealPlanView.vue @@ -0,0 +1,153 @@ + + + + + + diff --git a/frontend/src/components/PrepSessionView.vue b/frontend/src/components/PrepSessionView.vue new file mode 100644 index 0000000..9407abc --- /dev/null +++ b/frontend/src/components/PrepSessionView.vue @@ -0,0 +1,115 @@ + + + + + + diff --git a/frontend/src/components/ShoppingListPanel.vue b/frontend/src/components/ShoppingListPanel.vue new file mode 100644 index 0000000..8cd0042 --- /dev/null +++ b/frontend/src/components/ShoppingListPanel.vue @@ -0,0 +1,112 @@ + + + + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4865f4f..463f917 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -701,6 +701,112 @@ export const savedRecipesAPI = { }, } +// --- Meal Plan types --- + +export interface MealPlanSlot { + id: number + plan_id: number + day_of_week: number // 0 = Monday + meal_type: string + recipe_id: number | null + recipe_title: string | null + servings: number + custom_label: string | null +} + +export interface MealPlan { + id: number + week_start: string // ISO date, e.g. "2026-04-13" + meal_types: string[] + slots: MealPlanSlot[] + created_at: string +} + +export interface RetailerLink { + retailer: string + label: string + url: string +} + +export interface GapItem { + ingredient_name: string + needed_raw: string | null + have_quantity: number | null + have_unit: string | null + covered: boolean + retailer_links: RetailerLink[] +} + +export interface ShoppingList { + plan_id: number + gap_items: GapItem[] + covered_items: GapItem[] + disclosure: string | null +} + +export interface PrepTask { + id: number + recipe_id: number | null + task_label: string + duration_minutes: number | null + sequence_order: number + equipment: string | null + is_parallel: boolean + notes: string | null + user_edited: boolean +} + +export interface PrepSession { + id: number + plan_id: number + scheduled_date: string + status: 'draft' | 'reviewed' | 'done' + tasks: PrepTask[] +} + +// --- Meal Plan API --- + +export const mealPlanAPI = { + async list(): Promise { + const resp = await api.get('/meal-plans/') + return resp.data + }, + + async create(weekStart: string, mealTypes: string[]): Promise { + const resp = await api.post('/meal-plans/', { week_start: weekStart, meal_types: mealTypes }) + return resp.data + }, + + async get(planId: number): Promise { + const resp = await api.get(`/meal-plans/${planId}`) + return resp.data + }, + + async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise { + const resp = await api.put(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data) + return resp.data + }, + + async deleteSlot(planId: number, slotId: number): Promise { + await api.delete(`/meal-plans/${planId}/slots/${slotId}`) + }, + + async getShoppingList(planId: number): Promise { + const resp = await api.get(`/meal-plans/${planId}/shopping-list`) + return resp.data + }, + + async getPrepSession(planId: number): Promise { + const resp = await api.get(`/meal-plans/${planId}/prep-session`) + return resp.data + }, + + async updatePrepTask(planId: number, taskId: number, data: Partial>): Promise { + const resp = await api.patch(`/meal-plans/${planId}/prep-session/tasks/${taskId}`, data) + return resp.data + }, +} + // ========== Browser Types ========== export interface BrowserDomain { diff --git a/frontend/src/stores/mealPlan.ts b/frontend/src/stores/mealPlan.ts new file mode 100644 index 0000000..2c50324 --- /dev/null +++ b/frontend/src/stores/mealPlan.ts @@ -0,0 +1,135 @@ +// frontend/src/stores/mealPlan.ts +/** + * Meal Plan Store + * + * Manages the active week plan, shopping list, and prep session. + * Uses immutable update patterns — never mutates store state in place. + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { + mealPlanAPI, + type MealPlan, + type MealPlanSlot, + type ShoppingList, + type PrepSession, + type PrepTask, +} from '../services/api' + +export const useMealPlanStore = defineStore('mealPlan', () => { + const plans = ref([]) + const activePlan = ref(null) + const shoppingList = ref(null) + const prepSession = ref(null) + const loading = ref(false) + const shoppingListLoading = ref(false) + const prepLoading = ref(false) + + const slots = computed(() => activePlan.value?.slots ?? []) + + function getSlot(dayOfWeek: number, mealType: string): MealPlanSlot | undefined { + return slots.value.find(s => s.day_of_week === dayOfWeek && s.meal_type === mealType) + } + + async function loadPlans() { + loading.value = true + try { + plans.value = await mealPlanAPI.list() + } finally { + loading.value = false + } + } + + async function createPlan(weekStart: string, mealTypes: string[]): Promise { + const plan = await mealPlanAPI.create(weekStart, mealTypes) + plans.value = [plan, ...plans.value] + activePlan.value = plan + shoppingList.value = null + prepSession.value = null + return plan + } + + async function setActivePlan(planId: number) { + loading.value = true + try { + activePlan.value = await mealPlanAPI.get(planId) + shoppingList.value = null + prepSession.value = null + } finally { + loading.value = false + } + } + + async function upsertSlot(dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise { + if (!activePlan.value) return + const slot = await mealPlanAPI.upsertSlot(activePlan.value.id, dayOfWeek, mealType, data) + const current = activePlan.value + const idx = current.slots.findIndex( + s => s.day_of_week === dayOfWeek && s.meal_type === mealType + ) + activePlan.value = { + ...current, + slots: idx >= 0 + ? [...current.slots.slice(0, idx), slot, ...current.slots.slice(idx + 1)] + : [...current.slots, slot], + } + shoppingList.value = null + prepSession.value = null + } + + async function clearSlot(dayOfWeek: number, mealType: string): Promise { + if (!activePlan.value) return + const slot = getSlot(dayOfWeek, mealType) + if (!slot) return + await mealPlanAPI.deleteSlot(activePlan.value.id, slot.id) + activePlan.value = { + ...activePlan.value, + slots: activePlan.value.slots.filter(s => s.id !== slot.id), + } + shoppingList.value = null + prepSession.value = null + } + + async function loadShoppingList(): Promise { + if (!activePlan.value) return + shoppingListLoading.value = true + try { + shoppingList.value = await mealPlanAPI.getShoppingList(activePlan.value.id) + } finally { + shoppingListLoading.value = false + } + } + + async function loadPrepSession(): Promise { + if (!activePlan.value) return + prepLoading.value = true + try { + prepSession.value = await mealPlanAPI.getPrepSession(activePlan.value.id) + } finally { + prepLoading.value = false + } + } + + async function updatePrepTask(taskId: number, data: Partial>): Promise { + if (!activePlan.value || !prepSession.value) return + const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data) + const idx = prepSession.value.tasks.findIndex(t => t.id === taskId) + if (idx >= 0) { + prepSession.value = { + ...prepSession.value, + tasks: [ + ...prepSession.value.tasks.slice(0, idx), + updated, + ...prepSession.value.tasks.slice(idx + 1), + ], + } + } + } + + return { + plans, activePlan, shoppingList, prepSession, + loading, shoppingListLoading, prepLoading, slots, + getSlot, loadPlans, createPlan, setActivePlan, + upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask, + } +}) diff --git a/tests/api/test_meal_plans.py b/tests/api/test_meal_plans.py new file mode 100644 index 0000000..176f0c4 --- /dev/null +++ b/tests/api/test_meal_plans.py @@ -0,0 +1,116 @@ +# tests/api/test_meal_plans.py +"""Integration tests for /api/v1/meal-plans/ endpoints.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.cloud_session import get_session +from app.db.session import get_store +from app.main import app + +client = TestClient(app) + + +def _make_session(tier: str = "free") -> MagicMock: + m = MagicMock() + m.tier = tier + m.has_byok = False + return m + + +def _make_store() -> MagicMock: + m = MagicMock() + m.create_meal_plan.return_value = { + "id": 1, "week_start": "2026-04-14", + "meal_types": ["dinner"], "created_at": "2026-04-12T10:00:00", + } + m.list_meal_plans.return_value = [] + m.get_meal_plan.return_value = None + m.get_plan_slots.return_value = [] + m.upsert_slot.return_value = { + "id": 1, "plan_id": 1, "day_of_week": 0, "meal_type": "dinner", + "recipe_id": 42, "recipe_title": "Pasta", "servings": 2.0, "custom_label": None, + } + m.get_inventory.return_value = [] + m.get_plan_recipes.return_value = [] + m.get_prep_session_for_plan.return_value = None + m.create_prep_session.return_value = { + "id": 1, "plan_id": 1, "scheduled_date": "2026-04-13", + "status": "draft", "created_at": "2026-04-12T10:00:00", + } + m.get_prep_tasks.return_value = [] + m.bulk_insert_prep_tasks.return_value = [] + return m + + +@pytest.fixture() +def free_session(): + session = _make_session("free") + store = _make_store() + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_store] = lambda: store + yield store + app.dependency_overrides.clear() + + +@pytest.fixture() +def paid_session(): + session = _make_session("paid") + store = _make_store() + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_store] = lambda: store + yield store + app.dependency_overrides.clear() + + +def test_create_plan_free_tier_locks_to_dinner(free_session): + resp = client.post("/api/v1/meal-plans/", json={ + "week_start": "2026-04-13", "meal_types": ["breakfast", "dinner"] + }) + assert resp.status_code == 200 + # Free tier forced to dinner-only regardless of request + free_session.create_meal_plan.assert_called_once_with("2026-04-13", ["dinner"]) + + +def test_create_plan_paid_tier_respects_meal_types(paid_session): + resp = client.post("/api/v1/meal-plans/", json={ + "week_start": "2026-04-13", "meal_types": ["breakfast", "lunch", "dinner"] + }) + assert resp.status_code == 200 + paid_session.create_meal_plan.assert_called_once_with( + "2026-04-13", ["breakfast", "lunch", "dinner"] + ) + + +def test_list_plans_returns_200(free_session): + resp = client.get("/api/v1/meal-plans/") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_upsert_slot_returns_200(free_session): + free_session.get_meal_plan.return_value = { + "id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"], + "created_at": "2026-04-12T10:00:00", + } + resp = client.put( + "/api/v1/meal-plans/1/slots/0/dinner", + json={"recipe_id": 42, "servings": 2.0}, + ) + assert resp.status_code == 200 + + +def test_get_shopping_list_returns_200(free_session): + free_session.get_meal_plan.return_value = { + "id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"], + "created_at": "2026-04-12T10:00:00", + } + resp = client.get("/api/v1/meal-plans/1/shopping-list") + assert resp.status_code == 200 + body = resp.json() + assert "gap_items" in body + assert "covered_items" in body diff --git a/tests/api/test_recipe_build_endpoints.py b/tests/api/test_recipe_build_endpoints.py index f40ed4c..8d21a89 100644 --- a/tests/api/test_recipe_build_endpoints.py +++ b/tests/api/test_recipe_build_endpoints.py @@ -4,14 +4,14 @@ from fastapi.testclient import TestClient @pytest.fixture -def client(tmp_path): +def client(tmp_path, monkeypatch): """FastAPI test client with a seeded in-memory DB.""" import os - os.environ["KIWI_DB_PATH"] = str(tmp_path / "test.db") + db_path = tmp_path / "test.db" os.environ["CLOUD_MODE"] = "false" - from app.main import app + # Seed DB before app imports so migrations run and data is present from app.db.store import Store - store = Store(tmp_path / "test.db") + store = Store(db_path) store.conn.execute( "INSERT INTO products (name, barcode) VALUES (?,?)", ("chicken breast", None) ) @@ -25,6 +25,11 @@ def client(tmp_path): "INSERT INTO inventory_items (product_id, location, status) VALUES (2,'pantry','available')" ) store.conn.commit() + store.close() + # Patch the module-level DB path used by local-mode session resolution + import app.cloud_session as _cs + monkeypatch.setattr(_cs, "_LOCAL_KIWI_DB", db_path) + from app.main import app return TestClient(app) @@ -65,7 +70,7 @@ def test_post_build_returns_recipe(client): }) assert resp.status_code == 200 data = resp.json() - assert data["id"] == -1 + assert data["id"] > 0 # persisted to DB with real integer ID assert len(data["directions"]) > 0 diff --git a/tests/services/test_meal_plan_prep_scheduler.py b/tests/services/test_meal_plan_prep_scheduler.py new file mode 100644 index 0000000..39db27c --- /dev/null +++ b/tests/services/test_meal_plan_prep_scheduler.py @@ -0,0 +1,55 @@ +# tests/services/test_meal_plan_prep_scheduler.py +"""Unit tests for prep_scheduler.py — no DB or network.""" +from __future__ import annotations + +import pytest +from app.services.meal_plan.prep_scheduler import PrepTask, build_prep_tasks + + +def _recipe(id_: int, name: str, prep_time: int | None, cook_time: int | None, equipment: str) -> dict: + return { + "id": id_, "name": name, + "prep_time": prep_time, "cook_time": cook_time, + "_equipment": equipment, # test helper field + } + + +def _slot(slot_id: int, recipe: dict, day: int = 0) -> dict: + return {"id": slot_id, "recipe_id": recipe["id"], "day_of_week": day, + "meal_type": "dinner", "servings": 2.0} + + +def test_builds_task_per_slot(): + recipe = _recipe(1, "Pasta", 10, 20, "stovetop") + tasks = build_prep_tasks( + slots=[_slot(1, recipe)], + recipes=[recipe], + ) + assert len(tasks) == 1 + assert tasks[0].task_label == "Pasta" + assert tasks[0].duration_minutes == 30 # prep + cook + + +def test_oven_tasks_scheduled_first(): + oven_recipe = _recipe(1, "Roast Chicken", 10, 60, "oven") + stove_recipe = _recipe(2, "Rice", 2, 20, "stovetop") + tasks = build_prep_tasks( + slots=[_slot(1, stove_recipe), _slot(2, oven_recipe)], + recipes=[stove_recipe, oven_recipe], + ) + orders = {t.task_label: t.sequence_order for t in tasks} + assert orders["Roast Chicken"] < orders["Rice"] + + +def test_missing_corpus_time_leaves_duration_none(): + recipe = _recipe(1, "Mystery Dish", None, None, "stovetop") + tasks = build_prep_tasks(slots=[_slot(1, recipe)], recipes=[recipe]) + assert tasks[0].duration_minutes is None + + +def test_sequence_order_is_contiguous_from_one(): + recipes = [_recipe(i, f"Recipe {i}", 10, 10, "stovetop") for i in range(1, 4)] + slots = [_slot(i, r) for i, r in enumerate(recipes, 1)] + tasks = build_prep_tasks(slots=slots, recipes=recipes) + orders = sorted(t.sequence_order for t in tasks) + assert orders == [1, 2, 3] diff --git a/tests/services/test_meal_plan_shopping_list.py b/tests/services/test_meal_plan_shopping_list.py new file mode 100644 index 0000000..77d6b50 --- /dev/null +++ b/tests/services/test_meal_plan_shopping_list.py @@ -0,0 +1,51 @@ +# tests/services/test_meal_plan_shopping_list.py +"""Unit tests for shopping_list.py — no network, no DB.""" +from __future__ import annotations + +import pytest +from app.services.meal_plan.shopping_list import GapItem, compute_shopping_list + + +def _recipe(ingredient_names: list[str], ingredients: list[str]) -> dict: + return {"ingredient_names": ingredient_names, "ingredients": ingredients} + + +def _inv_item(name: str, quantity: float, unit: str) -> dict: + return {"name": name, "quantity": quantity, "unit": unit} + + +def test_item_in_pantry_is_covered(): + recipes = [_recipe(["pasta"], ["500g pasta"])] + inventory = [_inv_item("pasta", 400, "g")] + gaps, covered = compute_shopping_list(recipes, inventory) + assert len(covered) == 1 + assert covered[0].ingredient_name == "pasta" + assert covered[0].covered is True + assert len(gaps) == 0 + + +def test_item_not_in_pantry_is_gap(): + recipes = [_recipe(["chicken breast"], ["300g chicken breast"])] + inventory = [] + gaps, covered = compute_shopping_list(recipes, inventory) + assert len(gaps) == 1 + assert gaps[0].ingredient_name == "chicken breast" + assert gaps[0].covered is False + assert gaps[0].needed_raw == "300g" + + +def test_duplicate_ingredient_across_recipes_deduplicates(): + recipes = [ + _recipe(["onion"], ["2 onions"]), + _recipe(["onion"], ["1 onion"]), + ] + inventory = [] + gaps, _ = compute_shopping_list(recipes, inventory) + names = [g.ingredient_name for g in gaps] + assert names.count("onion") == 1 + + +def test_empty_plan_returns_empty_lists(): + gaps, covered = compute_shopping_list([], []) + assert gaps == [] + assert covered == [] diff --git a/tests/test_meal_plan_tiers.py b/tests/test_meal_plan_tiers.py new file mode 100644 index 0000000..16da202 --- /dev/null +++ b/tests/test_meal_plan_tiers.py @@ -0,0 +1,27 @@ +# tests/test_meal_plan_tiers.py +from app.tiers import can_use + + +def test_meal_planning_is_free(): + """Basic meal planning (dinner-only, manual) is available to free tier.""" + assert can_use("meal_planning", "free") is True + + +def test_meal_plan_config_requires_paid(): + """Configurable meal types (breakfast/lunch/snack) require Paid.""" + assert can_use("meal_plan_config", "free") is False + assert can_use("meal_plan_config", "paid") is True + + +def test_meal_plan_llm_byok_unlockable(): + """LLM plan generation is Paid but BYOK-unlockable on Free.""" + assert can_use("meal_plan_llm", "free", has_byok=False) is False + assert can_use("meal_plan_llm", "free", has_byok=True) is True + assert can_use("meal_plan_llm", "paid") is True + + +def test_meal_plan_llm_timing_byok_unlockable(): + """LLM time estimation is Paid but BYOK-unlockable on Free.""" + assert can_use("meal_plan_llm_timing", "free", has_byok=False) is False + assert can_use("meal_plan_llm_timing", "free", has_byok=True) is True + assert can_use("meal_plan_llm_timing", "paid") is True