Files
Meal-Planner/Review/ui-nielsen-audit.md
T
admin ccc70aaf72 feat(ui): close 6 P1 audit findings + 1 bonus mobile fix (Sprint 2)
- Dashboard MealCard: title truncate -> line-clamp-2, image shrinks to
  40x40 on <md to give the title room (B6).
- MealDetail: hero reworked to normal flow with stronger gradient;
  description runs through new cleanDescription() helper that strips
  14 spoonacular SEO patterns and trims to the last full sentence.
  Raw description moved to a 'Notes from source' disclosure (B7).
- Pantry: free-text aisle/unit replaced with <Select> populated from
  the new PANTRY_AISLES canonical enum; ingredient name field marked
  required. New PANTRY_AISLES export + PantryAisle type in types (B8).
- backend: alembic 0015_normalize_pantry_aisles maps free-text
  ingredient.aisle and grocery_item.aisle to canonical labels in a
  single transaction; downgrade raises (restore from snapshot).
  backend/scripts/dry_run_aisle_migration.sql is the read-only
  preview helper.
- ShoppingList: human-readable AISLE_LABEL map replaces raw snake_case
  aisle keys; 3-col stat grid with compact mobile sizing (B9 + S3.3).
- Pantry table: role/aria-label region and a right-edge white
  gradient hint at mobile horizontal overflow (B10).
- Recipes: pending/applied filter split, Apply and Reset buttons,
  active-count chip on the Filters button, role=region + aria-label
  on the panel (B11).
- Review/sprint2-verification.md and fix-ui-audit.md updated.

Build: npm run build (tsc + vite) green. tsc emits 0 errors.

Co-located audit + plan docs kept in sync: Review/ui-nielsen-audit.md
gains a Sprint 2 status block; fix-ui-audit.md has implementation
notes for each Sprint 2 task.
2026-06-03 17:36:26 -07:00

18 KiB
Raw Blame History

UI/UX Audit — Nielsen's 10 Heuristics

Scope: Live deployment at http://100.108.208.56:8082/, React frontend at frontend/src/, complementing the existing docs/repo reviews in this folder. Method: Playwright (system Chromium) navigated 20 routes/viewports; findings triangulated against source code with file:line references. Screenshots: /tmp/opencode/mp-review/screenshots/ (20 PNGs referenced inline). Severity scale: P0 (blocker) — broken core flow · P1 (major) — wrong or misleading · P2 (minor) — polish/aa.


Executive summary

The app looks polished on the surface (Tailwind palette, clean cards, working toasts, working focus rings), but a live walkthrough surfaces multiple silent failures and three outright broken data-rendering bugs. The most damaging issues are not visual — they are unmistakable data inconsistencies the user is expected to read and act on ($N/A per serving, blank lb Pork Chops rows, hidden empty meal slots on mobile, snake_case aisle labels). They erode trust faster than a missing button.

Top 5 to fix first (P0):

  1. Meal detail ingredients render without quantities (field-name bug, MealDetail.tsx:249-250) — a core function of the page is unreadable.
  2. $N/A per serving displayed literally (MealDetail.tsx:191).
  3. Recipe detail ingredients collapse unit and name (RecipeDetail.tsx:1612 canBlack Beans).
  4. /recommended returns a blank page (missing route + no 404 catch-all in App.tsx).
  5. Mobile dashboard hides empty meal slots (Dashboard.tsx:164,219 — users on phones cannot plan meals, only view them).

Sprint 1 status (commit f3e4a44, deployed by user 2026-06-02): Items 1, 2, 3, 4, 5 all addressed in the frontend source. Live at 100.108.208.56:8082/. Verification screenshots in /tmp/opencode/mp-review/screenshots/fix-sprint1/.

