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:
@@ -68,9 +68,12 @@ NORMALIZATION_RULES = [
|
|||||||
("snacks", "Pantry"),
|
("snacks", "Pantry"),
|
||||||
]
|
]
|
||||||
|
|
||||||
CASE_EXPR = "CASE LOWER(COALESCE(aisle, '')) " + " ".join(
|
CASE_EXPR = (
|
||||||
f"WHEN '{src}' THEN '{dst}' " for src, dst in NORMALIZATION_RULES
|
"CASE LOWER(COALESCE(aisle::text, '')) "
|
||||||
) + " WHEN NULLIF(LOWER(COALESCE(aisle, '')), '') IS NULL THEN NULL " + " ELSE 'Other' END"
|
+ " ".join(f"WHEN '{src}' THEN '{dst}' " for src, dst in NORMALIZATION_RULES)
|
||||||
|
+ " WHEN '' THEN NULL "
|
||||||
|
+ " ELSE 'Other' END"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize(table: str) -> None:
|
def _normalize(table: str) -> None:
|
||||||
@@ -78,7 +81,12 @@ def _normalize(table: str) -> None:
|
|||||||
f"CREATE TEMP TABLE {table}_aisle_backup AS "
|
f"CREATE TEMP TABLE {table}_aisle_backup AS "
|
||||||
f"SELECT id, aisle FROM {table} WHERE aisle IS NOT NULL"
|
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:
|
def upgrade() -> None:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.services.feedback_analyzer import FeedbackAnalyzer
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import random
|
import random
|
||||||
@@ -28,14 +29,23 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=Optional[MealPlanResponse])
|
@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()
|
profile = db.query(FamilyProfile).first()
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
raise HTTPException(status_code=404, detail="Family profile not found")
|
||||||
|
|
||||||
meal_plan = db.query(MealPlan).filter(
|
query = db.query(MealPlan).filter(MealPlan.family_profile_id == profile.id)
|
||||||
MealPlan.family_profile_id == profile.id
|
if week_start is not None:
|
||||||
).order_by(MealPlan.week_start_date.desc()).first()
|
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:
|
if not meal_plan:
|
||||||
return None
|
return None
|
||||||
@@ -361,6 +371,17 @@ def deny_meal_item(item_id: UUID, db: Session = Depends(get_db)):
|
|||||||
return {"message": "Meal denied", "item": item}
|
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")
|
@router.post("/{meal_plan_id}/generate-item")
|
||||||
def generate_single_item(
|
def generate_single_item(
|
||||||
meal_plan_id: UUID,
|
meal_plan_id: UUID,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -8,7 +8,7 @@ from app.models import (
|
|||||||
)
|
)
|
||||||
from app.schemas import ShoppingListResponse, ShoppingListItem
|
from app.schemas import ShoppingListResponse, ShoppingListItem
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -25,11 +25,23 @@ def _parse_uuid(value):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ShoppingListResponse)
|
@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()
|
profile = db.query(FamilyProfile).first()
|
||||||
if not profile:
|
if not profile:
|
||||||
raise HTTPException(status_code=404, detail="Family profile not found")
|
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(
|
current_plan = db.query(MealPlan).filter(
|
||||||
MealPlan.family_profile_id == profile.id,
|
MealPlan.family_profile_id == profile.id,
|
||||||
MealPlan.status.in_(['approved', 'locked'])
|
MealPlan.status.in_(['approved', 'locked'])
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const mealPlannerApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
meals: {
|
meals: {
|
||||||
getPlanned: () => api.get('/meals'),
|
getPlanned: (weekStart?: string) => api.get('/meals', { params: { week_start: weekStart } }),
|
||||||
get: (id: string) => api.get(`/meals/${id}`),
|
get: (id: string) => api.get(`/meals/${id}`),
|
||||||
getItem: (id: string) => api.get(`/meals/items/${id}`),
|
getItem: (id: string) => api.get(`/meals/items/${id}`),
|
||||||
create: (data: any) => api.post('/meals', data),
|
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 } }),
|
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`),
|
approveItem: (itemId: string) => api.post(`/meals/items/${itemId}/approve`),
|
||||||
denyItem: (itemId: string) => api.post(`/meals/items/${itemId}/deny`),
|
denyItem: (itemId: string) => api.post(`/meals/items/${itemId}/deny`),
|
||||||
|
deleteItem: (itemId: string) => api.delete(`/meals/items/${itemId}`),
|
||||||
generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) =>
|
generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) =>
|
||||||
api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }),
|
api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }),
|
||||||
},
|
},
|
||||||
@@ -59,7 +60,7 @@ export const mealPlannerApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
shoppingList: {
|
shoppingList: {
|
||||||
get: () => api.get('/shopping-list'),
|
get: (weekStart?: string) => api.get('/shopping-list', { params: { week_start: weekStart } }),
|
||||||
getPrint: () => api.get('/shopping-list/print'),
|
getPrint: () => api.get('/shopping-list/print'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,38 @@ export function cleanDescription(input: string | undefined | null, maxLen = 280)
|
|||||||
}
|
}
|
||||||
return s;
|
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' })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
|
CookingPot, CalendarDays, ShoppingCart, ChevronRight, ChevronLeft, Sparkles, Loader2,
|
||||||
GripVertical, X
|
GripVertical, X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { showToast, showApiError } from '../lib/toast'
|
import { showToast, showApiError } from '../lib/toast'
|
||||||
|
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
|
||||||
import {
|
import {
|
||||||
DragDropContext,
|
DragDropContext,
|
||||||
Droppable,
|
Droppable,
|
||||||
@@ -306,16 +307,26 @@ function VoteEmailButton() {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const queryClient = useQueryClient()
|
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>({
|
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
||||||
queryKey: ['mealPlan'],
|
queryKey: ['mealPlan', weekStart],
|
||||||
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
|
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleDrop(itemId: string, newDay: number, newType: string) {
|
async function handleDrop(itemId: string, newDay: number, newType: string) {
|
||||||
try {
|
try {
|
||||||
await mealPlannerApi.meals.moveItem(itemId, newDay, newType)
|
await mealPlannerApi.meals.moveItem(itemId, newDay, newType)
|
||||||
toast.success('Meal moved')
|
toast.success('Meal moved')
|
||||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
||||||
} catch {
|
} catch {
|
||||||
// Error toast fires from the global MutationCache handler.
|
// Error toast fires from the global MutationCache handler.
|
||||||
}
|
}
|
||||||
@@ -333,7 +344,7 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
await mealPlannerApi.meals.approveItem(itemId)
|
await mealPlannerApi.meals.approveItem(itemId)
|
||||||
toast.success('Meal approved')
|
toast.success('Meal approved')
|
||||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
||||||
} catch {
|
} catch {
|
||||||
// Error toast fires from the global MutationCache handler.
|
// Error toast fires from the global MutationCache handler.
|
||||||
}
|
}
|
||||||
@@ -343,7 +354,7 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
await mealPlannerApi.meals.denyItem(itemId)
|
await mealPlannerApi.meals.denyItem(itemId)
|
||||||
toast.success('Meal denied')
|
toast.success('Meal denied')
|
||||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
||||||
} catch {
|
} catch {
|
||||||
// Error toast fires from the global MutationCache handler.
|
// Error toast fires from the global MutationCache handler.
|
||||||
}
|
}
|
||||||
@@ -355,7 +366,7 @@ export default function Dashboard() {
|
|||||||
if (!item) return
|
if (!item) return
|
||||||
try {
|
try {
|
||||||
await mealPlannerApi.meals.deleteItem(itemId)
|
await mealPlannerApi.meals.deleteItem(itemId)
|
||||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
||||||
showToast.undo(
|
showToast.undo(
|
||||||
'Meal deleted',
|
'Meal deleted',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -365,7 +376,7 @@ export default function Dashboard() {
|
|||||||
item.day_of_week,
|
item.day_of_week,
|
||||||
item.meal_type
|
item.meal_type
|
||||||
)
|
)
|
||||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
||||||
toast.success('Slot filled with a new meal')
|
toast.success('Slot filled with a new meal')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showApiError(err, 'Failed to refill slot')
|
showApiError(err, 'Failed to refill slot')
|
||||||
@@ -382,7 +393,7 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType)
|
await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType)
|
||||||
toast.success('Meal generated')
|
toast.success('Meal generated')
|
||||||
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
|
queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] })
|
||||||
} catch {
|
} catch {
|
||||||
// Error toast fires from the global MutationCache handler.
|
// Error toast fires from the global MutationCache handler.
|
||||||
}
|
}
|
||||||
@@ -427,14 +438,38 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-surface-900">
|
<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>
|
</h1>
|
||||||
<p className="text-sm text-surface-500">
|
<p className="text-sm text-surface-500">
|
||||||
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
|
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Badge
|
||||||
variant={statusVariant}
|
variant={statusVariant}
|
||||||
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
|
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { mealPlannerApi } from '../api'
|
||||||
import type { ShoppingList } from '../types'
|
import type { ShoppingList } from '../types'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
@@ -8,6 +9,7 @@ import { Badge } from '../components/ui/Badge'
|
|||||||
import { Card, CardBody } from '../components/ui/Card'
|
import { Card, CardBody } from '../components/ui/Card'
|
||||||
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
|
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
|
||||||
|
|
||||||
const AISLE_LABEL: Record<string, string> = {
|
const AISLE_LABEL: Record<string, string> = {
|
||||||
produce: 'Produce',
|
produce: 'Produce',
|
||||||
@@ -78,9 +80,17 @@ function storageKey(week: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ShoppingListPage() {
|
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>({
|
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
|
||||||
queryKey: ['shoppingList'],
|
queryKey: ['shoppingList', weekStart],
|
||||||
queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data),
|
queryFn: () => mealPlannerApi.shoppingList.get(weekStart).then(r => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
const week = shoppingList?.week_start_date || ''
|
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">
|
<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" />
|
<ShoppingCart className="w-5 h-5 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-surface-900">Shopping List</h1>
|
<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>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Receipt}
|
icon={Receipt}
|
||||||
title="No shopping list yet"
|
title={isCurrentWeek ? 'No shopping list yet' : 'No plan for that week'}
|
||||||
description="Generate a meal plan first to see your aisle-organized shopping list with sale tracking."
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -163,11 +202,35 @@ export default function ShoppingListPage() {
|
|||||||
Shopping List
|
Shopping List
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-surface-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{progress > 0 && (
|
||||||
<span className="text-sm text-surface-500">{progress}% complete</span>
|
<span className="text-sm text-surface-500">{progress}% complete</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user