Files
Meal-Planner/backend/alembic/versions/0015_normalize_pantry_aisles.py
T
admin ccc70aaf72 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.
2026-06-03 17:36:26 -07:00

102 lines
2.9 KiB
Python

"""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."
)