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 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
|
from app.api.endpoints.community import router as community_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(feedback.router, prefix="/feedback", tags=["feedback"])
|
||||||
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
api_router.include_router(household.router, prefix="/household", tags=["household"])
|
||||||
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
|
||||||
api_router.include_router(community_router)
|
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",
|
"ingredients", "ingredient_names", "directions",
|
||||||
"keywords", "element_coverage",
|
"keywords", "element_coverage",
|
||||||
# saved recipe columns
|
# saved recipe columns
|
||||||
"style_tags"):
|
"style_tags",
|
||||||
|
# meal plan columns
|
||||||
|
"meal_types"):
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
try:
|
try:
|
||||||
d[key] = json.loads(d[key])
|
d[key] = json.loads(d[key])
|
||||||
|
|
@ -1079,6 +1081,123 @@ class Store:
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
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:
|
def get_current_pseudonym(self, directus_user_id: str) -> str | None:
|
||||||
"""Return the current community pseudonym for this user, or None if not set."""
|
"""Return the current community pseudonym for this user, or None if not set."""
|
||||||
cur = self.conn.execute(
|
cur = self.conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import api_router
|
from app.api.routes import api_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.services.meal_plan.affiliates import register_kiwi_programs
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info("Starting Kiwi API...")
|
logger.info("Starting Kiwi API...")
|
||||||
settings.ensure_dirs()
|
settings.ensure_dirs()
|
||||||
|
register_kiwi_programs()
|
||||||
|
|
||||||
# Start LLM background task scheduler
|
# Start LLM background task scheduler
|
||||||
from app.tasks.scheduler import get_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",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
"style_classifier",
|
"style_classifier",
|
||||||
|
"meal_plan_llm",
|
||||||
|
"meal_plan_llm_timing",
|
||||||
"community_fork_adapt",
|
"community_fork_adapt",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -44,7 +46,10 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"receipt_ocr": "paid", # BYOK-unlockable
|
"receipt_ocr": "paid", # BYOK-unlockable
|
||||||
"recipe_suggestions": "paid", # BYOK-unlockable
|
"recipe_suggestions": "paid", # BYOK-unlockable
|
||||||
"expiry_llm_matching": "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",
|
"dietary_profiles": "paid",
|
||||||
"style_picker": "paid",
|
"style_picker": "paid",
|
||||||
"recipe_collections": "paid",
|
"recipe_collections": "paid",
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,18 @@
|
||||||
<span class="sidebar-label">Receipts</span>
|
<span class="sidebar-label">Receipts</span>
|
||||||
</button>
|
</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')">
|
<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">
|
<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"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|
@ -79,6 +91,9 @@
|
||||||
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
||||||
<SettingsView />
|
<SettingsView />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
||||||
|
<MealPlanView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,6 +133,17 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Settings</span>
|
<span class="nav-label">Settings</span>
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
<!-- 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 ReceiptsView from './components/ReceiptsView.vue'
|
||||||
import RecipesView from './components/RecipesView.vue'
|
import RecipesView from './components/RecipesView.vue'
|
||||||
import SettingsView from './components/SettingsView.vue'
|
import SettingsView from './components/SettingsView.vue'
|
||||||
|
import MealPlanView from './components/MealPlanView.vue'
|
||||||
import FeedbackButton from './components/FeedbackButton.vue'
|
import FeedbackButton from './components/FeedbackButton.vue'
|
||||||
import { useInventoryStore } from './stores/inventory'
|
import { useInventoryStore } from './stores/inventory'
|
||||||
import { useEasterEggs } from './composables/useEasterEggs'
|
import { useEasterEggs } from './composables/useEasterEggs'
|
||||||
import { householdAPI } from './services/api'
|
import { householdAPI } from './services/api'
|
||||||
|
|
||||||
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
|
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
|
||||||
|
|
||||||
const currentTab = ref<Tab>('recipes')
|
const currentTab = ref<Tab>('recipes')
|
||||||
const sidebarCollapsed = ref(false)
|
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 ==========
|
// ========== Browser Types ==========
|
||||||
|
|
||||||
export interface BrowserDomain {
|
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
|
@pytest.fixture
|
||||||
def client(tmp_path):
|
def client(tmp_path, monkeypatch):
|
||||||
"""FastAPI test client with a seeded in-memory DB."""
|
"""FastAPI test client with a seeded in-memory DB."""
|
||||||
import os
|
import os
|
||||||
os.environ["KIWI_DB_PATH"] = str(tmp_path / "test.db")
|
db_path = tmp_path / "test.db"
|
||||||
os.environ["CLOUD_MODE"] = "false"
|
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
|
from app.db.store import Store
|
||||||
store = Store(tmp_path / "test.db")
|
store = Store(db_path)
|
||||||
store.conn.execute(
|
store.conn.execute(
|
||||||
"INSERT INTO products (name, barcode) VALUES (?,?)", ("chicken breast", None)
|
"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')"
|
"INSERT INTO inventory_items (product_id, location, status) VALUES (2,'pantry','available')"
|
||||||
)
|
)
|
||||||
store.conn.commit()
|
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)
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -65,7 +70,7 @@ def test_post_build_returns_recipe(client):
|
||||||
})
|
})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["id"] == -1
|
assert data["id"] > 0 # persisted to DB with real integer ID
|
||||||
assert len(data["directions"]) > 0
|
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