Public Access
- Drop the empty batch_alter_table block and the meaningless set_config call from migration 0015. Temp tables still persist for the migration's session (Alembic's transactional_ddl). - New backend/scripts/persist_aisle_backup.sql creates public.ingredient_aisle_backup_0015 and public.grocery_item_aisle_backup_0015 permanent tables for operators who want a recoverable record beyond the migration. - Update Review/sprint2-verification.md, Review/ui-nielsen-audit.md and fix-ui-audit.md with the correct container-based deploy steps: docker compose exec db psql -U mealplanner -d mealplanner -f /dev/stdin < ...sql. Host psql is not available on the deployment host; the db runs inside the container.
308 lines
18 KiB
Markdown
308 lines
18 KiB
Markdown
# 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 `<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 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<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 ✅ |
|