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,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;
|
||||
Reference in New Issue
Block a user