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:
@@ -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' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user