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
+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, ' ')}`}
+71 -8
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>
<h1 className="text-2xl font-bold text-surface-900">Shopping List</h1>
<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>
)}