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
+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, ' ')}`}