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
@@ -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:
+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,
+15 -3
View File
@@ -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,11 +25,23 @@ 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")
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'])
+3 -2
View File
@@ -36,7 +36,7 @@ export const mealPlannerApi = {
},
meals: {
getPlanned: () => api.get('/meals'),
getPlanned: (weekStart?: string) => api.get('/meals', { params: { week_start: weekStart } }),
get: (id: string) => api.get(`/meals/${id}`),
getItem: (id: string) => api.get(`/meals/items/${id}`),
create: (data: any) => api.post('/meals', data),
@@ -47,6 +47,7 @@ export const mealPlannerApi = {
moveItem: (itemId: string, newDayOfWeek: number, newMealType: string) => api.put(`/meals/items/${itemId}/move`, null, { params: { new_day_of_week: newDayOfWeek, new_meal_type: newMealType } }),
approveItem: (itemId: string) => api.post(`/meals/items/${itemId}/approve`),
denyItem: (itemId: string) => api.post(`/meals/items/${itemId}/deny`),
deleteItem: (itemId: string) => api.delete(`/meals/items/${itemId}`),
generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) =>
api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }),
},
@@ -59,7 +60,7 @@ export const mealPlannerApi = {
},
shoppingList: {
get: () => api.get('/shopping-list'),
get: (weekStart?: string) => api.get('/shopping-list', { params: { week_start: weekStart } }),
getPrint: () => api.get('/shopping-list/print'),
},
+35
View File
@@ -35,3 +35,38 @@ export function cleanDescription(input: string | undefined | null, maxLen = 280)
}
return s;
}
/* ------------------------------------------------------------------ */
/* Week helpers (used by F5 URL week selector) */
/* ------------------------------------------------------------------ */
/** Return the ISO date (YYYY-MM-DD) of the Monday of the given date's week. */
export function isoMonday(d: Date = new Date()): string {
const day = d.getUTCDay() // 0=Sun, 1=Mon, ..., 6=Sat
// Treat Sunday as end-of-week (offset 6), Mon-Sat as offset (day-1).
const offset = day === 0 ? 6 : day - 1
const monday = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - offset))
return monday.toISOString().slice(0, 10)
}
/** Parse a YYYY-MM-DD string into a Date (UTC midnight). Returns null if invalid. */
export function parseIsoDate(s: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null
const d = new Date(`${s}T00:00:00Z`)
return Number.isNaN(d.getTime()) ? null : d
}
/** Shift an ISO date string by `days` calendar days. */
export function shiftIsoDate(s: string, days: number): string {
const d = parseIsoDate(s)
if (!d) return s
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/** Format a YYYY-MM-DD string for display: "Jun 1, 2026". */
export function formatIsoDate(s: string): string {
const d = parseIsoDate(s)
if (!d) return s
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
+47 -12
View File
@@ -1,12 +1,13 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import {
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
CookingPot, CalendarDays, ShoppingCart, ChevronRight, ChevronLeft, Sparkles, Loader2,
GripVertical, X
} from 'lucide-react'
import toast from 'react-hot-toast'
import { showToast, showApiError } from '../lib/toast'
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import {
DragDropContext,
Droppable,
@@ -306,16 +307,26 @@ function VoteEmailButton() {
/* ------------------------------------------------------------------ */
export default function Dashboard() {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const weekParam = searchParams.get('week')
const parsedWeek = weekParam ? parseIsoDate(weekParam) : null
const weekStart = weekParam && parsedWeek
? weekParam
: isoMonday()
const isCurrentWeek = weekStart === isoMonday()
const navigateWeek = (next: string) => {
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true })
}
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
queryKey: ['mealPlan'],
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
queryKey: ['mealPlan', weekStart],
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
})
async function handleDrop(itemId: string, newDay: number, newType: string) {
try {
await mealPlannerApi.meals.moveItem(itemId, newDay, newType)
toast.success('Meal moved')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch {
// Error toast fires from the global MutationCache handler.
}
@@ -333,7 +344,7 @@ export default function Dashboard() {
try {
await mealPlannerApi.meals.approveItem(itemId)
toast.success('Meal approved')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch {
// Error toast fires from the global MutationCache handler.
}
@@ -343,7 +354,7 @@ export default function Dashboard() {
try {
await mealPlannerApi.meals.denyItem(itemId)
toast.success('Meal denied')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch {
// Error toast fires from the global MutationCache handler.
}
@@ -355,7 +366,7 @@ export default function Dashboard() {
if (!item) return
try {
await mealPlannerApi.meals.deleteItem(itemId)
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
showToast.undo(
'Meal deleted',
async () => {
@@ -365,7 +376,7 @@ export default function Dashboard() {
item.day_of_week,
item.meal_type
)
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
toast.success('Slot filled with a new meal')
} catch (err) {
showApiError(err, 'Failed to refill slot')
@@ -382,7 +393,7 @@ export default function Dashboard() {
try {
await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType)
toast.success('Meal generated')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
} catch {
// Error toast fires from the global MutationCache handler.
}
@@ -427,14 +438,38 @@ export default function Dashboard() {
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">
Week of {new Date(mealPlan.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
Week of {formatIsoDate(mealPlan.week_start_date)}
</h1>
<p className="text-sm text-surface-500">
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="inline-flex items-center rounded-lg border border-surface-200 bg-white">
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, -7))}
aria-label="Previous week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => navigateWeek(isoMonday())}
aria-label="Jump to current week"
title="Jump to current week"
className={`px-3 py-2 text-sm font-medium border-x border-surface-200 focus:outline-none focus:ring-2 focus:ring-primary-400 ${isCurrentWeek ? 'text-primary-700 bg-primary-50' : 'text-surface-600 hover:bg-surface-100'}`}
>
{isCurrentWeek ? 'This week' : 'Current'}
</button>
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, 7))}
aria-label="Next week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
<Badge
variant={statusVariant}
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
+70 -7
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw } from 'lucide-react'
import { useSearchParams } from 'react-router-dom'
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, ChevronLeft, ChevronRight } from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { ShoppingList } from '../types'
import { Button } from '../components/ui/Button'
@@ -8,6 +9,7 @@ import { Badge } from '../components/ui/Badge'
import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
const AISLE_LABEL: Record<string, string> = {
produce: 'Produce',
@@ -78,9 +80,17 @@ function storageKey(week: string) {
}
export default function ShoppingListPage() {
const [searchParams, setSearchParams] = useSearchParams()
const weekParam = searchParams.get('week')
const parsedWeek = weekParam ? parseIsoDate(weekParam) : null
const weekStart = weekParam && parsedWeek ? weekParam : isoMonday()
const isCurrentWeek = weekStart === isoMonday()
const navigateWeek = (next: string) => {
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true })
}
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
queryKey: ['shoppingList'],
queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data),
queryKey: ['shoppingList', weekStart],
queryFn: () => mealPlannerApi.shoppingList.get(weekStart).then(r => r.data),
})
const week = shoppingList?.week_start_date || ''
@@ -139,12 +149,41 @@ export default function ShoppingListPage() {
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
<ShoppingCart className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">Shopping List</h1>
<p className="text-sm text-surface-500">Week of {formatIsoDate(weekStart)}</p>
</div>
</div>
<div className="inline-flex items-center rounded-lg border border-surface-200 bg-white">
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, -7))}
aria-label="Previous week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => navigateWeek(isoMonday())}
aria-label="Jump to current week"
title="Jump to current week"
className={`px-3 py-2 text-sm font-medium border-x border-surface-200 focus:outline-none focus:ring-2 focus:ring-primary-400 ${isCurrentWeek ? 'text-primary-700 bg-primary-50' : 'text-surface-600 hover:bg-surface-100'}`}
>
{isCurrentWeek ? 'This week' : 'Current'}
</button>
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, 7))}
aria-label="Next week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
<EmptyState
icon={Receipt}
title="No shopping list yet"
description="Generate a meal plan first to see your aisle-organized shopping list with sale tracking."
title={isCurrentWeek ? 'No shopping list yet' : 'No plan for that week'}
description={isCurrentWeek
? 'Generate a meal plan first to see your aisle-organized shopping list with sale tracking.'
: 'There is no meal plan for this week. Go to the Dashboard and generate one, or pick a different week.'}
/>
</div>
)
@@ -163,11 +202,35 @@ export default function ShoppingListPage() {
Shopping List
</h1>
<p className="text-sm text-surface-500">
Week of {new Date(shoppingList.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
Week of {formatIsoDate(shoppingList.week_start_date)}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="inline-flex items-center rounded-lg border border-surface-200 bg-white">
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, -7))}
aria-label="Previous week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => navigateWeek(isoMonday())}
aria-label="Jump to current week"
title="Jump to current week"
className={`px-3 py-2 text-sm font-medium border-x border-surface-200 focus:outline-none focus:ring-2 focus:ring-primary-400 ${isCurrentWeek ? 'text-primary-700 bg-primary-50' : 'text-surface-600 hover:bg-surface-100'}`}
>
{isCurrentWeek ? 'This week' : 'Current'}
</button>
<button
onClick={() => navigateWeek(shiftIsoDate(weekStart, 7))}
aria-label="Next week"
className="p-2 text-surface-600 hover:bg-surface-100 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{progress > 0 && (
<span className="text-sm text-surface-500">{progress}% complete</span>
)}