feat(kiwi): merge meal planner feature into main

Adds full meal planning workflow to Kiwi:
- Weekly meal plan creation with configurable meal types (Paid gate)
- Drag-and-assign recipe slots per day
- Prep session generation with sequenced task lists and time estimates
- LLM-assisted full-week plan and timing fill-in (BYOK-unlockable)
- Community feed (local ActivityPub-compat + cloud federation)
- Build Your Own recipe tab with assembly templates
- Save/bookmark any recipe with star rating, notes, and style tags
- Shopping list export from built recipes
- Tab reorder: Saved > Build > Community > Find > Browse
- Auto-redirect from empty Saved tab to Build
- Custom ingredient injection persists in candidate list
- z-index fix: save modal above recipe detail panel
- Route ordering fix: /recipes/saved before /{recipe_id} catch-all
This commit is contained in:
pyr0ball 2026-04-14 15:37:57 -07:00
commit 1a6898324c
30 changed files with 2165 additions and 10 deletions

View file

@ -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)

View file

@ -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(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
api_router.include_router(community_router)

View file

@ -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'))
);

View file

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

View file

@ -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'))
);

View file

@ -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'))
);

View file

@ -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(

View file

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

View file

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

View file

@ -0,0 +1 @@
"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core)."""

View file

@ -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())

View file

@ -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": <int or null>, "suggestion": "<recipe name>"}
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 []

View file

@ -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)

View file

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

View file

@ -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}

View file

@ -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)
]

View file

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

View file

@ -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",

View file

@ -46,6 +46,18 @@
<span class="sidebar-label">Receipts</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="8" y1="4" x2="8" y2="9"/>
<line x1="16" y1="4" x2="16" y2="9"/>
<line x1="7" y1="14" x2="11" y2="14"/>
<line x1="7" y1="17" x2="14" y2="17"/>
</svg>
<span class="sidebar-label">Meal Plan</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
@ -79,6 +91,9 @@
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView />
</div>
<div v-show="currentTab === 'mealplan'" class="tab-content">
<MealPlanView />
</div>
</div>
</main>
</div>
@ -118,6 +133,17 @@
</svg>
<span class="nav-label">Settings</span>
</button>
<button :class="['nav-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2"/>
<line x1="3" y1="9" x2="21" y2="9"/>
<line x1="8" y1="4" x2="8" y2="9"/>
<line x1="16" y1="4" x2="16" y2="9"/>
<line x1="7" y1="14" x2="11" y2="14"/>
<line x1="7" y1="17" x2="14" y2="17"/>
</svg>
<span class="nav-label">Meal Plan</span>
</button>
</nav>
<!-- Feedback FAB hidden when FORGEJO_API_TOKEN not configured -->
@ -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<Tab>('recipes')
const sidebarCollapsed = ref(false)

View file

