From ccc70aaf7225bee371b3b2d1235b81308cef9137 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 3 Jun 2026 17:36:26 -0700 Subject: [PATCH] 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 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. --- Review/sprint2-verification.md | 52 +++++ Review/ui-nielsen-audit.md | 21 +- .../versions/0015_normalize_pantry_aisles.py | 101 ++++++++ backend/scripts/dry_run_aisle_migration.sql | 76 ++++++ fix-ui-audit.md | 219 ++++++++++++++++++ frontend/src/lib/utils.ts | 37 +++ frontend/src/pages/Dashboard.tsx | 8 +- frontend/src/pages/MealDetail.tsx | 47 +++- frontend/src/pages/Pantry.tsx | 66 ++++-- frontend/src/pages/Recipes.tsx | 91 +++++--- frontend/src/pages/ShoppingList.tsx | 78 +++++-- frontend/src/types/index.ts | 13 ++ 12 files changed, 728 insertions(+), 81 deletions(-) create mode 100644 Review/sprint2-verification.md create mode 100644 backend/alembic/versions/0015_normalize_pantry_aisles.py create mode 100644 backend/scripts/dry_run_aisle_migration.sql create mode 100644 fix-ui-audit.md create mode 100644 frontend/src/lib/utils.ts diff --git a/Review/sprint2-verification.md b/Review/sprint2-verification.md new file mode 100644 index 0000000..b0a20fe --- /dev/null +++ b/Review/sprint2-verification.md @@ -0,0 +1,52 @@ +# Sprint 2 — Verification Log + +**Date:** 2026-06-02 +**Scope:** Six P1s from `Review/ui-nielsen-audit.md` plus the S3.3 mobile stat-grid fix. +**Build:** `cd frontend && npm run build` → green (0 tsc errors, 0 eslint warnings, dist emitted). + +## Files changed + +| File | Change | +|---|---| +| `frontend/src/pages/Dashboard.tsx` | B6: meal-card title `truncate` → `line-clamp-2`; image 56→40 on mobile. | +| `frontend/src/pages/MealDetail.tsx` | B7: hero rework (relative flow, gradient `from-black/80`, `line-clamp-2` description via `cleanDescription`); new "Notes from source" disclosure. | +| `frontend/src/lib/utils.ts` | B7: `cleanDescription(input, maxLen=280)` strips 14 spoonacular boilerplate patterns and trims to last sentence. | +| `frontend/src/pages/Pantry.tsx` | B8: aisle/unit → `` with `` 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). + - `UPDATE`s both columns via a generated `CASE LOWER(COALESCE(aisle,'')) WHEN ... END` mapping. Mapped variants: `canned goods`/`canned` → `Pantry`, `freezer`/`frozen` → `Frozen`, `dairy`/`eggs`/`cheese`/`milk`/`yogurt` → `Dairy & Eggs`, `meat`/`seafood`/`fish`/`chicken`/`beef`/`pork`/`meat_seafood` → `Meat & Seafood`, `bakery`/`bread` → `Bakery`, `beverage`/`beverages`/`drinks` → `Beverages`, `spice`/`spices`/`seasoning` → `Spices`, `pantry`/`dry`/`snack`/`snacks` → `Pantry`, 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 `psql` query that counts rows that *would* change per table, no writes. +- **Verify (on dev DB):** + ```bash + psql "$DATABASE_URL" -f backend/scripts/dry_run_aisle_migration.sql + 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 `

{aisleDisplay(aisle)}

