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
@@ -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;