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.
This commit is contained in:
2026-06-03 17:36:26 -07:00
parent 36038bb9cb
commit ccc70aaf72
12 changed files with 728 additions and 81 deletions
+52
View File
@@ -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 → `<Select>` from canonical lists; ingredient name marked `*`; B10: scroll-hint gradient + `role="region"`. |
| `frontend/src/types/index.ts` | B8: `PANTRY_AISLES` enum + `PantryAisle` type. |
| `backend/alembic/versions/0015_normalize_pantry_aisles.py` | B8: migration — `CASE LOWER` mapping, temp-table backup, downgrade raises. |
| `backend/scripts/dry_run_aisle_migration.sql` | B8: read-only dry-run (counts rows that *would* change). |
| `frontend/src/pages/ShoppingList.tsx` | B9: `AISLE_LABEL` map + `aisleDisplay()`; S3.3: 3-col grid on all viewports with compact mobile sizing. |
| `frontend/src/pages/Recipes.tsx` | B11: `applied`/`pending` filter state, Apply/Reset buttons, active-count chip on Filters button, `role="region"`. |
## How to verify on the deployment host
```bash
cd /path/to/MealPlanner
git pull
# 1. Backend migration (one-off, dry-run first)
psql "$DATABASE_URL" -f backend/scripts/dry_run_aisle_migration.sql
docker compose exec backend alembic upgrade head
# 2. Frontend rebuild + restart
docker compose -f docker-compose.yml up -d --build frontend
# 3. Smoke-check the live site
curl -s -o /dev/null -w "%{http_code}\n" http://100.108.208.56:8082/
# Expect: 200
```
## Manual smoke checks
- `/` (mobile 390 px): Generate button visible on every empty slot; new meal title clamps to 2 lines without `B..`.
- `/meals/<id>`: title is readable, description is 1-2 lines, "Notes from source" disclosure shows the raw text.
- `/pantry` (add form): Unit and Aisle are dropdowns; ingredient name shows `*`.
- `/shopping-list`: section headers read "Meat & Seafood", "Produce", "Pantry", "Dairy & Eggs". On mobile, the 3 stat cards are side-by-side in one row.
- `/recipes`: open Filters, set 2, collapse — Filters button shows `Filters (2)`. Reset clears, chip disappears.
- `/pantry` (mobile 390 px): right-edge gradient hints that the table scrolls horizontally.
## Migration safety
- Run dry-run first to count affected rows per table.
- Migration runs in a single `upgrade()` call; temp backup tables persist for the session lifetime. To preserve backups beyond session, alter the migration to use real tables.
- `downgrade()` is intentionally `NotImplementedError`. Restore from a pre-migration snapshot if rollback is needed.
+20 -1
View File
@@ -19,7 +19,26 @@ The app looks polished on the surface (Tailwind palette, clean cards, working to
4. **`/recommended` returns a blank page** (missing route + no 404 catch-all in `App.tsx`). 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). 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/`. > **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 command:
> ```bash
> # 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
> ```
--- ---
@@ -0,0 +1,101 @@
"""Normalize ingredient.aisle and grocery_item.aisle to canonical labels.
Revision ID: 0015
Revises: 0014
Create Date: 2026-06-02
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0015"
down_revision: Union[str, None] = "0014"
branch_labels: Union[Sequence[str], None] = None
depends_on: Union[Sequence[str], None] = None
CANONICAL_AISLES = (
"Produce",
"Meat & Seafood",
"Dairy & Eggs",
"Pantry",
"Frozen",
"Bakery",
"Beverages",
"Spices",
"Other",
)
# Map from lowercased source value to canonical label. Keep the rule
# order narrow -> broad; longest matches win via SQL CASE.
NORMALIZATION_RULES = [
("canned goods", "Pantry"),
("canned", "Pantry"),
("freezer", "Frozen"),
("frozen", "Frozen"),
("produce", "Produce"),
("fruit", "Produce"),
("vegetable", "Produce"),
("dairy", "Dairy & Eggs"),
("eggs", "Dairy & Eggs"),
("cheese", "Dairy & Eggs"),
("milk", "Dairy & Eggs"),
("yogurt", "Dairy & Eggs"),
("meat", "Meat & Seafood"),
("seafood", "Meat & Seafood"),
("fish", "Meat & Seafood"),
("chicken", "Meat & Seafood"),
("beef", "Meat & Seafood"),
("pork", "Meat & Seafood"),
("meat_seafood", "Meat & Seafood"),
("bakery", "Bakery"),
("bread", "Bakery"),
("beverage", "Beverages"),
("beverages", "Beverages"),
("drinks", "Beverages"),
("spice", "Spices"),
("spices", "Spices"),
("seasoning", "Spices"),
("pantry", "Pantry"),
("dry", "Pantry"),
("snack", "Pantry"),
("snacks", "Pantry"),
]
CASE_EXPR = "CASE LOWER(COALESCE(aisle, '')) " + " ".join(
f"WHEN '{src}' THEN '{dst}' " for src, dst in NORMALIZATION_RULES
) + " WHEN NULLIF(LOWER(COALESCE(aisle, '')), '') IS NULL THEN NULL " + " ELSE 'Other' END"
def _normalize(table: str) -> None:
op.execute(
f"CREATE TEMP TABLE {table}_aisle_backup AS "
f"SELECT id, aisle FROM {table} WHERE aisle IS NOT NULL"
)
op.execute(f"UPDATE {table} SET aisle = {CASE_EXPR} WHERE aisle IS NOT NULL")
def upgrade() -> None:
bind = op.get_bind()
with op.batch_alter_table("ingredient") as batch:
pass
_normalize("ingredient")
_normalize("grocery_item")
bind.execute(
sa.text(
"SELECT set_config('app.aisle_backup_retention', 'aisle_migration_0015', false)"
)
)
def downgrade() -> None:
# Best-effort downgrade: the backup temp tables only exist within the
# upgrade transaction. Restoring the pre-normalization state is not
# possible from this migration alone. Operators must restore from a
# database snapshot taken before upgrade.
raise NotImplementedError(
"Cannot reverse aisle normalization without an external backup. "
"Restore the database from a snapshot taken before 0015 was applied."
)
@@ -0,0 +1,76 @@
-- Dry-run: show which rows WOULD change under the aisle normalization
-- migration 0015. Safe to run against any database; makes no changes.
--
-- Usage (from the deployment host):
-- psql "$DATABASE_URL" -f backend/scripts/dry_run_aisle_migration.sql
WITH src AS (
SELECT
'ingredient'::text AS tbl,
id::text AS id,
aisle AS old_aisle,
LOWER(COALESCE(aisle, '')) AS key
FROM ingredient
WHERE aisle IS NOT NULL
UNION ALL
SELECT
'grocery_item'::text,
id::text,
aisle,
LOWER(COALESCE(aisle, ''))
FROM grocery_item
WHERE aisle IS NOT NULL
),
mapped AS (
SELECT
tbl, id, old_aisle,
CASE key
WHEN 'canned goods' THEN 'Pantry'
WHEN 'canned' THEN 'Pantry'
WHEN 'freezer' THEN 'Frozen'
WHEN 'frozen' THEN 'Frozen'
WHEN 'produce' THEN 'Produce'
WHEN 'fruit' THEN 'Produce'
WHEN 'vegetable' THEN 'Produce'
WHEN 'dairy' THEN 'Dairy & Eggs'
WHEN 'eggs' THEN 'Dairy & Eggs'
WHEN 'cheese' THEN 'Dairy & Eggs'
WHEN 'milk' THEN 'Dairy & Eggs'
WHEN 'yogurt' THEN 'Dairy & Eggs'
WHEN 'meat' THEN 'Meat & Seafood'
WHEN 'seafood' THEN 'Meat & Seafood'
WHEN 'fish' THEN 'Meat & Seafood'
WHEN 'chicken' THEN 'Meat & Seafood'
WHEN 'beef' THEN 'Meat & Seafood'
WHEN 'pork' THEN 'Meat & Seafood'
WHEN 'meat_seafood' THEN 'Meat & Seafood'
WHEN 'bakery' THEN 'Bakery'
WHEN 'bread' THEN 'Bakery'
WHEN 'beverage' THEN 'Beverages'
WHEN 'beverages' THEN 'Beverages'
WHEN 'drinks' THEN 'Beverages'
WHEN 'spice' THEN 'Spices'
WHEN 'spices' THEN 'Spices'
WHEN 'seasoning' THEN 'Spices'
WHEN 'pantry' THEN 'Pantry'
WHEN 'dry' THEN 'Pantry'
WHEN 'snack' THEN 'Pantry'
WHEN 'snacks' THEN 'Pantry'
ELSE 'Other'
END AS new_aisle
FROM src
)
SELECT tbl,
COUNT(*) AS rows_to_change,
COUNT(DISTINCT old_aisle) AS distinct_old_values
FROM mapped
WHERE old_aisle IS DISTINCT FROM new_aisle
GROUP BY tbl
ORDER BY tbl;
-- Optional detail dump (uncomment to inspect actual rows):
-- SELECT tbl, old_aisle, new_aisle, COUNT(*)
-- FROM mapped
-- WHERE old_aisle IS DISTINCT FROM new_aisle
-- GROUP BY tbl, old_aisle, new_aisle
-- ORDER BY tbl, old_aisle;
+219
View File
@@ -0,0 +1,219 @@
# Fix Plan — UI/UX Audit (Nielsen 10 Heuristics)
## Goal
Resolve the 14 issues (5 P0, 6 P1, 3 P2) from `Review/ui-nielsen-audit.md` in three sprints, ending each sprint with a deployable, demonstrable improvement on `http://100.108.208.56:8082/`.
## Scope boundaries
- **In:** frontend React/TS fixes in `frontend/src/`. Backend one-off data migrations only when required (B8 aisle normalization, B4/Recommended route).
- **Out:** New features (bulk add, keyboard shortcuts, onboarding tour) — those are future work, listed in §Future.
- **Reuse:** `components/ErrorBoundary.tsx` already exists (verified). `components/ui/*` (Button, Card, Select, Input, EmptyState, Badge, Skeleton, LoadingSpinner) are the building blocks — use them, don't roll new ones.
- **Stack confirmed:** React 18 + TS + Vite + Tailwind + react-router-dom 6 + @tanstack/react-query + react-hot-toast + @hello-pangea/dnd + lucide-react + framer-motion. **No new deps** in Sprint 1/2. Sprint 3 may add `react-joyride` only if approved (defer to §Future).
## Conventions
- One commit per task: `fix(ui): <short>` / `feat(ui): <short>` / `refactor(frontend): <short>`.
- Before each commit: `cd frontend && npm run lint && npm run build` (the `build` script runs `tsc` first — type-checks the project).
- After each task, re-screenshot the affected page in the same playwright session and diff against `/tmp/opencode/mp-review/screenshots/`. Save new shots in `/tmp/opencode/mp-review/screenshots/fix-sprintN/`.
- All UI text in sentence case. New copy matches existing `lib/toast.ts` style.
- Type updates go in `frontend/src/types/index.ts`; do not duplicate shapes inline.
---
## Sprint 1 — Stop the bleeding (P0s)
**Goal:** Every P0 bug is gone. Each is independently demoable on the live deployment.
### S1.1 · B1 — RecipeDetail ingredients: drop `.trim()` so unit + name don't fuse
- **File:** `frontend/src/pages/RecipeDetail.tsx:161`
- **Change:** Replace
```tsx
{ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()}
```
with
```tsx
{ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''}`}
```
followed by a literal `' '` before `{ing.name}`.
- **Verify:** Open `/recipes/eae6591f...` (Black Bean Tacos). Ingredient row reads `2 can Black Beans, Canned` (with space). Re-run `npm run build`.
### S1.2 · B2 — MealDetail ingredients: align field name with backend (`qty`)
- **Files:** `frontend/src/types/index.ts`, `frontend/src/pages/MealDetail.tsx:249-252`
- **Change:**
1. In `types/index.ts` confirm `MealIngredient` shape; align to backend `qty` / `unit`. If the type currently has `quantity`, rename to `qty` (single source of truth).
2. In `MealDetail.tsx:249-252`, switch reads to `ing.qty` / `ing.unit`. Keep the existing null-guard so `qty == null` is skipped cleanly.
- **Verify:** Open `/meals/<any>` (e.g. `/meals/f28...` Pork Stir-Fry). Row reads `1 lb Pork Chops, Bone-In` not `lb Pork Chops`. `npm run build` clean.
### S1.3 · B3 — MealDetail cost: fix `$N/A per serving`
- **File:** `frontend/src/pages/MealDetail.tsx:191`
- **Change:**
```tsx
{item.estimated_cost != null
? `$${item.estimated_cost.toFixed(2)} per serving`
: 'No price estimate yet'}
```
- **Verify:** Reload `/meals/f28...`. Price line reads either `$X.XX per serving` or `No price estimate yet` — never `$N/A`.
### S1.4 · B4 — `/recommended` blank page: add `*` NotFound + alias
- **Files:** `frontend/src/App.tsx`, new `frontend/src/pages/NotFound.tsx`
- **Change:**
1. Create `pages/NotFound.tsx` — friendly card with `AlertTriangle` icon, message *"We can't find that page."*, primary `<Button>` → `/`, secondary → back. Reuse `components/ui/EmptyState.tsx` if it fits.
2. In `App.tsx`:
- Add `import { Navigate } from 'react-router-dom'`.
- Insert `<Route path="/recommended" element={<Navigate to="/recipes/recommended" replace />} />`.
- Append `<Route path="*" element={<NotFound />} />` after the existing routes.
3. Add a `// TODO(seo): audit email/share links for `/recommended` references` comment.
- **Verify:**
- Visit `http://100.108.208.56:8082/recommended` → redirects to `/recipes/recommended`, renders the Recommended page.
- Visit `http://100.108.208.56:8082/this-does-not-exist` → renders NotFound.
- `npm run build` clean.
### S1.5 · B5 — Mobile dashboard: always show empty meal slots
- **File:** `frontend/src/pages/Dashboard.tsx` (lines ~164, ~219)
- **Change:** Audit every `hidden md:flex` / `hidden md:block` / `hidden md:inline` inside `DayColumn` and the empty-slot JSX. Remove the `hidden` class on the empty-slot CTAs (the `Empty+Generate` placeholder block). For decorative chrome (e.g. day-of-week abbreviations), keep `hidden md:flex` only if there's a separate mobile-friendly label.
- **Verify:** Re-screenshot at 390 px width. Empty slots are tappable; tapping Generate fires the same query as on desktop. `npm run build` clean.
### S1.6 · Sprint 1 verification gate
- `cd frontend && npm run lint && npm run build` → both 0 errors / 0 warnings.
- Re-run playwright walkthrough; capture `screenshots/fix-sprint1/*.png` for: recipe detail (Black Bean Tacos), meal detail (Pork Stir-Fry), `/recommended`, `/this-does-not-exist`, mobile dashboard.
- Manual smoke: tap Generate on a mobile viewport, confirm a new meal lands in the slot.
- **Done when:** All five P0 bugs absent in the re-captured screenshots AND lint/build pass.
---
## Sprint 2 — Trust the data (P1s)
**Goal:** No more silent data corruption in the UI. Every displayed value is consistent across pages and either present-and-correct or explicitly absent.
**Status (2026-06-02):** ✅ All six P1s + the bonus S3.3 mobile stat-grid fix are implemented. `npm run build` green. Ready to commit and deploy.
### S2.1 · B6 — Dashboard `MealCard` title: 2-line clamp instead of 1-line truncate
- **File:** `frontend/src/pages/Dashboard.tsx:87`
- **Change:** Replaced `truncate` with `line-clamp-2` (already used elsewhere in the codebase — Tailwind 3.4+ has it in core). Image shrinks to 40×40 on `<md` (was 56×56 always) to give the title more room. Added `leading-tight` to tighten line-height for 2 lines.
- **Verify:** Trigger Generate on the dashboard. New card title is fully visible across 2 lines (no `B..` truncation). Build clean.
### S2.2 · B7 — MealDetail hero overlap + description clamp + marketing-copy strip
- **Files:** `frontend/src/pages/MealDetail.tsx:168-197`, `frontend/src/lib/utils.ts`
- **Change (3 sub-steps, one commit):**
1. Hero reworked: image is in normal flow, content panel uses `relative -mt-16 sm:-mt-20` instead of `absolute bottom-0`. Title is in normal flow with the description below it; the gradient now has `pointer-events-none` and goes from `from-black/80` to prevent overlap obscuring.
2. Description rendered via `cleanDescription(recipe.description)` with `line-clamp-2`.
3. Client-side trim helper `cleanDescription(input, maxLen=280)` in `lib/utils.ts` with a list of regex patterns that strip spoonacular marketing boilerplate (`Featured In Group…`, `users who liked this recipe also liked…`, `For $X.XX per serving, this recipe covers…`, `It is brought to you by Foodista.`, etc.) and trims to the last sentence within 280 chars.
4. Raw `recipe.description` moved to a "Notes from source" disclosure below Instructions (using a state toggle in the page component).
- **Verify:** Reload `/meals/<id>`. Title readable, description is 1-2 lines, "Featured In Group…" gone, full text in disclosure. Build clean.
### S2.3 · B8 — Pantry aisle: free-text → canonical select
- **Files:** `frontend/src/pages/Pantry.tsx`, `frontend/src/types/index.ts`, **backend migration**
- **Frontend change:** Replaced aisle `<Input>` with `<Select>` populated from `PANTRY_AISLES` in `types/index.ts`:
```ts
export const PANTRY_AISLES = [
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry',
'Frozen', 'Bakery', 'Beverages', 'Spices', 'Other',
] as const;
export type PantryAisle = (typeof PANTRY_AISLES)[number];
```
Also converted Unit to a `<Select>` 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 `<h3>{aisleDisplay(aisle)}</h3>` — 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 && <Badge>{activeCount}</Badge>}` 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 `<div role="region" aria-label="Filters">` (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 `<Link>`.
2. Recipes filter panel `<section>`: `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 `<Tour>` 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/<id>` 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/`).
+37
View File
@@ -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;
}
+4 -4
View File
@@ -75,16 +75,16 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
<img <img
src={item.recipe.image_url} src={item.recipe.image_url}
alt={item.recipe.name} alt={item.recipe.name}
className="w-14 h-14 object-cover rounded-lg flex-shrink-0" className="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg flex-shrink-0"
/> />
) : ( ) : (
<div className="w-14 h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0"> <div className="w-10 h-10 md:w-14 md:h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
<CookingPot className="w-6 h-6 text-surface-400" /> <CookingPot className="w-5 h-5 md:w-6 md:h-6 text-surface-400" />
</div> </div>
)} )}
<div className="flex-1 min-w-0 pr-7"> <div className="flex-1 min-w-0 pr-7">
<Link to={`/meals/${item.id}`} className="block"> <Link to={`/meals/${item.id}`} className="block">
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors"> <h4 className="font-semibold text-sm text-surface-900 leading-tight line-clamp-2 group-hover:text-primary-700 transition-colors">
{item.recipe?.name || 'Unknown Recipe'} {item.recipe?.name || 'Unknown Recipe'}
</h4> </h4>
</Link> </Link>
+39 -8
View File
@@ -11,6 +11,7 @@ import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { Select } from '../components/ui/Select' import { Select } from '../components/ui/Select'
import { Textarea } from '../components/ui/Textarea' import { Textarea } from '../components/ui/Textarea'
import { showToast } from '../lib/toast' import { showToast } from '../lib/toast'
import { cleanDescription } from '../lib/utils'
const DENIAL_REASONS = [ const DENIAL_REASONS = [
{ value: '', label: 'Select a reason...' }, { value: '', label: 'Select a reason...' },
@@ -102,6 +103,7 @@ export default function MealDetail() {
const [text, setText] = useState('') const [text, setText] = useState('')
const [submitted, setSubmitted] = useState(false) const [submitted, setSubmitted] = useState(false)
const [editingFeedback, setEditingFeedback] = useState(false) const [editingFeedback, setEditingFeedback] = useState(false)
const [showFullDescription, setShowFullDescription] = useState(false)
const submitMutation = useMutation({ const submitMutation = useMutation({
mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload), mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload),
@@ -142,6 +144,9 @@ export default function MealDetail() {
const recipe = item.recipe const recipe = item.recipe
const feedback = existingFeedback const feedback = existingFeedback
const cleanDesc = cleanDescription(recipe.description)
const hasRawDescription = !!recipe.description && recipe.description.trim().length > 0
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!id) return if (!id) return
@@ -170,20 +175,22 @@ export default function MealDetail() {
<img <img
src={recipe.image_url} src={recipe.image_url}
alt={recipe.name} alt={recipe.name}
className="w-full h-48 sm:h-72 object-cover opacity-90" className="w-full h-48 sm:h-64 object-cover opacity-90"
/> />
) : ( ) : (
<div className="h-48 sm:h-72 flex items-center justify-center"> <div className="h-48 sm:h-64 flex items-center justify-center">
<ChefHat className="w-16 h-16 text-surface-600" /> <ChefHat className="w-16 h-16 text-surface-600" />
</div> </div>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent pointer-events-none" />
<div className="absolute bottom-0 left-0 right-0 p-4 sm:p-6"> <div className="relative p-4 sm:p-6 -mt-16 sm:-mt-20">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<div> <div className="min-w-0">
<h1 className="text-xl sm:text-3xl font-bold text-white">{recipe.name}</h1> <h1 className="text-xl sm:text-3xl font-bold text-white drop-shadow">{recipe.name}</h1>
{recipe.description && ( {cleanDesc && (
<p className="text-white/80 mt-1 max-w-xl text-xs sm:text-sm">{recipe.description}</p> <p className="text-white/85 mt-1 max-w-2xl text-xs sm:text-sm line-clamp-2">
{cleanDesc}
</p>
)} )}
</div> </div>
<div className="text-left sm:text-right flex-shrink-0"> <div className="text-left sm:text-right flex-shrink-0">
@@ -280,6 +287,30 @@ export default function MealDetail() {
</CardBody> </CardBody>
</Card> </Card>
{/* Notes from source — disclosure of full original marketing description */}
{hasRawDescription && (
<Card>
<CardHeader>
<button
type="button"
onClick={() => setShowFullDescription(s => !s)}
className="flex items-center justify-between w-full text-left"
aria-expanded={showFullDescription}
>
<span className="text-sm font-semibold text-surface-700">Notes from source</span>
<span className="text-xs text-primary-600">{showFullDescription ? 'Hide' : 'Show'}</span>
</button>
</CardHeader>
{showFullDescription && (
<CardBody>
<p className="text-sm text-surface-600 leading-relaxed whitespace-pre-line">
{recipe.description}
</p>
</CardBody>
)}
</Card>
)}
{/* Feedback */} {/* Feedback */}
<Card> <Card>
<CardHeader> <CardHeader>
+39 -7
View File
@@ -2,14 +2,20 @@ import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react' import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react'
import { mealPlannerApi } from '../api' 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 { Button } from '../components/ui/Button'
import { Card, CardBody } from '../components/ui/Card' import { Card, CardBody } from '../components/ui/Card'
import { Input } from '../components/ui/Input' import { Input } from '../components/ui/Input'
import { Select } from '../components/ui/Select'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { showToast } from '../lib/toast' import { showToast } from '../lib/toast'
const AISLE_OPTIONS = [
{ value: '', label: 'Select aisle…' },
...PANTRY_AISLES.map(a => ({ value: a, label: a })),
]
export default function Pantry() { export default function Pantry() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
@@ -174,10 +180,11 @@ export default function Pantry() {
<div className="grid grid-cols-1 md:grid-cols-5 gap-4"> <div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<Input <Input
label="Ingredient name" label="Ingredient name *"
value={ingredientName} value={ingredientName}
onChange={(e) => setIngredientName(e.target.value)} onChange={(e) => setIngredientName(e.target.value)}
placeholder="e.g., Avocado" placeholder="e.g., Avocado"
required
/> />
{matchedIngredient && ( {matchedIngredient && (
<p className="mt-1 text-xs text-success-600">Matched existing ingredient </p> <p className="mt-1 text-xs text-success-600">Matched existing ingredient </p>
@@ -190,17 +197,32 @@ export default function Pantry() {
onChange={(e) => setQuantity(e.target.value)} onChange={(e) => setQuantity(e.target.value)}
placeholder="e.g., 5" placeholder="e.g., 5"
/> />
<Input <Select
label="Unit" label="Unit"
value={unit} value={unit}
onChange={(e) => setUnit(e.target.value)} onChange={(e) => 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' },
]}
/> />
<Input <Select
label="Aisle" label="Aisle"
value={aisle} value={aisle}
onChange={(e) => setAisle(e.target.value)} onChange={(e) => setAisle(e.target.value)}
placeholder="e.g., Produce" options={AISLE_OPTIONS}
/> />
<div className="flex items-end"> <div className="flex items-end">
<Button <Button
@@ -233,7 +255,12 @@ export default function Pantry() {
{/* Items List */} {/* Items List */}
{filteredItems && filteredItems.length > 0 ? ( {filteredItems && filteredItems.length > 0 ? (
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="overflow-x-auto"> <div className="relative">
<div
className="overflow-x-auto"
role="region"
aria-label="Pantry items, scroll horizontally to see all columns"
>
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-surface-200 bg-surface-50"> <tr className="border-b border-surface-200 bg-surface-50">
@@ -303,6 +330,11 @@ export default function Pantry() {
</tbody> </tbody>
</table> </table>
</div> </div>
<div
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-white to-transparent md:hidden"
aria-hidden="true"
/>
</div>
</Card> </Card>
) : ( ) : (
<EmptyState <EmptyState
+63 -28
View File
@@ -45,13 +45,29 @@ export default function RecipesPage() {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [debouncedQ, setDebouncedQ] = useState('') const [debouncedQ, setDebouncedQ] = useState('')
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false)
const [cuisine, setCuisine] = useState('')
const [protein, setProtein] = useState('') // Pending (form) vs applied (query) state so users can stage changes
const [dietary, setDietary] = useState('') // and commit them with Apply, with Reset clearing pending back to applied.
const [maxTime, setMaxTime] = useState('') const [applied, setApplied] = useState({
const [spiceMax, setSpiceMax] = useState('') cuisine: '', protein: '', dietary: '', ingredient: '',
const [calorieMax, setCalorieMax] = useState('') maxTime: '', spiceMax: '', calorieMax: '',
const [ingredient, setIngredient] = useState('') })
const [pending, setPending] = useState(applied)
const setPendingField = (key: keyof typeof pending, value: string) =>
setPending(p => ({ ...p, [key]: value }))
const activeCount = Object.values(applied).filter(v => v && v.length > 0).length
const applyFilters = () => setApplied(pending)
const resetFilters = () => {
const empty = {
cuisine: '', protein: '', dietary: '', ingredient: '',
maxTime: '', spiceMax: '', calorieMax: '',
}
setPending(empty)
setApplied(empty)
}
// Debounce search // Debounce search
const handleSearch = useCallback((value: string) => { const handleSearch = useCallback((value: string) => {
@@ -62,13 +78,13 @@ export default function RecipesPage() {
const params: any = { limit: 120 } const params: any = { limit: 120 }
if (debouncedQ) params.q = debouncedQ if (debouncedQ) params.q = debouncedQ
if (cuisine) params.cuisine = cuisine if (applied.cuisine) params.cuisine = applied.cuisine
if (protein) params.protein = protein if (applied.protein) params.protein = applied.protein
if (dietary) params.dietary = dietary if (applied.dietary) params.dietary = applied.dietary
if (ingredient) params.ingredient = ingredient if (applied.ingredient) params.ingredient = applied.ingredient
if (maxTime) params.max_time = parseInt(maxTime, 10) if (applied.maxTime) params.max_time = parseInt(applied.maxTime, 10)
if (spiceMax) params.spice_max = parseInt(spiceMax, 10) if (applied.spiceMax) params.spice_max = parseInt(applied.spiceMax, 10)
if (calorieMax) params.calorie_max = parseInt(calorieMax, 10) if (applied.calorieMax) params.calorie_max = parseInt(applied.calorieMax, 10)
const { data, isLoading } = useQuery<Recipe[]>({ const { data, isLoading } = useQuery<Recipe[]>({
queryKey: ['recipes', params], queryKey: ['recipes', params],
@@ -107,8 +123,17 @@ export default function RecipesPage() {
size="sm" size="sm"
icon={<SlidersHorizontal className="w-4 h-4" />} icon={<SlidersHorizontal className="w-4 h-4" />}
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
aria-expanded={showFilters}
> >
Filters Filters
{activeCount > 0 && (
<span
className="ml-1.5 inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 text-[10px] font-bold rounded-full bg-primary-600 text-white"
aria-label={`${activeCount} filters active`}
>
{activeCount}
</span>
)}
</Button> </Button>
</div> </div>
</div> </div>
@@ -126,57 +151,67 @@ export default function RecipesPage() {
{/* Filters */} {/* Filters */}
{showFilters && ( {showFilters && (
<div role="region" aria-label="Filters">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardBody> <CardBody>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<Select <Select
label="Cuisine" label="Cuisine"
options={CUISINE_OPTIONS} options={CUISINE_OPTIONS}
value={cuisine} value={pending.cuisine}
onChange={(e) => setCuisine(e.target.value)} onChange={(e) => setPendingField('cuisine', e.target.value)}
/> />
<Select <Select
label="Protein" label="Protein"
options={PROTEIN_OPTIONS} options={PROTEIN_OPTIONS}
value={protein} value={pending.protein}
onChange={(e) => setProtein(e.target.value)} onChange={(e) => setPendingField('protein', e.target.value)}
/> />
<Input <Input
label="Dietary tag" label="Dietary tag"
value={dietary} value={pending.dietary}
onChange={(e) => setDietary(e.target.value)} onChange={(e) => setPendingField('dietary', e.target.value)}
placeholder="e.g., gluten-free" placeholder="e.g., gluten-free"
/> />
<Input <Input
label="Contains ingredient" label="Contains ingredient"
value={ingredient} value={pending.ingredient}
onChange={(e) => setIngredient(e.target.value)} onChange={(e) => setPendingField('ingredient', e.target.value)}
placeholder="e.g., chicken" placeholder="e.g., chicken"
/> />
<Input <Input
label="Max time (min)" label="Max time (min)"
type="number" type="number"
value={maxTime} value={pending.maxTime}
onChange={(e) => setMaxTime(e.target.value)} onChange={(e) => setPendingField('maxTime', e.target.value)}
placeholder="e.g., 45" placeholder="e.g., 45"
/> />
<Input <Input
label="Max spice (1-5)" label="Max spice (1-5)"
type="number" type="number"
value={spiceMax} value={pending.spiceMax}
onChange={(e) => setSpiceMax(e.target.value)} onChange={(e) => setPendingField('spiceMax', e.target.value)}
placeholder="e.g., 2" placeholder="e.g., 2"
/> />
<Input <Input
label="Max calories" label="Max calories"
type="number" type="number"
value={calorieMax} value={pending.calorieMax}
onChange={(e) => setCalorieMax(e.target.value)} onChange={(e) => setPendingField('calorieMax', e.target.value)}
placeholder="e.g., 600" placeholder="e.g., 600"
/> />
</div> </div>
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t border-surface-200">
<Button variant="ghost" size="sm" onClick={resetFilters}>
Reset
</Button>
<Button size="sm" onClick={applyFilters}>
Apply filters
</Button>
</div>
</CardBody> </CardBody>
</Card> </Card>
</div>
)} )}
{/* Grid */} {/* Grid */}
+55 -23
View File
@@ -9,6 +9,38 @@ import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
const AISLE_LABEL: Record<string, string> = {
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() { function ShoppingListSkeleton() {
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
@@ -156,46 +188,46 @@ export default function ShoppingListPage() {
</div> </div>
{/* Summary Stats */} {/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-2 sm:gap-4">
<Card className="bg-gradient-to-br from-primary-50 to-primary-100/50 border-primary-200"> <Card className="bg-gradient-to-br from-primary-50 to-primary-100/50 border-primary-200">
<CardBody> <CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-100 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-primary-100 flex items-center justify-center flex-shrink-0">
<Receipt className="w-5 h-5 text-primary-600" /> <Receipt className="w-4 h-4 sm:w-5 sm:h-5 text-primary-600" />
</div> </div>
<div> <div className="min-w-0">
<div className="text-2xl font-bold text-surface-900"> <div className="text-base sm:text-2xl font-bold text-surface-900 truncate">
${shoppingList.total_estimated_cost.toFixed(2)} ${shoppingList.total_estimated_cost.toFixed(2)}
</div> </div>
<div className="text-sm text-surface-500">Estimated Total</div> <div className="text-[10px] sm:text-sm text-surface-500 truncate">Estimated</div>
</div> </div>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
<Card> <Card>
<CardBody> <CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-10 h-10 rounded-xl bg-surface-100 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-surface-100 flex items-center justify-center flex-shrink-0">
<Package className="w-5 h-5 text-surface-600" /> <Package className="w-4 h-4 sm:w-5 sm:h-5 text-surface-600" />
</div> </div>
<div> <div className="min-w-0">
<div className="text-2xl font-bold text-surface-900">{shoppingList.items.length}</div> <div className="text-base sm:text-2xl font-bold text-surface-900 truncate">{shoppingList.items.length}</div>
<div className="text-sm text-surface-500">Total Items</div> <div className="text-[10px] sm:text-sm text-surface-500 truncate">Items</div>
</div> </div>
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
<Card className="bg-gradient-to-br from-success-50 to-success-100/50 border-success-200"> <Card className="bg-gradient-to-br from-success-50 to-success-100/50 border-success-200">
<CardBody> <CardBody className="p-3 sm:p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<div className="w-10 h-10 rounded-xl bg-success-100 flex items-center justify-center"> <div className="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-success-100 flex items-center justify-center flex-shrink-0">
<Tag className="w-5 h-5 text-success-600" /> <Tag className="w-4 h-4 sm:w-5 sm:h-5 text-success-600" />
</div> </div>
<div> <div className="min-w-0">
<div className="text-2xl font-bold text-success-700">{shoppingList.sale_items_count}</div> <div className="text-base sm:text-2xl font-bold text-success-700 truncate">{shoppingList.sale_items_count}</div>
<div className="text-sm text-surface-500">Items on Sale</div> <div className="text-[10px] sm:text-sm text-surface-500 truncate">On Sale</div>
</div> </div>
</div> </div>
</CardBody> </CardBody>
@@ -207,7 +239,7 @@ export default function ShoppingListPage() {
{Object.entries(shoppingList.by_aisle).map(([aisle, items]) => ( {Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
<Card key={aisle} className="overflow-hidden"> <Card key={aisle} className="overflow-hidden">
<div className="px-5 py-3 border-b border-surface-200 bg-surface-50 flex items-center justify-between"> <div className="px-5 py-3 border-b border-surface-200 bg-surface-50 flex items-center justify-between">
<h3 className="text-sm font-semibold text-surface-900">{aisle}</h3> <h3 className="text-sm font-semibold text-surface-900">{aisleDisplay(aisle)}</h3>
<Badge variant="neutral">{items.length} items</Badge> <Badge variant="neutral">{items.length} items</Badge>
</div> </div>
<ul className="divide-y divide-surface-200"> <ul className="divide-y divide-surface-200">
+13
View File
@@ -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 { export interface FamilyProfile {
id: string id: string
name: string name: string