Public Access
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:
@@ -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.
|
||||||
@@ -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
@@ -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/`).
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user