fix(ui): align 'this week' to upcoming Monday (Sprint 7)

User report 2026-06-05: 'webui Meal Planner page is empty' on Friday
morning after the Friday email went out. Root cause: the orchestrator
keyed plans by the most-recent-Friday while the frontend's isoMonday()
returned the most-recent-Monday — a 7-day mismatch on Fridays.

Fixes (one semantic across the stack):
- runner._current_week_start() returns the upcoming Monday (today if
  Mon, else the next Mon). The Friday email subject
  ('Meal plan for week of <date>') automatically picks up the new
  value via run.week_start_date.
- frontend isoMonday -> upcomingMonday (same logic; renamed for
  intent). isoMonday kept as a deprecated alias.
- New WeekRangeNav component (Dashboard + ShoppingList share it).
  Renders [<]  Jun 8 - Jun 14  [>] with clickable chevrons and a
  clickable range label that jumps to the upcoming week. Replaces
  the Sprint 5 inline segmented control on both pages.
- New formatWeekRange(mondayIso) helper (UTC-stable; uses
  timeZone: 'UTC' so the rendered date matches the stored ISO date
  regardless of viewer TZ; closes a latent bug in formatIsoDate too).
- New SQL fix script that retargets the user's 3-pending-items plan
  from 2026-06-05 (Friday-keyed) to 2026-06-08 (upcoming Monday).
  Idempotent + transaction-wrapped. Optional block for 2026-05-29.

No backend migration. No new dependencies. Deploy is git pull +
run the SQL fix + docker compose up -d --build backend frontend.
See Review/sprint7-verification.md for the full deploy + smoke flow.

