Files
Meal-Planner/.agent/context.md
T
MealPlanner 09c7525a12 fix(ui): align 'this week' to upcoming Monday (Sprint 7)
User report 2026-06-05: 'webui Meal Planner page is empty' on Friday
morning after the Friday email went out. Root cause: the orchestrator
keyed plans by the most-recent-Friday while the frontend's isoMonday()
returned the most-recent-Monday — a 7-day mismatch on Fridays.

Fixes (one semantic across the stack):
- runner._current_week_start() returns the upcoming Monday (today if
  Mon, else the next Mon). The Friday email subject
  ('Meal plan for week of <date>') automatically picks up the new
  value via run.week_start_date.
- frontend isoMonday -> upcomingMonday (same logic; renamed for
  intent). isoMonday kept as a deprecated alias.
- New WeekRangeNav component (Dashboard + ShoppingList share it).
  Renders [<]  Jun 8 - Jun 14  [>] with clickable chevrons and a
  clickable range label that jumps to the upcoming week. Replaces
  the Sprint 5 inline segmented control on both pages.
- New formatWeekRange(mondayIso) helper (UTC-stable; uses
  timeZone: 'UTC' so the rendered date matches the stored ISO date
  regardless of viewer TZ; closes a latent bug in formatIsoDate too).
- New SQL fix script that retargets the user's 3-pending-items plan
  from 2026-06-05 (Friday-keyed) to 2026-06-08 (upcoming Monday).
  Idempotent + transaction-wrapped. Optional block for 2026-05-29.

No backend migration. No new dependencies. Deploy is git pull +
run the SQL fix + docker compose up -d --build backend frontend.
See Review/sprint7-verification.md for the full deploy + smoke flow.