` — 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 && {activeCount}}` 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 `
` (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. + +### S3.1 · B12 — Undo-toast replaces `confirm()` for delete +- **Files:** `frontend/src/pages/Dashboard.tsx`, `Pantry.tsx`, `ShoppingList.tsx`, `lib/toast.ts` +- **Change:** + 1. Extend `lib/toast.ts` with `toastUndo(msg, onUndo, ms=5000)` that uses `react-hot-toast` custom render with an "Undo" button. + 2. Replace every `confirm('Delete…?')` and `window.confirm(...)` with the new helper. The undo handler re-fires the create mutation. +- **Verify:** Delete a meal → toast appears "Meal removed" with Undo. Click Undo within 5s → meal re-appears. Build clean. + +### S3.2 · B13 — Mobile nav: `whitespace-nowrap` on link text +- **File:** `frontend/src/App.tsx:30-33` +- **Change:** Add `whitespace-nowrap` to the `linkClass` helper return string. Consider also reducing the `px-3` to `px-2 sm:px-3` to keep all 4 links 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:** Replace the current 3 stacked full-width tiles (mobile) with `grid grid-cols-3 gap-2` and shrink padding. Hide the descriptive label on `< sm`; show only the value. +- **Verify:** Mobile screenshot shows the 3 stats in one row, much less vertical scroll. Build clean. + +### S3.4 · ErrorBoundary is already present (verified) +- No action. Note this in commit message: `chore(docs): ErrorBoundary already mounted in App.tsx:42; B-H9 closed without code change.` + +### S3.5 · A11y sweep (4 small fixes, one commit) +- **Files:** `frontend/src/App.tsx`, `frontend/src/pages/Recipes.tsx`, `frontend/src/pages/Dashboard.tsx`, `frontend/src/components/ui/Badge.tsx` +- **Change:** + 1. `Navigation.tsx`/`App.tsx`: add `aria-current={isActive(prefix) ? 'page' : undefined}` on each ``. + 2. Recipes filter panel `
`: `role="region" aria-label="Filters"`. + 3. Empty Generate slots: bump to `min-h-11` (44 px) on the button itself. + 4. Badge component: add optional `icon` prop + `aria-label` for color-only badges. +- **Verify:** Tab through the nav: active link has `aria-current="page"`. Inspect filter panel DOM. Measure empty-slot buttons at 390 px width. + +### 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 `` 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/` 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) +- [ ] All 14 audit findings closed and screenshot-verified. +- [ ] `npm run lint && npm run build` green in CI. +- [ ] Backend aisle-migration run on dev; row counts logged. +- [ ] `Review/ui-nielsen-audit.md` updated with `[x]` per finding + commit refs. +- [ ] No regressions in existing Playwright walkthrough (full screenshot diff vs `/tmp/opencode/mp-review/screenshots/`). diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..f32affe --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,37 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +const SEO_PATTERNS: RegExp[] = [ + /\bFeatured In Group[^.!?]*[.!?]?/gi, + /\busers? who liked this recipe also liked[^.!?]*[.!?]?/gi, + /\bSimilar recipes (include|are)[^.!?]*[.!?]?/gi, + /\b\d+ people (found this recipe|made this recipe|have made this recipe)[^.!?]*[.!?]?/gi, + /\bOverall,? this recipe earns[^.!?]*[.!?]?/gi, + /\b\d+ people have made this recipe and would make it again\.?/gi, + /\b\d+ person has tried and liked this recipe\.?/gi, + /\bFor \$[\d.]+ per serving,? this recipe covers[^.!?]*[.!?]?/gi, + /\bThis recipe serves \d+\.?\s?/gi, + /\bIt is brought to you by [^.!?]+[.!?]?/gi, + /\bFrom preparation to the plate,? this recipe takes[^.!?]*[.!?]?/gi, + /\bIt works well as [^.!?]+[.!?]?/gi, + /\bIf you have [^,.]+(,\s*[^,.]+){0,5},? you can make it\.?/gi, + /\bOne serving contains [^.!?]+[.!?]?/gi, + /\bFor \d+ cents per serving,? this recipe covers[^.!?]*[.!?]?/gi, +]; + +export function cleanDescription(input: string | undefined | null, maxLen = 280): string { + if (!input) return ''; + let s = input; + for (const re of SEO_PATTERNS) s = s.replace(re, ''); + s = s.replace(/\s{2,}/g, ' ').replace(/\.\s*\./g, '.').trim(); + if (s.length > maxLen) { + const cut = s.slice(0, maxLen); + const lastDot = cut.lastIndexOf('.'); + s = (lastDot > 80 ? cut.slice(0, lastDot + 1) : cut.trimEnd() + '…'); + } + return s; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9dd15c9..21b7ee9 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -75,16 +75,16 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on {item.recipe.name} ) : ( -
- +
+
)}
-

