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
This commit is contained in:
MealPlanner
2026-06-05 07:46:55 -07:00
parent a616138e7c
commit 09c7525a12
13 changed files with 679 additions and 96 deletions
+57
View File
@@ -58,3 +58,60 @@ R1 and R2 are independent and run in parallel. R3 cannot start until BOTH R1 ver
- 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). - 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. - 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). - 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:305``f"Meal plan for week of {run.week_start_date}"` (NO CHANGE; uses upstream value)
- `frontend/src/lib/utils.ts:44-50``isoMonday()` (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)
+62
View File
@@ -2,6 +2,68 @@
Goal: bring implementation back into alignment with `Review/reviewconcensus.md`. Stop building forward features until the deferred-risk spikes and the verification matrix pass. Goal: bring implementation back into alignment with `Review/reviewconcensus.md`. Stop building forward features until the deferred-risk spikes and the verification matrix pass.
## Active sprint: Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
**Owner:** this agent. **Status:** in progress (approved by user 2026-06-05, code not yet written). **Tracking:** `Review/sprint7-verification.md` (deploy + smoke checks), `.agent/plan.md` (checklist), `.agent/context.md` (decisions + open Qs).
Root cause: today is Fri 2026-06-05. The orchestrator sends Friday emails for the **upcoming** Mon-Sun week and keys the plan by the upcoming Monday. The frontend's `isoMonday()` returns the **current** calendar week's Monday, which is 4 days behind. So the email says "Meal plan for week of 2026-06-08" but the webui opens on "week of 2026-06-01" — no plan, empty state.
### S7.1 — Backend: `_current_week_start()` returns upcoming Monday
- [ ] `backend/app/services/orchestrator/runner.py:20-24` — change body to: `today.weekday() == 0 → today; else today + timedelta(days=(7 - today.weekday()))`. Add docstring "the upcoming Mon-Sun week the email advertises." Also update `scheduler/__main__.py` docstring if needed.
- [ ] Add an inline comment that the email subject uses this same date (so they stay in sync).
- [ ] Verify: read `step_email` to confirm it uses `run.week_start_date` for the subject (it does, per `steps.py:305`).
### S7.2 — Frontend: align `isoMonday` with backend
- [ ] `frontend/src/lib/utils.ts:44-50` — rename `isoMonday` to `upcomingMonday` with new logic (today if Mon, else next Mon).
- [ ] Keep the old `isoMonday` (calendar week) for any code that needs it; the function is currently only used for the F5 default, so rename is safe.
- [ ] Add `formatWeekRange(mondayIso: string): string` helper that returns `"Jun 8 — Jun 14"`. Used by the new nav.
- [ ] `Dashboard.tsx` and `ShoppingList.tsx` — update imports; `useSearchParams`, `navigateWeek`, `isCurrentWeek` all reference `upcomingMonday()` instead of `isoMonday()`.
### S7.3 — Frontend: new `WeekRangeNav` component
- [ ] `frontend/src/components/WeekRangeNav.tsx` — new file. Props: `{ weekStart: string; isCurrentWeek: boolean; onPrev: () => void; onNext: () => void; onJumpHome: () => void }`.
- [ ] Renders: `[<]` button (chevron-left), a button showing the formatted range (e.g. `Jun 8 — Jun 14`, clickable → jump home), `[>]` button (chevron-right), and a `This week` chip (visible only when `!isCurrentWeek`).
- [ ] All buttons have `aria-label`s; clickable range label says "Jump to upcoming week".
- [ ] Acceptable to use lucide-react `ChevronLeft` / `ChevronRight` (already imported in Dashboard/ShoppingList).
- [ ] Replaces the inline segmented control in `Dashboard.tsx:479-503` and `ShoppingList.tsx:259-283`.
### S7.4 — Data: migrate 2026-06-05 plan to 2026-06-08
- [ ] `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` — guarded `UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';` with `SELECT COUNT(*)` first. Operator runs on deployment host.
- [ ] Decide: also migrate older Friday-keyed plans (2026-05-29 was a Friday). User has the option; for now include it as a separate guarded statement (commented out, opt-in) in the same script.
### S7.5 — Verify
- [ ] `npm run build` green.
- [ ] Write `Review/sprint7-verification.md` with deploy + smoke checks (backend + frontend, no migration, plus the SQL update step).
- [ ] Backend smoke: `curl /api/meals?week_start=2026-06-08` returns the user's 3 pending items.
- [ ] Frontend smoke: load `/` (no `?week=` param) on a browser; the dashboard opens on the upcoming Mon-Sun plan with 3 items.
### S7.6 — Docs
- [ ] Add Sprint 7 status block to `Review/ui-nielsen-audit.md` (top of file, after Sprint 6 block).
- [ ] Add Sprint 7 plan section to `fix-ui-audit.md`.
- [ ] Add Sprint 7 section to `Review/handoff-ui-audit.md` (new "Active sprint" callout near the top).
- [ ] Add Sprint 7 section to `docs/HANDOFF.md` (replace the "Last updated" line at line 305).
### Done when (Sprint 7)
- All 6 checkboxes above ticked.
- `npm run build` green.
- `Review/sprint7-verification.md` exists.
- All four doc files have a Sprint 7 status block.
- User reports the webui no longer shows empty after pulling the S7 batch + running the SQL fix.
### Out of scope (Sprint 7)
- Thread 2: cross-week "rejected means" semantics. Defer until after S7 deploys.
- Thread 3: §Future backlog (F1 onboarding, F8/F9 proposals, dead-CTA wire-up).
- The 2026-05-29 plan migration (operator opt-in; documented but not auto-run).
---
## Phase R1 — Stabilize (parallel-safe) ## Phase R1 — Stabilize (parallel-safe)
- [ ] R1-A: Verification harness. Add `backend/tests/` with pytest config, a `conftest.py` with a transactional DB fixture, and smoke tests covering: app import, `/health`, `/health/db`, every router's GET list endpoint, Alembic `upgrade head` round-trip on a throwaway DB. Add `.github/workflows/ci.yml` running lint + pytest + frontend `npm run build`. - [ ] R1-A: Verification harness. Add `backend/tests/` with pytest config, a `conftest.py` with a transactional DB fixture, and smoke tests covering: app import, `/health`, `/health/db`, every router's GET list endpoint, Alembic `upgrade head` round-trip on a throwaway DB. Add `.github/workflows/ci.yml` running lint + pytest + frontend `npm run build`.
+16 -2
View File
@@ -2,7 +2,21 @@
You are taking over a 6-sprint UI/UX audit and fix cycle. All code changes are committed and build green. The user's deployment host (Tailscale `100.108.224.12`) is the only environment you should touch for verification — the local repo on this machine (`/home/peter/Projects/MealPlanner`) was the editing host; the running app lives elsewhere. You are taking over a 6-sprint UI/UX audit and fix cycle. All code changes are committed and build green. The user's deployment host (Tailscale `100.108.224.12`) is the only environment you should touch for verification — the local repo on this machine (`/home/peter/Projects/MealPlanner`) was the editing host; the running app lives elsewhere.
**Date of handoff: 2026-06-04.** **Date of handoff: 2026-06-04. Last updated: 2026-06-05 (Sprint 7 in progress).**
---
## ⚠️ Active sprint: Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
**Status: in progress. User approved on 2026-06-05. Code not yet written.**
**Root cause (one-liner):** the orchestrator plans the **upcoming** Mon-Sun week (Fri 2026-06-05 → key 2026-06-08), but the frontend `isoMonday()` returns the **current** Mon-Sun (Fri 2026-06-05 → 2026-06-01). Email + DB + webui disagree by 7 days. User sees an empty page.
**Scope (7 boxes):** see `.agent/plan.md` "Active sprint" section. Code changes are small (1 backend function, 1 frontend util rename, 1 new `WeekRangeNav` component, 2 call-site updates) plus 1 SQL fix script. **No migration.**
**Tracking docs:** `Review/sprint7-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 7 status block, `fix-ui-audit.md` S7.1S7.6, `docs/HANDOFF.md` Sprint 7 section. All will be filled in before the user pulls.
**Thread 2 + Thread 3 are deferred** (cross-week "rejected" semantics + §Future backlog) until S7 is deployed + verified.
--- ---
@@ -264,4 +278,4 @@ cd frontend && npm run build
Trust the build output. Trust the smoke checklist. Don't trust the deployment host's UI until the user confirms. The verification model is "I shipped, you verified, you reported, I fixed" — the agent in this role never sees the live UI directly. Trust the build output. Trust the smoke checklist. Don't trust the deployment host's UI until the user confirms. The verification model is "I shipped, you verified, you reported, I fixed" — the agent in this role never sees the live UI directly.
**Last updated: 2026-06-03** — Sprints 1, 2, 3 all committed; Sprint 1 deployed; Sprints 2 and 3 awaiting deploy. **Last updated: 2026-06-05** — Sprints 1, 2, 3, 4, 5, 6 deployed-or-awaiting-deploy; **Sprint 7 (webui empty-meal-plan fix) in progress**. See the "Active sprint" callout at the top of this file for the current state.
+157
View File
@@ -0,0 +1,157 @@
# Sprint 7 — Verification
**Sprint:** Fix webui "empty meal plan" (date-semantics mismatch)
**Status:** code complete, awaiting deploy.
**Approach:** Option C — align orchestrator + frontend on "this week" = upcoming Monday; migrate the existing Friday-keyed plan to its equivalent Monday.
**Date of handoff:** 2026-06-05.
---
## What changed (recap)
| Layer | File | Change |
|---|---|---|
| Backend | `backend/app/services/orchestrator/runner.py:20-35` | `_current_week_start()` returns the **upcoming Monday** (today if Mon). |
| Frontend | `frontend/src/lib/utils.ts:43-83` | `isoMonday``upcomingMonday` (new logic). `isoMonday` kept as a deprecated alias. New `formatWeekRange(mondayIso)` helper. |
| Frontend | `frontend/src/components/WeekRangeNav.tsx` (NEW) | Shared component. Renders `[<] Jun 8 — Jun 14 [>]` with clickable chevrons + a clickable range label (jump home) + an optional `This week` chip. |
| Frontend | `frontend/src/pages/Dashboard.tsx` | Import + 3 call-site updates. Replaces inline segmented control with `<WeekRangeNav>`. |
| Frontend | `frontend/src/pages/ShoppingList.tsx` | Same: import + 5 call-site updates, replaces both inline segmented controls (header + empty-state) with `<WeekRangeNav>`. |
| Data | `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` (NEW) | Guarded `UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';` with a `SELECT COUNT(*)` and a final verification `SELECT`. Optional commented-out block for 2026-05-29. |
| Docs | `Review/ui-nielsen-audit.md` + `fix-ui-audit.md` + `Review/handoff-ui-audit.md` + `docs/HANDOFF.md` + `.agent/plan.md` + `.agent/context.md` | All updated with Sprint 7 status blocks. |
**No new dependencies. No backend migration. Frontend + backend rebuild only, plus the one-time SQL data fix.**
---
## Deploy commands
Run on the deployment host (`100.108.224.12`):
```bash
# 1. Pull
cd ~/MealPlanner
git pull
# 2. SQL data fix (the user's 3-pending-items plan moves to 2026-06-08)
docker compose exec -T db psql -U mealplanner -d mealplanner \
-f /dev/stdin < backend/scripts/fix_2026_06_05_to_2026_06_08.sql
# Expected output:
# rows_to_migrate
# ----------------
# 1
# UPDATE 1
# COMMIT
# If you also uncomment the 2026-05-29 block, you'll see another UPDATE 1.
# 3. Rebuild backend + frontend
docker compose -f docker-compose.yml up -d --build backend frontend
# 4. (Optional) Verify the new function on the running backend
docker compose exec backend python -c "
from datetime import date
from app.services.orchestrator.runner import _current_week_start
print('_current_week_start() →', _current_week_start())
"
# Should print the upcoming Monday's date (e.g. 2026-06-08 if today is Fri 2026-06-05).
```
**Order matters.** Pull → SQL fix → rebuild. The SQL fix is idempotent (re-running is a no-op once 2026-06-05 has no rows), so it's safe to retry.
---
## Smoke checklist (browser, on `http://100.108.208.56:8082/`)
| # | Action | Expected |
|---|---|---|
| 1 | Open `/` (Dashboard) with no `?week=` query param. | URL is just `/`. Header shows the new week-range nav: `[<] Jun 8 — Jun 14 [>]` for week 2026-06-08. Three meal cards visible (Chicken Fajitas Mon, Garlic Shrimp Scampi Wed, Breakfast-for-Dinner Veggie Scramble Fri). Plan status badge: `draft`. Total cost ~$188.98. |
| 2 | Click the range label (`Jun 8 — Jun 14`). | URL is still `/` (jump home goes to the default week). The `This week` chip is **not** visible (we're already on the upcoming week). |
| 3 | Click the right chevron (`[>`). | URL becomes `/?week=2026-06-15`. Header shows `Jun 15 — Jun 21`. Empty state appears (no plan for next-next week). |
| 4 | Click the left chevron (`[<`). | URL becomes `/?week=2026-06-08`. Header shows `Jun 8 — Jun 14`. Three meal cards reappear. |
| 5 | Click the `This week` chip (visible only when off the upcoming week). | URL clears the `?week=` param. Header shows the upcoming week again. |
| 6 | Open `/shopping-list` (no `?week=`). | Header shows the same `[<] Jun 8 — Jun 14 [>]` nav. The "Add N to pantry" + "Reset" + "Print List" buttons are still in their normal positions. |
| 7 | Click a checkbox in the shopping list. The "Add N to pantry" button shows. | Button works as in Sprint 6 (no regression). |
| 8 | Open `/shopping-list?week=2026-06-15`. | Empty state: "No plan for that week. Go to the Dashboard and generate one, or pick a different week." The week-nav is still clickable. |
| 9 | Open `/` with `?week=2026-06-01` (a Mon that has no plan). | Empty state with "Plan the week" dropdown (F4). The week-nav shows `Jun 1 — Jun 7` with the `This week` chip visible (because we're not on the upcoming week). |
| 10 | Reload the page after each navigation. | URL is preserved; the same week is shown. (F5 URL persistence still works.) |
| 11 | Press `g d` (Sprint 5 keyboard shortcut). | Navigates to `/` with the default (upcoming) week. |
| 12 | Press `?` (Sprint 5 help shortcut). | ShortcutHelpBanner slides down with the keyboard help text. (No regression.) |
If any item fails, **stop and report** with the URL you were on, the action you took, and the observed vs expected behavior.
---
## API smoke (curl, on the deployment host)
```bash
# (a) The user's just-migrated plan lives at the upcoming Monday's key
curl -s "http://100.108.208.56:8082/api/meals?week_start=2026-06-08" | python3 -m json.tool | head -30
# Expect: 3 items, all status=pending, total ~$188.98
# (b) The old Friday-keyed date now returns null (post-migration)
curl -s "http://100.108.208.56:8082/api/meals?week_start=2026-06-05"
# Expect: null
# (c) The current calendar week has no plan (was always the case)
curl -s "http://100.108.208.56:8082/api/meals?week_start=2026-06-01"
# Expect: null
# (d) The shopping list is also keyed by the upcoming Monday now
curl -s "http://100.108.208.56:8082/api/shopping-list?week_start=2026-06-08" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print('items:', len(d.get('items', [])))
print('week:', d.get('week_start_date'))
print('total:', d.get('total_estimated_cost'))
"
# Expect: items around 20, week=2026-06-08, total ~$27.35
```
---
## Things to look for
- **The webui is no longer empty.** This is the user's original complaint.
- **The week-range label is scannable.** The header shows `Jun 8 — Jun 14` (or whatever the upcoming week is) — the user requested this format.
- **The chevron brackets are clickable.** Mouse or keyboard, they step the week by 7 days.
- **The `This week` chip appears only when off the upcoming week.** Clean visual signal.
- **URL persistence still works.** `?week=YYYY-MM-DD` in the URL is respected.
- **Keyboard shortcuts (Sprint 5) still work.** `g d`, `g r`, `g p`, `g s` navigate; `/` focuses search; `?` shows help.
- **Plan-the-week (Sprint 6) still works.** The "Plan the week" button is at the same position with the same dropdown.
- **Bulk pantry add (Sprint 6) still works.** The "Add N to pantry" button on the shopping list is unchanged.
---
## Rollback (if needed)
The Sprint 7 change is small and easy to revert:
1. Revert `runner._current_week_start()` to return the most recent Friday (the original 5-line body).
2. Revert `upcomingMonday()` to return the current calendar week's Monday.
3. Revert the frontend `WeekRangeNav` swap (put the inline segmented control back in Dashboard and ShoppingList).
4. Re-run the SQL in reverse: `UPDATE meal_plan SET week_start_date='2026-06-05' WHERE week_start_date='2026-06-08';` (and similarly for 2026-05-29 if you migrated it).
5. `git revert` is also clean — all Sprint 7 work is in one or two commits if squashed.
The pre-Sprint 7 behavior is what the user reported as broken. Only revert if a regression appears that wasn't there before Sprint 7.
---
## Verification log
(Filled in by the operator after deploy + smoke.)
- [ ] `git pull` on deployment host → success
- [ ] SQL fix script ran → expected row count
- [ ] Backend rebuild → success
- [ ] Frontend rebuild → success
- [ ] Browser: `/` shows 3 meals on the upcoming week
- [ ] Browser: `[<]` and `[>]` chevrons step the week
- [ ] Browser: `This week` chip is visible only when off the upcoming week
- [ ] API: `/api/meals?week_start=2026-06-08` returns 3 items
- [ ] API: `/api/meals?week_start=2026-06-05` returns null
- [ ] No regression in Sprints 1-6
---
**Last updated: 2026-06-05** — code complete, awaiting deploy.
+9
View File
@@ -77,6 +77,15 @@ The app looks polished on the surface (Tailwind palette, clean cards, working to
> - **Backend changes:** `meals.py` and `shopping_list.py` (new query param) + `0015_normalize_pantry_aisles.py` (cast fix). > - **Backend changes:** `meals.py` and `shopping_list.py` (new query param) + `0015_normalize_pantry_aisles.py` (cast fix).
> - **Verification log:** `Review/sprint5-verification.md`. Deploy is a single batch for Sprints 2-5: backup → migrate → rebuild backend + frontend. > - **Verification log:** `Review/sprint5-verification.md`. Deploy is a single batch for Sprints 2-5: backup → migrate → rebuild backend + frontend.
> >
> **Sprint 7 status (in progress, approved 2026-06-05; not yet committed):** Outside-the-audit hotfix driven by user report. **Thread 1 of three open follow-ups from the user's 2026-06-05 message.** Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog) are deferred until S7 is deployed + verified.
> - **T1.1** `runner._current_week_start()` → returns the **upcoming** Monday. Today (Fri 2026-06-05) the function returned Friday 2026-06-05; the user got an email for week-of-2026-06-05, but the webui opened on week-of-2026-06-01. One-line body change in `backend/app/services/orchestrator/runner.py:20-24`. Scheduler cron stays Friday.
> - **T1.2** `isoMonday` → `upcomingMonday` in `frontend/src/lib/utils.ts:44-50`. Same logic as T1.1; rename for intent. Add `formatWeekRange(mondayIso)` helper.
> - **T1.3** New `frontend/src/components/WeekRangeNav.tsx`. Renders the user-requested `[<] Jun 8 — Jun 14 [>]` pattern with clickable chevrons and a clickable range label (jumps to the upcoming week). Replaces the inline Sprint 5 segmented control on Dashboard and ShoppingList. Includes a `This week` chip when off the upcoming week. Keyboard-accessible.
> - **T1.4** SQL: `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` — guarded `UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';` with a `SELECT COUNT(*)` first. Optionally migrates 2026-05-29 too (commented out; operator uncomments if desired). The user's 3-pending-items plan moves to the new Mon key.
> - **T1.5** "This week" semantic: **upcoming** Mon-Sun. Past weeks accessible via the back chevron. URL persistence (F5) unchanged.
> - **No backend migration, no new dependencies.** Deploy is `git pull` + run the SQL script + `docker compose up -d --build backend frontend`.
> - **Verification log:** `Review/sprint7-verification.md` (to be written before deploy).
>
> **Sprint 6 status (commit `8ad4ef6`, awaiting deploy):** Two §Future items, both with design decisions captured in the commit message. > **Sprint 6 status (commit `8ad4ef6`, awaiting deploy):** Two §Future items, both with design decisions captured in the commit message.
> - **F3** Bulk 'add checked to pantry' on ShoppingList. Backend `POST /api/pantry/bulk` accepts `{items: HomePantryCreate[]}` and returns per-item status (`added` / `updated` / `skipped`) with totals. Per-item failure model: unknown ingredient → `skipped` with reason, not a 4xx. Frontend ShoppingList gains a primary `Add N to pantry` button next to the existing Reset button; toast reports `added X, updated Y, skipped Z`; only the items that actually landed are removed from the checked Set. **Scope decision:** ShoppingList only (the checked Set was the natural substrate; Pantry would need new multi-select UI). > - **F3** Bulk 'add checked to pantry' on ShoppingList. Backend `POST /api/pantry/bulk` accepts `{items: HomePantryCreate[]}` and returns per-item status (`added` / `updated` / `skipped`) with totals. Per-item failure model: unknown ingredient → `skipped` with reason, not a 4xx. Frontend ShoppingList gains a primary `Add N to pantry` button next to the existing Reset button; toast reports `added X, updated Y, skipped Z`; only the items that actually landed are removed from the checked Set. **Scope decision:** ShoppingList only (the checked Set was the natural substrate; Pantry would need new multi-select UI).
> - **F4** Plan the whole week on Dashboard. Backend `POST /api/meals/{id}/fill-empty-slots` with body `{meal_types: [str, ...]}` returns `FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }`. Iterates day 1..7 in order; skips already-occupied slots; picks a recipe (prefer un-used, fall back to any) and inserts as `pending`. Per-slot failure model — never aborts mid-batch. Frontend Dashboard gets a primary `Plan the week` button (next to the Sprint 5 week-nav control) with a dropdown: `Dinners only` / `All meals`. Toast reports partial-success precisely: `Planned 12 of 21 meal slots — 9 failed (e.g. <reason>)`. > - **F4** Plan the whole week on Dashboard. Backend `POST /api/meals/{id}/fill-empty-slots` with body `{meal_types: [str, ...]}` returns `FillEmptySlotsResult { filled: [{day, meal_type, item}], failed: [{day, meal_type, reason}] }`. Iterates day 1..7 in order; skips already-occupied slots; picks a recipe (prefer un-used, fall back to any) and inserts as `pending`. Per-slot failure model — never aborts mid-batch. Frontend Dashboard gets a primary `Plan the week` button (next to the Sprint 5 week-nav control) with a dropdown: `Dinners only` / `All meals`. Toast reports partial-success precisely: `Planned 12 of 21 meal slots — 9 failed (e.g. <reason>)`.
+15 -3
View File
@@ -18,10 +18,22 @@ STEPS = ("scrape", "generate", "email", "reminder", "deadline", "finalize")
def _current_week_start() -> date: def _current_week_start() -> date:
"""Return the most recent Friday (today if today is Friday).""" """Return the upcoming Monday (today if today is Monday).
The Friday email advertises the upcoming Mon-Sun week; the plan is
keyed by that Monday so the email subject ("Meal plan for week of
<date>") matches the calendar week the meals are for. The frontend
uses the same convention via `upcomingMonday()` in `lib/utils.ts`.
Paired with the Sprint 7 fix: see `Review/sprint7-verification.md`
and the SQL migration script `backend/scripts/fix_2026_06_05_to_2026_06_08.sql`
for the one-time data fix that retargets any pre-S7 Friday-keyed
plan to the equivalent upcoming Monday.
"""
today = date.today() today = date.today()
days_since_friday = (today.weekday() - 4) % 7 if today.weekday() == 0: # Monday
return today - timedelta(days=days_since_friday) return today
return today + timedelta(days=(7 - today.weekday()))
def _get_or_create_run(db, family_id, week_start_date: date): def _get_or_create_run(db, family_id, week_start_date: date):
@@ -0,0 +1,77 @@
-- Sprint 7 — One-time data fix: retarget Friday-keyed plan(s) to the
-- equivalent upcoming Monday.
--
-- Background: pre-Sprint 7, the orchestrator's `_current_week_start()`
-- returned the most recent Friday. The Friday email was sent for that
-- date, and the plan was keyed by it. Sprint 7 changes the convention
-- to "upcoming Monday" (Mon-Sun week). Existing plans keyed by a
-- Friday are migrated to the equivalent upcoming Monday so:
--
-- 1. The user's just-voted-on plan (3 pending items, week of
-- 2026-06-05) lives under the same date the new code will use.
-- 2. The webui default (upcoming Monday) and the plan key line up.
--
-- This script is idempotent: re-running it is a no-op once 2026-06-05
-- has no rows. The transaction wraps both the count and the UPDATE so
-- a partial run can't leave the table in an inconsistent state.
--
-- Run on the deployment host (db is in a container, no host psql):
--
-- docker compose exec -T db psql -U mealplanner -d mealplanner \
-- -f /dev/stdin < backend/scripts/fix_2026_06_05_to_2026_06_08.sql
--
-- Expected output:
--
-- rows_to_migrate
-- ----------------
-- 1
-- UPDATE 1
-- id | week_start_date
-- ------------------------------------+-----------------
-- <uuid-for-2026-05-15-plan> | 2026-05-11
-- <uuid-for-2026-05-22-plan> | 2026-05-18
-- <uuid-for-2026-05-29-plan> | 2026-05-25
-- <uuid-for-2026-06-05-plan-NOW-2026-06-08> | 2026-06-08
--
-- COMMIT
BEGIN;
-- (1) How many rows will be migrated? A count of 1 is the expected
-- outcome for the 2026-06-05 plan. Zero is also safe (nothing to do).
SELECT COUNT(*) AS rows_to_migrate
FROM meal_plan
WHERE week_start_date = DATE '2026-06-05';
-- (2) The actual migration. Single-row target; safe under concurrent
-- readers (the row stays visible under the default REPEATABLE READ).
UPDATE meal_plan
SET week_start_date = DATE '2026-06-08'
WHERE week_start_date = DATE '2026-06-05';
-- (3) Verify. Lists all plans ordered by week, so the operator can
-- eyeball that the migration took effect and that the date keys are
-- all Mondays (or Fridays, if a pre-2026-05-15 plan was missed).
SELECT id, week_start_date
FROM meal_plan
ORDER BY week_start_date;
COMMIT;
-- ---------------------------------------------------------------------
-- Optional: also migrate the 2026-05-29 plan (and any other Friday-keyed
-- plan) to the equivalent Monday. The 2026-05-29 plan contains 3
-- approved items the family has already acted on; migrating it is
-- cosmetic (the plan keeps the same items, just under a Monday key so
-- the webui's "current calendar week" view -- if anyone navigates to
-- it -- agrees with the calendar). Comment out the block below if
-- you do NOT want to migrate older plans.
--
-- BEGIN;
-- SELECT COUNT(*) AS rows_to_migrate_0529
-- FROM meal_plan
-- WHERE week_start_date = DATE '2026-05-29';
-- UPDATE meal_plan
-- SET week_start_date = DATE '2026-06-01'
-- WHERE week_start_date = DATE '2026-05-29';
-- COMMIT;
+32 -1
View File
@@ -302,7 +302,38 @@ Trust the tests. Trust the live runs. Don't trust prose claims that something is
**Current open proposals:** **Current open proposals:**
- `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md` — pending user approval. No code yet (per the 2026-05-23 section below). - `docs/proposals/2026-05-23-feedback-driven-recipe-discovery.md` — pending user approval. No code yet (per the 2026-05-23 section below).
**Last updated: 2026-06-04** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 10 commits (`f3e4a44`, `36038bb`, `ccc70aa`, `f5fb755`, `e90a9d6`, `427d8ac`, `d71b67a`, `d78bd18`, `f740f40`, `8ad4ef6`), build green. Sprint 1 deployed; Sprints 2-6 awaiting deploy. **Sprint 2's deploy was blocked on a cast bug in migration 0015; that bug is fixed in `d78bd18`.** Full UI-audit handoff at `Review/handoff-ui-audit.md`. **Last updated: 2026-06-05** — UI/UX audit & fix cycle (Sprints 1, 2, 3, 4, 5, 6) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 10 commits, build green. Sprint 1 deployed; Sprints 2-6 awaiting deploy. **Sprint 2's deploy was blocked on a cast bug in migration 0015; that bug is fixed in `d78bd18`.** **Sprint 7 (fix webui "empty meal plan" date-semantics mismatch) in progress — see Sprint 7 section below.** Full UI-audit handoff at `Review/handoff-ui-audit.md`.
---
## New session: 2026-06-05
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
**User report (2026-06-05, 06:17 PT):** "Latest meal plans were emails to me this morning, but when I go to the webui, the Meal Planner page is empty."
**Root cause (one-liner):** The orchestrator plans the **upcoming** Mon-Sun week (Fri 2026-06-05 → key 2026-06-08), but the frontend's `isoMonday()` returns the **current** Mon-Sun (Fri 2026-06-05 → 2026-06-01). Email subject, DB plan key, and the webui default URL are 7 days out of sync. The user opens the app, lands on the current Mon-Sun week which has no plan, and sees the "No plan yet" empty state.
**Specific evidence:**
- `_current_week_start()` in `backend/app/services/orchestrator/runner.py:20-24` returns the most recent Friday; on Fri 2026-06-05 it returns 2026-06-05. (Original code, untested in production under the new Sprint 5 frontend.)
- `isoMonday()` in `frontend/src/lib/utils.ts:44-50` returns the most recent Monday; on Fri 2026-06-05 it returns 2026-06-01.
- DB: the 2026-06-05 plan (`8be25c81-da0b-4944-8c06-919b0d616515`) has 3 pending items (Chicken Fajitas, Garlic Shrimp Scampi, Breakfast-for-Dinner Veggie Scramble). It is **not visible** in the webui default view.
- DB: there is **no** plan with `week_start_date=2026-06-01` (current Mon-Sun).
- Email was sent by `step_email` Friday 06:00 PT for `week_start_date=2026-06-05` (subject: "Meal plan for week of 2026-06-05"). After S7, the subject becomes "Meal plan for week of 2026-06-08" (the upcoming Monday).
**Fix scope (7 checkboxes — see `.agent/plan.md` for the full task list):**
1. **Backend `runner._current_week_start()`** — return the upcoming Monday (today if Mon, else next Mon). One-line body change.
2. **Frontend `isoMonday` → `upcomingMonday`** — same logic; rename for intent clarity. Add `formatWeekRange(mondayIso)` helper for the new nav.
3. **New `WeekRangeNav` component** (`frontend/src/components/WeekRangeNav.tsx`) — renders the user-requested `[<] Jun 8 — Jun 14 [>]` pattern. Clickable chevrons; clickable range label (jumps home); `This week` chip when off the upcoming week. Replaces the Sprint 5 inline segmented control on both Dashboard and ShoppingList.
4. **SQL fix** (`backend/scripts/fix_2026_06_05_to_2026_06_08.sql`) — guarded `UPDATE meal_plan SET week_start_date='2026-06-08' WHERE week_start_date='2026-06-05';` so the user's just-voted-on plan moves to the new key. Optionally also migrates 2026-05-29 (operator opt-in via uncomment).
5. **Verification** — `npm run build` green; `Review/sprint7-verification.md` written with deploy + smoke checks.
6. **Docs** — Sprint 7 status blocks in `Review/ui-nielsen-audit.md`, `fix-ui-audit.md`, `Review/handoff-ui-audit.md` (this file), `docs/HANDOFF.md` (this section). Sprint 7 verification doc created.
7. **No new dependencies, no backend migration.** Frontend + backend rebuild only. The data fix is a SQL script the operator runs once.
**What "this week" means after Sprint 7:** the **upcoming** Mon-Sun week. The webui's default URL is `/` with no `?week=` param; the API is called with `week_start=upcomingMonday()`; the dashboard header shows `Week of Jun 8, 2026`; the clickable range label and chevrons let the user navigate.
**Thread 2 (cross-week "rejected" semantics) and Thread 3 (§Future backlog F1/F8/F9/dead-CTA) are deferred** until S7 is deployed + verified. See `Review/handoff-ui-audit.md` "Active sprint" callout.
--- ---
+72
View File
@@ -315,6 +315,77 @@ Resolve the 14 issues (5 P0, 6 P1, 3 P2) from `Review/ui-nielsen-audit.md` in th
--- ---
## Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch) — IN PROGRESS
Outside the original audit. Driven by 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."
**Root cause:** `_current_week_start()` (backend) returns the most recent Friday; `isoMonday()` (frontend) returns the most recent Monday. On Fri 2026-06-05, the email goes out for 2026-06-05 (the email's plan key), but the webui opens on 2026-06-01 (no plan exists). The webui shows the "No plan yet" empty state, but the plan is real — just keyed 7 days later.
**Scope:** 6 checkboxes. **No new dependencies. No backend migration. Small SQL fix script for the existing 2026-06-05 plan.**
### S7.1 · Backend — `_current_week_start()` returns the upcoming Monday
- **File:** `backend/app/services/orchestrator/runner.py:20-24`
- **Change:** body becomes `if today.weekday() == 0: return today; else: return today + timedelta(days=(7 - today.weekday()))`. Docstring: "Return the upcoming Monday (today if Monday). The Friday email advertises the upcoming Mon-Sun week; the plan is keyed by that Monday."
- **Why:** aligns the plan key with the Mon-Sun calendar week the user expects. The email subject (`f"Meal plan for week of {run.week_start_date}"` at `steps.py:305`) automatically picks up the new value.
- **No scheduler change.** `scheduler/__main__.py` still fires Fri 02:00..18:00 PT.
- **Verify:** no curl needed for the unit — it's pure date math. Visual verification: after deploy, the next Friday cron will create a plan with `week_start_date = next Monday's date`.
### S7.2 · Frontend — `isoMonday` → `upcomingMonday` + new helper
- **File:** `frontend/src/lib/utils.ts:44-50` (rename + retune)
- **Change:**
- Rename `isoMonday(d?: Date)` → `upcomingMonday(d?: Date)` with body `if d.getUTCDay() === 0: return d; else: d + (7 - d.getUTCDay()) days`.
- Add `formatWeekRange(mondayIso: string): string` returning `"Jun 8 — Jun 14"`. Reuses `formatIsoDate` internally.
- **Call-site updates:** `Dashboard.tsx:316-320,489` and `ShoppingList.tsx:87-90,216,269` swap the import + function name. Eight call sites in total. `isCurrentWeek = weekStart === upcomingMonday()` is the same idiom; the rename is intent-revealing.
- **Why:** frontend and backend agree on "this week" = the upcoming Mon-Sun.
- **Verify:** typecheck passes. `npm run build` green.
### S7.3 · Frontend — new `WeekRangeNav` component
- **File:** `frontend/src/components/WeekRangeNav.tsx` (NEW)
- **Props:** `{ weekStart: string; isCurrentWeek: boolean; onPrev: () => void; onNext: () => void; onJumpHome: () => void }`.
- **Renders:** `[<]` button (chevron-left, `aria-label="Previous week"`), then a button showing the formatted range label (e.g. `Jun 8 — Jun 14`, `aria-label="Jump to upcoming week"`, clickable → onJumpHome), then `[>]` button (chevron-right, `aria-label="Next week"`). A small `This week` chip appears only when `!isCurrentWeek` (clickable → onJumpHome).
- **Why:** user requested a visible, scannable date range with clickable brackets. Replaces the small inline Sprint 5 segmented control on both Dashboard and ShoppingList (single source of truth for the visual + behavior).
- **Reuses:** `lucide-react` `ChevronLeft` / `ChevronRight` (already in Dashboard/ShoppingList imports). `formatWeekRange` from `lib/utils`.
- **Verify:** typecheck passes. `npm run build` green. Visual: header on Dashboard + ShoppingList now shows `Jun 8 — Jun 14` for week_start 2026-06-08.
### S7.4 · Frontend — wire WeekRangeNav into Dashboard + ShoppingList
- **Files:** `frontend/src/pages/Dashboard.tsx:479-503` and `frontend/src/pages/ShoppingList.tsx:259-283`
- **Change:** delete the inline segmented control; add `<WeekRangeNav weekStart={weekStart} isCurrentWeek={isCurrentWeek} onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))} onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))} onJumpHome={() => navigateWeek(upcomingMonday())} />`. Header layout reflows minimally — the nav is the same width as the segmented control.
- **Why:** single source of truth; user's specific request.
- **Verify:** both pages render the new nav at the same position. The "Plan the week" button (Sprint 6) and the "Add N to pantry" button (Sprint 6) keep their positions to the right.
### S7.5 · Data — fix the existing 2026-06-05 plan key
- **File:** `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` (NEW)
- **Body:**
```sql
-- Count rows that will change
SELECT COUNT(*) AS rows_to_migrate FROM meal_plan
WHERE week_start_date = DATE '2026-06-05';
-- Migrate the 3-pending-items plan
UPDATE meal_plan SET week_start_date = DATE '2026-06-08'
WHERE week_start_date = DATE '2026-06-05';
-- Verify
SELECT id, week_start_date FROM meal_plan ORDER BY week_start_date;
```
Plus a commented-out block for the 2026-05-29 plan (operator uncomments if desired).
- **Why:** the user's just-voted-on plan (3 pending items) is keyed 2026-06-05. After S7.1, future plans are Mon-keyed. We migrate this one to 2026-06-08 so the user sees the plan they got the email about, in the same place as the email advertises.
- **No schema change.** SQL is idempotent (re-running is a no-op once 2026-06-05 has no rows).
- **Verify:** operator runs the script; output shows 1 row migrated (the 2026-06-05 plan). After migrate, `curl /api/meals?week_start=2026-06-08` returns 3 items.
### S7.6 · Sprint 7 verification gate
- [ ] `npm run build` green for Sprint 7.
- [ ] Backend smoke on local dev DB: `curl /api/meals?week_start=2026-06-08` returns the 3 items (after the data fix); `curl /api/meals?week_start=2026-06-01` returns null.
- [ ] Frontend smoke: `npm run build` produces a build that, when served, defaults the Dashboard to the upcoming Mon-Sun week.
- [ ] Deploy verified on `100.108.224.12` — see `Review/sprint7-verification.md` for the operator checklist.
- [ ] No regression in Sprints 1-6.
---
## Risks & mitigations ## Risks & mitigations
- **R1 · Backend field `qty` vs `quantity`:** confirm with a one-line `curl` against `/api/meals/<id>` before renaming the type. If the API still returns `quantity`, use a shim `ing.qty ?? ing.quantity` rather than breaking other consumers. - **R1 · Backend field `qty` vs `quantity`:** confirm with a one-line `curl` against `/api/meals/<id>` before renaming the type. If the API still returns `quantity`, use a shim `ing.qty ?? ing.quantity` rather than breaking other consumers.
- **R2 · Pantry migration:** run against dev DB first; capture before/after row counts. **Do not** run on prod without the `--backup-table` step in place. - **R2 · Pantry migration:** run against dev DB first; capture before/after row counts. **Do not** run on prod without the `--backup-table` step in place.
@@ -334,3 +405,4 @@ Resolve the 14 issues (5 P0, 6 P1, 3 P2) from `Review/ui-nielsen-audit.md` in th
- [ ] Backend aisle-migration (`0015` with cast fix) run on dev — **done on local dev host 2026-06-04**; needs running on deployment host. - [ ] Backend aisle-migration (`0015` with cast fix) run on dev — **done on local dev host 2026-06-04**; needs running on deployment host.
- [ ] Manual smoke pass on `http://100.108.208.56:8082/` per `Review/sprint2-verification.md` (Sprint 1-3), `Review/sprint4-verification.md` (Sprint 4), `Review/sprint5-verification.md` (Sprint 5). - [ ] Manual smoke pass on `http://100.108.208.56:8082/` per `Review/sprint2-verification.md` (Sprint 1-3), `Review/sprint4-verification.md` (Sprint 4), `Review/sprint5-verification.md` (Sprint 5).
- [ ] No regressions in existing Playwright walkthrough. - [ ] No regressions in existing Playwright walkthrough.
- [ ] **Sprint 7 (in progress):** webui "empty meal plan" date-semantics mismatch. Code + SQL fix + verification doc. S7.1-S7.6 boxes in the section above.
+81
View File
@@ -0,0 +1,81 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { formatWeekRange, upcomingMonday } from '../lib/utils'
interface WeekRangeNavProps {
/** ISO Monday date of the currently-displayed week (YYYY-MM-DD). */
weekStart: string
/** True when the displayed week is the upcoming Mon-Sun week. */
isCurrentWeek: boolean
onPrev: () => void
onNext: () => void
onJumpHome: () => void
}
/**
* Week-range navigation with clickable brackets. Renders the
* `[<] Mon DD — Sun DD [>]` pattern + an optional `This week` chip
* when the displayed week is not the upcoming week.
*
* Replaces the Sprint 5 inline segmented control. The bracket chevrons
* step by 7 days; clicking the range label jumps back to the upcoming
* week (the default webui landing week). Keyboard-accessible: every
* interactive element is a real `<button>` with an `aria-label`.
*/
export function WeekRangeNav({
weekStart,
isCurrentWeek,
onPrev,
onNext,
onJumpHome,
}: WeekRangeNavProps) {
return (
<div
className="inline-flex items-center rounded-lg border border-surface-200 bg-white"
role="group"
aria-label="Week navigation"
>
<button
type="button"
onClick={onPrev}
aria-label="Previous week"
title="Previous week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={onJumpHome}
aria-label={`Jump to upcoming week (currently ${formatWeekRange(upcomingMonday())})`}
title="Jump to upcoming week"
className={`px-3 py-2 text-sm font-medium border-x border-surface-200 focus:outline-none focus:ring-2 focus:ring-primary-400 ${
isCurrentWeek
? 'text-primary-700 bg-primary-50'
: 'text-surface-600 hover:bg-surface-100'
}`}
>
{formatWeekRange(weekStart)}
</button>
<button
type="button"
onClick={onNext}
aria-label="Next week"
title="Next week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronRight className="w-4 h-4" />
</button>
{!isCurrentWeek && (
<button
type="button"
onClick={onJumpHome}
className="ml-2 inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-primary-50 text-primary-700 hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-primary-400"
aria-label="Jump to upcoming week"
title="Jump to upcoming week"
>
This week
</button>
)}
</div>
)
}
+67 -9
View File
@@ -37,16 +37,48 @@ export function cleanDescription(input: string | undefined | null, maxLen = 280)
} }
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Week helpers (used by F5 URL week selector) */ /* Week helpers (used by F5 URL week selector + Sprint 7 WeekRangeNav) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** Return the ISO date (YYYY-MM-DD) of the Monday of the given date's week. */ /**
export function isoMonday(d: Date = new Date()): string { * Return the ISO date (YYYY-MM-DD) of the *upcoming* Monday for the given date.
* - If `d` is a Monday → returns `d` itself.
* - Otherwise → returns the next Monday (1..6 days ahead).
*
* Pairs with `runner._current_week_start()` on the backend (see
* `backend/app/services/orchestrator/runner.py`). "This week" means the
* upcoming Mon-Sun week the Friday email advertises.
*/
export function upcomingMonday(d: Date = new Date()): string {
const day = d.getUTCDay() // 0=Sun, 1=Mon, ..., 6=Sat const day = d.getUTCDay() // 0=Sun, 1=Mon, ..., 6=Sat
// Treat Sunday as end-of-week (offset 6), Mon-Sat as offset (day-1). if (day === 0) {
const offset = day === 0 ? 6 : day - 1 // Sunday: upcoming Monday is tomorrow (1 day ahead).
const monday = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - offset)) const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
return monday.toISOString().slice(0, 10) return next.toISOString().slice(0, 10)
}
if (day === 1) {
// Monday: today.
return d.toISOString().slice(0, 10)
}
// Tue..Sat: next Monday is (7 - day) days ahead.
const offset = 7 - day + 1
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + offset))
return next.toISOString().slice(0, 10)
}
/**
* Legacy alias kept for any external code that might still reference the
* calendar-week Monday. The frontend codebase has been updated to use
* `upcomingMonday`; this alias is intentionally a no-op redirect to the
* new function with a deprecation note.
*
* @deprecated Use `upcomingMonday()` — see Sprint 7 root-cause in
* `Review/handoff-ui-audit.md`. The "current calendar week" semantic
* mismatched the orchestrator's "upcoming week" semantic, causing the
* webui to show an empty state on Fridays.
*/
export function isoMonday(d: Date = new Date()): string {
return upcomingMonday(d)
} }
/** Parse a YYYY-MM-DD string into a Date (UTC midnight). Returns null if invalid. */ /** Parse a YYYY-MM-DD string into a Date (UTC midnight). Returns null if invalid. */
@@ -64,9 +96,35 @@ export function shiftIsoDate(s: string, days: number): string {
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
} }
/** Format a YYYY-MM-DD string for display: "Jun 1, 2026". */ /**
* Format a YYYY-MM-DD string for display: "Jun 1, 2026".
* Always renders in UTC so the displayed date matches the ISO date
* stored in the DB regardless of the viewer's local timezone.
*/
export function formatIsoDate(s: string): string { export function formatIsoDate(s: string): string {
const d = parseIsoDate(s) const d = parseIsoDate(s)
if (!d) return s if (!d) return s
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) return d.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
}
/**
* Format a Monday's ISO date as a MonSun range label: "Jun 8 — Jun 14".
* Used by `WeekRangeNav` (Sprint 7) for the visible week range.
*
* Always renders in UTC so the date the user sees matches the ISO date
* stored in the DB regardless of the viewer's local timezone.
*/
export function formatWeekRange(mondayIso: string): string {
const monday = parseIsoDate(mondayIso)
if (!monday) return mondayIso
const sunday = new Date(monday)
sunday.setUTCDate(sunday.getUTCDate() + 6)
const fmt = (d: Date) =>
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
return `${fmt(monday)}${fmt(sunday)}`
} }
+13 -29
View File
@@ -2,12 +2,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom' import { Link, useSearchParams } from 'react-router-dom'
import { import {
CookingPot, CalendarDays, ShoppingCart, ChevronRight, ChevronLeft, Sparkles, Loader2, CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
GripVertical, X GripVertical, X
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { showToast, showApiError } from '../lib/toast' import { showToast, showApiError } from '../lib/toast'
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils' import { upcomingMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import { import {
DragDropContext, DragDropContext,
@@ -26,6 +26,7 @@ import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card' import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton' import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { WeekRangeNav } from '../components/WeekRangeNav'
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
@@ -313,10 +314,10 @@ export default function Dashboard() {
const parsedWeek = weekParam ? parseIsoDate(weekParam) : null const parsedWeek = weekParam ? parseIsoDate(weekParam) : null
const weekStart = weekParam && parsedWeek const weekStart = weekParam && parsedWeek
? weekParam ? weekParam
: isoMonday() : upcomingMonday()
const isCurrentWeek = weekStart === isoMonday() const isCurrentWeek = weekStart === upcomingMonday()
const navigateWeek = (next: string) => { const navigateWeek = (next: string) => {
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true }) setSearchParams(next === upcomingMonday() ? {} : { week: next }, { replace: true })
} }
const [planningWeek, setPlanningWeek] = useState(false) const [planningWeek, setPlanningWeek] = useState(false)
const [planMenuOpen, setPlanMenuOpen] = useState(false) const [planMenuOpen, setPlanMenuOpen] = useState(false)
@@ -477,30 +478,13 @@ export default function Dashboard() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<div className="inline-flex items-center rounded-lg border border-surface-200 bg-white"> <WeekRangeNav
<button weekStart={weekStart}
onClick={() => navigateWeek(shiftIsoDate(weekStart, -7))} isCurrentWeek={isCurrentWeek}
aria-label="Previous week" onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))}
className="p-2 text-surface-600 hover:bg-surface-100 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary-400" onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
> onJumpHome={() => navigateWeek(upcomingMonday())}
<ChevronLeft className="w-4 h-4" /> />
</button>
<button
onClick={() => navigateWeek(isoMonday())}
aria-label="Jump to current week"
title="Jump to current week"
className={`px-3 py-2 text-sm font-medium border-x border-surface-200 focus:outline-none focus:ring-2 focus:ring-primary-400 ${isCurrentWeek ? 'text-primary-700 bg-primary-50' : 'text-surface-600 hover:bg-surface-100'}`}
>
{isCurrentWeek ? 'This week' : 'Current'}
</button>
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, 7))}
aria-label="Next week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setPlanMenuOpen(o => !o)} onClick={() => setPlanMenuOpen(o => !o)}
+21 -52
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, ChevronLeft, ChevronRight, PackagePlus } from 'lucide-react' import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, PackagePlus } from 'lucide-react'
import { mealPlannerApi } from '../api' import { mealPlannerApi } from '../api'
import type { ShoppingList } from '../types' import type { ShoppingList } from '../types'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
@@ -9,7 +9,8 @@ import { Badge } from '../components/ui/Badge'
import { Card, CardBody } from '../components/ui/Card' import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils' import { WeekRangeNav } from '../components/WeekRangeNav'
import { upcomingMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { showToast, showApiError } from '../lib/toast' import { showToast, showApiError } from '../lib/toast'
const AISLE_LABEL: Record<string, string> = { const AISLE_LABEL: Record<string, string> = {
@@ -84,10 +85,10 @@ export default function ShoppingListPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const weekParam = searchParams.get('week') const weekParam = searchParams.get('week')
const parsedWeek = weekParam ? parseIsoDate(weekParam) : null const parsedWeek = weekParam ? parseIsoDate(weekParam) : null
const weekStart = weekParam && parsedWeek ? weekParam : isoMonday() const weekStart = weekParam && parsedWeek ? weekParam : upcomingMonday()
const isCurrentWeek = weekStart === isoMonday() const isCurrentWeek = weekStart === upcomingMonday()
const navigateWeek = (next: string) => { const navigateWeek = (next: string) => {
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true }) setSearchParams(next === upcomingMonday() ? {} : { week: next }, { replace: true })
} }
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({ const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
queryKey: ['shoppingList', weekStart], queryKey: ['shoppingList', weekStart],
@@ -204,29 +205,14 @@ export default function ShoppingListPage() {
<p className="text-sm text-surface-500">Week of {formatIsoDate(weekStart)}</p> <p className="text-sm text-surface-500">Week of {formatIsoDate(weekStart)}</p>
</div> </div>
</div> </div>
<div className="inline-flex items-center rounded-lg border border-surface-200 bg-white"> <div className="flex items-center gap-2 flex-wrap">
<button <WeekRangeNav
onClick={() => navigateWeek(shiftIsoDate(weekStart, -7))} weekStart={weekStart}
aria-label="Previous week" isCurrentWeek={isCurrentWeek}
className="p-2 text-surface-600 hover:bg-surface-100 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary-400" onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))}
> onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
<ChevronLeft className="w-4 h-4" /> onJumpHome={() => navigateWeek(upcomingMonday())}
</button> />
<button
onClick={() => navigateWeek(isoMonday())}
aria-label="Jump to current week"
title="Jump to current week"
className={`px-3 py-2 text-sm font-medium border-x border-surface-200 focus:outline-none focus:ring-2 focus:ring-primary-400 ${isCurrentWeek ? 'text-primary-700 bg-primary-50' : 'text-surface-600 hover:bg-surface-100'}`}
>
{isCurrentWeek ? 'This week' : 'Current'}
</button>
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, 7))}
aria-label="Next week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronRight className="w-4 h-4" />
</button>
</div> </div>
<EmptyState <EmptyState
icon={Receipt} icon={Receipt}
@@ -257,30 +243,13 @@ export default function ShoppingListPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<div className="inline-flex items-center rounded-lg border border-surface-200 bg-white"> <WeekRangeNav
<button weekStart={weekStart}
onClick={() => navigateWeek(shiftIsoDate(weekStart, -7))} isCurrentWeek={isCurrentWeek}
aria-label="Previous week" onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))}
className="p-2 text-surface-600 hover:bg-surface-100 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary-400" onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
> onJumpHome={() => navigateWeek(upcomingMonday())}
<ChevronLeft className="w-4 h-4" /> />
</button>
<button
onClick={() => navigateWeek(isoMonday())}
aria-label="Jump to current week"
title="Jump to current week"
className={`px-3 py-2 text-sm font-medium border-x border-surface-200 focus:outline-none focus:ring-2 focus:ring-primary-400 ${isCurrentWeek ? 'text-primary-700 bg-primary-50' : 'text-surface-600 hover:bg-surface-100'}`}
>
{isCurrentWeek ? 'This week' : 'Current'}
</button>
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, 7))}
aria-label="Next week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{progress > 0 && ( {progress > 0 && (
<span className="text-sm text-surface-500">{progress}% complete</span> <span className="text-sm text-surface-500">{progress}% complete</span>
)} )}