Sprint 2 status (commit pending, ready for deploy): All six P1s plus the S3.3 mobile shopping-list stat-grid fix are addressed in source.

  • B6 Dashboard MealCard title: truncateline-clamp-2; image shrinks to 40×40 on <md to give title more room.
  • B7 MealDetail hero: title/description no longer overlap; description stripped of spoonacular SEO copy via lib/utils.cleanDescription; raw text moved to a "Notes from source" disclosure.
  • B8 Pantry aisle/unit: free-text → canonical Select from PANTRY_AISLES enum (types/index.ts). Ingredient name field now marked * required. Backend migration 0015_normalize_pantry_aisles.py normalizes ingredient.aisle and grocery_item.aisle to canonical labels. Dry-run SQL helper at backend/scripts/dry_run_aisle_migration.sql.
  • B9 ShoppingList aisle section headers now human-readable via AISLE_LABEL map; falls back to raw key for unknown values.
  • B10 Mobile pantry table: right-edge white-to-transparent gradient overlay hints at horizontal overflow; container has role="region" + descriptive aria-label.
  • B11 Recipes filters: refactored to pending/applied state with explicit Apply / Reset buttons. Filters button shows active-count chip when filters are set. Wrapped in role="region" aria-label="Filters".
  • S3.3 Shopping list stat cards: now grid-cols-3 on all viewports with compact mobile sizing.

Deployment command:

# On the deployment host:
git pull
# Apply the backend migration (one-off):
docker compose exec backend alembic upgrade head
# Rebuild & restart frontend:
docker compose -f docker-compose.yml up -d --build frontend

Findings mapped to Nielsen's 10 Heuristics

H1 · Visibility of system status — Partial

Works well

  • Toasts (react-hot-toast) for generate/delete are top-right and persist.
  • Status badges on the dashboard (e.g. $206.21 total) update reactively.
  • Loading skeletons render on data fetch.

⚠️ Gaps

  • Filters (P2). Active filter count is not shown when the filter panel is collapsed (Recipes.tsx:99 area, 16-recipes-filters-open.png). User has no way to know a filter is on.
  • Pantry search (P2). No "X of N results" indicator.
  • Sync status (P2). When a meal is being generated, no spinner on the slot itself — only the global toast after success.

Fix: Render an activeFilters.length chip on the Filters button; add a small "Searching…" indicator inside the Pantry search input.


H2 · Match between system and the real world — Multiple violations

🚨 P1 · Snake-case aisle labels on Shopping List (page-shopping-list.png)

  • Sections display meat_seafood, produce, pantry, dairy.
  • Fix: human-readable map in ShoppingList.tsx:
    const AISLE_LABEL: Record<string,string> = {
      meat_seafood: 'Meat & Seafood', produce: 'Produce',
      pantry: 'Pantry', dairy: 'Dairy & Eggs',
    };
    

🚨 P1 · $N/A per serving (MealDetail.tsx:191, 14-meal-detail.png)

  • ${item.estimated_cost?.toFixed(2) || 'N/A'} renders $N/A literally because the $ is outside the conditional.
  • Fix:
    {item.estimated_cost != null
      ? `$${item.estimated_cost.toFixed(2)} per serving`
      : 'No price estimate yet'}
    

🚨 P0 · Ingredients render without quantities on the Meal page (MealDetail.tsx:249-252, 14-meal-detail.png)

  • Code reads ing.quantity / ing.unit but the backend returns qty (per RecipeDetail.tsx:161 working correctly). Result: lb Pork Chops, Bone-In instead of 1 lb Pork Chops, Bone-In.
  • Fix: rename both fields to a single canonical name (recommend qty to match backend), or apply a compatibility shim:
    const qty = ing.qty ?? ing.quantity;
    const unit = ing.unit ?? ing.unit;
    
    and update the type definition.

🚨 P1 · Spoonacular marketing copy leaks into meal description (14-meal-detail.png)

  • The meal page description includes: "Featured In Group could be just the gluten free, dairy free, and ketogenic recipe you've been looking for… users who liked this recipe also liked Baked Chicken In Avocado Boat…"
  • Fix: backend Meal.description should be truncated to ~280 chars on import, with a regex strip of the "Featured In Group…" / "users who liked…" boilerplate. Alternatively, render description.split('. ').slice(0,2).join('. ')+'.' on the frontend with a line-clamp-3 parent.

