Files
Meal-Planner/Review/sprint9-verification.md
admin 4c85c929d3 docs: record Sprint 9 post-deploy dismiss-bug fix across all 6 running docs
Sprint 9 (commit 6e386ba) shipped a working OnboardingTour but a
broken dismiss path: clicking X / Skip / Esc / "Got it" did
nothing. Root cause: useOnboarding().reset() was wired to the
dismiss handler at App.tsx, but reset() does the inverse of
dismiss — it clears the localStorage key and flips isComplete to
FALSE, so the tour re-rendered, the early-return did not fire,
and the dialog stayed visible. Fix: commit 1562929 split the
dismiss and reset paths into two distinct callbacks (onComplete
and onReset). User confirmed browser smoke passes.

This commit updates the 6 running docs that track Sprint 9:

- .agent/plan.md — S9.4.1 sub-task (post-deploy fix) added.
- .agent/context.md — D9 (root cause + fix) + Q4 (Vitest?) added.
- Review/sprint9-verification.md — full post-deploy fix section
  appended (root cause, fix, post-fix verification, lessons).
- Review/handoff-ui-audit.md — Sprint 9 status banner + Last
  updated footer updated to reference the fix commit.
- fix-ui-audit.md — T3.4.1 sub-task added under the T3.4
  verification gate.
- docs/HANDOFF.md — post-deploy fix paragraph added to the
  Sprint 9 section.

All 6 docs now reflect the post-deploy reality. No code changes.
2026-06-05 14:09:10 -07:00

10 KiB
Raw Permalink Blame History

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):

  • 14 → 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)

Post-deploy fix (2026-06-05) — tour dismiss did nothing

User reported after first deploy: clicking the X button, "Skip tour" text, "Got it" final-step button, OR pressing Esc on the dialog did nothing — the tour stayed visible and could not be exited.

Root cause

The OnboardingTour dialog's early-return is gated on isComplete === true:

if (isComplete || !currentStep) return null

But App.tsx was wiring the dismiss handler (onComplete) to useOnboarding().reset(). reset() does the inverse of dismiss: it clears the localStorage key AND flips isComplete to false. So when the user clicked X:

  1. finish() ran — writeComplete() wrote "1" to localStorage.mealplanner:onboarding-complete
  2. onComplete() ran — onboarding.reset() cleared the key AND set isComplete = false
  3. Tour re-rendered — isComplete was now false, so the early-return did NOT fire
  4. The dialog stayed visible. Forever (until ?reset-tour=1 was visited).

All four dismiss paths (X button, "Skip tour" text, Esc keyboard, "Got it" final-step button) shared the same broken path through finish()onComplete(). None of them worked.

The ?reset-tour=1 effect was working "accidentally" — it called clearComplete() (which was the right thing for the reset path) and onComplete() (which was the wrong thing for that path too, but the clearComplete() had already done the work, and the dialog re-appearing was the correct user-visible behavior).

Fix (commit 1562929)

Split the dismiss and reset paths into two distinct callbacks:

  1. Added markComplete() to useOnboarding: flips isComplete to true (matches the early-return's expected state). reset() and show() are unchanged.
  2. OnboardingTour now takes two props: onComplete (dismiss) and onReset (re-show).
  3. App.tsx wires:
    • onComplete → onboarding.markComplete() (X / Skip / Esc / "Got it" all hide the dialog)
    • onReset → onboarding.reset() (?reset-tour=1 re-shows)
  4. The tour's finish() still calls writeComplete() + onComplete(). Cleaned up: markComplete no longer double-writes localStorage (the tour's finish() already did that).

Post-fix verification

  • npm run build green on docker-willester (495.64 kB, no size change from Sprint 9 build).
  • User confirmed browser smoke on http://100.108.208.56:8082/:
    • X button → dialog disappears, localStorage.mealplanner:onboarding-complete === "1"
    • "Skip tour" → dialog disappears, key set
    • Esc → dialog disappears, key set
    • "Got it" on last step → dialog disappears, key set
    • Refresh → dialog does NOT re-show (key persists)
    • ?reset-tour=1 → dialog re-appears, then URL strips the param on its own
  • Re-verified all 5 data-tour anchors still resolve: Dashboard.tsx:602, Pantry.tsx:185, 208, Recipes.tsx:131, ShoppingList.tsx:231.
  • Re-verified the URL effect calls onReset() (not the old onComplete()) for the ?reset-tour=1 path.

Lessons learned

  • Lesson 1: A green build is not verification. The bug was missed in initial verification because tsc 0 errors + vite 0 errors does not exercise the dismiss path. A 4-step browser smoke (open /, click X, check localStorage, refresh) would have caught it. Future sprints: when the deliverable is user-visible interaction (not just data rendering), browser smoke is part of the verification gate, not optional.
  • Lesson 2: Inverse paths deserve inverse APIs. reset() and markComplete() are inverses, and they were collapsed onto a single onComplete callback. The collapse worked for reset and broke for markComplete. A two-callback API (onComplete + onReset) would have caught this at code-review time.
  • Lesson 3: Unit tests for state hooks are cheap insurance. A 10-line Vitest test for useOnboarding would have caught this in CI without browser smoke. Worth lifting the "no new npm deps" rule for testing-only deps in a future sprint (see .agent/context.md Q4).