# 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:161` — `2 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: `truncate` → `line-clamp-2`; image shrinks to 40×40 on ` - **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 commands (run on the deployment host — DB is in a container, no host psql needed): > ```bash > cd ~/MealPlanner > git pull > > # Optional: persistent backup of aisle values BEFORE the migration > docker compose exec -T db psql -U mealplanner -d mealplanner \ > -f /dev/stdin < backend/scripts/persist_aisle_backup.sql > > # Dry-run preview (no writes) > docker compose exec -T db psql -U mealplanner -d mealplanner \ > -f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql > > # Apply the migration > 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`: ```ts const AISLE_LABEL: Record = { 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: ```tsx {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: ```tsx 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 `` 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 `