🚨 P1 · Hero title overlaps description (MealDetail.tsx:168-197, 14-meal-detail.png)

  • Long description text (no line-clamp) sits over the absolute-positioned title block, making the title literally unreadable.
  • Fix: add line-clamp-3 and max-w-2xl on the description; ensure the title is in normal flow (not absolute) on this view.

H3 · User control and freedom — Partial

Works

  • Back links on Recipe and Meal detail pages.
  • Drag-and-drop on dashboard (via @hello-pangea/dnd) is reversible.

⚠️ Gaps

  • P1 · Native confirm() dialogs for delete — jarring, breaks visual continuity. Replace with an inline "Undo" toast (e.g. react-hot-toast with a 5s undo that re-fires the create query). See Dashboard.tsx meal delete and Pantry.tsx row delete.
  • P2 · No keyboard shortcut to focus search on Recipes/Pantry/Shopping List. Convention is / or Cmd+K.
  • P2 · Filters have no Reset button (16-recipes-filters-open.png).

H4 · Consistency and standards — Multiple violations

🚨 P1 · Aisle casing inconsistency in Pantry (page-pantry.png, Pantry.tsx:200)

  • Rows show Canned Goods, Pantry, pantry, Produce, Freezer — all derived from free-text input. Aisle should be a fixed enum.
  • Fix: replace the free-text input with a <select> populated from ['Produce','Meat & Seafood','Dairy & Eggs','Pantry','Frozen','Bakery','Beverages','Spices','Other']. Migrate existing rows via a one-off script that lowercases + maps.

🚨 P1 · qty vs quantity field mismatch between Recipe and Meal detail (covered under H2). The shared Ingredient type should have one canonical field.

⚠️ P2 · Aisle filter pill on dashboard Shopping List card is uppercase by Tailwind class; the rest of the app uses sentence case.

⚠️ P2 · Mixed icon setlucide-react everywhere except a few hand-rolled SVGs in the dashboard's empty state.


H5 · Error prevention — Violations

🚨 P1 · Add Pantry Item form has no required markers and no validation (17-pantry-add-item.png, Pantry.tsx:~180-220)

  • "Add" button looks pre-disabled (light blue) but the user has no idea why. No * indicator on the required Ingredient Name field, no inline error, no disabled-until-valid logic explained.
  • Fix: add <span className="text-danger">*</span> to required field labels; use aria-describedby to attach an inline help text; show an inline error on submit fail (e.g. duplicate item).

⚠️ P2 · Filters apply immediately on change — user can lose their current result set by accidently nudging "Max time". Add explicit Apply (or debounce 400 ms with a clear "Applying…" indicator).

⚠️ P2 · Meal generate (Generate button) has no confirmation for the current week — clicking accidentally overwrites. A confirm() for destructive regenerate is acceptable; better: a small "Replace existing?" toggle.


H6 · Recognition rather than recall — Partial

Works

  • Recipe cards show tags (cuisine, diet) and quick stats.
  • Status badges (Approved, etc.) are color-coded consistently.

⚠️ Gaps

  • P2 · No breadcrumbs on detail pages. From /meals/f28… the user cannot see "Meal Plan Pork Stir-Fry" without remembering.
  • P2 · No active filter chips on the Recipes page — when filters are collapsed, user has no visible reminder of what's on (see H1).
  • P2 · Empty Pantry state has no illustration or "Add your first item" primary CTA; just a blank table.

H7 · Flexibility and efficiency of use — Weak

🚨 P1 · No bulk actions on Shopping List or Pantry (page-shopping-list.png, page-pantry.png)

  • Adding common items (salt, pepper, oil) is one-by-one. Add a "Multi-select" mode with a header that says 2 selected · [Delete] [Move aisle].

