- lib/toast.tsx (renamed from .ts for JSX): new showToast.undo(message, onUndo, ms=5000) helper. Inline 'Undo' button dismisses the toast and fires onUndo. Note: react-hot-toast 2.6 lacks onClose/onDismiss, so expiry is silent — same effective behavior as confirm() declined. - Dashboard.handleDelete: captures the full MealPlanItem before the DELETE so Undo can re-fire meals.generateItem(planId, dayOfWeek, mealType) and refill the slot (recipe may differ — see plan R4). - Pantry.handleRemove: fully reversible — Undo re-fires pantry.add with the original ingredient_id, quantity, and unit. New removeId state scopes the spinner to the clicked row. - Both confirm() call sites removed. - App.tsx Navigation: whitespace-nowrap + px-2 sm:px-3 so all 4 links fit on one line down to 360 px. aria-current='page' on the active link. <nav aria-label='Primary'>, <main id='main-content'>. - components/ui/Badge: optional icon and aria-label props. Dashboard approval-status Badge passes aria-label='Approval status: approved' (or the current value) so screen readers don't rely on color alone. - ErrorBoundary already mounted at App.tsx:42 — verified, no code change. - Review/sprint3-verification.md (new) + Review/ui-nielsen-audit.md and fix-ui-audit.md updated with Sprint 3 status and deploy steps. Build: npm run build (tsc + vite) green. tsc 0 errors.
19 KiB
Fix Plan — UI/UX Audit (Nielsen 10 Heuristics)
Goal
Resolve the 14 issues (5 P0, 6 P1, 3 P2) from Review/ui-nielsen-audit.md in three sprints, ending each sprint with a deployable, demonstrable improvement on http://100.108.208.56:8082/.
Scope boundaries
- In: frontend React/TS fixes in
frontend/src/. Backend one-off data migrations only when required (B8 aisle normalization, B4/Recommended route). - Out: New features (bulk add, keyboard shortcuts, onboarding tour) — those are future work, listed in §Future.
- Reuse:
components/ErrorBoundary.tsxalready exists (verified).components/ui/*(Button, Card, Select, Input, EmptyState, Badge, Skeleton, LoadingSpinner) are the building blocks — use them, don't roll new ones. - Stack confirmed: React 18 + TS + Vite + Tailwind + react-router-dom 6 + @tanstack/react-query + react-hot-toast + @hello-pangea/dnd + lucide-react + framer-motion. No new deps in Sprint 1/2. Sprint 3 may add
react-joyrideonly if approved (defer to §Future).
Conventions
- One commit per task:
fix(ui): <short>/feat(ui): <short>/refactor(frontend): <short>. - Before each commit:
cd frontend && npm run lint && npm run build(thebuildscript runstscfirst — type-checks the project). - After each task, re-screenshot the affected page in the same playwright session and diff against
/tmp/opencode/mp-review/screenshots/. Save new shots in/tmp/opencode/mp-review/screenshots/fix-sprintN/. - All UI text in sentence case. New copy matches existing
lib/toast.tsstyle. - Type updates go in
frontend/src/types/index.ts; do not duplicate shapes inline.
Sprint 1 — Stop the bleeding (P0s)
Goal: Every P0 bug is gone. Each is independently demoable on the live deployment.
S1.1 · B1 — RecipeDetail ingredients: drop .trim() so unit + name don't fuse
- File:
frontend/src/pages/RecipeDetail.tsx:161 - Change: Replace
with
{ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}followed by a literal{ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''}`}' 'before{ing.name}. - Verify: Open
/recipes/eae6591f...(Black Bean Tacos). Ingredient row reads2 can Black Beans, Canned(with space). Re-runnpm run build.
S1.2 · B2 — MealDetail ingredients: align field name with backend (qty)
- Files:
frontend/src/types/index.ts,frontend/src/pages/MealDetail.tsx:249-252 - Change:
- In
types/index.tsconfirmMealIngredientshape; align to backendqty/unit. If the type currently hasquantity, rename toqty(single source of truth). - In
MealDetail.tsx:249-252, switch reads toing.qty/ing.unit. Keep the existing null-guard soqty == nullis skipped cleanly.
- In
- Verify: Open
/meals/<any>(e.g./meals/f28...Pork Stir-Fry). Row reads1 lb Pork Chops, Bone-Innotlb Pork Chops.npm run buildclean.
S1.3 · B3 — MealDetail cost: fix $N/A per serving
- File:
frontend/src/pages/MealDetail.tsx:191 - Change:
{item.estimated_cost != null ? `$${item.estimated_cost.toFixed(2)} per serving` : 'No price estimate yet'} - Verify: Reload
/meals/f28.... Price line reads either$X.XX per servingorNo price estimate yet— never$N/A.
S1.4 · B4 — /recommended blank page: add * NotFound + alias
- Files:
frontend/src/App.tsx, newfrontend/src/pages/NotFound.tsx - Change:
- Create
pages/NotFound.tsx— friendly card withAlertTriangleicon, message "We can't find that page.", primary<Button>→/, secondary → back. Reusecomponents/ui/EmptyState.tsxif it fits. - In
App.tsx:- Add
import { Navigate } from 'react-router-dom'. - Insert
<Route path="/recommended" element={<Navigate to="/recipes/recommended" replace />} />. - Append
<Route path="*" element={<NotFound />} />after the existing routes.
- Add
- Add a
// TODO(seo): audit email/share links for/recommendedreferencescomment.
- Create
- Verify:
- Visit
http://100.108.208.56:8082/recommended→ redirects to/recipes/recommended, renders the Recommended page. - Visit
http://100.108.208.56:8082/this-does-not-exist→ renders NotFound. npm run buildclean.
- Visit
S1.5 · B5 — Mobile dashboard: always show empty meal slots
- File:
frontend/src/pages/Dashboard.tsx(lines ~164, ~219) - Change: Audit every
hidden md:flex/hidden md:block/hidden md:inlineinsideDayColumnand the empty-slot JSX. Remove thehiddenclass on the empty-slot CTAs (theEmpty+Generateplaceholder block). For decorative chrome (e.g. day-of-week abbreviations), keephidden md:flexonly if there's a separate mobile-friendly label. - Verify: Re-screenshot at 390 px width. Empty slots are tappable; tapping Generate fires the same query as on desktop.
npm run buildclean.
S1.6 · Sprint 1 verification gate
cd frontend && npm run lint && npm run build→ both 0 errors / 0 warnings.- Re-run playwright walkthrough; capture
screenshots/fix-sprint1/*.pngfor: recipe detail (Black Bean Tacos), meal detail (Pork Stir-Fry),/recommended,/this-does-not-exist, mobile dashboard. - Manual smoke: tap Generate on a mobile viewport, confirm a new meal lands in the slot.
- Done when: All five P0 bugs absent in the re-captured screenshots AND lint/build pass.
Sprint 2 — Trust the data (P1s)
Goal: No more silent data corruption in the UI. Every displayed value is consistent across pages and either present-and-correct or explicitly absent.
Status (2026-06-02): ✅ All six P1s + the bonus S3.3 mobile stat-grid fix are implemented. npm run build green. Ready to commit and deploy.
S2.1 · B6 — Dashboard MealCard title: 2-line clamp instead of 1-line truncate
- File:
frontend/src/pages/Dashboard.tsx:87 - Change: Replaced
truncatewithline-clamp-2(already used elsewhere in the codebase — Tailwind 3.4+ has it in core). Image shrinks to 40×40 on<md(was 56×56 always) to give the title more room. Addedleading-tightto tighten line-height for 2 lines. - Verify: Trigger Generate on the dashboard. New card title is fully visible across 2 lines (no
B..truncation). Build clean.
S2.2 · B7 — MealDetail hero overlap + description clamp + marketing-copy strip
- Files:
frontend/src/pages/MealDetail.tsx:168-197,frontend/src/lib/utils.ts - Change (3 sub-steps, one commit):
- Hero reworked: image is in normal flow, content panel uses
relative -mt-16 sm:-mt-20instead ofabsolute bottom-0. Title is in normal flow with the description below it; the gradient now haspointer-events-noneand goes fromfrom-black/80to prevent overlap obscuring. - Description rendered via
cleanDescription(recipe.description)withline-clamp-2. - Client-side trim helper
cleanDescription(input, maxLen=280)inlib/utils.tswith a list of regex patterns that strip spoonacular marketing boilerplate (Featured In Group…,users who liked this recipe also liked…,For $X.XX per serving, this recipe covers…,It is brought to you by Foodista., etc.) and trims to the last sentence within 280 chars. - Raw
recipe.descriptionmoved to a "Notes from source" disclosure below Instructions (using a state toggle in the page component).
- Hero reworked: image is in normal flow, content panel uses
- Verify: Reload
/meals/<id>. Title readable, description is 1-2 lines, "Featured In Group…" gone, full text in disclosure. Build clean.
S2.3 · B8 — Pantry aisle: free-text → canonical select
- Files:
frontend/src/pages/Pantry.tsx,frontend/src/types/index.ts, backend migration - Frontend change: Replaced aisle
<Input>with<Select>populated fromPANTRY_AISLESintypes/index.ts:Also converted Unit to aexport const PANTRY_AISLES = [ 'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry', 'Frozen', 'Bakery', 'Beverages', 'Spices', 'Other', ] as const; export type PantryAisle = (typeof PANTRY_AISLES)[number];<Select>with the canonical unit list. Added*to "Ingredient name" label as a required-field marker. - Backend migration (
backend/alembic/versions/0015_normalize_pantry_aisles.py):- Revises
0014. Runs in a single upgrade step. - Creates a
TEMPbackup table for each ofingredient.aisleandgrocery_item.aisle(so a DBA can recover viaSELECT * FROM pg_temp.ingredient_aisle_backupif needed). UPDATEs both columns via a generatedCASE LOWER(COALESCE(aisle,'')) WHEN ... ENDmapping. Mapped variants:canned goods/canned→Pantry,freezer/frozen→Frozen,dairy/eggs/cheese/milk/yogurt→Dairy & Eggs,meat/seafood/fish/chicken/beef/pork/meat_seafood→Meat & Seafood,bakery/bread→Bakery,beverage/beverages/drinks→Beverages,spice/spices/seasoning→Spices,pantry/dry/snack/snacks→Pantry, anything else →Other. NULL stays NULL.- Downgrade: raises
NotImplementedError— operator must restore from a pre-migration snapshot. Documented in migration docstring.
- Revises
- Dry-run SQL helper (
backend/scripts/dry_run_aisle_migration.sql): standalone SQL that counts rows that would change per table, no writes. Run viadocker compose exec -T db psql -U mealplanner -d mealplanner -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql(no host psql needed; the db runs in a container). - Persistent backup helper (
backend/scripts/persist_aisle_backup.sql): createspublic.ingredient_aisle_backup_0015andpublic.grocery_item_aisle_backup_0015permanent tables. Run BEFORE the migration if you want a recoverable record beyond the migration's session. - Verify (on dev DB):
Add a new item with aisle "pantry" → stored as
# Persistent backup (optional, recommended) docker compose exec -T db psql -U mealplanner -d mealplanner \ -f /dev/stdin < backend/scripts/persist_aisle_backup.sql # Dry-run docker compose exec -T db psql -U mealplanner -d mealplanner \ -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql # Apply docker compose exec backend alembic upgrade headPantry. Open Pantry list → all rows show sentence-case canonical labels. Frontendnpm run buildclean.
S2.4 · B9 — ShoppingList aisle labels: human-readable map
- File:
frontend/src/pages/ShoppingList.tsx - Change: Added
AISLE_LABELmap covering all backend aisle keys (snake_case and singular variants) at the top of the file, plus a tinyaisleDisplay(key)helper. Section header is now<h3>{aisleDisplay(aisle)}</h3>— unknown keys fall back to the raw key (no silent data loss). Also collapsed the S3.3 mobile stat-card grid into this edit since the file was already open. - Verify: Reload
/shopping-list. Section headers readMeat & Seafood,Produce,Pantry,Dairy & Eggs— nomeat_seafoodliteral. Build clean.
S2.5 · B10 — Mobile pantry table: scroll hint
- File:
frontend/src/pages/Pantry.tsx:236 - Change: Wrapped
overflow-x-autoin arelativecontainer. Addedrole="region" aria-label="Pantry items, scroll horizontally to see all columns". Right-edge gradient overlay (pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent md:hidden,aria-hidden) hints at overflow on mobile only. - Verify: Screenshot at 390 px. The Expires + Actions columns are reachable via swipe, and a subtle right-edge fade hints at overflow. Build clean.
S2.6 · B11 — Recipes filters: Apply / Reset / active count
- File:
frontend/src/pages/Recipes.tsx - Change:
- Lifted filter state into a single
appliedobject (query-bound) and apendingobject (form-bound). Form fields mutatepending; the query usesapplied. - Added
activeCount = Object.values(applied).filter(Boolean).length. - The Filters button now shows
{activeCount > 0 && <Badge>{activeCount}</Badge>}plusaria-expanded={showFilters}. - Added a Reset and "Apply filters" button at the bottom of the filter panel, separated by a top border. Apply commits
pending → applied; Reset clears both. - The filter panel is now wrapped in a
<div role="region" aria-label="Filters">(Card doesn't forward extra HTML attrs).
- Lifted filter state into a single
- Verify: Open
/recipes, apply 2 filters, collapse panel → button showsFilters (2). Click Reset → all cleared, badge gone. Build clean.
S2.7 · Sprint 2 verification gate
npm run lint && npm run buildpass.- Backend migration run on dev DB; row counts logged to
Review/sprint2-migration-log.md. - Re-screenshot pantry, shopping list, recipes, meal detail, dashboard (new meal card width).
- Done when: All six P1s visually absent in the new screenshots, no regression in Sprint 1 fixes.
Sprint 3 — Polish (P2s + a11y)
Goal: A daily-driver app — no jarring native dialogs, no mobile wrap, no 44 px-target misses, no silent crashes on bad routes.
Status (2026-06-03): ✅ All P2s and a11y sweep items implemented. npm run build green. Ready to commit and deploy.
S3.1 · B12 — Undo-toast replaces confirm() for delete
- Files:
frontend/src/pages/Dashboard.tsx,Pantry.tsx,lib/toast.tsx - Change (committed, ready for deploy):
lib/toast.tsx— renamed from.ts(needed for JSX). NewshowToast.undo(message, onUndo, ms=5000)helper renders a custom toast with an inline "Undo" button that firesonUndoand dismisses the toast. Note:react-hot-toast2.6'sToastOptionsdoesn't exposeonClose/onDismiss, so the helper does NOT run a callback on expiry — the Undo button is the only path to recovery. This is honest UX, equivalent in spirit to aconfirm()declined.- Dashboard
handleDelete(itemId)captures the fullMealPlanItem(day_of_week, meal_type, recipe_id) before the DELETE, thenshowToast.undowhose Undo handler re-firesmeals.generateItem(planId, dayOfWeek, mealType)to refill the slot with a (possibly different) recipe. The plan R4 caveat applies: the exact recipe isn't restored, but the slot is filled. - Pantry
handleRemove(item)is fully reversible. Undo callspantry.add({ingredient_id, quantity, unit})with the original values. Per-row loading state via newremoveIdstate so only the clicked row's button shows the spinner. confirm()deleted in both call sites.
- Verify: Delete a meal → toast appears "Meal deleted" with Undo. Click Undo within 5s → slot refills. Same flow on Pantry: click Remove, click Undo → item returns. Build clean.
- Verify: Delete a meal → toast appears "Meal removed" with Undo. Click Undo within 5s → meal re-appears. Build clean.
S3.2 · B13 — Mobile nav: whitespace-nowrap on link text
- File:
frontend/src/App.tsx:30-33 - Change (committed): Added
whitespace-nowrapto thelinkClasshelper return string; reducedpx-3topx-2 sm:px-3. All 4 links fit on one line down to 360 px. - Verify: Screenshot at 360 px. All 4 links on one line. Build clean.
S3.3 · B14 — Mobile shopping-list stat cards: 3-col compact
- File:
frontend/src/pages/ShoppingList.tsx - Change (committed in Sprint 2): Replaced the 3 stacked full-width tiles with
grid grid-cols-3 gap-2 sm:gap-4. CardBody padding reduces top-3 sm:p-6; descriptive label collapses to "Estimated / Items / On Sale" on mobile. The 3 stats now sit in one row even on a 360 px viewport. - Verify: Mobile screenshot shows the 3 stats in one row, much less vertical scroll. Build clean.
S3.4 · ErrorBoundary is already present (verified)
- No code change. Verified
components/ErrorBoundary.tsxis mounted inApp.tsx:42. Audit item B-H9 (B = background, the B-prefixed list) closed without a code change.
S3.5 · A11y sweep (4 small fixes, one commit)
- Files:
frontend/src/App.tsx,frontend/src/pages/Recipes.tsx(already done in Sprint 2),frontend/src/pages/Dashboard.tsx,frontend/src/components/ui/Badge.tsx - Change (committed):
App.tsxNavigation: addedaria-current={isActive(prefix) ? 'page' : undefined}on each<Link>; added<nav aria-label="Primary">and<main id="main-content">for skip-link targets.- Recipes filter panel
<div role="region" aria-label="Filters">— done in Sprint 2. - Empty Generate slots:
min-h-11(44 px) — done in Sprint 1. Badgecomponent: added optionalicon: ReactNodeandaria-label: stringprops.Dashboardapproval-status Badge now passesaria-label="Approval status: approved"(or the current value) so screen readers announce it explicitly.
- Verify: Tab through the nav: active link has
aria-current="page". Inspect Recipes filter panel:role="region" aria-label="Filters". Measure empty-slot buttons at 390 px width = ≥ 44 px tall. Approval status reads aloud as "Approval status: approved" on the meal card.
S3.6 · Sprint 3 verification gate
npm run lint && npm run buildpass.- Final playwright walkthrough. All 14 audit findings closed in screenshots.
- Update
Review/ui-nielsen-audit.mdto mark each fix with a[x]and commit hash reference.
Future (NOT in this plan — capture as follow-up tickets)
- F1. Onboarding hints (H10) — needs
react-joyrideor hand-rolled<Tour>component. - F2. Keyboard shortcuts (
/,g p,g s,n m). - F3. Bulk add on Pantry/Shopping List (H7).
- F4. Plan-the-whole-week button (H7).
- F5. Persistent week selector in URL.
- F6.
aria-labelon color-only status badges (generalized). - F7. Global
react-queryonErrortoast handler.
Risks & mitigations
- R1 · Backend field
qtyvsquantity: confirm with a one-linecurlagainst/api/meals/<id>before renaming the type. If the API still returnsquantity, use a shiming.qty ?? ing.quantityrather 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-tablestep in place. - R3 · Tailwind
line-clamp-N: verify the project'stailwind.config.jsenables thelineClampcore plugin (Tailwind 3.3+ has it on by default; project is on^3.4.1, so it should work). - R4 · Undo-toast: requires the delete mutation to be reversible (i.e. we have the prior item body). Confirm the API has a
POSTcreate, not aDELETEtombstone, before implementing undo. - R5 · Build/runtime parity:
npm run buildrunstsc && vite build. If a teammate runsvite buildalone, type errors slip through. Add a CI hint in PR template.
Done when (overall)
- Sprint 1: 5 P0 fixes — committed
f3e4a44, deployed by user 2026-06-02. - Sprint 2: 6 P1 fixes + 1 bonus S3.3 — committed
ccc70aa, deploy helperf5fb755. Awaiting deploy. - Sprint 3: 3 P2 fixes + a11y sweep — committed (this branch), awaiting deploy.
npm run buildgreen for all three sprints (tsc 0 errors, vite 0 errors).- Backend aisle-migration (
0015) run on dev; row counts logged toReview/sprint2-verification.md. - Manual smoke pass on
http://100.108.208.56:8082/perReview/sprint2-verification.md. - No regressions in existing Playwright walkthrough.