# 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 ───────────────────────────┐ web ─► nginx :80/:443 ─► React/Vite ───────┼─► FastAPI ──► PostgreSQL 15 │ │ │ └─► Swiftly JSON API │ (prod.swiftlyapi.net) │ └─► /api/admin/scrape (BackgroundTasks) scheduler ─► APScheduler (America/Los_Angeles) Fri 02:00 scrape → 05:00 generate → 06:00 email Fri 16:00 reminder → 17:00 deadline → 18:00 finalize+shopping-list (same backend image; command: python -m app.scheduler) ``` - **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-08) | # | 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) | **Complete** — Friday Pacific cadence; APScheduler container; `weekly_run` state table; idempotent steps; stale-data fallback; configurable deadline policy. | | 6 | SendGrid email integration (proposal/reminder/confirmation) | **Complete** — `SendGridEmailBackend` live; `step_reminder` (Fri 16:00 PT) nudges non-voters; all templates HTML-safe. | | 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 (current, 2026-05-08): **123/123 pytest green**; alembic upgrade→downgrade→upgrade clean (0001–0009); frontend `npm run build` clean; live scrape persists **9,980 grocery_item rows in 44 s** with auto-minted JWT (no env var); 29,779 ingredient_grocery_match rows produced; email approval round-trip (approve/deny/single-use) verified end-to-end; scheduler container starts with all **6** Friday jobs registered. --- ## Auth model (R1-B+D, locked in) - Bearer token `ADMIN_TOKEN` for 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/login` accepts `{password}` matching `SESSION_PASSWORD`. Sets the cookie. - `/api/meals/vote/{item_id}?token=…` keeps its per-voter token flow; not session-gated. - **Bootstrap hatch**: when no `family_profile` row 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, 0006 thin Phase 4 (ingredient.aliases, recipe.calories_per_serving, ingredient_grocery_match), 0007 seed canonical ingredients (50) + 30 starter recipes, 0008 Phase 5 (`weekly_run` table + `family_profile.pending_approval_policy`), 0009 Phase 6 (`weekly_run.reminded_at TIMESTAMPTZ NULL`). Full schema: `docs/database-schema.md`. --- ## Environment variables (verified) ```bash # 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=... # required when EMAIL_BACKEND=sendgrid SENDGRID_FROM_EMAIL=peter@research.bike # sender address (default) SENDGRID_REPLY_TO=peter@research.bike # reply-to address (default) # Phase 5 orchestration ADMIN_EMAIL=... # alert destination; empty = alerts dropped silently APP_BASE_URL=http://localhost # base URL for vote links in emails # Lucky / Swiftly LUCKY_STORE_ID=757 # Lucky California — San Pablo SWIFTLY_API_BASE=https://prod.swiftlyapi.net SWIFTLY_CATEGORIES_URL=https://luckysupermarkets.com/categories # (no bearer-token env var — minted on demand by app/services/swiftly_auth.py) # Other LUCKY_CA_URL=https://luckysupermarkets.com AI_IMAGE_ENABLED=false LOG_LEVEL=INFO ``` The Swiftly bearer JWT is auto-minted at request time via Firebase REST anon-signUp (`backend/app/services/swiftly_auth.py`, spec `docs/specs/2026-05-06-swiftly-token-auto-mint.md`). Process-local cache; ~99% cache-hit rate in steady state. A mint failure surfaces as `SwiftlyAuthMintError` and lands verbatim in `ScrapeLog.error_message`. --- ## Verification commands ```bash # Full local stack (include scheduler for Phase 5) docker compose --env-file .env.test up -d db backend scheduler 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/ # → 123 passed # Verify scheduler docker compose --env-file .env.test logs scheduler | grep Registered # Expected: 6 lines — scrape/generate/email/reminder/deadline/finalize # Manual step trigger (useful for testing without waiting for Friday) curl -X POST http://localhost:8000/api/admin/orchestrate/scrape \ -H "Authorization: Bearer ${ADMIN_TOKEN}" # 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`/`/planned` suffixes. - Tests: pytest; `requires_postgres` marker auto-skips locally without `TEST_DATABASE_URL`. - Migrations: Alembic only. Never `Base.metadata.create_all()` at runtime. - Background work: FastAPI `BackgroundTasks` for on-demand scrape. APScheduler `BlockingScheduler` in the `scheduler` container for the weekly cycle (6 Friday Pacific jobs — Phases 5+6 complete). --- ## 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. - `docs/specs/2026-05-05-meal-planner-algorithm-design.md` — Phase 9 + thin Phase 4 design. - `docs/specs/2026-05-06-swiftly-token-auto-mint.md` — Swiftly JWT auto-mint design (Implemented 2026-05-06). - `docs/superpowers/plans/2026-05-07-phase5-orchestration.md` — Phase 5 implementation plan (executed 2026-05-07). - `docs/superpowers/specs/2026-05-07-phase6-sendgrid-design.md` — Phase 6 design spec. - `docs/superpowers/plans/2026-05-07-phase6-sendgrid.md` — Phase 6 implementation plan (executed 2026-05-08). - `.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-08 — Phase 6 SendGrid shipped; 123/123 pytest green; scheduler container live with 6 Friday Pacific jobs. Next pickup: Frontend login UI.