⚠️ P2 · No keyboard shortcuts.

  • / focus search
  • g p go to Pantry
  • g s go to Shopping List
  • n m new meal
  • A small useShortcuts hook in App.tsx plus a "?" help modal would cover this.

⚠️ P2 · Generate button regenerates one slot at a time. A "Plan whole week" button would be a huge efficiency win for a meal planner.

⚠️ P2 · No persistent week selector in the URL — back/forward loses the week you're viewing.


H8 · Aesthetic and minimalist design — Mostly good, with one outlier

Works

  • Palette is restrained (surface, primary, warning, success, danger).
  • Card hierarchy is clear on Recipes grid.

🚨 P1 · Meal detail hero is chaotic (14-meal-detail.png)

  • Title, badge, description, and metadata all compete; title is unreadable due to the overlap (H2). Long marketing copy adds noise. Tighten to: title → single-line subtitle (cuisine · 25 min · 4 servings) → 1-2 sentence description → CTA. Move the long marketing body into a "Notes from source" collapsible at the bottom.

⚠️ P2 · Stat cards on Shopping List stack 3 full-width tiles on mobile (mobile-shopping-list.png) — heavy vertical scroll. Consider a 3-up compact layout (icon + value, label below) for < sm.

⚠️ P2 · Mobile nav wraps "Shopping List" onto a second line (04-dashboard-mobile.png). Add whitespace-nowrap to nav links.


H9 · Help users recognize, diagnose, and recover from errors — Violations

🚨 P0 · /recommended is a blank page (page-recommended.png, 13-recommended-broken.png, mobile-recommended.png)

  • The Navigation links do not point to /recommended (they correctly point to /recipes/recommended), but the URL is referenced in user-facing strings somewhere (most likely an email link or share URL) and resolves to an empty React Router outlet.
  • The "Recommended" link in the Recipes header also has a known link to /recipes/recommended which works.
  • Fix: add a * catch-all route in App.tsx rendering a friendly NotFound component with a "Back to dashboard" CTA; optionally also alias /recommended → /recipes/recommended via <Navigate replace />.

🚨 P1 · No error boundary — if a single component throws (e.g. an ingredient with null.qty), the whole page goes blank. Add a top-level <ErrorBoundary> in App.tsx that shows "Something went wrong. [Reload] [Report]".

⚠️ P2 · Recipes with no image show a generic cooking-pot icon silently. Add a title="Image not available" and consider a "Report missing image" link.

⚠️ P2 · 401/403/500 errors from the API are not surfaced as user-readable toasts. Hook into the react-query onError global handler.


H10 · Help and documentation — Missing

🚨 P1 · No onboarding for first-time users — empty dashboard, empty pantry, empty shopping list with no guidance.

  • Add a one-time tour (e.g. react-joyride) or just 3 inline hint cards on the dashboard: 1. Add items to your pantry · 2. Generate this week's meals · 3. Review the shopping list.
  • Add a "?" icon in the nav that opens a Help modal with a quick-start, FAQ, and a link to docs/.

⚠️ P2 · No tooltips on advanced filter labels (Max time, Max spice, Max calories) — units and ranges are not obvious. Use aria-describedby + a small "?" popover.

⚠️ P2 · Print List button is hidden behind the page scroll on mobile. Make it sticky on lg: viewports at minimum.


Additional concrete bugs

