Files
Meal-Planner/backend/app/api/shopping_list.py
T
admin d78bd1864e 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.
2026-06-04 12:30:49 -07:00

202 lines
7.2 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import (
MealPlan, MealPlanItem, Recipe, HomePantry,
FamilyProfile, Ingredient, GroceryItem
)
from app.schemas import ShoppingListResponse, ShoppingListItem
from datetime import date
from typing import List, Optional
from collections import defaultdict
from uuid import UUID
router = APIRouter()
def _parse_uuid(value):
if not value:
return None
try:
return UUID(value) if isinstance(value, str) else value
except ValueError:
return None
@router.get("", response_model=ShoppingListResponse)
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")
if week_start is not None:
current_plan = db.query(MealPlan).filter(
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(),
items=[],
total_estimated_cost=0.0,
sale_items_count=0,
by_aisle={}
)
pantry_items = {
p.ingredient_id: p for p in db.query(HomePantry).filter(
HomePantry.family_profile_id == profile.id
).all()
}
ingredient_map = {}
all_ingredient_ids = set()
for item in current_plan.items:
recipe = db.query(Recipe).filter(Recipe.id == item.recipe_id).first()
if recipe and recipe.ingredients:
for ing in recipe.ingredients:
ing_id = _parse_uuid(ing.get('ingredient_id'))
if ing_id:
all_ingredient_ids.add(ing_id)
if all_ingredient_ids:
ingredients = db.query(Ingredient).filter(
Ingredient.id.in_(all_ingredient_ids)
).all()
ingredient_map = {i.id: i for i in ingredients}
grocery_items = {}
if all_ingredient_ids:
groceries = db.query(GroceryItem).filter(
GroceryItem.ingredient_id.in_(all_ingredient_ids),
GroceryItem.is_on_sale == True
).all()
for g in groceries:
grocery_items[g.ingredient_id] = g
aggregated = defaultdict(lambda: {"quantity": 0.0, "unit": None, "name": ""})
for plan_item in current_plan.items:
recipe = db.query(Recipe).filter(Recipe.id == plan_item.recipe_id).first()
if recipe and recipe.ingredients:
for ing in recipe.ingredients:
ing_id = _parse_uuid(ing.get('ingredient_id'))
name = ing.get('name')
if not name and ing_id and ing_id in ingredient_map:
name = ingredient_map[ing_id].name
if not name:
name = 'Unknown'
quantity = ing.get('quantity', 1.0) or 1.0
unit = ing.get('unit')
key = name.lower()
aggregated[key]["quantity"] += quantity
aggregated[key]["unit"] = unit
aggregated[key]["name"] = name
aggregated[key]["ingredient_id"] = ing_id
shopping_items = []
total_cost = 0.0
sale_count = 0
for name_key, data in aggregated.items():
ingredient_id = data.get("ingredient_id")
in_pantry = ingredient_id and ingredient_id in pantry_items
ing_obj = ingredient_map.get(ingredient_id) if ingredient_id else None
price = float(ing_obj.typical_price) if ing_obj and ing_obj.typical_price is not None else None
sale_price = None
is_on_sale = False
if ingredient_id and ingredient_id in grocery_items:
g = grocery_items[ingredient_id]
is_on_sale = True
sale_price = float(g.current_price) if g.current_price else None
price = sale_price
sale_count += 1
if price:
total_cost += price * data["quantity"]
shopping_items.append(ShoppingListItem(
ingredient_id=ingredient_id,
name=data["name"],
quantity=data["quantity"],
unit=data["unit"],
aisle=ing_obj.aisle if ing_obj else None,
estimated_price=price,
is_on_sale=is_on_sale,
sale_price=sale_price,
in_season=grocery_items.get(ingredient_id).in_season if ingredient_id and ingredient_id in grocery_items else False,
in_pantry=in_pantry
))
by_aisle = defaultdict(list)
for item in shopping_items:
aisle = item.aisle or "Other"
by_aisle[aisle].append(item)
return ShoppingListResponse(
week_start_date=current_plan.week_start_date,
items=shopping_items,
total_estimated_cost=round(total_cost, 2),
sale_items_count=sale_count,
by_aisle=dict(by_aisle)
)
@router.get("/print")
def print_shopping_list(db: Session = Depends(get_db)):
shopping_list = get_shopping_list.__wrapped__(None, db)
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Shopping List - Week of {shopping_list.week_start_date}</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
h1 {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
.aisle {{ margin: 20px 0; }}
.aisle h2 {{ background: #f5f5f5; padding: 10px; margin: 0; }}
.item {{ padding: 8px 0; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }}
.item .name {{ flex: 1; }}
.sale {{ color: red; font-weight: bold; }}
.total {{ margin-top: 30px; font-size: 1.2em; font-weight: bold; }}
</style>
</head>
<body>
<h1>Shopping List - Week of {shopping_list.week_start_date}</h1>
<div class="total">Estimated Total: ${shopping_list.total_estimated_cost:.2f}</div>
<div class="total">Sale Items: {shopping_list.sale_items_count}</div>
"""
for aisle, items in shopping_list.by_aisle.items():
html += f'<div class="aisle"><h2>{aisle}</h2>'
for item in items:
sale_class = 'sale' if item.is_on_sale else ''
price = f'${item.sale_price:.2f}' if item.sale_price else (f'${item.estimated_price:.2f}' if item.estimated_price else '')
html += f'<div class="item {sale_class}"><span class="name">{item.name}</span><span>{item.quantity} {item.unit or ""} {price}</span></div>'
html += '</div>'
html += '</body></html>'
return {"html": html}