Files
Meal-Planner/Review/ui-nielsen-audit.md
T

279 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):** Items 1, 2, 3, 4, 5 all addressed in the frontend source. ⚠️ **Not yet deployed to the live server at `100.108.208.56:8082/`** — that host is a different machine (Tailscale IP `100.108.224.12`) than the one I edited on. The user must `git pull` and `docker compose up -d --build frontend` on the deployment host. Verification screenshots in `/tmp/opencode/mp-review/screenshots/fix-sprint1/`.
---
## 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<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:
```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 `<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 set** — `lucide-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`.
---
## Recommended implementation order
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 ✅ |