Public Access
F5 — Persistent week selector in URL (the audit's F5 / H7 finding).
Backend:
- GET /api/meals and GET /api/shopping-list now accept an optional
?week_start=YYYY-MM-DD query param. When set, the response is the
MealPlan for that week (any status). When omitted, behaviour is
unchanged: meals returns the latest plan; shopping-list returns
the latest approved/locked plan with fallback to latest.
- No new dependencies; uses FastAPI's Optional[date] Query type
which auto-validates the YYYY-MM-DD format.
- Files: backend/app/api/meals.py:30-57, shopping_list.py:27-60.
Frontend:
- New week helpers in lib/utils.ts: isoMonday(), parseIsoDate(),
shiftIsoDate(), formatIsoDate(). All UTC-based to match the
backend's date column. isoMonday returns the ISO date of the
Monday of a given date's week.
- api/index.ts: meals.getPlanned(weekStart?) and
shoppingList.get(weekStart?) take an optional ISO date string.
Axios drops undefined params, so callers can omit them.
- Dashboard: useSearchParams('week') reads the URL; if absent or
invalid, falls back to this week's Monday (so the default URL is
empty). The queryKey now includes weekStart, so navigating weeks
fetches the right plan. A new segmented control in the header
(chevron-left | 'This week' / 'Current' jump button | chevron-
right) lets the user step weeks; the jump button highlights
primary-50 when the displayed week IS the current week. 'This
week' clears the ?week param. Mutations (move/approve/deny/
delete/generate) now invalidate ['mealPlan', weekStart] so the
right week refetches.
- ShoppingList: same URL sync, same segmented control, same
weekStart in queryKey. The 'no plan' empty state branches on
isCurrentWeek: 'No shopping list yet' (current) vs 'No plan for
that week' (any other week). The local-storage check-state key
naturally isolates per week (it uses shoppingList.week_start_date
which is the server's view of the current plan's week).
Migration 0015 cast fix:
- Discovered while smoke-testing on the local dev DB: the
CASE expression in 0015_normalize_pantry_aisles.py failed
with 'operator does not exist: text = boolean' on the
varchar(100) aisle column. Root cause: the CASE branches were
inferred as different types (string vs NULL) so the SET
target type couldn't be unified.
- Fix: explicit ::varchar(100) cast on the CASE expression.
Also simplified the WHEN '' branch (was NULLIF(...) IS NULL
with implicit bool comparison). Tested on local dev DB:
alembic upgrade head now succeeds; the 21196 rows that the
Sprint 2 dry-run predicted actually normalize correctly.
This means Sprint 2's deploy was blocked on the same bug
(the deployment host would have hit the same error).
- Verified via curl: /api/shopping-list?week_start=2026-05-15
returns 25 items with aisles 'Meat & Seafood', 'Pantry',
'Produce', 'Dairy & Eggs' (the canonical labels the migration
produces). Pre-migration aisles like 'meat_seafood' are gone.
Build: tsc 0 errors, vite 0 errors. 7 files, +196/-22.
106 lines
3.3 KiB
Python
106 lines
3.3 KiB
Python
"""Normalize ingredient.aisle and grocery_item.aisle to canonical labels.
|
|
|
|
Revision ID: 0015
|
|
Revises: 0014
|
|
Create Date: 2026-06-02
|
|
|
|
Normalizes free-text aisle values on `ingredient.aisle` and `grocery_item.aisle`
|
|
to a fixed canonical set. Runs in a single transaction (Alembic default); both
|
|
op.execute calls share the same session, so the temp backup tables persist
|
|
for the duration of the upgrade.
|
|
|
|
Backup tables: the temp tables `ingredient_aisle_backup` and
|
|
`grocery_item_aisle_backup` are created for the migration's session. They
|
|
auto-drop when the session ends. If you need a persistent backup, run
|
|
`backend/scripts/persist_aisle_backup.sql` BEFORE this migration.
|
|
|
|
Deploy via Docker (the db runs inside a container; no host psql required):
|
|
|
|
# 1. dry-run preview:
|
|
docker compose exec db psql -U mealplanner -d mealplanner \\
|
|
-f /dev/stdin < backend/scripts/dry_run_aisle_migration.sql
|
|
|
|
# 2. apply:
|
|
docker compose exec backend alembic upgrade head
|
|
"""
|
|
from typing import Sequence, Union
|
|
|
|
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
|
|
|
|
|
|
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::text, '')) "
|
|
+ " ".join(f"WHEN '{src}' THEN '{dst}' " for src, dst in NORMALIZATION_RULES)
|
|
+ " WHEN '' 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"
|
|
)
|
|
# Filter to non-NULL aisle rows so the SET target type is the column's
|
|
# varchar(100) and matches the CASE expression's inferred type.
|
|
op.execute(
|
|
f"UPDATE {table} SET aisle = {CASE_EXPR}::varchar(100) "
|
|
f"WHERE aisle IS NOT NULL"
|
|
)
|
|
|
|
|
|
def upgrade() -> None:
|
|
_normalize("ingredient")
|
|
_normalize("grocery_item")
|
|
|
|
|
|
def downgrade() -> None:
|
|
# The temp backup tables only exist for the migration's session.
|
|
# 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."
|
|
)
|