+

{item.recipe?.name || 'Unknown Recipe'}

diff --git a/frontend/src/pages/MealDetail.tsx b/frontend/src/pages/MealDetail.tsx index a6b6336..bffd713 100644 --- a/frontend/src/pages/MealDetail.tsx +++ b/frontend/src/pages/MealDetail.tsx @@ -11,6 +11,7 @@ import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Select } from '../components/ui/Select' import { Textarea } from '../components/ui/Textarea' import { showToast } from '../lib/toast' +import { cleanDescription } from '../lib/utils' const DENIAL_REASONS = [ { value: '', label: 'Select a reason...' }, @@ -102,6 +103,7 @@ export default function MealDetail() { const [text, setText] = useState('') const [submitted, setSubmitted] = useState(false) const [editingFeedback, setEditingFeedback] = useState(false) + const [showFullDescription, setShowFullDescription] = useState(false) const submitMutation = useMutation({ mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload), @@ -142,6 +144,9 @@ export default function MealDetail() { const recipe = item.recipe const feedback = existingFeedback + const cleanDesc = cleanDescription(recipe.description) + const hasRawDescription = !!recipe.description && recipe.description.trim().length > 0 + const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (!id) return @@ -170,20 +175,22 @@ export default function MealDetail() { {recipe.name} ) : ( -
+
)} -
-
+
+
-
-

{recipe.name}

- {recipe.description && ( -

{recipe.description}

+
+

{recipe.name}

+ {cleanDesc && ( +

+ {cleanDesc} +

)}
@@ -280,6 +287,30 @@ export default function MealDetail() { + {/* Notes from source — disclosure of full original marketing description */} + {hasRawDescription && ( + + + + + {showFullDescription && ( + +

+ {recipe.description} +

+
+ )} +
+ )} + {/* Feedback */} diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx index fb1c3a0..0dc9ff5 100644 --- a/frontend/src/pages/Pantry.tsx +++ b/frontend/src/pages/Pantry.tsx @@ -2,14 +2,20 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react' import { mealPlannerApi } from '../api' -import type { HomePantryItem, Ingredient } from '../types' +import { PANTRY_AISLES, type HomePantryItem, type Ingredient } from '../types' import { Button } from '../components/ui/Button' import { Card, CardBody } from '../components/ui/Card' import { Input } from '../components/ui/Input' +import { Select } from '../components/ui/Select' import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' import { showToast } from '../lib/toast' +const AISLE_OPTIONS = [ + { value: '', label: 'Select aisle…' }, + ...PANTRY_AISLES.map(a => ({ value: a, label: a })), +] + export default function Pantry() { const queryClient = useQueryClient() const [showAddForm, setShowAddForm] = useState(false) @@ -174,10 +180,11 @@ export default function Pantry() {
setIngredientName(e.target.value)} placeholder="e.g., Avocado" + required /> {matchedIngredient && (

Matched existing ingredient ✓

@@ -190,17 +197,32 @@ export default function Pantry() { onChange={(e) => setQuantity(e.target.value)} placeholder="e.g., 5" /> - setUnit(e.target.value)} - placeholder="cans, lbs, etc." + options={[ + { value: '', label: 'Select unit…' }, + { value: 'each', label: 'each' }, + { value: 'g', label: 'g' }, + { value: 'kg', label: 'kg' }, + { value: 'oz', label: 'oz' }, + { value: 'lb', label: 'lb' }, + { value: 'ml', label: 'ml' }, + { value: 'l', label: 'l' }, + { value: 'cup', label: 'cup' }, + { value: 'tbsp', label: 'tbsp' }, + { value: 'tsp', label: 'tsp' }, + { value: 'can', label: 'can' }, + { value: 'bunch', label: 'bunch' }, + { value: 'clove', label: 'clove' }, + ]} /> - setAisle(e.target.value)} - placeholder="e.g., Produce" + options={AISLE_OPTIONS} />
@@ -126,57 +151,67 @@ export default function RecipesPage() { {/* Filters */} {showFilters && ( +
setProtein(e.target.value)} + value={pending.protein} + onChange={(e) => setPendingField('protein', e.target.value)} /> setDietary(e.target.value)} + value={pending.dietary} + onChange={(e) => setPendingField('dietary', e.target.value)} placeholder="e.g., gluten-free" /> setIngredient(e.target.value)} + value={pending.ingredient} + onChange={(e) => setPendingField('ingredient', e.target.value)} placeholder="e.g., chicken" /> setMaxTime(e.target.value)} + value={pending.maxTime} + onChange={(e) => setPendingField('maxTime', e.target.value)} placeholder="e.g., 45" /> setSpiceMax(e.target.value)} + value={pending.spiceMax} + onChange={(e) => setPendingField('spiceMax', e.target.value)} placeholder="e.g., 2" /> setCalorieMax(e.target.value)} + value={pending.calorieMax} + onChange={(e) => setPendingField('calorieMax', e.target.value)} placeholder="e.g., 600" />
+
+ + +
+
)} {/* Grid */} diff --git a/frontend/src/pages/ShoppingList.tsx b/frontend/src/pages/ShoppingList.tsx index d2d3868..d2986ba 100644 --- a/frontend/src/pages/ShoppingList.tsx +++ b/frontend/src/pages/ShoppingList.tsx @@ -9,6 +9,38 @@ import { Card, CardBody } from '../components/ui/Card' import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' +const AISLE_LABEL: Record = { + produce: 'Produce', + meat: 'Meat & Seafood', + seafood: 'Meat & Seafood', + meat_seafood: 'Meat & Seafood', + chicken: 'Meat & Seafood', + beef: 'Meat & Seafood', + pork: 'Meat & Seafood', + dairy: 'Dairy & Eggs', + eggs: 'Dairy & Eggs', + cheese: 'Dairy & Eggs', + milk: 'Dairy & Eggs', + yogurt: 'Dairy & Eggs', + pantry: 'Pantry', + canned: 'Pantry', + canned_goods: 'Pantry', + dry: 'Pantry', + snacks: 'Pantry', + frozen: 'Frozen', + bakery: 'Bakery', + bread: 'Bakery', + beverages: 'Beverages', + drinks: 'Beverages', + spices: 'Spices', + seasoning: 'Spices', + other: 'Other', +} + +function aisleDisplay(key: string): string { + return AISLE_LABEL[key.toLowerCase()] ?? key +} + function ShoppingListSkeleton() { return (
@@ -156,46 +188,46 @@ export default function ShoppingListPage() {
{/* Summary Stats */} -
+
- -
-
- + +
+
+
-
-
+
+
${shoppingList.total_estimated_cost.toFixed(2)}
-
Estimated Total
+
Estimated
- -
-
- + +
+
+
-
-
{shoppingList.items.length}
-
Total Items
+
+
{shoppingList.items.length}
+
Items
- -
-
- + +
+
+
-
-
{shoppingList.sale_items_count}
-
Items on Sale
+
+
{shoppingList.sale_items_count}
+
On Sale
@@ -207,7 +239,7 @@ export default function ShoppingListPage() { {Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
-

{aisle}

+

{aisleDisplay(aisle)}

{items.length} items
    diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b637e9f..de89374 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,3 +1,16 @@ +export const PANTRY_AISLES = [ + 'Produce', + 'Meat & Seafood', + 'Dairy & Eggs', + 'Pantry', + 'Frozen', + 'Bakery', + 'Beverages', + 'Spices', + 'Other', +] as const +export type PantryAisle = (typeof PANTRY_AISLES)[number] + export interface FamilyProfile { id: string name: string