7.8 KiB
Meal Planner — Orientation
First stop for any agent resuming work. Read this, then docs/HANDOFF.md for the deep dive.
What this project is
Self-hosted meal planning for one family of 4. Pulls weekly grocery prices from Lucky California (San Pablo, store 757) via the Swiftly JSON API, generates a 7-day meal plan, emails per-member approval links, builds a shopping list grouped by aisle. Replaces meal-kit subscriptions (Blue Apron / Sunbasket / etc.) which marked up ingredients ~3× and produced repetitive meals.
Constraint that drives the design: 3 of 4 members do not like mushrooms; family is calorie/budget conscious; no allergies. Must work for non-technical wife + 2 kids; technical owner self-hosts.
Architecture (current)
email ─► SendGrid (R3-C, not yet wired) ──┐
web ─► nginx :80/:443 ─► React/Vite ────┼─► FastAPI ──► PostgreSQL 15
│ │
│ └─► Swiftly JSON API
│ (prod.swiftlyapi.net)
│
└─► /api/admin/scrape (BackgroundTasks)
- backend (FastAPI 0.109, SQLAlchemy 2.0, Alembic) — internal only,
expose: 8000 - frontend (React 18 + TS + Vite + Tailwind) — internal only via nginx
- db (Postgres 15-alpine) — internal only
- nginx — sole external entry, ports 80/443
Phase status (2026-05-06)
| # | Phase | Status |
|---|---|---|
| 1 | Infra (Docker, FastAPI, React, nginx, Postgres) | Complete |
| 2 | DB & models (Alembic, Pydantic schemas, API endpoints) | Complete (real, verified) |
| 3 | Lucky California ingestion (Swiftly JSON API) | Complete — 17 categories, ~10k products live |
| 4 | Recipe engine (CRUD, search, tagging, never-suggest filter) | Thin slice complete — recipe + ingredient CRUD, ingredient↔grocery match layer (rapidfuzz, manual override), NeverSuggest CRUD, 30-recipe seed. Ingestion source decision deferred (see spec). |
| 5 | Meal planner orchestration (generate → email → vote → finalize) | Not started |
| 6 | SendGrid email integration (proposal/reminder/confirmation) | Stub only — app/services/email.py::SendGridEmailBackend raises NotImplementedError |
| 7 | Web UI core (Dashboard / Meal Detail / Pantry / Shopping List) | Complete (no auth UI yet) |
| 8 | Web UI feedback portal | Not started |
| 9 | Meal-planner generation algorithm | Complete — POST /api/admin/meal-plans/generate produces 3-dinner plans against seeded recipes + matched grocery prices. Filter (6 hard constraints), score (5 signals), top-K=20 set enumeration with diversity penalty. |
| 10 | Image strategy (scraped + AI fallback) | Not started |
| 11 | Polish (variety analysis, budget tracking, APScheduler) | Not started |
Verification gate (R1+R2 + R3-0): 31/31 pytest green; alembic upgrade→downgrade→upgrade clean; frontend npm run build clean; live scrape persists 9,960 grocery_item rows in 36 s; email approval round-trip (approve/deny/single-use) verified end-to-end.
Auth model (R1-B+D, locked in)
- Bearer token
ADMIN_TOKENfor every/api/admin/*route. - Signed-cookie session (itsdangerous, key=
SECRET_KEY) for all NON-GET routes on profile/pantry/recipes/meals/shopping-list. GET reads stay open inside the trusted network. /api/auth/loginaccepts{password}matchingSESSION_PASSWORD. Sets the cookie./api/meals/vote/{item_id}?token=…keeps its per-voter token flow; not session-gated.- Bootstrap hatch: when no
family_profilerow exists, login signs the literal string"bootstrap"instead of a UUID. First-run convenience only — replace with a real setup gate before any non-trusted exposure.
Database schema highlights
Core tables: family_profile, family_member, recipe, ingredient, meal_plan, meal_plan_item, meal_plan_vote, approval_token, home_pantry, feedback, grocery_item, scrape_log, email_log.
Conventions: UUID PKs everywhere, TIMESTAMPTZ, Postgres ENUMs (with values_callable=lambda obj: [e.value for e in obj] on every SQLEnum — name-mode silently breaks otherwise), ISO day-of-week (1=Mon).
Key relationships: family_profile→family_member; family_member→meal_plan_vote (per-voter); recipe.ingredients JSONB (no recipe-ingredient join table); grocery_item.(source, external_id) is the upsert key for scrape ingestion.
Migrations applied: 0001 initial, 0002 seed (idempotent via ON CONFLICT DO NOTHING), 0003 grocery_item.description, 0004 family_profile.calorie_target, 0005 grocery_item.external_id + source + composite index.
Full schema: docs/database-schema.md.
Environment variables (verified)
# Database
POSTGRES_PASSWORD=...
DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner
# Auth
SECRET_KEY=... # signs session cookies + approval tokens
ADMIN_TOKEN=... # bearer for /api/admin/*
SESSION_PASSWORD=... # family-shared password for /api/auth/login
# Email
EMAIL_BACKEND=console # 'console' (default) or 'sendgrid'
SENDGRID_API_KEY=... # only when EMAIL_BACKEND=sendgrid (R3-C)
# Lucky / Swiftly
LUCKY_STORE_ID=757 # Lucky California — San Pablo
SWIFTLY_API_BASE=https://prod.swiftlyapi.net
SWIFTLY_CATEGORIES_URL=https://luckysupermarkets.com/categories
SWIFTLY_BEARER_TOKEN=... # Firebase anon JWT, expires hourly
# Other
LUCKY_CA_URL=https://luckysupermarkets.com
AI_IMAGE_ENABLED=false
LOG_LEVEL=INFO
.env.example carries a literal expiring bearer token — rotate before any real run. On expiry, the next scrape's ScrapeLog.error_message reads SWIFTLY_BEARER_TOKEN expired — request a fresh token from the user (capture from luckysupermarkets.com network tab on a /search/api/v1 request). Fix: capture a fresh Authorization: Bearer … from devtools, update env, restart backend, re-trigger.
Verification commands
# Full local stack
docker compose --env-file .env.test up -d db backend
docker compose --env-file .env.test exec backend alembic upgrade head
docker compose --env-file .env.test exec -e TEST_DATABASE_URL=postgresql://mealplanner:${POSTGRES_PASSWORD}@db:5432/mealplanner backend pytest -q tests/
# Frontend
cd frontend && npm ci && npm run build
# CI: .github/workflows/ci.yml runs both jobs on push/PR.
A .env.test template lives in the repo root (gitignored) for local stack runs. Pytest uses TEST_DATABASE_URL; alembic uses DATABASE_URL.
Conventions
- Python: Black + isort. TypeScript: Prettier + ESLint.
- Conventional commits (
feat:,fix:,docs:,refactor:,test:). - API paths: no trailing slash, no
/list//plannedsuffixes. - Tests: pytest;
requires_postgresmarker auto-skips locally withoutTEST_DATABASE_URL. - Migrations: Alembic only. Never
Base.metadata.create_all()at runtime. - Background work: FastAPI
BackgroundTasks(current). APScheduler with--workers 1planned for Phase 11.
Where to look
docs/HANDOFF.md— comprehensive handoff for fresh agents (start here for non-trivial work).docs/SPEC.md— product spec.docs/ARCHITECTURE.md— system design.docs/database-schema.md— full DDL reference.docs/implementation-plan.md— original phased plan.docs/RUNNING.md— local dev workflow..agent/plan.md,.agent/context.md,.agent/phase-summaries/— recovery decisions and per-phase summaries from the R1+R2+R3-0 work.Review/reviewconcensus.md— the adversarial review that drove the recovery.
Last updated: 2026-05-06 — Phase 9 complete (meal-plan generation algorithm: filter→score→set-select pipeline; persisted MealPlan + items via POST /api/admin/meal-plans/generate). 88/88 pytest green.