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).
12 KiB
Recovery Plan — MealPlanner
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 8 — "Deny" semantics (C + Z, hard-filter escalation)
Owner: this agent. Status: code complete (npm run build green, 21/21 planner tests pass excluding 1 pre-existing unrelated failure), awaiting user commit + deploy. Tracking: Review/sprint8-verification.md (deploy + smoke), .agent/plan.md (checklist), .agent/context.md (decisions + open Qs).
User policy decision (2026-06-05, exact): "Hard filter. If it is denied this week twice, it should be considered denied for good." — collapses the design to C + Z with a server-side 2-denial auto-escalation.
S8.1 — Migration: 0016_denial_decay_and_scope.py (NEW)
- Adds
meal_plan_item.denial_expires_at TIMESTAMPTZ NULL. - Adds
meal_plan_vote.denial_scope VARCHAR(16) NULL. - Partial index on
meal_plan_item.denial_expires_at(postgresql_where IS NOT NULL) for the planner's soft-deny lookup. - Downgrade reverses all three.
S8.2 — Model: app/models/__init__.py
MealPlanItem.denial_expires_atcolumn added.MealPlanVote.denial_scopecolumn added.
S8.3 — Schema: app/schemas/__init__.py
MealPlanItemResponse.denial_expires_at: Optional[datetime].VoteRequest.denial_scope: Optional[str]withpattern=^(this_week|never_again)$.VoteResponse.denial_scope: Optional[str].
S8.4 — Backend helpers: app/api/meals.py
_apply_denial(db, item, scope)— single source of truth for the deny path. Returns{item, promoted_to_permanent, scope}. Commits._ensure_never_suggest_recipe(db, family_id, recipe_id, reason)— idempotent NeverSuggest insert. ReturnsTrueif new,Falseif existing._has_prior_active_soft_denial(db, family_id, recipe_id, current_item_id=None)— count query for the 2-denial check.DENIAL_DECAY_DAYS = 90constant.
S8.5 — Backend endpoints: app/api/meals.py
POST /api/meals/items/{id}/deny?scope=this_week|never_again(defaultthis_week).- Returns
{message, item, promoted_to_permanent, scope}. swap_meal_itemalso clearsdenial_expires_at(defensive: a new recipe_id is a fresh start).
- Returns
POST /api/meals/vote/{id}extended:vote: "approve" | "deny" | "never_again".- Returns
{status, item_status, denial_scope, promoted_to_permanent}. - The 2-denial auto-escalation runs server-side for both
denyandnever_again.
- Returns
GET /api/meals/vote/{id}HTML page renders 3 buttons. Supports one-click?scope=...for the email's per-button links.
S8.6 — Email template: app/services/orchestrator/steps.py
- 3 direct-action links per recipe (Approve / Deny this week / Never again).
- Legacy "Vote on this meal" preserved as a secondary "Open vote page (all 3 options)" link.
S8.7 — Planner: app/services/planner/generate.py
_load_blocklistsreturns 3 sets:(blocked_ingredients, blocked_recipes, soft_denied_recipes).soft_denied_recipesis the hard filter (per user decision: same asblocked_recipes).rejected_summaryadds asoft_denied_recipediagnostic bucket.
S8.8 — Frontend: Dashboard.tsx + api/index.ts
api/index.ts:48-58—meals.denyItem(itemId, { scope }).Dashboard.tsx:38-50, 385-410—MealCardaccepts scope-awareonDeny; renders 3 buttons (Approve / Deny this week / Never again) for pending items.handleDenyis scope-aware; toast reflects the server'spromoted_to_permanentflag.- "Never again" is gated by
window.confirmto prevent accidental permanent blocks. - Buttons only show on
pendingitems (approved/denied items show the badge only).
S8.9 — Verify
npm run buildgreen for Sprint 8 (tsc 0 errors, vite 0 errors).- Backend smoke: 21/21 planner tests pass (1 pre-existing
test_filter_blocks_by_costfailure is not introduced by S8 — verified viagit stash+ re-run on a clean tree). - Static checks: all 6 new modules import cleanly, helper logic verified via Python AST + import-test against
backend/venv. Review/sprint8-verification.mdwritten with deploy + 11-step browser smoke + 4 API curls + email-render procedure + rollback.- Deploy verified on
100.108.224.12— see verification log. - No regression in Sprints 1-7.
S8.10 — Docs (all 6 running docs updated)
Review/ui-nielsen-audit.md— Sprint 8 status block at the top (T2.1–T2.10).fix-ui-audit.md— Sprint 8 plan section (T2.1–T2.10).Review/handoff-ui-audit.md— "Active sprint" callout + bottom "Last updated" line.docs/HANDOFF.md— Sprint 7 + Sprint 8 sections before the 2026-06-03 session..agent/plan.md— this section..agent/context.md— Sprint 8 decisions, file:line references, verification gate.
Done when (Sprint 8)
- All 12 boxes above ticked.
npm run buildgreen.Review/sprint8-verification.mdexists.- All 6 doc files have a Sprint 8 status block.
- User commits + runs the deploy + runs the SQL + reports the smoke checklist.
Out of scope (Sprint 8)
- Thread 3: §Future backlog (F1 onboarding, F8/F9 proposals, dead
Generate Meal PlanCTA atDashboard.tsx:415). - "Unblock" UI on the webui. The
NeverSuggestAPI exists; no UI to remove a row. User can use the API directly. - Decay-sweep cron. The 90-day filter is at read time; expired rows just become invisible. No cleanup needed.
- Pre-existing denied row (2026-05-15 day-2 Roasted Sweet Potato and Chickpea Bowl) — left untouched.
denial_expires_atstays NULL; the recipe is effectively forgotten after 90d from now (today is 2026-06-05, so it'll be eligible again ~2026-09-03). If the user wants it remembered permanently, they can re-trigger the soft-deny cycle by clicking "Deny this week" on the next plan that includes it.
Phase R1 — Stabilize (parallel-safe)
- R1-A: Verification harness. Add
backend/tests/with pytest config, aconftest.pywith a transactional DB fixture, and smoke tests covering: app import,/health,/health/db, every router's GET list endpoint, Alembicupgrade headround-trip on a throwaway DB. Add.github/workflows/ci.ymlrunning lint + pytest + frontendnpm run build. - R1-B: Auth dependencies on existing routers. Implement an
app.securitymodule with: (1)require_admindep — bearer token compared tosettings.ADMIN_TOKEN, applied to ALL/api/admin/*routes; (2)require_sessiondep — signed-cookie session (itsdangerous, key =SECRET_KEY) for profile/pantry/recipes/meals/shopping-list mutations; reads stay open inside the trusted network. Per-voter approval token flow stays as-is. Update.env.examplewithADMIN_TOKEN. Document the model indocs/SECURITY.md. - R1-C: Make
/api/admin/scrapeasync. Convert the endpoint to enqueue a background job (FastAPIBackgroundTasksfor now; APScheduler later). Endpoint returns 202 +scrape_log_id; status polled via/api/admin/logs/{id}. ScraperService must open its own DB session inside the task (the request-scopeddbis gone by then).
Phase R2 — De-risk deferred work (parallel-safe, must run BEFORE further feature work per review §2.4)
- R2-A: Live-scrape spike. Run
LuckyCaliforniaScraperagainsthttps://luckysupermarkets.comonce, capture the raw HTML/PNG tobackend/tests/fixtures/lucky_ca/, write a unit test that parses the captured fixture (no live network in CI). Document selector decisions in.agent/context.md. If the page can't be parsed, file the schema impact before going further. - R2-B: Email + approval round-trip spike. Implement minimal SendGrid sender (
app/services/email.py), anapp/services/approval.pythat issues per-voter signed tokens (TTL, single-use), the GET confirmation page + POST submit handler (the routes already exist as stubs inmeals.py), and a CLI scriptscripts/send_test_approval.pythat creates a fake meal plan, emails one voter, and verifies the click→POST→DB write path end to end against a sandboxed inbox orMAIL_BACKEND=console. Goal: prove the schema (family_member, approval_token tables) survives one full round trip BEFORE building Phase 4/5/9.
Phase R3 — Resume feature work (sequential, only after R1+R2 green)
- R3-A: Phase 4 Recipe Engine — search, tagging, never-suggest filter.
- R3-B: Phase 9 Meal Planner generation algorithm.
- R3-C: Phase 6 SendGrid templated emails (proposal, reminder, confirmation).
- R3-D: Phase 8 Feedback UI.
- R3-E: APScheduler with
--workers 1for weekly scrape + plan generation + email send. - R3-F: Phase 10 image strategy.
Halt conditions
- R2 spikes fail → stop, propose schema/spec change, await approval.
- Verification matrix in
Review/reviewconcensus.md §6not 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)
- 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. - Tooltip card pinned to anchor (top/bottom/center fallback for off-route steps).
- Anchor highlight = primary-400 ring + soft scrim; tooltip is a real
<div role="dialog" aria-modal="true">. - Step progress = 4 progress bars.
- Keyboard:
1–4jump,←/→step,Escdismiss,Taborder isSkip → Back → Next. useOnboarding()hook +?reset-tour=1re-trigger; localStorage keymealplanner:onboarding-complete.- Focus captured on open (primary action), restored on close.
- All reads/writes to localStorage wrapped in try/catch (private mode safe).
S9.2 — Anchor points (5 lines of code total)
pages/Dashboard.tsx:602—<Card data-tour="dashboard">on the Weekly Overview grid.pages/Pantry.tsx:185—<div data-tour="pantry">on the page header (always present).pages/Pantry.tsx:208— second anchor on the add-form<Card>(when the form is open).pages/Recipes.tsx:124—<Button data-tour="recipes">on the Filters button.pages/ShoppingList.tsx:231—<div data-tour="shopping-list">on the page header.
S9.3 — App.tsx mount
useOnboarding()at App root,isCompletepassed to<OnboardingTour>.onCompletemapped toonboarding.reset()(flips the flag so re-renders don't re-show).- Mounted as sibling of
<ShortcutHelpBanner />inside<BrowserRouter>(souseLocation/useNavigatework).
S9.4 — Verify
npm run buildgreen (tsc 0 errors, vite 0 errors).- Browser smoke (8 steps) on
http://100.108.208.56:8082/perReview/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)
Review/ui-nielsen-audit.md— Sprint 9 status block at the top.fix-ui-audit.md— Sprint 9 plan section (T3.1–T3.4).Review/handoff-ui-audit.md— Sprint 9 entry in the "How to take over" section + TL;DR row.docs/HANDOFF.md— Sprint 9 section..agent/plan.md— this section..agent/context.md— Sprint 9 decisions + file:line references.Review/sprint9-verification.md— written (8-step browser smoke + a11y check + reset-link test).
Done when (Sprint 9)
- All boxes above ticked.
npm run buildgreen.Review/sprint9-verification.mdexists.- 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 PlanCTA atDashboard.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.