Files
Meal-Planner/fix-ui-audit.md
T
admin e90a9d6683 feat(ui): close 3 P2 audit findings + a11y sweep (Sprint 3)
- 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.
2026-06-03 18:09:35 -07:00

19 KiB
Raw Blame History

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.tsx already 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-joyride only 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 (the build script runs tsc first — 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.ts style.
  • 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
    {ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}
    
    with
    {ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''}`}
    
    followed by a literal ' ' before {ing.name}.
  • Verify: Open /recipes/eae6591f... (Black Bean Tacos). Ingredient row reads 2 can Black Beans, Canned (with space). Re-run npm 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:
    1. In types/index.ts confirm MealIngredient shape; align to backend qty / unit. If the type currently has quantity, rename to qty (single source of truth).
    2. In MealDetail.tsx:249-252, switch reads to ing.qty / ing.unit. Keep the existing null-guard so qty == null is skipped cleanly.
  • Verify: Open /meals/<any> (e.g. /meals/f28... Pork Stir-Fry). Row reads 1 lb Pork Chops, Bone-In not lb Pork Chops. npm run build clean.

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 serving or No price estimate yet — never $N/A.
  • Files: frontend/src/App.tsx, new frontend/src/pages/NotFound.tsx
  • Change:
    1. Create pages/NotFound.tsx — friendly card with AlertTriangle icon, message "We can't find that page.", primary <Button>/, secondary → back. Reuse components/ui/EmptyState.tsx if it fits.
    2. 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.
    3. Add a // TODO(seo): audit email/share links for /recommended references comment.
  • 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 build clean.

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:inline inside DayColumn and the empty-slot JSX. Remove the hidden class on the empty-slot CTAs (the Empty+Generate placeholder block). For decorative chrome (e.g. day-of-week abbreviations), keep hidden md:flex only 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 build clean.

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/*.png for: 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 truncate with line-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. Added leading-tight to 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):
    1. Hero reworked: image is in normal flow, content panel uses relative -mt-16 sm:-mt-20 instead of absolute bottom-0. Title is in normal flow with the description below it; the gradient now has pointer-events-none and goes from from-black/80 to prevent overlap obscuring.
    2. Description rendered via cleanDescription(recipe.description) with line-clamp-2.
    3. Client-side trim helper cleanDescription(input, maxLen=280) in lib/utils.ts with 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.
    4. Raw recipe.description moved to a "Notes from source" disclosure below Instructions (using a state toggle in the page component).
  • 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 from PANTRY_AISLES in types/index.ts:
    export const PANTRY_AISLES = [
      'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry',
      'Frozen', 'Bakery', 'Beverages', 'Spices', 'Other',
    ] as const;
    export type PantryAisle = (typeof PANTRY_AISLES)[number];
    
    Also converted Unit to a <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 TEMP backup table for each of ingredient.aisle and grocery_item.aisle (so a DBA can recover via SELECT * FROM pg_temp.ingredient_aisle_backup if needed).
    • UPDATEs both columns via a generated CASE LOWER(COALESCE(aisle,'')) WHEN ... END mapping. Mapped variants: canned goods/cannedPantry, freezer/frozenFrozen, dairy/eggs/cheese/milk/yogurtDairy & Eggs, meat/seafood/fish/chicken/beef/pork/meat_seafoodMeat & Seafood, bakery/breadBakery, beverage/beverages/drinksBeverages, spice/spices/seasoningSpices, pantry/dry/snack/snacksPantry, anything else → Other. NULL stays NULL.
    • Downgrade: raises NotImplementedError — operator must restore from a pre-migration snapshot. Documented in migration docstring.
  • Dry-run SQL helper (backend/scripts/dry_run_aisle_migration.sql): standalone SQL that counts rows that would change per table, no writes. Run via docker 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): creates public.ingredient_aisle_backup_0015 and public.grocery_item_aisle_backup_0015 permanent tables. Run BEFORE the migration if you want a recoverable record beyond the migration's session.
  • Verify (on dev DB):
    # 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 head
    
    Add a new item with aisle "pantry" → stored as Pantry. Open Pantry list → all rows show sentence-case canonical labels. Frontend npm run build clean.

S2.4 · B9 — ShoppingList aisle labels: human-readable map

  • File: frontend/src/pages/ShoppingList.tsx
  • Change: Added AISLE_LABEL map covering all backend aisle keys (snake_case and singular variants) at the top of the file, plus a tiny aisleDisplay(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 read Meat & Seafood, Produce, Pantry, Dairy & Eggs — no meat_seafood literal. Build clean.

S2.5 · B10 — Mobile pantry table: scroll hint

  • File: frontend/src/pages/Pantry.tsx:236
  • Change: Wrapped overflow-x-auto in a relative container. Added role="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:
    1. Lifted filter state into a single applied object (query-bound) and a pending object (form-bound). Form fields mutate pending; the query uses applied.
    2. Added activeCount = Object.values(applied).filter(Boolean).length.
    3. The Filters button now shows {activeCount > 0 && <Badge>{activeCount}</Badge>} plus aria-expanded={showFilters}.
    4. 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.
    5. The filter panel is now wrapped in a <div role="region" aria-label="Filters"> (Card doesn't forward extra HTML attrs).
  • Verify: Open /recipes, apply 2 filters, collapse panel → button shows Filters (2). Click Reset → all cleared, badge gone. Build clean.

S2.7 · Sprint 2 verification gate

  • npm run lint && npm run build pass.
  • 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):
    1. lib/toast.tsx — renamed from .ts (needed for JSX). New showToast.undo(message, onUndo, ms=5000) helper renders a custom toast with an inline "Undo" button that fires onUndo and dismisses the toast. Note: react-hot-toast 2.6's ToastOptions doesn't expose onClose/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 a confirm() declined.
    2. Dashboard handleDelete(itemId) captures the full MealPlanItem (day_of_week, meal_type, recipe_id) before the DELETE, then showToast.undo whose Undo handler re-fires meals.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.
    3. Pantry handleRemove(item) is fully reversible. Undo calls pantry.add({ingredient_id, quantity, unit}) with the original values. Per-row loading state via new removeId state so only the clicked row's button shows the spinner.
    4. 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.
  • File: frontend/src/App.tsx:30-33
  • Change (committed): Added whitespace-nowrap to the linkClass helper return string; reduced px-3 to px-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 to p-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.tsx is mounted in App.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):
    1. App.tsx Navigation: added aria-current={isActive(prefix) ? 'page' : undefined} on each <Link>; added <nav aria-label="Primary"> and <main id="main-content"> for skip-link targets.
    2. Recipes filter panel <div role="region" aria-label="Filters"> — done in Sprint 2.
    3. Empty Generate slots: min-h-11 (44 px) — done in Sprint 1.
    4. Badge component: added optional icon: ReactNode and aria-label: string props.
    5. Dashboard approval-status Badge now passes aria-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 build pass.
  • Final playwright walkthrough. All 14 audit findings closed in screenshots.
  • Update Review/ui-nielsen-audit.md to 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-joyride or 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-label on color-only status badges (generalized).
  • F7. Global react-query onError toast handler.

Risks & mitigations

  • R1 · Backend field qty vs quantity: confirm with a one-line curl against /api/meals/<id> before renaming the type. If the API still returns quantity, use a shim ing.qty ?? ing.quantity rather than breaking other consumers.
  • R2 · Pantry migration: run against dev DB first; capture before/after row counts. Do not run on prod without the --backup-table step in place.
  • R3 · Tailwind line-clamp-N: verify the project's tailwind.config.js enables the lineClamp core 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 POST create, not a DELETE tombstone, before implementing undo.
  • R5 · Build/runtime parity: npm run build runs tsc && vite build. If a teammate runs vite build alone, 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 helper f5fb755. Awaiting deploy.
  • Sprint 3: 3 P2 fixes + a11y sweep — committed (this branch), awaiting deploy.
  • npm run build green for all three sprints (tsc 0 errors, vite 0 errors).
  • Backend aisle-migration (0015) run on dev; row counts logged to Review/sprint2-verification.md.
  • Manual smoke pass on http://100.108.208.56:8082/ per Review/sprint2-verification.md.
  • No regressions in existing Playwright walkthrough.