@ -0,0 +1,126 @@
<!-- frontend/src/components/MealPlanGrid.vue -->
<template>
<div class="meal-plan-grid">
<!-- Collapsible header (mobile) -->
<div class="grid-toggle-row">
<span class="grid-label">This week</span>
<button
class="grid-toggle-btn"
:aria-expanded="!collapsed"
:aria-label="collapsed ? 'Show plan' : 'Hide plan'"
@click="collapsed = !collapsed"
>{{ collapsed ? 'Show plan' : 'Hide plan' }}</button>
</div>
<div v-show="!collapsed" class="grid-body">
<!-- Day headers -->
<div class="day-headers">
<div class="meal-type-col-spacer" />
<div
v-for="(day, i) in DAY_LABELS"
:key="i"
class="day-header"
:aria-label="day"
>{{ day }}</div>
</div>
<!-- One row per meal type -->
<div
v-for="mealType in activeMealTypes"
:key="mealType"
class="meal-row"
>
<div class="meal-type-label">{{ mealType }}</div>
<button
v-for="dayIndex in 7"
:key="dayIndex - 1"
class="slot-btn"
:class="{ filled: !!getSlot(dayIndex - 1, mealType) }"
:aria-label="`${DAY_LABELS[dayIndex - 1]} ${mealType}: ${getSlot(dayIndex - 1, mealType)?.recipe_title ?? 'empty'}`"
@click="$emit('slot-click', { dayOfWeek: dayIndex - 1, mealType })"
>
<span v-if="getSlot(dayIndex - 1, mealType)" class="slot-title">
{{ getSlot(dayIndex - 1, mealType)!.recipe_title ?? getSlot(dayIndex - 1, mealType)!.custom_label ?? '...' }}
</span>
<span v-else class="slot-empty" aria-hidden="true">+</span>
</button>
</div>
<!-- Add meal type row (Paid only) -->
<div v-if="canAddMealType" class="add-meal-type-row">
<button class="add-meal-type-btn" @click="$emit('add-meal-type')">
+ Add meal type
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMealPlanStore } from '../stores/mealPlan'
defineProps<{
activeMealTypes: string[]
canAddMealType: boolean
}>()
defineEmits<{
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
(e: 'add-meal-type'): void
}>()
const store = useMealPlanStore()
const { getSlot } = store
const collapsed = ref(false)
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
</script>
<style scoped>
.meal-plan-grid { display: flex; flex-direction: column; gap: 0.5rem; }
.grid-toggle-row {
display: flex; align-items: center; justify-content: space-between;
padding: 0.25rem 0;
}
.grid-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.07em; opacity: 0.6; }
.grid-toggle-btn {
font-size: 0.75rem; background: none; border: none; cursor: pointer;
color: var(--color-accent); padding: 0.2rem 0.5rem;
}
.grid-body { display: flex; flex-direction: column; gap: 3px; }
.day-headers { display: grid; grid-template-columns: 3rem repeat(7, 1fr); gap: 3px; }
.meal-type-col-spacer { }
.day-header { text-align: center; font-size: 0.7rem; font-weight: 700; padding: 3px; background: var(--color-surface-2); border-radius: 4px; }
.meal-row { display: grid; grid-template-columns: 3rem repeat(7, 1fr); gap: 3px; align-items: start; }
.meal-type-label { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.55; display: flex; align-items: center; font-weight: 600; }
.slot-btn {
border: 1px dashed var(--color-border);
border-radius: 6px;
min-height: 44px;
background: var(--color-surface);
cursor: pointer;
padding: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 0.65rem;
transition: border-color 0.15s, background 0.15s;
width: 100%;
}
.slot-btn:hover { border-color: var(--color-accent); }
.slot-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.slot-btn.filled { border-color: var(--color-success); background: var(--color-success-subtle); }
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); }
.slot-empty { opacity: 0.25; font-size: 1rem; }
.add-meal-type-row { padding: 0.4rem 0 0.2rem; }
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
@media (max-width: 600px) {
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
}
</style>

View file

@ -0,0 +1,153 @@
<!-- frontend/src/components/MealPlanView.vue -->
<template>
<div class="meal-plan-view">
<!-- Week picker + new plan button -->
<div class="plan-controls">
<select
class="week-select"
:value="activePlan?.id ?? ''"
aria-label="Select week"
@change="onSelectPlan(Number(($event.target as HTMLSelectElement).value))"
>
<option value="" disabled>Select a week...</option>
<option v-for="p in plans" :key="p.id" :value="p.id">
Week of {{ p.week_start }}
</option>
</select>
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
</div>
<template v-if="activePlan">
<!-- Compact expandable week grid (always visible) -->
<MealPlanGrid
:active-meal-types="activePlan.meal_types"
:can-add-meal-type="canAddMealType"
@slot-click="onSlotClick"
@add-meal-type="onAddMealType"
/>
<!-- Panel tabs: Shopping List | Prep Schedule -->
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
<button
v-for="tab in TABS"
:key="tab.id"
role="tab"
:aria-selected="activeTab === tab.id"
:aria-controls="`tabpanel-${tab.id}`"
:id="`tab-${tab.id}`"
class="panel-tab"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<div
v-show="activeTab === 'shopping'"
id="tabpanel-shopping"
role="tabpanel"
aria-labelledby="tab-shopping"
class="tab-panel"
>
<ShoppingListPanel @load="store.loadShoppingList()" />
</div>
<div
v-show="activeTab === 'prep'"
id="tabpanel-prep"
role="tabpanel"
aria-labelledby="tab-prep"
class="tab-panel"
>
<PrepSessionView @load="store.loadPrepSession()" />
</div>
</template>
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useMealPlanStore } from '../stores/mealPlan'
import MealPlanGrid from './MealPlanGrid.vue'
import ShoppingListPanel from './ShoppingListPanel.vue'
import PrepSessionView from './PrepSessionView.vue'
const TABS = [
{ id: 'shopping', label: 'Shopping List' },
{ id: 'prep', label: 'Prep Schedule' },
] as const
type TabId = typeof TABS[number]['id']
const store = useMealPlanStore()
const { plans, activePlan, loading } = storeToRefs(store)
const activeTab = ref<TabId>('shopping')
// canAddMealType is a UI hint backend enforces the paid gate authoritatively
const canAddMealType = computed(() =>
(activePlan.value?.meal_types.length ?? 0) < 4
)
onMounted(() => store.loadPlans())
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]
await store.createPlan(weekStart, ['dinner'])
}
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 onAddMealType() {
// Add meal type picker Paid gate enforced by backend
}
</script>
<style scoped>
.meal-plan-view { display: flex; flex-direction: column; gap: 1rem; }
.plan-controls { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.week-select {
flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px;
border: 1px solid var(--color-border); background: var(--color-surface);
color: var(--color-text); font-size: 0.85rem;
}
.new-plan-btn {
padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.82rem;
background: var(--color-accent-subtle); color: var(--color-accent);
border: 1px solid var(--color-accent); cursor: pointer; white-space: nowrap;
}
.new-plan-btn:hover { background: var(--color-accent); color: white; }
.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;
background: none; border: 1px solid transparent; border-bottom: none; cursor: pointer;
color: var(--color-text-secondary); transition: color 0.15s, background 0.15s;
}
.panel-tab.active {
color: var(--color-accent); background: var(--color-accent-subtle);
border-color: var(--color-border); border-bottom-color: transparent;
}
.panel-tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.tab-panel { padding-top: 0.75rem; }
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
</style>

