feat(ui): URL week selector + aisle-migration 0015 cast fix (Sprint 5 F5)

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.
This commit is contained in:
2026-06-04 12:30:49 -07:00
parent 62dfc1eb4a
commit d78bd1864e
7 changed files with 215 additions and 40 deletions
+25 -4
View File
@@ -20,6 +20,7 @@ from app.services.feedback_analyzer import FeedbackAnalyzer
from uuid import UUID
from typing import List, Optional
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from uuid import UUID
from typing import List, Optional
import random
@@ -28,14 +29,23 @@ router = APIRouter()
@router.get("", response_model=Optional[MealPlanResponse])
def get_planned_meals(db: Session = Depends(get_db)):
def get_planned_meals(
week_start: Optional[date] = Query(
None,
description="ISO date of the week's Monday (YYYY-MM-DD). Omit for the most recent plan.",
),
db: Session = Depends(get_db),
):
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="Family profile not found")
meal_plan = db.query(MealPlan).filter(
MealPlan.family_profile_id == profile.id
).order_by(MealPlan.week_start_date.desc()).first()
query = db.query(MealPlan).filter(MealPlan.family_profile_id == profile.id)
if week_start is not None:
query = query.filter(MealPlan.week_start_date == week_start)
meal_plan = query.first()
else:
meal_plan = query.order_by(MealPlan.week_start_date.desc()).first()
if not meal_plan:
return None
@@ -361,6 +371,17 @@ def deny_meal_item(item_id: UUID, db: Session = Depends(get_db)):
return {"message": "Meal denied", "item": item}
@router.delete("/items/{item_id}")
def delete_meal_item(item_id: UUID, db: Session = Depends(get_db)):
"""Delete a meal plan item entirely."""
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
db.delete(item)
db.commit()
return {"message": "Meal deleted"}
@router.post("/{meal_plan_id}/generate-item")
def generate_single_item(
meal_plan_id: UUID,