Public Access
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:
@@ -68,9 +68,12 @@ NORMALIZATION_RULES = [
|
||||
("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"
|
||||
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:
|
||||
@@ -78,7 +81,12 @@ def _normalize(table: str) -> None:
|
||||
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")
|
||||
# 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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.database import get_db
|
||||
@@ -8,7 +8,7 @@ from app.models import (
|
||||
)
|
||||
from app.schemas import ShoppingListResponse, ShoppingListItem
|
||||
from datetime import date
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from collections import defaultdict
|
||||
from uuid import UUID
|
||||
|
||||
@@ -25,21 +25,33 @@ def _parse_uuid(value):
|
||||
|
||||
|
||||
@router.get("", response_model=ShoppingListResponse)
|
||||
def get_shopping_list(db: Session = Depends(get_db)):
|
||||
def get_shopping_list(
|
||||
week_start: Optional[date] = Query(
|
||||
None,
|
||||
description="ISO date of the week's Monday (YYYY-MM-DD). Omit for the latest approved/locked plan.",
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
profile = db.query(FamilyProfile).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||
|
||||
current_plan = db.query(MealPlan).filter(
|
||||
MealPlan.family_profile_id == profile.id,
|
||||
MealPlan.status.in_(['approved', 'locked'])
|
||||
).order_by(MealPlan.week_start_date.desc()).first()
|
||||
|
||||
if not current_plan:
|
||||
if week_start is not None:
|
||||
current_plan = db.query(MealPlan).filter(
|
||||
MealPlan.family_profile_id == profile.id
|
||||
MealPlan.family_profile_id == profile.id,
|
||||
MealPlan.week_start_date == week_start,
|
||||
).first()
|
||||
else:
|
||||
current_plan = db.query(MealPlan).filter(
|
||||
MealPlan.family_profile_id == profile.id,
|
||||
MealPlan.status.in_(['approved', 'locked'])
|
||||
).order_by(MealPlan.week_start_date.desc()).first()
|
||||
|
||||
if not current_plan:
|
||||
current_plan = db.query(MealPlan).filter(
|
||||
MealPlan.family_profile_id == profile.id
|
||||
).order_by(MealPlan.week_start_date.desc()).first()
|
||||
|
||||
if not current_plan:
|
||||
return ShoppingListResponse(
|
||||
week_start_date=date.today(),
|
||||
|
||||
Reference in New Issue
Block a user