Files:
- backend/app/services/orchestrator/runner.py:20-35
- backend/scripts/fix_2026_06_05_to_2026_06_08.sql (new)
- frontend/src/lib/utils.ts:43-130
- frontend/src/components/WeekRangeNav.tsx (new)
- frontend/src/pages/Dashboard.tsx (3 call sites + 1 segmented control)
- frontend/src/pages/ShoppingList.tsx (5 call sites + 2 segmented controls)
- Review/{sprint7-verification,ui-nielsen-audit,handoff-ui-audit}.md
- fix-ui-audit.md
- docs/HANDOFF.md
- .agent/{plan,context}.md
This commit is contained in:
MealPlanner
2026-06-05 07:46:55 -07:00
parent a616138e7c
commit 09c7525a12
13 changed files with 679 additions and 96 deletions
+81
View File
@@ -0,0 +1,81 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { formatWeekRange, upcomingMonday } from '../lib/utils'
interface WeekRangeNavProps {
/** ISO Monday date of the currently-displayed week (YYYY-MM-DD). */
weekStart: string
/** True when the displayed week is the upcoming Mon-Sun week. */
isCurrentWeek: boolean
onPrev: () => void
onNext: () => void
onJumpHome: () => void
}
/**
* Week-range navigation with clickable brackets. Renders the
* `[<] Mon DD — Sun DD [>]` pattern + an optional `This week` chip
* when the displayed week is not the upcoming week.
*
* Replaces the Sprint 5 inline segmented control. The bracket chevrons
* step by 7 days; clicking the range label jumps back to the upcoming
* week (the default webui landing week). Keyboard-accessible: every
* interactive element is a real `<button>` with an `aria-label`.
*/
export function WeekRangeNav({
weekStart,
isCurrentWeek,
onPrev,
onNext,
onJumpHome,
}: WeekRangeNavProps) {
return (
<div
className="inline-flex items-center rounded-lg border border-surface-200 bg-white"
role="group"
aria-label="Week navigation"
>
<button
type="button"
onClick={onPrev}
aria-label="Previous week"
title="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
type="button"
onClick={onJumpHome}
aria-label={`Jump to upcoming week (currently ${formatWeekRange(upcomingMonday())})`}
title="Jump to upcoming 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'
}`}
>
{formatWeekRange(weekStart)}
</button>
<button
type="button"
onClick={onNext}
aria-label="Next week"
title="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>
{!isCurrentWeek && (
<button
type="button"
onClick={onJumpHome}
className="ml-2 inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md bg-primary-50 text-primary-700 hover:bg-primary-100 focus:outline-none focus:ring-2 focus:ring-primary-400"
aria-label="Jump to upcoming week"
title="Jump to upcoming week"
>
This week
</button>
)}
</div>
)
}
+67 -9
View File
@@ -37,16 +37,48 @@ export function cleanDescription(input: string | undefined | null, maxLen = 280)
}
/* ------------------------------------------------------------------ */
/* Week helpers (used by F5 URL week selector) */
/* Week helpers (used by F5 URL week selector + Sprint 7 WeekRangeNav) */
/* ------------------------------------------------------------------ */
/** Return the ISO date (YYYY-MM-DD) of the Monday of the given date's week. */
export function isoMonday(d: Date = new Date()): string {
/**
* Return the ISO date (YYYY-MM-DD) of the *upcoming* Monday for the given date.
* - If `d` is a Monday → returns `d` itself.
* - Otherwise → returns the next Monday (1..6 days ahead).
*
* Pairs with `runner._current_week_start()` on the backend (see
* `backend/app/services/orchestrator/runner.py`). "This week" means the
* upcoming Mon-Sun week the Friday email advertises.
*/
export function upcomingMonday(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)
if (day === 0) {
// Sunday: upcoming Monday is tomorrow (1 day ahead).
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1))
return next.toISOString().slice(0, 10)
}
if (day === 1) {
// Monday: today.
return d.toISOString().slice(0, 10)
}
// Tue..Sat: next Monday is (7 - day) days ahead.
const offset = 7 - day + 1
const next = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + offset))
return next.toISOString().slice(0, 10)
}
/**
* Legacy alias kept for any external code that might still reference the
* calendar-week Monday. The frontend codebase has been updated to use
* `upcomingMonday`; this alias is intentionally a no-op redirect to the
* new function with a deprecation note.
*
* @deprecated Use `upcomingMonday()` — see Sprint 7 root-cause in
* `Review/handoff-ui-audit.md`. The "current calendar week" semantic
* mismatched the orchestrator's "upcoming week" semantic, causing the
* webui to show an empty state on Fridays.
*/
export function isoMonday(d: Date = new Date()): string {
return upcomingMonday(d)
}
/** Parse a YYYY-MM-DD string into a Date (UTC midnight). Returns null if invalid. */
@@ -64,9 +96,35 @@ export function shiftIsoDate(s: string, days: number): string {
return d.toISOString().slice(0, 10)
}
/** Format a YYYY-MM-DD string for display: "Jun 1, 2026". */
/**
* Format a YYYY-MM-DD string for display: "Jun 1, 2026".
* Always renders in UTC so the displayed date matches the ISO date
* stored in the DB regardless of the viewer's local timezone.
*/
export function formatIsoDate(s: string): string {
const d = parseIsoDate(s)
if (!d) return s
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
return d.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
})
}
/**
* Format a Monday's ISO date as a MonSun range label: "Jun 8 — Jun 14".
* Used by `WeekRangeNav` (Sprint 7) for the visible week range.
*
* Always renders in UTC so the date the user sees matches the ISO date
* stored in the DB regardless of the viewer's local timezone.
*/
export function formatWeekRange(mondayIso: string): string {
const monday = parseIsoDate(mondayIso)
if (!monday) return mondayIso
const sunday = new Date(monday)
sunday.setUTCDate(sunday.getUTCDate() + 6)
const fmt = (d: Date) =>
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })
return `${fmt(monday)}${fmt(sunday)}`
}
+13 -29
View File
@@ -2,12 +2,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import {
CookingPot, CalendarDays, ShoppingCart, ChevronRight, ChevronLeft, Sparkles, Loader2,
CookingPot, CalendarDays, ShoppingCart, ChevronRight, 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 { upcomingMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { ChevronDown } from 'lucide-react'
import {
DragDropContext,
@@ -26,6 +26,7 @@ import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
import { WeekRangeNav } from '../components/WeekRangeNav'
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
@@ -313,10 +314,10 @@ export default function Dashboard() {
const parsedWeek = weekParam ? parseIsoDate(weekParam) : null
const weekStart = weekParam && parsedWeek
? weekParam
: isoMonday()
const isCurrentWeek = weekStart === isoMonday()
: upcomingMonday()
const isCurrentWeek = weekStart === upcomingMonday()
const navigateWeek = (next: string) => {
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true })
setSearchParams(next === upcomingMonday() ? {} : { week: next }, { replace: true })
}
const [planningWeek, setPlanningWeek] = useState(false)
const [planMenuOpen, setPlanMenuOpen] = useState(false)
@@ -477,30 +478,13 @@ export default function Dashboard() {
</div>
</div>
<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>
<WeekRangeNav
weekStart={weekStart}
isCurrentWeek={isCurrentWeek}
onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))}
onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
onJumpHome={() => navigateWeek(upcomingMonday())}
/>
<div className="relative">
<button
onClick={() => setPlanMenuOpen(o => !o)}
+21 -52
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, ChevronLeft, ChevronRight, PackagePlus } from 'lucide-react'
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, PackagePlus } from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { ShoppingList } from '../types'
import { Button } from '../components/ui/Button'
@@ -9,7 +9,8 @@ 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'
import { WeekRangeNav } from '../components/WeekRangeNav'
import { upcomingMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { showToast, showApiError } from '../lib/toast'
const AISLE_LABEL: Record<string, string> = {
@@ -84,10 +85,10 @@ 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 weekStart = weekParam && parsedWeek ? weekParam : upcomingMonday()
const isCurrentWeek = weekStart === upcomingMonday()
const navigateWeek = (next: string) => {
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true })
setSearchParams(next === upcomingMonday() ? {} : { week: next }, { replace: true })
}
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
queryKey: ['shoppingList', weekStart],
@@ -204,29 +205,14 @@ export default function ShoppingListPage() {
<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 className="flex items-center gap-2 flex-wrap">
<WeekRangeNav
weekStart={weekStart}
isCurrentWeek={isCurrentWeek}
onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))}
onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
onJumpHome={() => navigateWeek(upcomingMonday())}
/>
</div>
<EmptyState
icon={Receipt}
@@ -257,30 +243,13 @@ export default function ShoppingListPage() {
</div>
</div>
<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>
<WeekRangeNav
weekStart={weekStart}
isCurrentWeek={isCurrentWeek}
onPrev={() => navigateWeek(shiftIsoDate(weekStart, -7))}
onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))}
onJumpHome={() => navigateWeek(upcomingMonday())}
/>
{progress > 0 && (
<span className="text-sm text-surface-500">{progress}% complete</span>
)}