Fix Home dashboard integration findings (Feature 21)
- F21-01: plan_ready and list_ready links carry their week (/plan?week=, /plan/shopping?week=), and the Plan opens on ?week= and keeps it on the week shown, so an older update opens its week. - F21-02: a failed refresh of the Rotation check shows its ErrorBanner beside the cached pending card instead of replacing it. - F21-03: Home carries the household's timezone and is read again at its next local midnight. - F21-04: Home counts pending Side quests through a read-only query (suggest.PendingCount), so reading Home never writes batches. Task: ac3dddf0-a05f-4e43-a5ad-3105fe6bdd95 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -160,16 +160,25 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
// the Plan page at entry, over queryClient, with shopping as the Supply run
|
||||
// screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />, entry = '/plan') {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route
|
||||
path="/plan"
|
||||
element={
|
||||
<>
|
||||
<PlanPage />
|
||||
<Searched />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
@@ -190,6 +199,11 @@ function Opened() {
|
||||
)
|
||||
}
|
||||
|
||||
// Searched shows the Plan's search, the week it keeps in ?week=.
|
||||
function Searched() {
|
||||
return <p data-testid="search">{useLocation().search}</p>
|
||||
}
|
||||
|
||||
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||
// queryClient keeps.
|
||||
function pushesKept(queryClient: QueryClient) {
|
||||
@@ -1114,6 +1128,30 @@ describe('PlanPage', () => {
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens on the week of ?week=, as a plan_ready update for an older week links it', async () => {
|
||||
stubServer({
|
||||
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
...lists,
|
||||
})
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-09-28')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
|
||||
// The week buttons keep ?week= on the week shown.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-05')
|
||||
})
|
||||
|
||||
it('says so when ?week= is not a Monday, and reads no plan', async () => {
|
||||
const fetchMock = stubServer({ ...lists })
|
||||
await renderPage(createQueryClient(), <Opened />, '/plan?week=2026-10-07')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-10-07” is not the Monday of a week, as YYYY-MM-DD.')
|
||||
expect(fetchMock.mock.calls.some(([input]) => String(input).startsWith('/api/v1/plans/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { ApiError, useJob } from '../api/index.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
@@ -666,19 +667,28 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
// PlanPage is the Draft board on the week chosen with the previous / next
|
||||
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
||||
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
||||
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
||||
// Regulars in a right panel on desktop (2d). It opens on the week chosen by
|
||||
// ?week=, as a plan_ready update links it, else on next week once this
|
||||
// week's plan_draft time has come in the household's zone, otherwise on this
|
||||
// week. The board keeps its place in the tree across layouts, so crossing a
|
||||
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
|
||||
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [week, setWeek] = useState<string | null>(null)
|
||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||
const [params, setParams] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
// The default week is chosen once, not on every render: the plan_draft
|
||||
// time passing does not move the page to another week.
|
||||
const [opened, setOpened] = useState<string | null>(null)
|
||||
if (opened === null && settings.data) setOpened(defaultWeek(settings.data, new Date()))
|
||||
const week = chosen ?? opened
|
||||
const setWeek = (weekStart: string) => setParams({ [WEEK_PARAM]: weekStart }, { replace: true })
|
||||
|
||||
let content
|
||||
if (settings.isError) {
|
||||
if (chosen !== null && !isMonday(chosen)) {
|
||||
content = <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||
} else if (settings.isError) {
|
||||
content = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import {
|
||||
addDays,
|
||||
dayAndDate,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
isMonday,
|
||||
localNow,
|
||||
untilLocalMidnight,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
weekLabel,
|
||||
weekStart,
|
||||
} from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
@@ -72,4 +84,20 @@ describe('week helpers', () => {
|
||||
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
|
||||
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
|
||||
})
|
||||
|
||||
it('says whether a date is a real Monday', () => {
|
||||
expect(isMonday('2026-10-05')).toBe(true)
|
||||
expect(isMonday('2026-10-07')).toBe(false)
|
||||
expect(isMonday('2026-02-30')).toBe(false)
|
||||
expect(isMonday('next week')).toBe(false)
|
||||
})
|
||||
|
||||
it('counts the time to the next midnight in the household zone, not UTC', () => {
|
||||
// 23:59:30.250 in New York is already 03:59 the next day in UTC.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750)
|
||||
// At midnight itself the next is a day away.
|
||||
expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000)
|
||||
// The same instant is 13:00 in Tokyo: eleven hours to go.
|
||||
expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,6 +35,15 @@ export function weekStart(date: string): string {
|
||||
return addDays(date, -sinceMonday)
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
export function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekStart(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
|
||||
export function weekdayKicker(date: string): string {
|
||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||
@@ -115,3 +124,20 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number
|
||||
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
|
||||
return drafted ? addDays(thisWeek, 7) : thisWeek
|
||||
}
|
||||
|
||||
// untilLocalMidnight is how many milliseconds after instant now the next
|
||||
// midnight comes in IANA zone. It counts from the zone's wall clock at now,
|
||||
// so a change of UTC offset before that midnight moves it by the change;
|
||||
// offsets change in the small hours, after midnight.
|
||||
export function untilLocalMidnight(zone: string, now: Date): number {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now)
|
||||
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
|
||||
const sinceMidnight = ((part('hour') * 60 + part('minute')) * 60 + part('second')) * 1000 + now.getUTCMilliseconds()
|
||||
return DAY_MS - sinceMidnight
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user