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:
commit
1a6898324c
30 changed files with 2165 additions and 10 deletions
294
app/api/endpoints/meal_plans.py
Normal file
294
app/api/endpoints/meal_plans.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
8
app/db/migrations/022_meal_plans.sql
Normal file
8
app/db/migrations/022_meal_plans.sql
Normal 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'))
|
||||
);
|
||||
11
app/db/migrations/023_meal_plan_slots.sql
Normal file
11
app/db/migrations/023_meal_plan_slots.sql
Normal 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)
|
||||
);
|
||||
10
app/db/migrations/024_prep_sessions.sql
Normal file
10
app/db/migrations/024_prep_sessions.sql
Normal 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'))
|
||||
);
|
||||
15
app/db/migrations/025_prep_tasks.sql
Normal file
15
app/db/migrations/025_prep_tasks.sql
Normal 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'))
|
||||
);
|
||||
121
app/db/store.py
121
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
96
app/models/schemas/meal_plan.py
Normal file
96
app/models/schemas/meal_plan.py
Normal 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
|
||||
1
app/services/meal_plan/__init__.py
Normal file
1
app/services/meal_plan/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core)."""
|
||||
108
app/services/meal_plan/affiliates.py
Normal file
108
app/services/meal_plan/affiliates.py
Normal 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())
|
||||
91
app/services/meal_plan/llm_planner.py
Normal file
91
app/services/meal_plan/llm_planner.py
Normal 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 []
|
||||
96
app/services/meal_plan/llm_router.py
Normal file
96
app/services/meal_plan/llm_router.py
Normal 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)
|
||||
65
app/services/meal_plan/llm_timing.py
Normal file
65
app/services/meal_plan/llm_timing.py
Normal 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
|
||||
26
app/services/meal_plan/planner.py
Normal file
26
app/services/meal_plan/planner.py
Normal 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}
|
||||
91
app/services/meal_plan/prep_scheduler.py
Normal file
91
app/services/meal_plan/prep_scheduler.py
Normal 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)
|
||||
]
|
||||
88
app/services/meal_plan/shopping_list.py
Normal file
88
app/services/meal_plan/shopping_list.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
126
frontend/src/components/MealPlanGrid.vue
Normal file
126
frontend/src/components/MealPlanGrid.vue
Normal 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>
|
||||
153
frontend/src/components/MealPlanView.vue
Normal file
153
frontend/src/components/MealPlanView.vue
Normal 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>
|
||||
115
frontend/src/components/PrepSessionView.vue
Normal file
115
frontend/src/components/PrepSessionView.vue
Normal 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>
|
||||
112
frontend/src/components/ShoppingListPanel.vue
Normal file
112
frontend/src/components/ShoppingListPanel.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
135
frontend/src/stores/mealPlan.ts
Normal file
135
frontend/src/stores/mealPlan.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
116
tests/api/test_meal_plans.py
Normal file
116
tests/api/test_meal_plans.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
55
tests/services/test_meal_plan_prep_scheduler.py
Normal file
55
tests/services/test_meal_plan_prep_scheduler.py
Normal 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]
|
||||
51
tests/services/test_meal_plan_shopping_list.py
Normal file
51
tests/services/test_meal_plan_shopping_list.py
Normal 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 == []
|
||||
27
tests/test_meal_plan_tiers.py
Normal file
27
tests/test_meal_plan_tiers.py
Normal 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
|
||||
Loading…
Reference in a new issue