# Where Bug Severity Fix
B1 RecipeDetail.tsx:161 ing.qty != null && 2 canBlack Beans` (no space) P0 Drop .trim() or add explicit before {ing.name}
B2 MealDetail.tsx:249-252 ing.quantity undefined → no quantities shown P0 Use ing.qty ?? ing.quantity or rename to qty
B3 MealDetail.tsx:191 $N/A per serving P0 Conditional on cost != null
B4 App.tsx (routes) No /recommended, no * NotFound P0 Add <Route path="*" element={<NotFound/>}> + alias /recommended
B5 Dashboard.tsx:164,219 Empty slots hidden on mobile P0 Remove hidden md:flex / hidden md:block (or replace with flex on both)
B6 Dashboard.tsx:87 truncate cuts meal name to 1-2 chars P1 line-clamp-2 and shrink image on narrow grid
B7 MealDetail.tsx:168-197 Title overlaps description P1 Remove absolute positioning, add line-clamp-3 on description
B8 Pantry.tsx:200 Free-text aisle P1 Convert to <select> with canonical list
B9 ShoppingList.tsx snake_case aisle names P1 Human-readable map
B10 Pantry.tsx:236 overflow-x-auto without scroll hint on mobile P1 Add a faded right-edge gradient + aria role="region" with descriptive label
B11 Recipes.tsx Filters have no Apply/Reset/active count P1 Add Reset, Apply, and an activeCount chip on the Filters button
B12 various Native confirm() for delete P2 Replace with react-hot-toast undo pattern
B13 Navigation.tsx:14-38 "Shopping List" wraps on mobile P2 Add whitespace-nowrap
B14 dashboard/Shopping List Stat cards stack full-width on mobile P2 Use 3-col compact layout for <sm

Accessibility (WCAG 2.1 AA quick scan)

  • P1 · aria-current="page" missing on the active nav link. Add it in Navigation.tsx.
  • P1 · Recipes filter panel opens inline but is not announced as a region. Add role="region" aria-label="Filters".
  • P1 · Modal/dialogs (none observed, but recommend focus-trap-react whenever added).
  • P2 · Color-only signals — "Approved" badge relies on green alone; add an icon or aria-label="Approved".
  • P2 · Touch targets — Generate buttons in empty slots are < 44 px tall on mobile. Bump to min-h-11.

A pragmatic 3-sprint plan, each ending in something visible to a user testing the deployment.

Sprint 1 — Stop the bleeding (P0s, ~3 days)

  1. B1 (recipe ingredients space)
  2. B2 (meal ingredient field rename + shim)
  3. B3 ($N/A fix)
  4. B4 (404 + /recommended alias)
  5. B5 (mobile empty slots visible)

Sprint 2 — Trust the data (P1s, ~4 days)

  1. B6 (card title line-clamp)
  2. B7 (meal hero overlap + description clamp)
  3. B8 (pantry aisle select)
  4. B9 (shopping list aisle map)
  5. B10 (mobile pantry scroll hint)
  6. B11 (filters: Apply, Reset, active count)

Sprint 3 — Polish (P2s + a11y, ~3 days)

  1. Undo-toast replaces confirm() (B12)
  2. Mobile nav wrap (B13)
  3. Stat card responsive layout (B14)
  4. Onboarding hints on empty dashboard (H10)
  5. Error boundary (H9)
  6. A11y sweep (aria-current, regions, 44 px targets)

Appendix · Captured screenshots

Screenshot Notes
03-dashboard.png Desktop dashboard — full week grid, status badge, $206.21
04-dashboard-mobile.png Mobile dashboard — empty slots hidden (B5)
page-recipes.png 30 recipe grid, search + filters
mobile-recipes.png 2-col on mobile, OK
page-pantry.png Mixed-case aisles (B8)
mobile-pantry.png Columns cut off silently (B10)
page-shopping-list.png snake_case aisles (B9)
mobile-shopping-list.png Stat cards stack full-width (B14)
page-recommended.png BLANK — missing route (B4)
mobile-recommended.png Same blank on mobile
10-recipe-detail.png /recipes/recommended — actually renders fine
11-recipe-detail-real.png Bug: 2 canBlack Beans (B1)
12-recipe-detail-mobile.png Stacks OK on mobile
13-recommended-broken.png Blank /recommended
14-meal-detail.png Bugs: overlap, $N/A, missing quantities, SEO copy leak (B2/B3/B7)
15-after-generate-click.png Toast works; new meal title clipped to B.. (B6)
16-recipes-filters-open.png Filters inline, no Apply/Reset (B11)
17-pantry-add-item.png No required marker, pre-disabled looking Add (B12)
18-focus-state.png Focus ring on nav link works