Files:
- backend/app/services/orchestrator/runner.py:20-35
- backend/scripts/fix_2026_06_05_to_2026_06_08.sql (new)
- frontend/src/lib/utils.ts:43-130
- frontend/src/components/WeekRangeNav.tsx (new)
- frontend/src/pages/Dashboard.tsx (3 call sites + 1 segmented control)
- frontend/src/pages/ShoppingList.tsx (5 call sites + 2 segmented controls)
- Review/{sprint7-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md
2026-06-05 07:46:55 -07:00

10 KiB

Context — Recovery Takeover

Why this plan exists

Prior agent marked Phases 1, 2, 3, 7 complete and consensus blockers "addressed" in docs, but verification of the repo shows:

  1. Auth blocker (review §1.2) closed in docs only — no auth dependency on any router; /api/admin/scrape is open.
  2. No tests, no CI; verification matrix from Review/reviewconcensus.md §6 was never run.
  3. Review §2.4 explicitly warned: spike scrape + email-approval BEFORE schema/UI commits. Prior agent did the opposite — schema, full API surface, and UI shell first; scrape unverified, email-approval not started.
  4. /api/admin/scrape runs Playwright synchronously inside the request handler; will time out in production.
  5. Phase 7 UI ships above engines (4/5/9) that don't exist — Dashboard renders meal plans the system can't generate.

Decisions (locked in for this recovery branch)

  • Auth model: bearer-token admin (single shared ADMIN_TOKEN env var) + signed-cookie session for family web UI. Matches what was claimed in ORIENTATION.md "Adversarial Review" section. No public-internet exposure assumed; nginx is sole entrypoint, already correct in docker-compose.yml.
  • Path canonicalization (R1-B+D): dropped /list and /planned suffixes; routers use @router.get("") (no trailing slash) so the canonical paths are /api/profile, /api/recipes, /api/recipes/ingredients, /api/meals, /api/pantry, /api/shopping-list. Frontend frontend/src/api/index.ts and smoke tests updated to enforce.
  • Login bootstrap: /api/auth/login signs the family-profile id; if no profile row exists yet, signs literal "bootstrap" so first-run isn't blocked. Cookie validates regardless; downstream code that needs a real id should re-issue after profile creation.
  • Recipe-ingredient: stay JSONB-only (already chosen). Do not reopen.
  • Household model: keep family_member table (already chosen). Do not reopen.
  • Day-of-week: ISO (1=Mon). Already chosen.
  • Migrations: Alembic only. Never Base.metadata.create_all() at runtime.
  • Background work: FastAPI BackgroundTasks for the scrape now; APScheduler container with --workers 1 later (R3-E).

Open questions to surface to the user, not to assume

  • Is ADMIN_TOKEN acceptable, or does the user want OIDC/Tailscale-style auth? Default for now: bearer token, easy to swap.
  • Email backend for the spike: real SendGrid (needs key) or a console/file backend? Default for spike: console backend, swap to SendGrid in R3-C.

Verification gate (Phase R1 must pass all)

  • cd backend && pytest → green
  • docker compose run --rm backend alembic upgrade head → no error, schema matches models
  • docker compose run --rm backend python -c "from app.main import app; print(app.title)" → "MealPlanner"
  • docker compose run --rm frontend npm run build → no error
  • curl -X POST http://localhost/api/admin/scrape (no token) → 401
  • curl http://localhost/api/profile (no session) → 200 (read), POST/PUT → 401
  • CI workflow runs all of the above on push.

Phase ordering rule (do not violate)

R1 and R2 are independent and run in parallel. R3 cannot start until BOTH R1 verification and R2 spikes pass. If R2 reveals schema impact, schema changes happen on this branch BEFORE R3-A.

Swiftly API (R3-0, replaces Playwright path)

  • Discovery: GET https://luckysupermarkets.com/categories (HTML, no auth). Selector: <a class="swiftlyCouponCategory" href="/categories/<urlencoded slug>">. Slug regex: /categories/(.+)$ then urllib.parse.unquote. Fixture (2026-05-05) yielded 17 distinct slugs (e.g. Product/meat_seafood, Product/produce, ...).
  • Products: GET https://prod.swiftlyapi.net/search/api/v1/products/categories?cat=<slug>&store=757&limit=10000 with Authorization: Bearer <SWIFTLY_BEARER_TOKEN>. Response shape: {"products": {"info": {"count": N}, "items": [...], "facets": [...]}}. meat_seafood returned 256 items.
  • Field mapping (item dict → grocery_item):
    • id (string) → new external_id column (migration 0005)
    • namename
    • descriptiondescription
    • brandbrand
    • primaryImage.urlimage_url
    • price.ok.regPriceText (e.g. "$3.49 /lb") → parsed regular_price (Decimal) + unit (e.g. "lb", may be NULL when no /unit suffix)
    • price.ok.promoArea.promoText (e.g. "$2.49 /lb") → parsed sale_price (Decimal); when present is_on_sale=True, else is_on_sale=False
    • price.ok.promoArea.validityText (e.g. "Valid 04/29/26 - 05/05/26") → ignored for v1 (no migration to add date columns; existing sale_start_date / sale_end_date left null)
    • aisle: extracted from the queried category slug (Product/meat_seafoodmeat_seafood)
    • product_url → NULL (site has no public product page; per R2-A note kept nullable)
  • Auth scoping: bearer header is attached ONLY to prod.swiftlyapi.net requests, NOT to the public luckysupermarkets.com HTML page. Two requests.Session objects (one with default UA, one with the bearer header).
  • 401 detection: cannot use BaseScraper._get because it swallows HTTPError into a None return. The new client calls session.get(...) directly and checks resp.status_code == 401 BEFORE raise_for_status to raise SwiftlyAuthError. Token in .env.example expires hourly per spec; on 401 the scraper aborts with a fixed error_message instructing the admin to refresh the token.
  • Idempotency key: (source, external_id) upserts. Migration 0005 adds grocery_item.external_id (nullable text, indexed; not unique because legacy R2-A rows lack one).

Context — Sprint 7 (webui empty-meal-plan fix)

Why Sprint 7 exists

User report 2026-06-05: "Latest meal plans were emails to me this morning, but when I go to the webui, the Meal Planner page is empty." Investigation found a date-semantics mismatch.

Decisions (locked in for Sprint 7)

  • D1. "This week" = the upcoming Mon-Sun week. The Friday email advertises the upcoming week; the plan is keyed by the upcoming Monday; the webui opens on the upcoming Monday. Past weeks accessible via the back-arrow. (User asked for a clickable < Jun 8 — Jun 14 > style nav, so the range is visible at a glance.)
  • D2. Plan key changes from Friday to Monday. All future plans are Monday-keyed. Existing 2026-06-05 plan migrated to 2026-06-08 via guarded SQL.
  • D3. Email subject unchanged in form, changes in content. step_email already uses run.week_start_date for the subject (verified steps.py:305). After D1, subject becomes "Meal plan for week of 2026-06-08" — natural Mon-Sun.
  • D4. Frontend isoMonday renamed to upcomingMonday. Same surface (Dashboard + ShoppingList). No backward-compat alias needed; the only callers are within our codebase.
  • D5. New WeekRangeNav component is shared between Dashboard and ShoppingList. Single source of truth for the visual + behavior.
  • D6. The no-op Generate Meal Plan CTA at Dashboard.tsx:415 is still out of scope. F4 (plan-the-week) and the dead CTA solve different problems. Documented as a follow-up.

Open questions to surface to the user, not to assume

  • Q1. Migrate the 2026-05-29 plan too? It's also Friday-keyed. Operator can run a separate guarded UPDATE in the same SQL script. Default for now: include the statement but commented out; user uncomments if they want.
  • Q2. Recency logic in the planner. _load_last_cooked in planner/generate.py:80-94 compares MealPlan.week_start_date across plans. After D2, all values are Mondays, so the comparison is symmetric and "days since last cooked" stays correct. No change needed. (Verified by reading the code.)
  • Q3. Should the email subject line shift by one day (Thu instead of Fri)? No — the scheduler still fires Fri 02:00..18:00 PT (verified scheduler/__main__.py). The deadline (vote by Fri 17:00) still makes sense. The plan key shifts to Mon, the email timing stays Fri. No scheduler change.
  • Q4. Any URL bookmarked with ?week=2026-06-05? After the SQL fix, the plan moves to 2026-06-08. Any external link to ?week=2026-06-05 will hit "no plan for that week" (404-ish). Acceptable since the user uses the webui, not external links.

Sprint 7 verification gate

  • cd frontend && npm run build → green
  • curl http://100.108.208.56:8082/api/meals?week_start=2026-06-08 (after deploy + SQL) → 3 pending items
  • Browser: open / (no ?week= param) on deployment host → header shows Week of Jun 8, 2026, 3 meal cards visible
  • curl http://100.108.208.56:8082/api/meals?week_start=2026-06-01 → null (current calendar week has no plan; expected)
  • curl http://100.108.208.56:8082/api/meals?week_start=2026-05-29 → null if user opted in to migrate it, 3 items otherwise
  • Review/sprint7-verification.md is the source of truth for the deploy + smoke flow.

Sprint 7 — does NOT touch

  • The extractErrorMessage / showApiError flow (Sprint 4 F7) — unchanged.
  • The keyboard shortcuts (Sprint 5 F2) — unchanged. Note: g d still navigates to Dashboard at upcomingMonday().
  • The bulk pantry add (Sprint 6 F3) — unchanged.
  • The plan-the-week (Sprint 6 F4) — unchanged. It still operates on the active plan regardless of week.
  • The undo-toast (Sprint 3 B12) — unchanged.
  • The aisle-migration (Sprint 2 / Sprint 5 fix) — no migration in S7.

Key file:line references

  • backend/app/services/orchestrator/runner.py:20-24_current_week_start() (TO MODIFY)
  • backend/app/scheduler/__main__.py:31-66 — Friday cron schedule (NO CHANGE)
  • backend/app/services/orchestrator/steps.py:305f"Meal plan for week of {run.week_start_date}" (NO CHANGE; uses upstream value)
  • frontend/src/lib/utils.ts:44-50isoMonday() (TO RENAME + CHANGE)
  • frontend/src/pages/Dashboard.tsx:316-320 — default-week + navigateWeek (TO UPDATE)
  • frontend/src/pages/ShoppingList.tsx:87-90 — same (TO UPDATE)
  • frontend/src/pages/Dashboard.tsx:479-503 — inline week nav (TO REPLACE with <WeekRangeNav>)
  • frontend/src/pages/ShoppingList.tsx:259-283 — same (TO REPLACE)
  • frontend/src/components/ — new WeekRangeNav.tsx (TO ADD)
  • backend/scripts/fix_2026_06_05_to_2026_06_08.sql — new (TO ADD)
  • Review/sprint7-verification.md — new (TO ADD)