View file

@ -0,0 +1,115 @@
<!-- frontend/src/components/PrepSessionView.vue -->
<template>
<div class="prep-session-view">
<div v-if="loading" class="panel-loading">Building prep schedule...</div>
<template v-else-if="prepSession">
<p class="prep-intro">
Tasks are ordered to make the most of your oven and stovetop.
Edit any time estimate that looks wrong your changes are saved.
</p>
<ol class="task-list" role="list">
<li
v-for="task in prepSession.tasks"
:key="task.id"
class="task-item"
:class="{ 'user-edited': task.user_edited }"
>
<div class="task-header">
<span class="task-order" aria-hidden="true">{{ task.sequence_order }}</span>
<span class="task-label">{{ task.task_label }}</span>
<span v-if="task.equipment" class="task-equip">{{ task.equipment }}</span>
</div>
<div class="task-meta">
<label class="duration-label">
Time estimate (min):
<input
type="number"
min="1"
class="duration-input"
:value="task.duration_minutes ?? ''"
:placeholder="task.duration_minutes ? '' : 'unknown'"
:aria-label="`Duration for ${task.task_label} in minutes`"
@change="onDurationChange(task.id, ($event.target as HTMLInputElement).value)"
/>
</label>
<span v-if="task.user_edited" class="edited-badge" title="You edited this">edited</span>
</div>
<div v-if="task.notes" class="task-notes">{{ task.notes }}</div>
</li>
</ol>
<div v-if="!prepSession.tasks.length" class="empty-state">
No recipes assigned yet add some to your plan first.
</div>
</template>
<div v-else class="empty-state">
<button class="load-btn" @click="$emit('load')">Build prep schedule</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useMealPlanStore } from '../stores/mealPlan'
import { storeToRefs } from 'pinia'
defineEmits<{ (e: 'load'): void }>()
const store = useMealPlanStore()
const { prepSession, prepLoading: loading } = storeToRefs(store)
async function onDurationChange(taskId: number, value: string) {
const minutes = parseInt(value, 10)
if (!isNaN(minutes) && minutes > 0) {
await store.updatePrepTask(taskId, { duration_minutes: minutes })
}
}
</script>
<style scoped>
.prep-session-view { display: flex; flex-direction: column; gap: 1rem; }
.panel-loading { font-size: 0.85rem; opacity: 0.6; padding: 1rem 0; }
.prep-intro { font-size: 0.82rem; opacity: 0.65; margin: 0; }
.task-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.5rem; }
.task-item {
padding: 0.6rem 0.8rem; border-radius: 8px;
background: var(--color-surface-2); border: 1px solid var(--color-border);
display: flex; flex-direction: column; gap: 0.35rem;
}
.task-item.user-edited { border-color: var(--color-accent); }
.task-header { display: flex; align-items: center; gap: 0.5rem; }
.task-order {
width: 22px; height: 22px; border-radius: 50%;
background: var(--color-accent); color: white;
font-size: 0.7rem; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.task-label { flex: 1; font-size: 0.88rem; font-weight: 500; }
.task-equip { font-size: 0.68rem; padding: 2px 6px; border-radius: 12px; background: var(--color-surface); opacity: 0.7; }
.task-meta { display: flex; align-items: center; gap: 0.75rem; }
.duration-label { font-size: 0.75rem; opacity: 0.7; display: flex; align-items: center; gap: 0.3rem; }
.duration-input {
width: 52px; padding: 2px 4px; border-radius: 4px;
border: 1px solid var(--color-border); background: var(--color-surface);
font-size: 0.78rem; color: var(--color-text);
}
.duration-input:focus { outline: 2px solid var(--color-accent); outline-offset: 1px; }
.edited-badge { font-size: 0.65rem; opacity: 0.5; font-style: italic; }
.task-notes { font-size: 0.75rem; opacity: 0.6; }
.empty-state { font-size: 0.85rem; opacity: 0.55; padding: 1rem 0; text-align: center; }
.load-btn {
font-size: 0.85rem; padding: 0.5rem 1.2rem;
background: var(--color-accent-subtle); color: var(--color-accent);
border: 1px solid var(--color-accent); border-radius: 20px; cursor: pointer;
}
.load-btn:hover { background: var(--color-accent); color: white; }
</style>

View file

@ -0,0 +1,112 @@
<!-- frontend/src/components/ShoppingListPanel.vue -->
<template>
<div class="shopping-list-panel">
<div v-if="loading" class="panel-loading">Loading shopping list...</div>
<template v-else-if="shoppingList">
<!-- Disclosure banner -->
<div v-if="shoppingList.disclosure && shoppingList.gap_items.length" class="disclosure-banner">
{{ shoppingList.disclosure }}
</div>
<!-- Gap items (need to buy) -->
<section v-if="shoppingList.gap_items.length" aria-label="Items to buy">
<h3 class="section-heading">To buy ({{ shoppingList.gap_items.length }})</h3>
<ul class="item-list" role="list">
<li v-for="item in shoppingList.gap_items" :key="item.ingredient_name" class="gap-item">
<label class="item-row">
<input type="checkbox" class="item-check" :aria-label="`Mark ${item.ingredient_name} as grabbed`" />
<span class="item-name">{{ item.ingredient_name }}</span>
<span v-if="item.needed_raw" class="item-qty gap">{{ item.needed_raw }}</span>
</label>
<div v-if="item.retailer_links.length" class="retailer-links">
<a
v-for="link in item.retailer_links"
:key="link.retailer"
:href="link.url"
target="_blank"
rel="noopener noreferrer sponsored"
class="retailer-link"
:aria-label="`Buy ${item.ingredient_name} at ${link.label}`"
>{{ link.label }}</a>
</div>
</li>
</ul>
</section>
<!-- Covered items (already in pantry) -->
<section v-if="shoppingList.covered_items.length" aria-label="Items already in pantry">
<h3 class="section-heading covered-heading">In your pantry ({{ shoppingList.covered_items.length }})</h3>
<ul class="item-list covered-list" role="list">
<li v-for="item in shoppingList.covered_items" :key="item.ingredient_name" class="covered-item">
<span class="check-icon" aria-hidden="true"></span>
<span class="item-name">{{ item.ingredient_name }}</span>
<span v-if="item.have_quantity" class="item-qty">{{ item.have_quantity }} {{ item.have_unit }}</span>
</li>
</ul>
</section>
<div v-if="!shoppingList.gap_items.length && !shoppingList.covered_items.length" class="empty-state">
No ingredients yet add some recipes to your plan first.
</div>
</template>
<div v-else class="empty-state">
<button class="load-btn" @click="$emit('load')">Generate shopping list</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useMealPlanStore } from '../stores/mealPlan'
import { storeToRefs } from 'pinia'
defineEmits<{ (e: 'load'): void }>()
const store = useMealPlanStore()
const { shoppingList, shoppingListLoading: loading } = storeToRefs(store)
</script>
<style scoped>
.shopping-list-panel { display: flex; flex-direction: column; gap: 1rem; }
.panel-loading { font-size: 0.85rem; opacity: 0.6; padding: 1rem 0; }
.disclosure-banner {
font-size: 0.72rem; opacity: 0.55; padding: 0.4rem 0.6rem;
background: var(--color-surface-2); border-radius: 6px;
}
.section-heading { font-size: 0.8rem; font-weight: 600; margin: 0 0 0.5rem; }
.covered-heading { opacity: 0.6; }
.item-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.gap-item { display: flex; flex-direction: column; gap: 3px; padding: 6px 0; border-bottom: 1px solid var(--color-border); }
.gap-item:last-child { border-bottom: none; }
.item-row { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; }
.item-check { width: 16px; height: 16px; flex-shrink: 0; }
.item-name { flex: 1; font-size: 0.85rem; }
.item-qty { font-size: 0.75rem; opacity: 0.7; }
.item-qty.gap { color: var(--color-warning, #e88); opacity: 1; }
.retailer-links { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 1.5rem; }
.retailer-link {
font-size: 0.68rem; padding: 2px 8px; border-radius: 20px;
background: var(--color-surface-2); color: var(--color-accent);
text-decoration: none; border: 1px solid var(--color-border);
transition: background 0.15s;
}
.retailer-link:hover { background: var(--color-accent-subtle); }
.retailer-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.covered-item { display: flex; align-items: center; gap: 0.5rem; padding: 4px 0; opacity: 0.6; font-size: 0.82rem; }
.check-icon { color: var(--color-success); font-size: 0.75rem; }
.empty-state { font-size: 0.85rem; opacity: 0.55; padding: 1rem 0; text-align: center; }
.load-btn {
font-size: 0.85rem; padding: 0.5rem 1.2rem;
background: var(--color-accent-subtle); color: var(--color-accent);
border: 1px solid var(--color-accent); border-radius: 20px; cursor: pointer;
}
.load-btn:hover { background: var(--color-accent); color: white; }
</style>

View file

@ -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<MealPlan[]> {
const resp = await api.get<MealPlan[]>('/meal-plans/')
return resp.data
},
async create(weekStart: string, mealTypes: string[]): Promise<MealPlan> {
const resp = await api.post<MealPlan>('/meal-plans/', { week_start: weekStart, meal_types: mealTypes })
return resp.data
},
async get(planId: number): Promise<MealPlan> {
const resp = await api.get<MealPlan>(`/meal-plans/${planId}`)
return resp.data
},
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
return resp.data
},
async deleteSlot(planId: number, slotId: number): Promise<void> {
await api.delete(`/meal-plans/${planId}/slots/${slotId}`)
},
async getShoppingList(planId: number): Promise<ShoppingList> {
const resp = await api.get<ShoppingList>(`/meal-plans/${planId}/shopping-list`)
return resp.data
},
async getPrepSession(planId: number): Promise<PrepSession> {
const resp = await api.get<PrepSession>(`/meal-plans/${planId}/prep-session`)
return resp.data
},
async updatePrepTask(planId: number, taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<PrepTask> {
const resp = await api.patch<PrepTask>(`/meal-plans/${planId}/prep-session/tasks/${taskId}`, data)
return resp.data
},
}
// ========== Browser Types ==========
export interface BrowserDomain {

View file

@ -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<MealPlan[]>([])
const activePlan = ref<MealPlan | null>(null)
const shoppingList = ref<ShoppingList | null>(null)
const prepSession = ref<PrepSession | null>(null)
const loading = ref(false)
const shoppingListLoading = ref(false)
const prepLoading = ref(false)
const slots = computed<MealPlanSlot[]>(() => 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<MealPlan> {
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<void> {
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<void> {
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<void> {
if (!activePlan.value) return
shoppingListLoading.value = true
try {
shoppingList.value = await mealPlanAPI.getShoppingList(activePlan.value.id)
} finally {
shoppingListLoading.value = false
}
}
async function loadPrepSession(): Promise<void> {
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<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)
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,
}
})

View file

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

View file

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

View file

@ -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]

View file

@ -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 == []

View file

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