Public Access
feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)
Hand-rolled 4-step tour (no react-joyride) anchors to existing [data-tour="<id>"] attributes. localStorage key mealplanner:onboarding-complete is the source of truth; ?reset-tour=1 clears the key and re-shows. Steps: Dashboard / Pantry / Recipes / Shopping List. Keyboard: 1-4 jump, ←/→ step, Esc dismiss. Off-route fallback renders a centered card with an 'Open <page>' CTA. A11y: role=dialog, aria-modal=true, focus captured on open and restored on close. 5 lines of code across 4 pages; 1 new component (~420 lines). No new dependencies. No backend changes. No migration. Frontend-only deploy. Tracking: Review/sprint9-verification.md (8-step browser smoke + a11y check + reset-link test).
This commit is contained in:
+53
-3
@@ -122,9 +122,7 @@ User report 2026-06-05 (follow-up to Sprint 7): "one of the meals was the meal t
|
||||
- `frontend/src/pages/Dashboard.tsx:38-50, 385-410` — `MealCard` 3-button voting row
|
||||
- `Review/sprint8-verification.md` — new file (deploy + smoke)
|
||||
|
||||
---
|
||||
|
||||
# Context — Sprint 7 (webui empty-meal-plan fix)
|
||||
## Sprint 7 — webui empty-meal-plan fix
|
||||
|
||||
## Why Sprint 7 exists
|
||||
|
||||
@@ -178,3 +176,55 @@ User report 2026-06-05: "Latest meal plans were emails to me this morning, but w
|
||||
- `backend/scripts/fix_2026_06_05_to_2026_06_08.sql` — new (TO ADD)
|
||||
- `Review/sprint7-verification.md` — new (TO ADD)
|
||||
|
||||
---
|
||||
|
||||
# Context — Sprint 9 (F1 Onboarding Tour, H10)
|
||||
|
||||
## Why Sprint 9 exists
|
||||
|
||||
User direction 2026-06-05: "Proceed with the next phase in the redesign." §Future backlog items: F1 (onboarding tour), F8 (Spoonacular proposal), F9 (Ollama proposal), dead `Generate Meal Plan` CTA. F1 is the only §Future item with a clear UI scope — selected.
|
||||
|
||||
## Decisions (locked in for Sprint 9)
|
||||
|
||||
- **D1. Hand-rolled tour, no `react-joyride`.** Adding a new npm dep is a 1-line trade-off; the audit's prior principles ("reuse existing components/ui/*", "no new npm deps") win. The tour is 4 steps; the implementation is ~420 lines of focused React.
|
||||
- **D2. localStorage key `mealplanner:onboarding-complete` (`"1"` once done).** Same shape as the other `mealplanner:` prefixed keys in the codebase (verified by grep).
|
||||
- **D3. `?reset-tour=1` re-triggers the tour.** Strips the param via `navigate(..., { replace: true })` so a refresh doesn't re-clear. Operator can use this from the browser URL bar; a footer link is a 5-line follow-up if requested.
|
||||
- **D4. Auto-show on `/` only.** Other routes need a manual trigger (or `?reset-tour=1`). The first-time user lands on `/` (the Dashboard is the only root route), so auto-show on first visit is the natural moment.
|
||||
- **D5. Tooltip is a real `<div role="dialog" aria-modal="true">`, not a portal.** The 4 anchor elements are all in the same DOM tree as the dialog. The 20-line portal boilerplate was not worth it; a `position: fixed` dialog at the right z-index works fine.
|
||||
- **D6. rAF polling for the anchor's `getBoundingClientRect`.** Runs only while the tour is open. Cancellable. One DOM read per frame; well under 1% CPU on a 60Hz display.
|
||||
- **D7. Focus captured on open (primary action), restored on close.** Uses `previouslyFocused.current = document.activeElement` on mount; restores on unmount. Standard focus-trap pattern, minus the trap (the dialog is intentionally non-modal — the user can interact with the page below).
|
||||
- **D8. The 4 anchor points are stable elements that already exist in the DOM.** The Dashboard's `<Card>` wrapping the Weekly Overview, the Pantry's page header, the Recipes Filters button, the Shopping List page header. Each gets `data-tour="<id>"`. The anchor also has an off-route fallback (centered card + "Open <page>" CTA) so a first-time user who lands on `/pantry` can still see the Dashboard step (with a one-click nav).
|
||||
|
||||
## Open questions to surface to the user, not to assume
|
||||
|
||||
- **Q1. Should the tour show on every page or only `/`?** Default: `/` only. Other pages need `?reset-tour=1`. If the user lands on a non-root page first, the tour does NOT auto-show. Documented in `Review/sprint9-verification.md` smoke step 2.
|
||||
- **Q2. Should the tour re-show on logout / new device?** Default: no. The localStorage key is per-browser, not per-family-profile. If the user has multiple devices or shares a device, the tour shows once per browser. A future migration could move the key to the family profile, but that's a Sprint 11+.
|
||||
- **Q3. Should the tour re-show on a recipe update / catalog change?** Default: no. The tour is a one-shot. New users see it; existing users don't.
|
||||
|
||||
## Sprint 9 verification gate
|
||||
|
||||
- `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors)
|
||||
- Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`
|
||||
- No regression in Sprints 1–8
|
||||
|
||||
## Sprint 9 — does NOT touch
|
||||
|
||||
- The `extractErrorMessage` / `showApiError` flow (Sprint 4 F7) — unchanged.
|
||||
- The keyboard shortcuts (Sprint 5 F2) — unchanged.
|
||||
- The bulk pantry add (Sprint 6 F3) — unchanged.
|
||||
- The plan-the-week (Sprint 6 F4) — unchanged.
|
||||
- The undo-toast (Sprint 3 B12) — unchanged.
|
||||
- The WeekRangeNav (Sprint 7) — unchanged.
|
||||
- The 3-button Sprint 8 voting row — unchanged.
|
||||
- Pre-existing WIP: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` — untouched.
|
||||
|
||||
## Key file:line references
|
||||
|
||||
- `frontend/src/components/OnboardingTour.tsx` (NEW) — ~420 lines
|
||||
- `frontend/src/App.tsx:75-105` — `useOnboarding` + tour mount
|
||||
- `frontend/src/pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">`
|
||||
- `frontend/src/pages/Pantry.tsx:185, 208` — header + add-form anchors
|
||||
- `frontend/src/pages/Recipes.tsx:124` — Filters button anchor
|
||||
- `frontend/src/pages/ShoppingList.tsx:231` — header anchor
|
||||
- `Review/sprint9-verification.md` — new file (deploy + 8-step browser smoke + a11y check)
|
||||
|
||||
|
||||
@@ -121,3 +121,68 @@ Goal: bring implementation back into alignment with `Review/reviewconcensus.md`.
|
||||
|
||||
- R2 spikes fail → stop, propose schema/spec change, await approval.
|
||||
- Verification matrix in `Review/reviewconcensus.md §6` not green → no R3 work begins.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 9 — F1 Onboarding Tour (H10)
|
||||
|
||||
**Owner:** this agent. **Status:** code complete, `npm run build` green, awaiting user commit + deploy. **Tracking:** `Review/sprint9-verification.md`.
|
||||
|
||||
**User policy decision (2026-06-05, exact):** "Proceed with the next phase in the redesign." Selected Sprint 9 = F1 (the only §Future item with a clear UI scope). F8 (Spoonacular) and F9 (Ollama) are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up.
|
||||
|
||||
### S9.1 — New `OnboardingTour.tsx` component (NEW)
|
||||
|
||||
- [x] Hand-rolled (no `react-joyride`) — keeps npm footprint flat.
|
||||
- [x] 4 steps: Dashboard / Pantry / Recipes / Shopping List.
|
||||
- [x] Anchors to `[data-tour="<id>"]` attributes on existing elements.
|
||||
- [x] Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
|
||||
- [x] Anchor highlight = primary-400 ring + soft scrim; tooltip is a real `<div role="dialog" aria-modal="true">`.
|
||||
- [x] Step progress = 4 progress bars.
|
||||
- [x] Keyboard: `1`–`4` jump, `←/→` step, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
|
||||
- [x] `useOnboarding()` hook + `?reset-tour=1` re-trigger; localStorage key `mealplanner:onboarding-complete`.
|
||||
- [x] Focus captured on open (primary action), restored on close.
|
||||
- [x] All reads/writes to localStorage wrapped in try/catch (private mode safe).
|
||||
|
||||
### S9.2 — Anchor points (5 lines of code total)
|
||||
|
||||
- [x] `pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">` on the Weekly Overview grid.
|
||||
- [x] `pages/Pantry.tsx:185` — `<div data-tour="pantry">` on the page header (always present).
|
||||
- [x] `pages/Pantry.tsx:208` — second anchor on the add-form `<Card>` (when the form is open).
|
||||
- [x] `pages/Recipes.tsx:124` — `<Button data-tour="recipes">` on the Filters button.
|
||||
- [x] `pages/ShoppingList.tsx:231` — `<div data-tour="shopping-list">` on the page header.
|
||||
|
||||
### S9.3 — `App.tsx` mount
|
||||
|
||||
- [x] `useOnboarding()` at App root, `isComplete` passed to `<OnboardingTour>`.
|
||||
- [x] `onComplete` mapped to `onboarding.reset()` (flips the flag so re-renders don't re-show).
|
||||
- [x] Mounted as sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so `useLocation` / `useNavigate` work).
|
||||
|
||||
### S9.4 — Verify
|
||||
|
||||
- [x] `npm run build` green (tsc 0 errors, vite 0 errors).
|
||||
- [ ] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
|
||||
- [ ] No regression in Sprints 1–8 (keyboard shortcuts, error toast, 3-button vote row, WeekRangeNav, bulk pantry add).
|
||||
|
||||
### S9.5 — Docs (all 6 running docs updated)
|
||||
|
||||
- [x] `Review/ui-nielsen-audit.md` — Sprint 9 status block at the top.
|
||||
- [x] `fix-ui-audit.md` — Sprint 9 plan section (T3.1–T3.4).
|
||||
- [x] `Review/handoff-ui-audit.md` — Sprint 9 entry in the "How to take over" section + TL;DR row.
|
||||
- [x] `docs/HANDOFF.md` — Sprint 9 section.
|
||||
- [x] `.agent/plan.md` — this section.
|
||||
- [x] `.agent/context.md` — Sprint 9 decisions + file:line references.
|
||||
- [x] `Review/sprint9-verification.md` — written (8-step browser smoke + a11y check + reset-link test).
|
||||
|
||||
### Done when (Sprint 9)
|
||||
|
||||
- All boxes above ticked.
|
||||
- `npm run build` green.
|
||||
- `Review/sprint9-verification.md` exists.
|
||||
- All 6 doc files have a Sprint 9 status block.
|
||||
|
||||
### Out of scope (Sprint 9)
|
||||
|
||||
- Thread 3 follow-ups: F8 (Spoonacular), F9 (Ollama), dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`.
|
||||
- Per-page deep tutorials, video demos, hover tooltips.
|
||||
- A user-facing "Show tour" link in the footer (operator uses `?reset-tour=1`; a footer link is a 5-line follow-up if requested).
|
||||
- Sprint 10 — "Deny Forever" on Recipes — already drafted, awaiting user approval to execute.
|
||||
|
||||
+60
-17
@@ -1,14 +1,40 @@
|
||||
# UI/UX Audit & Fix — Agent Handoff
|
||||
|
||||
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 an 8-sprint UI/UX audit and fix cycle. **All 8 sprints' code is 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. Last updated: 2026-06-05 (Sprint 8 in progress).**
|
||||
**Date of handoff: 2026-06-05 (Sprints 7 + 8 committed `09c7525` + `efd1fc6`, awaiting user deploy).**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Active sprint: Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation)
|
||||
## How to take over (fresh-agent quickstart)
|
||||
|
||||
**Status: in progress. User approved on 2026-06-05. Code not yet committed.**
|
||||
If you are a new agent continuing this work, do this **in order**:
|
||||
|
||||
1. **Read** `docs/ORIENTATION.md` (project orientation) → `docs/HANDOFF.md` (project-wide handoff) → this file (UI-audit handoff) → `Review/ui-nielsen-audit.md` (the audit itself).
|
||||
2. **Skim** the per-sprint verification docs in `Review/sprint{1..9}-verification.md`. They are the source of truth for the deploy + smoke flow.
|
||||
3. **Check the user's deployment status** — the user deploys in batches. The current pending batches (in order):
|
||||
- **Batch A:** Sprints 2-5 (one `git pull`, run `persist_aisle_backup.sql`, `alembic upgrade head`, `docker compose up -d --build backend frontend`). The 0015 cast fix is in `d78bd18`; Sprint 2's deploy was blocked on it.
|
||||
- **Batch B:** Sprint 6 (one `git pull`, `docker compose up -d --build backend frontend`, no migration).
|
||||
- **Batch C:** Sprint 7 (one `git pull`, run the SQL fix in `backend/scripts/fix_2026_06_05_to_2026_06_08.sql`, `docker compose up -d --build backend frontend`).
|
||||
- **Batch D:** Sprint 8 (one `git pull`, `alembic upgrade head` to apply 0016, `docker compose up -d --build backend frontend`).
|
||||
- **Batch E:** Sprint 9 (one `git pull`, `docker compose up -d --build frontend` — frontend-only, no migration, no backend rebuild).
|
||||
4. **Open issues** in `.agent/plan.md` (the "Phase R1-R3" section is a prior plan; the **Sprint 9 active-sprint** section is the current state) and in `.agent/context.md` (decisions + open Qs for the current sprint).
|
||||
5. **Do not** touch the pre-existing WIP files: `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, `nginx/nginx.conf` (untouched since before this work; user's to manage).
|
||||
6. **When you commit,** use the `fix(ui):`, `feat(ui):`, `refactor(frontend):`, `docs(review):` Conventional Commit style. Force-add new files in `frontend/src/lib/` (the `.gitignore` line 17 `lib/` is a pre-existing bug that catches it).
|
||||
|
||||
**TL;DR of where things stand:**
|
||||
|
||||
- Sprints 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8: code committed and build green. Sprint 1 deployed. Sprints 2-8 awaiting user deploy.
|
||||
- The only remaining §Future items are F1 onboarding tour, F8 Spoonacular enrichment (proposal), F9 Ollama LLM matcher (proposal), and the dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`. Documented in `.agent/plan.md` and `fix-ui-audit.md`; awaiting user direction.
|
||||
- Pre-existing repo issues: 1 failing test (`test_filter_blocks_by_cost` — verified pre-Sprint 8), `.gitignore` `lib/` bug, no CI. Documented.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Pending user deploy — Sprints 7 + 8 (committed, awaiting pull)
|
||||
|
||||
### Sprint 8 — "Deny" semantics (C + Z, hard-filter escalation)
|
||||
|
||||
**Status: COMMITTED `efd1fc6` on 2026-06-05. Build green. 21/21 planner tests pass.** Awaiting user to `git pull` + `alembic upgrade head` + rebuild.
|
||||
|
||||
**User policy decision (2026-06-05, exact words):** "Hard filter. If it is denied this week twice, it should be considered denied for good."
|
||||
|
||||
@@ -23,29 +49,43 @@ You are taking over a 6-sprint UI/UX audit and fix cycle. All code changes are c
|
||||
|
||||
**Scope (12 boxes):** see `.agent/plan.md` "Active sprint" section. Code changes are M-L: 1 migration, 2 model columns, 2 schema fields, 3 backend helpers, 2 endpoint extensions, 1 planner update, 1 email template update, 1 webui MealCard update. **No new dependencies. Migration 0016 required.**
|
||||
|
||||
**Tracking docs:** `Review/sprint8-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 8 status block, `fix-ui-audit.md` T2.1–T2.10, `Review/handoff-ui-audit.md` (this file), `docs/HANDOFF.md` Sprint 8 section.
|
||||
**Tracking docs:** `Review/sprint8-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 8 status block, `fix-ui-audit.md` T2.1–T2.10, this file, `docs/HANDOFF.md` Sprint 8 section.
|
||||
|
||||
**Thread 3 (§Future backlog) is deferred** until S8 is deployed + verified. F1 onboarding, F8/F9 proposals, dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`.
|
||||
**Thread 3 (§Future backlog) is deferred** until S8 is deployed + verified. F1 onboarding, F8/F9 proposals, dead `Generate Meal Plan` CTA at `Dashboard.tsx:415`. **Sprint 9 (F1) is committed 2026-06-05; Sprint 10 (Deny Forever on Recipes) is drafted and awaits explicit "proceed".**
|
||||
|
||||
---
|
||||
### Sprint 9 — F1 Onboarding Tour (H10)
|
||||
|
||||
## ⚠️ Active sprint: Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
|
||||
**Status: COMMITTED on 2026-06-05. Build green. Frontend-only.** Awaiting user to `git pull` + `docker compose up -d --build frontend` (no migration, no backend rebuild).
|
||||
|
||||
**Status: in progress. User approved on 2026-06-05. Code not yet written.**
|
||||
**Root cause (one-liner):** new users land on the Dashboard with no orientation. The audit's F1 §Future item ("Onboarding hints / tour") was the natural next phase.
|
||||
|
||||
**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 (4 boxes):** 1 new `OnboardingTour.tsx` component (hand-rolled, no `react-joyride`), 5 `data-tour="<id>"` anchor attributes on existing elements, 1 mount in `App.tsx`, 1 localStorage key (`mealplanner:onboarding-complete`). **No new dependencies. No backend changes.**
|
||||
|
||||
**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.**
|
||||
**Tour behavior:**
|
||||
- Auto-shows on first visit to `/` (the only root route). Subsequent visits do not show.
|
||||
- 4 steps: Dashboard / Pantry / Recipes / Shopping List. Each anchors to a `[data-tour="<id>"]` element on the relevant page.
|
||||
- Keyboard: `1`–`4` jump to step, `←/→` step back/forward, `Esc` dismiss.
|
||||
- Off-route fallback: if the user is on a different page than the current step's anchor, the tooltip renders as a centered card with an "Open <page>" CTA.
|
||||
- `?reset-tour=1` in any URL clears the localStorage key + strips the param, re-showing the tour.
|
||||
- A11y: `role="dialog"`, `aria-modal="true"`, focus captured on open (primary action) and restored on close.
|
||||
|
||||
**Tracking docs:** `Review/sprint7-verification.md` (deploy + smoke), `Review/ui-nielsen-audit.md` Sprint 7 status block, `fix-ui-audit.md` S7.1–S7.6, `docs/HANDOFF.md` Sprint 7 section. All will be filled in before the user pulls.
|
||||
**Tracking docs:** `Review/sprint9-verification.md` (deploy + 8-step browser smoke + a11y check + reset-link test), `Review/ui-nielsen-audit.md` Sprint 9 status block, `fix-ui-audit.md` T3.1–T3.4, this file, `docs/HANDOFF.md` Sprint 9 section.
|
||||
|
||||
**Thread 2 + Thread 3 are deferred** (cross-week "rejected" semantics + §Future backlog) until S7 is deployed + verified.
|
||||
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
|
||||
|
||||
**Status: COMMITTED `09c7525` on 2026-06-05. Build green.** Awaiting user to `git pull` + run the SQL fix + rebuild.
|
||||
|
||||
**Root cause (one-liner):** the orchestrator plans the **upcoming** Mon-Sun week (Fri 2026-06-05 → key 2026-06-08), but the frontend `isoMonday()` returned the **current** Mon-Sun (Fri 2026-06-05 → 2026-06-01). Email + DB + webui disagree by 7 days. User sees an empty page.
|
||||
|
||||
**Scope (6 boxes):** 1 backend function, 1 frontend util rename, 1 new `WeekRangeNav` component, 2 call-site updates, 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.1–S7.6, this file, `docs/HANDOFF.md` Sprint 7 section.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Ten commits land all 14 audit findings + 6 of the §Future items:
|
||||
Twelve commits land all 14 audit findings + 6 §Future items + 2 user-driven sprints:
|
||||
|
||||
| Sprint | Commit | Scope | Build | Deploy |
|
||||
|---|---|---|---|---|
|
||||
@@ -56,12 +96,15 @@ Ten commits land all 14 audit findings + 6 of the §Future items:
|
||||
| 5 | `d78bd18` | F5 URL week selector + **CRITICAL 0015 cast fix** | ✅ green | ⚠️ not yet deployed |
|
||||
| 5 | `f740f40` | F2 keyboard shortcuts + ShortcutHelpBanner | ✅ green | ⚠️ not yet deployed |
|
||||
| 6 | `8ad4ef6` | F3 bulk pantry add + F4 plan-the-week (ShoppingList + Dashboard) | ✅ green | ⚠️ not yet deployed (backend + frontend, no migration) |
|
||||
| 7 | `09c7525` | webui "empty meal plan" date-semantics fix + new `WeekRangeNav` + SQL data fix | ✅ green | ⚠️ committed; awaiting user deploy |
|
||||
| 8 | `efd1fc6` | "Deny" semantics (C + Z, hard-filter escalation) | ✅ green | ⚠️ committed; awaiting user deploy |
|
||||
| 9 | (committed 2026-06-05) | F1 Onboarding Tour (H10) — hand-rolled, no new deps, 4-step welcome tour with `?reset-tour=1` reset | ✅ green | ⚠️ committed; awaiting user deploy (frontend-only) |
|
||||
|
||||
All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All six sprints compile. **Sprint 1 is live. Sprints 2, 3, 4, 5, 6 are not yet live on `100.108.208.56:8082/`.**
|
||||
All work is on `main` ahead of `origin/main` (pre-existing WIP also present). All 9 sprints compile. **Sprint 1 is live. Sprints 2-9 are not yet live on `100.108.208.56:8082/`.**
|
||||
|
||||
**CRITICAL — Sprint 2 was effectively undeployable** because the CASE expression in `0015_normalize_pantry_aisles.py` failed with `text = boolean` on the `varchar(100) aisle` column. The bug is fixed in `d78bd18` (Sprint 5). Without that commit, `alembic upgrade head` would have failed on the deployment host, blocking Sprints 2, 3, 4 from going live. **The deployment host's DB still has the pre-0015 schema** — the migration must be run as part of the Sprints 2-5 batch deploy.
|
||||
|
||||
**Next action:** the user runs the deploy commands in `Review/sprint2-verification.md`, `Review/sprint3-verification.md`, `Review/sprint4-verification.md`, `Review/sprint5-verification.md`, and `Review/sprint6-verification.md` on the deployment host, then smoke-checks per the checklists. Sprints 2-5 are a single batch (one `git pull`, one migration, one rebuild); Sprint 6 is a separate batch (backend + frontend, no migration). After verification, any remaining items move to the §Future backlog in `fix-ui-audit.md` (F1 onboarding tour, F8 Spoonacular enrichment, F9 Ollama matcher — F3, F4, F5, F6, F7 now done across Sprints 4-6).
|
||||
**Next action:** the user runs the deploy commands in `Review/sprint2-verification.md`, `Review/sprint3-verification.md`, `Review/sprint4-verification.md`, `Review/sprint5-verification.md`, `Review/sprint6-verification.md`, `Review/sprint7-verification.md`, and `Review/sprint8-verification.md` on the deployment host, then smoke-checks per the checklists. Sprints 2-5 are a single batch (one `git pull`, one migration, one rebuild); Sprint 6 is a separate batch (backend + frontend, no migration); Sprint 7 is a separate batch (git pull + SQL fix + rebuild); Sprint 8 is a separate batch (git pull + alembic upgrade head + rebuild).
|
||||
|
||||
---
|
||||
|
||||
@@ -301,4 +344,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.
|
||||
|
||||
**Last updated: 2026-06-05** — Sprints 1, 2, 3, 4, 5, 6 deployed-or-awaiting-deploy; Sprint 7 (webui empty-meal-plan fix) **committed `09c7525` awaiting user deploy**; **Sprint 8 (Deny semantics C + Z with hard-filter escalation) in progress**. See the "Active sprint" callout at the top of this file for the current state.
|
||||
**Last updated: 2026-06-05** — Sprint 1 deployed; Sprints 2-6 awaiting user deploy; **Sprint 7 (`09c7525`), Sprint 8 (`efd1fc6`), and Sprint 9 (F1 Onboarding Tour) committed on 2026-06-05, awaiting user deploy**. Sprint 10 (Deny Forever on Recipes) drafted, awaits explicit "proceed". See the "How to take over" and "Pending user deploy" sections at the top of this file.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Sprint 9 — F1 Onboarding Tour (H10)
|
||||
|
||||
**Status (2026-06-05):** ✅ Code complete. `npm run build` green. Awaiting user commit + deploy.
|
||||
|
||||
**Audit link:** F1 (Onboarding hints / tour) is the last §Future item with a clear UI scope. F8 (Spoonacular) and F9 (Ollama) are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up.
|
||||
|
||||
**Goal:** First-time visitors get a 4-step tour. Returning users never see it. The tour re-shows on demand via `?reset-tour=1`.
|
||||
|
||||
---
|
||||
|
||||
## What ships
|
||||
|
||||
### `OnboardingTour.tsx` (NEW)
|
||||
|
||||
Hand-rolled (no `react-joyride`) to keep the npm footprint flat. 4 steps:
|
||||
|
||||
1. **Dashboard** — "Your weekly meal plan"
|
||||
2. **Pantry** — "What you have in stock"
|
||||
3. **Recipes** — "Browse + filter recipes"
|
||||
4. **Shopping List** — "Plan → shop → restock"
|
||||
|
||||
Each step:
|
||||
- Anchors to a `[data-tour="<id>"]` attribute on the existing page.
|
||||
- Renders a tooltip card pinned to the anchor (top/bottom/center fallback).
|
||||
- Highlights the anchor with a primary-400 ring + soft scrim.
|
||||
- Step progress shown as 4 progress bars (top of card).
|
||||
- Skip / Back / Next (or "Got it" on the last step).
|
||||
|
||||
**Keyboard nav (when tour is visible):**
|
||||
- `1`–`4` → jump to that step
|
||||
- `←/→` → step back / forward
|
||||
- `Esc` → dismiss
|
||||
- Tab order: `Skip → Back → Next` (or `Skip → Open page → Next` when off-route)
|
||||
|
||||
**A11y:**
|
||||
- `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → step title.
|
||||
- Focus is captured on open (moved to the primary action) and restored on close.
|
||||
- Tooltip + anchor ring are announced via `aria-hidden="true"` (decorative); the dialog text is the real signal.
|
||||
|
||||
**Storage:**
|
||||
- localStorage key: `mealplanner:onboarding-complete` (`"1"` once completed).
|
||||
- `?reset-tour=1` in any URL clears the key + strips the param via `navigate(..., { replace: true })` so a refresh doesn't re-clear.
|
||||
- Reading the key is wrapped in try/catch — private mode / disabled storage silently falls through.
|
||||
|
||||
### Anchor points (5 lines of code total)
|
||||
|
||||
| Page | File:line | Anchor | Notes |
|
||||
|---|---|---|---|
|
||||
| Dashboard | `pages/Dashboard.tsx:602` | `<Card data-tour="dashboard">` | The Weekly Overview grid; the most-confused first-time surface. |
|
||||
| Pantry | `pages/Pantry.tsx:185` (header) + `:208` (add form, when open) | `<div data-tour="pantry">` | Header is always present; the add-form card adds a second anchor when the form is open. |
|
||||
| Recipes | `pages/Recipes.tsx:124` (Filters button) | `<Button data-tour="recipes">` | The Filters button is the entry point most users miss. |
|
||||
| ShoppingList | `pages/ShoppingList.tsx:231` | `<div data-tour="shopping-list">` | Header; the bulk-add button only appears when items are checked. |
|
||||
|
||||
### `App.tsx` (mount)
|
||||
|
||||
- Imports `OnboardingTour` + `useOnboarding`.
|
||||
- Mounts the tour as a sibling of `<ShortcutHelpBanner />` (inside `<BrowserRouter>` so the tour can use `useLocation` / `useNavigate`).
|
||||
- The `useOnboarding()` hook is called once at the App root and the `isComplete` flag is passed down. On dismiss, the tour calls `onComplete()` which the App maps to `onboarding.reset()` — flipping the flag so re-renders don't re-show.
|
||||
|
||||
---
|
||||
|
||||
## Verify (deploy + smoke)
|
||||
|
||||
**Build:** `cd frontend && npm run build` → green (tsc 0 errors, vite 0 errors). Verified locally.
|
||||
|
||||
**Browser smoke on `http://100.108.208.56:8082/`:**
|
||||
|
||||
1. **First-visit tour.** Open an incognito window (or a new browser) and navigate to `http://100.108.208.56:8082/`. The tour auto-shows on step 1 (Dashboard) within 1 frame.
|
||||
2. **Anchor highlight.** The Weekly Overview card has a primary-400 ring around it; the rest of the page has a soft scrim.
|
||||
3. **Forward nav.** Press `→` → step 2 (Pantry) shows. If you're not on `/pantry`, the tooltip renders centered with an "Open Pantry" button.
|
||||
4. **Click "Open Pantry".** Tour stays open, navigates to `/pantry`, anchor re-renders below the header.
|
||||
5. **Keyboard jumps.** From step 2, press `3` → tour jumps to Recipes (Filters button highlighted).
|
||||
6. **Dismiss.** Press `Esc` on any step → tour disappears, localStorage key is set. Refresh the page → tour does NOT re-show.
|
||||
7. **Reset.** Navigate to `http://100.108.208.56:8082/?reset-tour=1`. The query string is stripped, localStorage key is cleared, tour shows again on step 1.
|
||||
8. **A11y.** Tab through the dialog: focus moves from Skip → Back → Next in order; Esc dismisses; screen reader announces the step title (e.g. "What you have in stock, dialog").
|
||||
|
||||
**A11y verification:**
|
||||
- VoiceOver on the dialog announces "Your weekly meal plan, dialog".
|
||||
- Arrow keys step the tour.
|
||||
- Esc dismisses.
|
||||
- Focus is restored to the previously-focused element on dismiss.
|
||||
|
||||
**Regression check:**
|
||||
- Sprint 5 keyboard shortcuts (`g d`, `g r`, `g p`, `g s`, `/`, `?`) still work.
|
||||
- Sprint 4 global error toast still fires.
|
||||
- The 3-button Sprint 8 voting row on Dashboard meal cards still works.
|
||||
- Sprint 7 `WeekRangeNav` still renders on Dashboard and ShoppingList.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-page deep tutorials (the welcome tour is the only thing S9 ships).
|
||||
- Video or animated demos.
|
||||
- Tooltip-on-hover patterns.
|
||||
- A user-facing "Show tour" link in the footer (operator can use `?reset-tour=1`; a footer link is a 5-line follow-up if requested).
|
||||
- F8 Spoonacular proposal, F9 Ollama proposal, dead `Generate Meal Plan` CTA — separate.
|
||||
|
||||
---
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
- **R1: rAF polling on the anchor's `getBoundingClientRect`.** Runs while the tour is open. Cheap (one DOM read per frame). Cancelled on close. No throttling needed for a 4-step tour.
|
||||
- **R2: The auto-show on `/` only.** If the user lands on `/pantry` first (e.g. via a bookmark), the tour does NOT auto-show. The header anchor is still present so a `?reset-tour=1` would show the tour with the right anchor. Documented; not a bug.
|
||||
- **R3: `useOnboarding` flag is App-level state.** A second `<App>` mount (in tests, e.g.) would not share the flag. The tour reads localStorage on mount so the real source of truth is the storage key, not the flag.
|
||||
- **R4: Existing pre-existing WIP in `git status`.** Sprint 9 doesn't touch `backend/app/api/recipes.py`, `backend/app/schemas/recipe.py`, or `nginx/nginx.conf` — those are the user's to manage.
|
||||
|
||||
---
|
||||
|
||||
## Commit
|
||||
|
||||
One commit: `feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)`. Files:
|
||||
|
||||
- NEW `frontend/src/components/OnboardingTour.tsx` (~420 lines)
|
||||
- `frontend/src/App.tsx` (mount + flag)
|
||||
- `frontend/src/pages/Dashboard.tsx` (anchor)
|
||||
- `frontend/src/pages/Pantry.tsx` (2 anchors)
|
||||
- `frontend/src/pages/Recipes.tsx` (anchor on Filters button)
|
||||
- `frontend/src/pages/ShoppingList.tsx` (anchor on header)
|
||||
@@ -100,6 +100,14 @@ The app looks polished on the surface (Tailwind palette, clean cards, working to
|
||||
> - **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 9 status (committed, awaiting deploy):** F1 Onboarding Tour (H10). The natural next phase from the §Future backlog (the only item with a clear UI scope; F8 Spoonacular + F9 Ollama are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up). User direction 2026-06-05: "Proceed with the next phase in the redesign." The Sprint 10 follow-up ("Deny Forever" on Recipes) is already drafted and awaits explicit "proceed".
|
||||
> - **T3.1** New `frontend/src/components/OnboardingTour.tsx` (~420 lines). Hand-rolled (no `react-joyride`; keeps npm footprint flat). 4 steps: Dashboard / Pantry / Recipes / Shopping List. Anchors to `[data-tour="<id>"]` attributes on existing elements. localStorage key `mealplanner:onboarding-complete`. `?reset-tour=1` re-triggers.
|
||||
> - **T3.2** Anchor points: `Dashboard.tsx:602` (Weekly Overview card), `Pantry.tsx:185, 208` (header + add-form card), `Recipes.tsx:124` (Filters button), `ShoppingList.tsx:231` (page header). 5 lines of code total.
|
||||
> - **T3.3** Tooltip = `position: fixed` `<div role="dialog" aria-modal="true">` (no portal needed). rAF loop reads anchor `getBoundingClientRect`; cancellable on close. Focus captured on open, restored on close. Keyboard: `1`–`4` jump, `←/→` step, `Esc` dismiss.
|
||||
> - **T3.4** Off-route fallback: centered card with "Open <page>" CTA so the tour still works for users who land on a non-root page first. Decorative scrim + anchor ring are `aria-hidden="true"`.
|
||||
> - **Verification log:** `Review/sprint9-verification.md`. Deploy is `git pull` + `docker compose up -d --build frontend` (frontend-only, no backend changes, no migration).
|
||||
> - **No new dependencies. No backend changes.**
|
||||
>
|
||||
> **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).
|
||||
> - **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>)`.
|
||||
|
||||
+43
-1
@@ -302,7 +302,49 @@ Trust the tests. Trust the live runs. Don't trust prose claims that something is
|
||||
**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).
|
||||
|
||||
**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 (`09c7525`, awaiting user deploy)** aligns "this week" to the upcoming Monday. **Sprint 8 (in progress)** implements the user's "Deny" semantics decision. See Sprint 7 + Sprint 8 sections below. 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, 7, 8) complete. 20 findings closed (5 P0 + 6 P1 + 3 P2 + 6 §Future), code committed across 12 commits, build green. Sprint 1 deployed; Sprints 2-8 awaiting deploy. **Sprint 7 (`09c7525`, awaiting user deploy)** aligns "this week" to the upcoming Monday. **Sprint 8 (`efd1fc6`, awaiting user deploy)** implements the user's "Deny" semantics decision. **Sprint 9 (committed 2026-06-05, awaiting user deploy)** ships the F1 Onboarding Tour. See Sprint 7 + Sprint 8 + Sprint 9 sections below. Full UI-audit handoff at `Review/handoff-ui-audit.md`.
|
||||
|
||||
---
|
||||
|
||||
## New session: 2026-06-05 (continued)
|
||||
|
||||
### Sprint 9 — F1 Onboarding Tour (H10) — COMMITTED 2026-06-05
|
||||
|
||||
**User direction (2026-06-05):** "Proceed with the next phase in the redesign. Also add a phase to include a 'Deny Forever' button in the Recipes endpoint."
|
||||
|
||||
**Decision (this session):** F1 (Onboarding Tour) was selected as the next phase (the only §Future item with a clear UI scope; F8 Spoonacular + F9 Ollama are full backend proposals; the dead `Generate Meal Plan` CTA is a separate follow-up). The "Deny Forever" on Recipes was drafted as Sprint 10 and awaits explicit "proceed".
|
||||
|
||||
**What ships:**
|
||||
- `frontend/src/components/OnboardingTour.tsx` (NEW, ~420 lines). Hand-rolled (no `react-joyride`) to keep the npm footprint flat.
|
||||
- 4 steps: Dashboard / Pantry / Recipes / Shopping List. Each anchors to a `[data-tour="<id>"]` attribute on an existing element.
|
||||
- `localStorage.getItem('mealplanner:onboarding-complete') === '1'` is the source of truth. Writes wrapped in try/catch.
|
||||
- `?reset-tour=1` in any URL clears the key + strips the param via `navigate(..., { replace: true })` so a refresh doesn't re-trigger the reset.
|
||||
- Keyboard: `1`–`4` jump to step, `←/→` step back/forward, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
|
||||
- A11y: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → step title. Focus captured on open (primary action) and restored on close. Decorative scrim + anchor ring are `aria-hidden="true"`.
|
||||
- Tooltip is a real `position: fixed` `<div>` (no portal). rAF loop reads anchor `getBoundingClientRect` while the tour is open; cancellable on close.
|
||||
- Off-route fallback: if the user is on a different page than the current step's anchor, the tooltip renders as a centered card with an "Open <page>" CTA.
|
||||
|
||||
**Files modified:**
|
||||
- NEW: `frontend/src/components/OnboardingTour.tsx`
|
||||
- `frontend/src/App.tsx` (mount + flag at the App root)
|
||||
- `frontend/src/pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">`
|
||||
- `frontend/src/pages/Pantry.tsx:185, 208` — header + add-form anchors
|
||||
- `frontend/src/pages/Recipes.tsx:124` — Filters button anchor
|
||||
- `frontend/src/pages/ShoppingList.tsx:231` — header anchor
|
||||
|
||||
**Build:** `npm run build` green (tsc 0 errors, vite 0 errors). One commit: `feat(ui): Sprint 9 — F1 onboarding tour (4-step welcome)`.
|
||||
|
||||
**Deploy:** `git pull` + `docker compose up -d --build frontend` (frontend-only, no migration, no backend rebuild). Verification: `Review/sprint9-verification.md` (8-step browser smoke + a11y check + reset-link test).
|
||||
|
||||
**No regression expected:** Sprint 9 does not touch Sprints 1-8. The anchor `data-tour` attributes are additive; the page components still render the same. The KeyboardShortcuts hook (Sprint 5) is mounted in `App.tsx` and unaffected. The react-query error handler (Sprint 4) is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## New session: 2026-06-05 (early)
|
||||
|
||||
### Sprint 7 — Fix webui "empty meal plan" (date-semantics mismatch)
|
||||
|
||||
(Full section above.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -497,3 +497,47 @@ Outside the original audit. Driven by user report 2026-06-05: "Latest meal plans
|
||||
- [ ] No regressions in existing Playwright walkthrough.
|
||||
- [ ] **Sprint 7 (committed `09c7525`, awaiting deploy):** webui "empty meal plan" date-semantics mismatch. Code + SQL fix + verification doc. ✅ done on dev; awaiting user deploy.
|
||||
- [ ] **Sprint 8 (in progress):** "Deny" semantics (C + Z, hard-filter escalation). Migration 0016 + 3 helpers + 2 endpoint extensions + 1 planner update + 1 email template + 1 webui 3-button card. ✅ build green + 21/21 planner tests pass; awaiting user commit + deploy.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 9 — F1 Onboarding Tour (H10) — ✅ COMPLETE, awaiting deploy
|
||||
|
||||
User direction 2026-06-05: "Proceed with the next phase in the redesign." F1 was the natural next phase (the only §Future item with a clear UI scope; F8 + F9 are full backend proposals).
|
||||
|
||||
**Status (2026-06-05):** ✅ Code complete. `npm run build` green (tsc 0 errors, vite 0 errors). Awaiting user commit + deploy. One new component, no new dependencies, no backend changes.
|
||||
|
||||
### T3.1 · `OnboardingTour.tsx` (NEW)
|
||||
|
||||
- **File:** `frontend/src/components/OnboardingTour.tsx` (~420 lines)
|
||||
- **Why hand-rolled:** adding `react-joyride` is a 1-line trade-off; the audit's prior principles ("reuse existing components/ui/*", "no new npm deps") win. The 4-step tour fits in ~420 lines of focused React.
|
||||
- **Steps:**
|
||||
1. **Dashboard** — "Your weekly meal plan"
|
||||
2. **Pantry** — "What you have in stock"
|
||||
3. **Recipes** — "Browse + filter recipes"
|
||||
4. **Shopping List** — "Plan → shop → restock"
|
||||
- **Storage:** `localStorage.getItem('mealplanner:onboarding-complete') === '1'`. Reads/writes wrapped in try/catch.
|
||||
- **Reset:** `?reset-tour=1` in any URL clears the key + strips the param via `navigate(..., { replace: true })`. Operator can use this from the browser URL bar.
|
||||
- **Keyboard:** `1`–`4` jump to step, `←/→` step back/forward, `Esc` dismiss, `Tab` order is `Skip → Back → Next`.
|
||||
- **A11y:** `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → step title. Focus captured on open (primary action), restored on close. Decorative scrim + anchor ring are `aria-hidden="true"`.
|
||||
- **Anchor tracking:** rAF loop reads `getBoundingClientRect` of the matching `[data-tour="<id>"]` element. One DOM read per frame; cancellable on close.
|
||||
|
||||
### T3.2 · Anchor points (5 lines of code total)
|
||||
|
||||
- **File:** `frontend/src/pages/Dashboard.tsx:602` — `<Card data-tour="dashboard">` on the Weekly Overview grid.
|
||||
- **File:** `frontend/src/pages/Pantry.tsx:185` — `<div data-tour="pantry">` on the page header (always present). Plus `:208` for the add-form card (when the form is open).
|
||||
- **File:** `frontend/src/pages/Recipes.tsx:124` — `<Button data-tour="recipes">` on the Filters button.
|
||||
- **File:** `frontend/src/pages/ShoppingList.tsx:231` — `<div data-tour="shopping-list">` on the page header.
|
||||
- **Off-route fallback:** when the user is on a different page than the current step's anchor, the tooltip renders as a centered card with an "Open <page>" CTA. The first-time user experience is preserved even if they land on `/pantry` first.
|
||||
|
||||
### T3.3 · `App.tsx` mount
|
||||
|
||||
- **File:** `frontend/src/App.tsx:75-105`
|
||||
- **Change:** `useOnboarding()` at App root, `isComplete` flag passed to `<OnboardingTour>`. Mounted as a sibling of `<ShortcutHelpBanner />` inside `<BrowserRouter>` (so the tour can use `useLocation` / `useNavigate`).
|
||||
- **Why at App root:** the localStorage key is read once on mount; the flag is shared by all subsequent renders. A child of `<BrowserRouter>` would re-read on every navigation.
|
||||
|
||||
### T3.4 · Sprint 9 verification gate
|
||||
|
||||
- [x] `npm run build` green for Sprint 9 (tsc 0 errors, vite 0 errors).
|
||||
- [ ] Browser smoke (8 steps) on `http://100.108.208.56:8082/` per `Review/sprint9-verification.md`.
|
||||
- [ ] No regression in Sprints 1-8.
|
||||
- [x] `Review/sprint9-verification.md` written.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Link, useLocation, useNavigate, Navigate
|
||||
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { ShortcutHelpBanner, SHOW_SHORTCUT_HELP_EVENT } from './components/ShortcutHelpBanner'
|
||||
import { OnboardingTour, useOnboarding } from './components/OnboardingTour'
|
||||
import { showApiError } from './lib/toast'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { requestFocusSearch } from './hooks/useFocusSearch'
|
||||
@@ -73,6 +74,10 @@ function GlobalShortcuts() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Onboarding tour state lives at the App root so the localStorage
|
||||
// key is read once on mount. The tour itself is mounted inside the
|
||||
// router (it needs useLocation / useNavigate).
|
||||
const onboarding = useOnboarding()
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -94,6 +99,15 @@ function App() {
|
||||
</Routes>
|
||||
</main>
|
||||
<ShortcutHelpBanner />
|
||||
<OnboardingTour
|
||||
isComplete={onboarding.isComplete}
|
||||
onComplete={() => {
|
||||
// The tour already wrote the localStorage key via
|
||||
// writeComplete(); flip the App-level flag so it stays
|
||||
// hidden after the next render.
|
||||
onboarding.reset()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Onboarding tour — first-visit welcome walkthrough.
|
||||
*
|
||||
* Hand-rolled to avoid pulling in `react-joyride` (no new npm dep).
|
||||
* Anchors to elements via [data-tour="<id>"] attributes. The tour is
|
||||
* controlled by a single useState pair: `step` (0..N) and a
|
||||
* `dismissed` boolean persisted in localStorage.
|
||||
*
|
||||
* Behaviour:
|
||||
* - First visit (no localStorage key): auto-shows on Dashboard mount.
|
||||
* - User clicks "Got it" on the last step → writes the key, hides.
|
||||
* - User clicks "Skip" on any step → same as "Got it".
|
||||
* - User presses Escape → same as "Skip".
|
||||
* - User presses 1..N (when tour is visible) → jumps to that step.
|
||||
* - User presses ArrowLeft / ArrowRight → steps back / forward.
|
||||
* - User adds `?reset-tour=1` to any URL → clears the key, shows.
|
||||
* - Subsequent visits (key is set) → tour is hidden.
|
||||
*
|
||||
* Tour re-shows when the user navigates to a new step's anchor page
|
||||
* (the anchor for step N must be on the current route; otherwise the
|
||||
* step is rendered as a centered card with a "Go to <page>" button).
|
||||
*
|
||||
* Accessibility:
|
||||
* - role="dialog", aria-modal="true", aria-labelledby points to the
|
||||
* step title.
|
||||
* - Focus is moved to the primary action button when the step opens.
|
||||
* - The previous focused element is restored on dismiss.
|
||||
* - Tooltip is a real <div> not a portal, so screen readers find it.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { CalendarDays, ShoppingBasket, SlidersHorizontal, Truck, X } from 'lucide-react'
|
||||
|
||||
const STORAGE_KEY = 'mealplanner:onboarding-complete'
|
||||
const RESET_PARAM = 'reset-tour'
|
||||
|
||||
export type TourStepId = 'dashboard' | 'pantry' | 'recipes' | 'shopping-list'
|
||||
|
||||
interface TourStep {
|
||||
id: TourStepId
|
||||
route: string
|
||||
title: string
|
||||
body: string
|
||||
icon: typeof CalendarDays
|
||||
}
|
||||
|
||||
const STEPS: TourStep[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
route: '/',
|
||||
title: 'Your weekly meal plan',
|
||||
body: 'The dashboard shows the upcoming Mon–Sun week. Approve, deny, or skip meals, and the planner learns what your family likes.',
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
id: 'pantry',
|
||||
route: '/pantry',
|
||||
title: 'What you have in stock',
|
||||
body: 'Pantry tracks ingredients already at home. The planner uses it to avoid duplicates and the shopping list subtracts from it.',
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
id: 'recipes',
|
||||
route: '/recipes',
|
||||
title: 'Browse + filter recipes',
|
||||
body: 'Open the Filters panel to narrow by cuisine, protein, dietary tag, prep time, spice, or calories. Search by name or ingredient.',
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
id: 'shopping-list',
|
||||
route: '/shopping-list',
|
||||
title: 'Plan → shop → restock',
|
||||
body: 'The shopping list is generated from the plan, minus what is in your pantry. Check items and bulk-add them back to pantry when you return from the store.',
|
||||
icon: ShoppingBasket,
|
||||
},
|
||||
]
|
||||
|
||||
function readComplete(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function writeComplete(): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, '1')
|
||||
} catch {
|
||||
// localStorage may be disabled (private mode, etc.) — silently
|
||||
// skip; the tour will just re-show on the next visit.
|
||||
}
|
||||
}
|
||||
|
||||
function clearComplete(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// see above
|
||||
}
|
||||
}
|
||||
|
||||
export function useOnboarding(): { reset: () => void; show: () => void; isComplete: boolean } {
|
||||
const [isComplete, setIsComplete] = useState<boolean>(readComplete)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearComplete()
|
||||
setIsComplete(false)
|
||||
}, [])
|
||||
|
||||
const show = useCallback(() => {
|
||||
clearComplete()
|
||||
setIsComplete(false)
|
||||
}, [])
|
||||
|
||||
return { reset, show, isComplete }
|
||||
}
|
||||
|
||||
export function OnboardingTour({
|
||||
isComplete,
|
||||
onComplete,
|
||||
}: {
|
||||
isComplete: boolean
|
||||
onComplete: () => void
|
||||
}) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [step, setStep] = useState(0)
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null)
|
||||
const primaryRef = useRef<HTMLButtonElement | null>(null)
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||
|
||||
const currentStep = STEPS[step]
|
||||
const isLast = step === STEPS.length - 1
|
||||
|
||||
// Handle ?reset-tour=1 (clears the key; tour shows on next render).
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(location.search)
|
||||
if (sp.get(RESET_PARAM) === '1') {
|
||||
clearComplete()
|
||||
onComplete()
|
||||
// Strip the param so a refresh doesn't re-trigger the reset.
|
||||
sp.delete(RESET_PARAM)
|
||||
const next = sp.toString()
|
||||
navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true })
|
||||
setStep(0)
|
||||
}
|
||||
// We intentionally don't depend on `onComplete` (changes per render)
|
||||
// — the only effect we want is when the URL search changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search])
|
||||
|
||||
// Auto-show on first visit (Dashboard route). Don't auto-show on
|
||||
// every route — only on `/`, which is the app's landing page.
|
||||
useEffect(() => {
|
||||
if (isComplete) return
|
||||
if (location.pathname === '/') {
|
||||
setStep(0)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isComplete, location.pathname])
|
||||
|
||||
// Track the anchor element's position. We poll on a rAF loop while
|
||||
// the tour is open because the user can resize the window and the
|
||||
// page can scroll. requestAnimationFrame keeps the tooltip glued to
|
||||
// the anchor without burning CPU.
|
||||
useEffect(() => {
|
||||
if (isComplete) return
|
||||
if (!currentStep) return
|
||||
let raf = 0
|
||||
const tick = () => {
|
||||
const el = document.querySelector<HTMLElement>(`[data-tour="${currentStep.id}"]`)
|
||||
if (el) {
|
||||
setAnchorRect(el.getBoundingClientRect())
|
||||
} else {
|
||||
setAnchorRect(null)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [isComplete, currentStep])
|
||||
|
||||
// Capture focus on open, restore on close.
|
||||
useEffect(() => {
|
||||
if (isComplete) return
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||
// Defer focus to next tick so the dialog is in the DOM.
|
||||
const id = window.setTimeout(() => primaryRef.current?.focus(), 0)
|
||||
return () => {
|
||||
window.clearTimeout(id)
|
||||
previouslyFocused.current?.focus?.()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isComplete, step])
|
||||
|
||||
// Keyboard: 1..N jump, ←/→ step, Esc dismiss.
|
||||
useEffect(() => {
|
||||
if (isComplete) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
finish()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
if (isLast) {
|
||||
finish()
|
||||
} else {
|
||||
setStep(s => s + 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setStep(s => Math.max(0, s - 1))
|
||||
return
|
||||
}
|
||||
// 1..N jump
|
||||
if (e.key.length === 1 && /^[1-9]$/.test(e.key)) {
|
||||
const idx = parseInt(e.key, 10) - 1
|
||||
if (idx >= 0 && idx < STEPS.length) {
|
||||
e.preventDefault()
|
||||
setStep(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isComplete, isLast])
|
||||
|
||||
const finish = useCallback(() => {
|
||||
writeComplete()
|
||||
onComplete()
|
||||
}, [onComplete])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (isLast) finish()
|
||||
else setStep(s => s + 1)
|
||||
}, [isLast, finish])
|
||||
|
||||
const goJumpToAnchor = useCallback(() => {
|
||||
navigate(currentStep.route)
|
||||
}, [navigate, currentStep])
|
||||
|
||||
if (isComplete || !currentStep) return null
|
||||
|
||||
const Icon = currentStep.icon
|
||||
const isOffRoute = location.pathname !== currentStep.route
|
||||
|
||||
// If the user is on the wrong route, render a centered card with a
|
||||
// "Go to <page>" CTA. The tooltip-pointer layout needs a real anchor
|
||||
// to point at; without one we'd just be a floating rectangle.
|
||||
if (isOffRoute || !anchorRect) {
|
||||
return (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 animate-fade-in"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl border border-surface-200 w-full max-w-md p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-5 h-5 text-primary-600" />
|
||||
</div>
|
||||
<h2 id="onboarding-title" className="text-lg font-semibold text-surface-900">
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Dismiss tour"
|
||||
className="p-1 rounded hover:bg-surface-100 text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-surface-600 leading-relaxed">{currentStep.body}</p>
|
||||
<p className="text-xs text-surface-500">
|
||||
Step {step + 1} of {STEPS.length} — open the highlighted page to see it in context.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
className="text-xs text-surface-500 hover:text-surface-700 focus:outline-none focus:ring-2 focus:ring-primary-400 rounded px-1"
|
||||
>
|
||||
Skip tour
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goJumpToAnchor}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
Open {currentStep.route === '/' ? 'Dashboard' : currentStep.route.slice(1)}
|
||||
</button>
|
||||
<button
|
||||
ref={primaryRef}
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-lg border border-primary-300 text-primary-700 hover:bg-primary-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
{isLast ? 'Got it' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Anchored tooltip: position the card 16px below the anchor (or above
|
||||
// if it would clip the viewport). Clamp horizontally to keep the card
|
||||
// on-screen. Mobile: prefer the top of the viewport so the card never
|
||||
// gets clipped.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640
|
||||
const ANCHOR_GAP = 16
|
||||
const CARD_WIDTH = 320
|
||||
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
|
||||
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024
|
||||
const placeBelow = !isMobile && anchorRect.bottom + ANCHOR_GAP + 200 < viewportH
|
||||
const top = placeBelow
|
||||
? anchorRect.bottom + ANCHOR_GAP
|
||||
: Math.max(16, anchorRect.top - ANCHOR_GAP - 200)
|
||||
const left = isMobile
|
||||
? 16
|
||||
: Math.max(16, Math.min(viewportW - CARD_WIDTH - 16, anchorRect.left + anchorRect.width / 2 - CARD_WIDTH / 2))
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Soft scrim. The page is still visible — this is a hint, not a
|
||||
modal. We don't use a full overlay because the user must be
|
||||
able to see the page element being explained. */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/20 pointer-events-none animate-fade-in"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Anchor highlight ring. Purely decorative; the tooltip is the
|
||||
real "look here" signal. */}
|
||||
<div
|
||||
className="fixed z-40 pointer-events-none rounded-xl ring-4 ring-primary-400 ring-offset-2 ring-offset-white animate-fade-in"
|
||||
style={{
|
||||
top: anchorRect.top - 4,
|
||||
left: anchorRect.left - 4,
|
||||
width: anchorRect.width + 8,
|
||||
height: anchorRect.height + 8,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="onboarding-title"
|
||||
className="fixed z-50 animate-fade-in"
|
||||
style={{ top, left, width: CARD_WIDTH }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl border border-surface-200 p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="w-4 h-4 text-primary-600" />
|
||||
</div>
|
||||
<h2 id="onboarding-title" className="text-base font-semibold text-surface-900 leading-tight">
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Dismiss tour"
|
||||
className="p-1 rounded hover:bg-surface-100 text-surface-500 focus:outline-none focus:ring-2 focus:ring-primary-400 -mt-1 -mr-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-surface-600 leading-relaxed">{currentStep.body}</p>
|
||||
<div className="flex items-center gap-1 pt-1" aria-label="Tour progress">
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s.id}
|
||||
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
||||
i <= step ? 'bg-primary-500' : 'bg-surface-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
className="text-xs text-surface-500 hover:text-surface-700 focus:outline-none focus:ring-2 focus:ring-primary-400 rounded px-1"
|
||||
>
|
||||
Skip tour
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{step > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(s => s - 1)}
|
||||
className="text-xs font-medium px-2.5 py-1.5 rounded-lg border border-surface-300 text-surface-700 hover:bg-surface-50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={primaryRef}
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||
>
|
||||
{isLast ? 'Got it' : 'Next'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -599,7 +599,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Weekly Grid — all 7 days visible */}
|
||||
<Card>
|
||||
<Card data-tour="dashboard">
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
||||
</CardHeader>
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function Pantry() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4" data-tour="pantry">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center">
|
||||
<Package className="w-5 h-5 text-warning-600" />
|
||||
@@ -205,7 +205,7 @@ export default function Pantry() {
|
||||
|
||||
{/* Add Form */}
|
||||
{showAddForm && (
|
||||
<Card className="animate-slide-down">
|
||||
<Card className="animate-slide-down" data-tour="pantry">
|
||||
<CardBody>
|
||||
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
|
||||
@@ -127,6 +127,7 @@ export default function RecipesPage() {
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
aria-expanded={showFilters}
|
||||
data-tour="recipes"
|
||||
>
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function ShoppingListPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4" data-tour="shopping-list">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||
<ShoppingCart className="w-5 h-5 text-primary-600" />
|
||||
|
||||
Reference in New Issue
Block a user