From 3483faafa93f8e51252ed00388d7dbc94e6ff6eb Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 11:11:28 -0400 Subject: [PATCH 1/4] Add the mobile Draft board at /plan (mock 1f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plan screen shows the selected week's plan: the week title with its Draft, Synced or "Changes not pushed" tag, the assistant's note beside the grape "Q" avatar, and a two-column grid of day tiles. Quest-log picks are teal with a "· NEW" kicker, crock-pot dinners show their start-by time in the household's zone, and the dashed "+ Rebuild week" tile redrafts the week, asking first when the plan was pushed to Calendar. "Looks good — push to Calendar" stays unavailable with a tooltip until Feature 16. The page opens on next week once this week's plan_draft time has come, otherwise on this week, with previous / next week buttons. A week with no plan offers "Draft this week", which starts the draft_week job and shows skeleton tiles until the plan is read again; a failed draft is shown with Retry and no plan is made up. web/src/plan/week.ts is the one household week helper, mirroring internal/household/week.go. The App route tests that used /plan as a placeholder now use /quick. Maestro: 4bb14d54-76bc-4e7d-8c35-fa738abd49ff Co-Authored-By: Claude Opus 5.5 --- web/src/App.test.tsx | 37 ++-- web/src/App.tsx | 2 + web/src/plan/PlanPage.module.css | 242 +++++++++++++++++++++++ web/src/plan/PlanPage.test.tsx | 329 +++++++++++++++++++++++++++++++ web/src/plan/PlanPage.tsx | 266 +++++++++++++++++++++++++ web/src/plan/api.ts | 129 ++++++++++++ web/src/plan/week.test.ts | 59 ++++++ web/src/plan/week.ts | 98 +++++++++ 8 files changed, 1144 insertions(+), 18 deletions(-) create mode 100644 web/src/plan/PlanPage.module.css create mode 100644 web/src/plan/PlanPage.test.tsx create mode 100644 web/src/plan/PlanPage.tsx create mode 100644 web/src/plan/api.ts create mode 100644 web/src/plan/week.test.ts create mode 100644 web/src/plan/week.ts diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 1d2a4d9..abcaadf 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { '/onboarding': true, + '/plan': true, '/discover': true, '/settings': true, '/settings/ai': true, @@ -165,19 +166,19 @@ describe('App first-run redirect', () => { it('opens the routes once onboarding is finished', async () => { mockViewport(390) stubFetchByPath(SIGNED_IN) - renderAt('/plan') - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + renderAt('/quick') + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) it('reports a household settings failure with a retry instead of rendering the routes', async () => { mockViewport(390) stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS)) - renderAt('/plan') + renderAt('/quick') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() screen.getByRole('button', { name: 'Retry' }).click() - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) }) @@ -185,12 +186,12 @@ describe('App bootstrap', () => { it('shows the skeleton until /me loads, then the routes', async () => { mockViewport(390) const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) - renderAt('/plan') + renderAt('/quick') expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' }) }) @@ -258,7 +259,7 @@ describe('App offline bootstrap', () => { }) it.each([ - ['/plan', 'Plan'], + ['/quick', 'Quick dinners'], ['/dinner/01J9ZK', 'Dinner'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() @@ -282,7 +283,7 @@ describe('App offline bootstrap', () => { () => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))), ).mockRejectedValue(new TypeError('Failed to fetch')), ) - renderAt('/plan') + renderAt('/quick') await vi.waitFor(() => expect(failMe).toBeDefined()) onLine = false act(() => { @@ -290,7 +291,7 @@ describe('App offline bootstrap', () => { }) act(() => failMe()) - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') expect(screen.queryByRole('alert')).not.toBeInTheDocument() @@ -299,15 +300,15 @@ describe('App offline bootstrap', () => { it('opens the saved pages when the server is unreachable but the browser reports online', async () => { await visitOnline() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) - renderAt('/plan') + renderAt('/quick') - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') }) it('reports the failure offline when this device never verified a session', async () => { goOffline() - renderAt('/plan') + renderAt('/quick') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() @@ -316,12 +317,12 @@ describe('App offline bootstrap', () => { it('reports the failure offline after the session was refused', async () => { await visitOnline() stubFetch(errorResponse(401, 'unauthenticated')) - const { location, unmount } = renderAt('/plan') + const { location, unmount } = renderAt('/quick') await vi.waitFor(() => expect(location.assign).toHaveBeenCalled()) unmount() goOffline() - renderAt('/plan') + renderAt('/quick') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() }) @@ -329,13 +330,13 @@ describe('App offline bootstrap', () => { it('loads /me again on reconnect and refuses writes until it has', async () => { await visitOnline() goOffline() - const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage)) render( - + , ) - expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) const fetchMock = stubFetchByPath(SIGNED_IN) @@ -350,6 +351,6 @@ describe('App offline bootstrap', () => { ]), ) await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) - expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4a2d297..da3b58a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -7,6 +7,7 @@ import { DiscoverPage } from './discover/DiscoverPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' +import { PlanPage } from './plan/PlanPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AISettingsPage } from './settings/AISettingsPage.tsx' @@ -36,6 +37,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/plan': , '/discover': , '/settings': , '/settings/ai': , diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css new file mode 100644 index 0000000..3803b71 --- /dev/null +++ b/web/src/plan/PlanPage.module.css @@ -0,0 +1,242 @@ +/* Plan · Draft board (mock 1f). */ +.page { + box-sizing: border-box; + max-width: 440px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 8px; +} + +.header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; +} + +.heading { + min-width: 0; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.titleRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 26px; + font-weight: 400; + line-height: 1.1; +} + +.weekNav { + flex: none; + display: flex; + gap: 8px; +} + +/* The assistant: a 32 px grape "Q" avatar beside its note. */ +.assistant { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.avatar { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 16px; + line-height: 1; +} + +.bubble { + margin: 0; + padding: 12px 14px; + border-radius: 4px 18px 18px 18px; + background: var(--color-surface); + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; +} + +/* Two columns of 86 px day tiles; the 8th cell is "+ Rebuild week". */ +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.tile { + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 6px; + min-height: 86px; + padding: 12px; + border-radius: 22px; + background: var(--color-surface); + color: var(--color-text); +} + +/* A Quest-log pick. */ +.newTile { + background: var(--color-accent-2-300); +} + +.dayKicker { + margin: 0; + font-family: var(--font-body); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-neutral-700); +} + +.newTile .dayKicker { + color: var(--color-accent-2-900); +} + +.dinner { + margin: 0; + font-family: var(--font-heading); + font-size: 15px; + font-weight: 400; + line-height: 1.15; + overflow-wrap: anywhere; +} + +.startBy { + margin: 4px 0 0; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.newTile .startBy { + color: var(--color-accent-2-900); +} + +.rebuildCell { + display: flex; +} + +.rebuild { + box-sizing: border-box; + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 86px; + padding: 12px; + border: 2px dashed var(--color-neutral-400); + border-radius: 22px; + background: transparent; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.rebuild:hover { + background: var(--color-neutral-100); +} + +.loading { + display: flex; + flex-direction: column; + gap: 12px; +} + +.skeletonTile { + height: 86px; + border-radius: 22px; +} + +.drafting { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + text-align: center; + color: var(--color-neutral-700); +} + +/* "Looks good — push to Calendar", unavailable until Feature 16: it keeps + focus, so hovering, tapping or focusing it shows the tooltip. */ +.push { + position: relative; + display: flex; + flex-direction: column; +} + +.pushButton { + min-height: 50px; +} + +.pushButton[aria-disabled='true'] { + opacity: 0.45; + cursor: not-allowed; +} + +/* Unavailable, it neither darkens nor presses. */ +.push .pushButton[aria-disabled='true']:hover, +.push .pushButton[aria-disabled='true']:active { + background: var(--color-accent); + transform: none; +} + +.hint { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: 100%; + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--color-text); + color: var(--color-bg); + font-family: var(--font-body); + font-size: 13px; + line-height: 1.4; + text-align: center; + box-shadow: var(--shadow-md); + visibility: hidden; + pointer-events: none; +} + +.push:hover .hint, +.pushButton:focus + .hint { + visibility: visible; +} diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx new file mode 100644 index 0000000..ebb6c00 --- /dev/null +++ b/web/src/plan/PlanPage.test.tsx @@ -0,0 +1,329 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { PlanPage } from './PlanPage.tsx' + +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const DRAFT = `${THIS_WEEK}/draft` +const JOB = '/api/v1/jobs/j1' + +const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] +const NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Crock pot tikka masala', 'Chicken stir-fry', 'Pizza night', 'Korean beef bowls', 'Sheet-pan salmon'] + +// planBody is the plan of the week of Oct 5 as GET /api/v1/plans/{weekStart} +// sends it: Wednesday's crock-pot tikka masala and Saturday's bowls are +// Quest-log picks, and the tikka masala must start by 1:30 pm New York time +// to be ready for 5:30 pm dinner. +function planBody(status: string, names = NAMES) { + return { + id: 'plan-1', + week_start: '2026-10-05', + week_label: 'Week 41 · Oct 5 – 11', + status, + ai_note: 'I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.', + calendar_synced_at: status === 'draft' ? null : '2026-10-04T15:00:00Z', + days: DAYS.map((date, i) => ({ + date, + dinner: { + id: `d${i}`, + name: names[i], + photo: null, + minutes: i === 2 ? 240 : 30, + method: i === 2 ? 'crockpot' : 'stovetop', + origin: i === 2 || i === 5 ? 'side_quest' : 'regular', + }, + is_new: i === 2 || i === 5, + start_by: i === 2 ? '2026-10-07T13:30:00-04:00' : null, + })), + updated_at: '2026-10-04T15:00:00Z', + } +} + +function draftJob(status: string, error: string | null = null) { + const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: 'plan-2' } : null + return { id: 'j1', kind: 'draft_week', status, result, error } +} + +type Responder = (init: RequestInit | undefined) => Response + +// stubServer answers "METHOD path" requests from responders, with /me and +// the household settings; any other request fails the test. +function stubServer(responders: Record) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// posts lists the POSTs sent, as "path body". +function posts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === 'POST') + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) +} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the Plan page. +async function renderPage() { + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + return render( + + + + + , + ) +} + +// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so +// the page opens on this week. +beforeEach(() => { + mockViewport(390) + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + Reflect.deleteProperty(window, 'matchMedia') +}) + +function tile(kicker: string) { + return screen.getByText(kicker).closest('li')! +} + +describe('PlanPage', () => { + it('renders mock 1f for a draft: title, Draft tag, the assistant note and the 2-column grid', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + const title = screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' }) + expect(declaredStyle(title).fontSize).toBe('26px') + expect(screen.getByText('Week 41')).toBeInTheDocument() + const tag = screen.getByText('Draft') + expect(declaredStyle(tag).background).toBe('var(--color-accent-100)') + expect(declaredStyle(tag).color).toBe('var(--color-accent-800)') + + // The assistant: a 32 px grape "Q" avatar beside the plan's ai_note. + const avatar = screen.getByText('Q') + expect(declaredStyle(avatar).width).toBe('32px') + expect(declaredStyle(avatar).background).toBe('var(--color-accent)') + expect(screen.getByText('I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.')).toBeInTheDocument() + + // Seven day tiles, Monday to Sunday, then the "+ Rebuild week" tile. + const grid = screen.getByRole('list', { name: 'Dinners this week' }) + expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))') + const items = within(grid).getAllByRole('listitem') + expect(items).toHaveLength(8) + expect(items.slice(0, 7).map((item) => item.firstChild!.textContent)).toEqual([ + 'MON', + 'TUE', + 'WED · NEW', + 'THU', + 'FRI', + 'SAT · NEW', + 'SUN', + ]) + expect(within(items[7]!).getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument() + const monday = tile('MON') + expect(declaredStyle(monday).minHeight).toBe('86px') + expect(declaredStyle(monday).borderRadius).toBe('22px') + expect(declaredStyle(monday).background).toBe('var(--color-surface)') + expect(declaredStyle(within(monday).getByText('Beef tacos')).fontFamily).toBe('var(--font-heading)') + expect(declaredStyle(within(monday).getByText('Beef tacos')).fontSize).toBe('15px') + }) + + it('highlights Quest-log picks in teal with the NEW kicker', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + await screen.findByText('WED · NEW') + const wednesday = tile('WED · NEW') + expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-300)') + expect(declaredStyle(within(wednesday).getByText('WED · NEW')).color).toBe('var(--color-accent-2-900)') + expect(declaredStyle(tile('SAT · NEW')).background).toBe('var(--color-accent-2-300)') + // A Regular stays on the surface, with no NEW. + expect(declaredStyle(tile('THU')).background).toBe('var(--color-surface)') + expect(screen.queryByText('THU · NEW')).not.toBeInTheDocument() + }) + + it('shows a crock-pot dinner its start-by time in the household zone, and no other dinner one', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + expect(await screen.findByText('Start by 1:30 pm')).toBeInTheDocument() + expect(within(tile('WED · NEW')).getByText('Start by 1:30 pm')).toBeInTheDocument() + expect(screen.getAllByText(/^Start by/)).toHaveLength(1) + }) + + it.each([ + ['synced', 'Synced', 'var(--color-accent-2-200)'], + ['changed', 'Changes not pushed', 'var(--color-accent-100)'], + ])('tags a %s plan "%s"', async (status, label, background) => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)) }) + await renderPage() + + const tag = await screen.findByText(label) + expect(declaredStyle(tag).background).toBe(background) + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + }) + + it('offers Push to Calendar unavailable, with a tooltip saying why', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) + await renderPage() + + const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' }) + expect(push).toHaveAttribute('aria-disabled', 'true') + expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.') + expect(declaredStyle(push).minHeight).toBe('50px') + fireEvent.click(push) + expect(posts(fetchMock)).toEqual([]) + }) + + it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { + let plan = planBody('draft') + let job = draftJob('running') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toHaveAttribute('aria-busy', 'true') + expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7) + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + + plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) + job = draftJob('succeeded') + expect(await screen.findByText('Lasagna', {}, { timeout: 3000 })).toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + }) + + it.each(['synced', 'changed'])('asks before rebuilding a %s plan, and sends confirm', async (status) => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' })) + const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' }) + expect(dialog).toHaveTextContent('already pushed to Calendar') + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep this plan' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(posts(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Rebuild week' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {"confirm":true}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + }) + + it('offers "Draft this week" with no plan, and shows skeleton tiles while it drafts', async () => { + let plan: unknown = null + let job = draftJob('queued') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + expect(await screen.findByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Draft this week' })) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7) + expect(screen.getByText('Drafting your week…')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) + + plan = planBody('draft') + job = draftJob('succeeded') + expect(await screen.findByText('Beef tacos', {}, { timeout: 3000 })).toBeInTheDocument() + expect(screen.getByText('Draft')).toBeInTheDocument() + }) + + it('shows a failed draft with Retry and makes up no plan', async () => { + let job = draftJob('failed', 'ai_failed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' })) + const alert = await screen.findByRole('alert', {}, { timeout: 3000 }) + expect(alert).toHaveTextContent("Couldn't draft this week. The AI assistant couldn't draft it.") + expect(screen.getByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + + job = draftJob('running') + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`, `${DRAFT} {}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('moves between weeks with the previous and next week buttons', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + expect(screen.getByText('Week 42')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + }) + + it("opens on next week once this week's plan_draft time has come", async () => { + vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York + stubServer({ [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found') }) + await renderPage() + + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + }) + + it("reports a plan that can't be read, with Retry", async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(500, 'internal') }) + await renderPage() + + expect(await screen.findByRole('alert', {}, { timeout: 3000 })).toHaveTextContent("Couldn't load this week's plan. internal message") + }) +}) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx new file mode 100644 index 0000000..670dcf2 --- /dev/null +++ b/web/src/plan/PlanPage.tsx @@ -0,0 +1,266 @@ +import { useQueryClient } from '@tanstack/react-query' +import { ChevronLeft, ChevronRight, Plus } from 'lucide-react' +import { useEffect, useId, useMemo, useState } from 'react' +import { useJob } from '../api/index.ts' +import { Confirm } from '../settings/LeaveGuard.tsx' +import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' +import { Button } from '../ui/Button.tsx' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type PlanDay, type PlanStatus } from './api.ts' +import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts' +import styles from './PlanPage.module.css' + +// statusTags is the tag beside the week's title for each plan status. +const statusTags: Record = { + draft: { label: 'Draft', tone: 'grape' }, + synced: { label: 'Synced', tone: 'teal' }, + changed: { label: 'Changes not pushed', tone: 'grape' }, +} + +// draftFailures says why a draft_week job failed, by its failure code. Any +// other code is shown as it is. +const draftFailures: Record = { + not_enough_dinners: 'There are fewer than seven Regulars and Quest-log dinners to draft from.', + ai_failed: "The AI assistant couldn't draft it.", + confirm_required: 'The plan was pushed to Calendar while it was drafting.', + plan_changed: 'The plan was edited while it was drafting.', + internal_error: 'Something went wrong on the server.', +} + +// PUSH_HINT is why Push to Calendar is unavailable. +// TODO(Feature 16): connect Push to Calendar once Google Calendar is. +const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.' + +// pushedToCalendar says whether drafting over a plan of this status needs +// confirm: it was pushed to Calendar. +function pushedToCalendar(status: PlanStatus): boolean { + return status !== 'draft' +} + +// DayTile is one day of the Draft board: a Regular on the surface, a +// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's +// start-by time in the household's zone. +function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) { + const kicker = weekdayKicker(day.date) + return ( +
  • +

    {day.is_new ? `${kicker} · NEW` : kicker}

    +
    +

    {day.dinner.name}

    + {day.start_by !== null && ( +

    Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}

    + )} +
    +
  • + ) +} + +// SkeletonTiles stands in for the seven days while the plan loads or the AI +// drafts it. +function SkeletonTiles({ label, children }: { label: string; children?: string }) { + return ( +
    +
    + {Array.from({ length: 7 }, (_, i) => ( + + ))} +
    + {children !== undefined &&

    {children}

    } +
    + ) +} + +// WeekBoard is the Draft board of the week starting on weekStart. Drafting +// it — the empty state's "Draft this week" or the "+ Rebuild week" tile — +// starts the draft_week job and shows skeleton tiles until the job ends and +// the plan is read again. Rebuilding a plan pushed to Calendar asks first, +// and sends confirm. A failed draft is shown with Retry; no plan is made up. +function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezone: string; onWeek: (weekStart: string) => void }) { + const queryClient = useQueryClient() + const plan = usePlan(weekStart) + const startDraft = useStartDraft() + const [jobId, setJobId] = useState(null) + const job = useJob(DRAFT_JOB_KIND, jobId) + const [confirming, setConfirming] = useState(false) + const label = weekLabel(weekStart) + + // The draft's result, once the job has succeeded, or why it cannot be read. + const resultError = useMemo(() => { + if (job.status !== 'succeeded') return null + try { + parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result) + return null + } catch (error) { + return error as Error + } + }, [job.status, job.result, jobId, weekStart]) + + // Once the job ends the plan is read again: a succeeded draft is followed + // until that read lands, which then shows it; a failed one stays shown, + // and may have met a plan changed meanwhile. + const ended = job.status === 'succeeded' || job.status === 'failed' + const drafted = job.status === 'succeeded' && resultError === null + useEffect(() => { + if (!ended) return + let active = true + void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => { + if (active && drafted) setJobId(null) + }) + return () => { + active = false + } + }, [queryClient, ended, drafted, weekStart]) + + const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) + + const { mutate } = startDraft + function start(confirm: boolean) { + setConfirming(false) + // A draft asked for again leaves the last one's failure behind. + setJobId(null) + mutate( + { weekStart, confirm }, + { + onSuccess: setJobId, + // A plan pushed to Calendar meanwhile needs confirm: read it again. + onError: () => void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }), + }, + ) + } + // rebuild drafts the week, asking first over a plan pushed to Calendar. + function rebuild() { + if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) + else start(false) + } + + const failure = startDraft.isError ? ( + Couldn't start drafting this week. {startDraft.error.message} + ) : resultError !== null ? ( + Couldn't read the draft. {resultError.message} + ) : job.error === null ? null : job.status === 'failed' ? ( + + Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message} + + ) : ( + Couldn't check on the draft. {job.error.message} + ) + + let body + if (plan.isError) { + body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} + } else if (drafting) { + body = Drafting your week… + } else if (plan.isPending) { + body = + } else if (plan.data === null) { + body = ( + + I'll draft seven dinners from your Regulars and Quest log. + + ) + } else { + const { ai_note, days } = plan.data + body = ( + <> + {ai_note !== null && ( +
    + +

    {ai_note}

    +
    + )} +
      + {days.map((day) => ( + + ))} +
    • + +
    • +
    + + + ) + } + + const tag = plan.data ? statusTags[plan.data.status] : null + return ( + <> +
    +
    +

    {label.week}

    +
    +

    {label.dates}

    + {tag !== null && {tag.label}} +
    +
    +
    + onWeek(addDays(weekStart, -7))}> + + + onWeek(addDays(weekStart, 7))}> + + +
    +
    + {body} + {failure} + setConfirming(false)} + onConfirm={() => start(true)} + > + This week is already pushed to Calendar. The AI assistant drafts a new plan in its place. + + + ) +} + +// PushToCalendar is "Looks good — push to Calendar", unavailable until +// Google Calendar can be connected: it stays focusable, so a tap or focus +// shows the tooltip saying why. +function PushToCalendar() { + const hintId = useId() + return ( +
    + + + {PUSH_HINT} + +
    + ) +} + +// PlanPage is mock 1f, the mobile Draft board, on the week chosen with the +// previous / next week buttons. It opens on next week once this week's +// plan_draft time has come in the household's zone, otherwise on this week. +export function PlanPage() { + const settings = useHouseholdSettings() + const [week, setWeek] = useState(null) + if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date())) + + let content + if (settings.isError) { + content = ( + void settings.refetch()}>Couldn't load the household settings. {settings.error.message} + ) + } else if (week === null || !settings.data) { + content = + } else { + content = + } + return
    {content}
    +} diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts new file mode 100644 index 0000000..a726ea3 --- /dev/null +++ b/web/src/plan/api.ts @@ -0,0 +1,129 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { ApiError, useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { oneOf } from '../dinners/api.ts' + +export const PLANS_PATH = '/api/v1/plans' + +// planPath is the plan of the week starting on Monday weekStart. +export function planPath(weekStart: string): string { + return `${PLANS_PATH}/${encodeURIComponent(weekStart)}` +} + +// planKey is the query of the plan of the week starting on weekStart. +export function planKey(weekStart: string) { + return ['plans', weekStart] as const +} + +export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const + +// PlanStatus is draft until Push to Calendar syncs the plan; an edit of a +// synced plan makes it changed. +export type PlanStatus = (typeof PLAN_STATUSES)[number] + +// PlanDay is one day of a plan: its household-local date, the dinner +// planned, whether that dinner is a Quest-log pick, and, for a crock-pot +// dinner of known minutes, the instant it must start by. +export type PlanDay = { + date: string + dinner: { id: string; name: string } + is_new: boolean + start_by: string | null +} + +// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to +// Sunday, and the AI's note on the draft, if any. +export type Plan = { + id: string + week_start: string + status: PlanStatus + ai_note: string | null + days: PlanDay[] +} + +// DRAFT_JOB_KIND is the kind of the job drafting a week's plan. +export const DRAFT_JOB_KIND = 'draft_week' + +// DraftResult is the draft_week job's result: the week drafted and its plan. +export type DraftResult = { week_start: string; plan_id: string } + +function parseDay(where: string, body: unknown): PlanDay { + if ( + !isRecord(body) || + typeof body.date !== 'string' || + !isRecord(body.dinner) || + typeof body.dinner.id !== 'string' || + typeof body.dinner.name !== 'string' || + typeof body.is_new !== 'boolean' || + (body.start_by !== null && typeof body.start_by !== 'string') + ) { + throw new Error(`${where}: response has a day that is not a plan day`) + } + return { date: body.date, dinner: { id: body.dinner.id, name: body.dinner.name }, is_new: body.is_new, start_by: body.start_by } +} + +function parsePlan(where: string, weekStart: string, body: unknown): Plan { + if ( + !isRecord(body) || + typeof body.id !== 'string' || + body.week_start !== weekStart || + (body.ai_note !== null && typeof body.ai_note !== 'string') || + !Array.isArray(body.days) || + body.days.length !== 7 + ) { + throw new Error(`${where}: response is not the plan of the week of ${weekStart}`) + } + return { + id: body.id, + week_start: weekStart, + status: oneOf(where, 'status', PLAN_STATUSES, body.status), + ai_note: body.ai_note, + days: body.days.map((day) => parseDay(where, day)), + } +} + +// parseDraftResult checks a draft_week job's result, which useJob passes on +// unread, is the draft of the week starting on weekStart. +export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult { + if (!isRecord(body) || body.week_start !== weekStart || typeof body.plan_id !== 'string') { + throw new Error(`${where}: result is not the draft of the week of ${weekStart}`) + } + return { week_start: weekStart, plan_id: body.plan_id } +} + +// usePlan is the plan of the week starting on Monday weekStart (GET +// /api/v1/plans/{weekStart}), or null when the week has none (404). +export function usePlan(weekStart: string) { + const { apiFetch } = useApi() + return useQuery({ + queryKey: planKey(weekStart), + queryFn: async (): Promise => { + const path = planPath(weekStart) + let body: unknown + try { + body = await apiFetch(path) + } catch (error) { + if (error instanceof ApiError && error.status === 404) return null + throw error + } + return parsePlan(`GET ${path}`, weekStart, body) + }, + }) +} + +// useStartDraft asks the AI to draft the week starting on weekStart (POST +// /api/v1/plans/{weekStart}/draft) and resolves with the id of the +// draft_week job, which the server answers with the draft already running +// when there is one. Drafting over a plan pushed to Calendar (synced or +// changed) needs confirm. +export function useStartDraft() { + const { apiFetch } = useApi() + return useMutation({ + mutationFn: async ({ weekStart, confirm }: { weekStart: string; confirm: boolean }): Promise => { + const path = `${planPath(weekStart)}/draft` + const body = await apiFetch(path, { method: 'POST', body: confirm ? { confirm: true } : {} }) + if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${path}: response has no jobId`) + return body.jobId + }, + }) +} diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts new file mode 100644 index 0000000..211d211 --- /dev/null +++ b/web/src/plan/week.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { addDays, defaultWeek, localNow, weekdayKicker, 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' } + +describe('week helpers', () => { + it('finds the Monday of a week and moves by days across months and years', () => { + expect(weekStart('2026-10-07')).toBe('2026-10-05') + expect(weekStart('2026-10-05')).toBe('2026-10-05') + expect(weekStart('2026-10-11')).toBe('2026-10-05') + expect(addDays('2026-10-05', 7)).toBe('2026-10-12') + expect(addDays('2026-10-05', -7)).toBe('2026-09-28') + expect(addDays('2026-12-28', 7)).toBe('2027-01-04') + }) + + it('crosses a DST change without losing a day', () => { + expect(addDays('2026-10-26', 7)).toBe('2026-11-02') + expect(addDays('2027-03-08', 7)).toBe('2027-03-15') + }) + + it('labels weeks as the API does, by ISO week', () => { + expect(weekLabel('2026-10-05')).toEqual({ week: 'Week 41', dates: 'Oct 5 – 11' }) + expect(weekLabel('2026-09-28')).toEqual({ week: 'Week 40', dates: 'Sep 28 – Oct 4' }) + expect(weekLabel('2026-12-28')).toEqual({ week: 'Week 53', dates: 'Dec 28 – Jan 3' }) + expect(weekLabel('2027-01-04')).toEqual({ week: 'Week 1', dates: 'Jan 4 – 10' }) + }) + + it('names the day of a date as a tile kicker', () => { + expect(weekdayKicker('2026-10-05')).toBe('MON') + expect(weekdayKicker('2026-10-11')).toBe('SUN') + }) + + it('refuses a week start that is not a Monday, and a date that is not real', () => { + expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday') + expect(() => addDays('2026-02-30', 1)).toThrow('not a real date') + expect(() => weekStart('Oct 5')).toThrow('not a YYYY-MM-DD date') + }) + + it('reads the household-local date and clock in its zone', () => { + // 03:30 UTC on Thursday is still Wednesday evening in New York. + expect(localNow('America/New_York', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-07', time: '23:30' }) + expect(localNow('Asia/Tokyo', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-08', time: '12:30' }) + }) + + it('opens on this week until the plan_draft time, then on next week', () => { + expect(defaultWeek(SCHEDULE, new Date('2026-10-07T16:00:00Z'))).toBe('2026-10-05') // Wed noon + expect(defaultWeek(SCHEDULE, new Date('2026-10-09T12:59:00Z'))).toBe('2026-10-05') // Fri 08:59 + expect(defaultWeek(SCHEDULE, new Date('2026-10-09T13:00:00Z'))).toBe('2026-10-12') // Fri 09:00 + expect(defaultWeek(SCHEDULE, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-12') // Sat + expect(defaultWeek(SCHEDULE, new Date('2026-10-12T04:30:00Z'))).toBe('2026-10-12') // the next Mon, 00:30 + }) + + it('counts a Sunday plan_draft day as the end of the Monday-start week', () => { + const sunday = { ...SCHEDULE, plan_draft_dow: 0, plan_draft_time: '18:00' } + 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 + }) +}) diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts new file mode 100644 index 0000000..d0ecb54 --- /dev/null +++ b/web/src/plan/week.ts @@ -0,0 +1,98 @@ +// Household-local calendar helpers for the weekly plan, mirroring +// internal/household/week.go. Weeks are ISO weeks, Monday to Sunday. Dates +// are household-local YYYY-MM-DD strings; arithmetic is done on UTC +// midnights, which have no DST, so a date plus seven days is always the same +// weekday a week later. + +const localDate = /^(\d{4})-(\d{2})-(\d{2})$/ + +const DAY_MS = 24 * 60 * 60 * 1000 + +const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const + +// midnightUTC reads date as midnight UTC, or throws when it is not a real +// YYYY-MM-DD date. +function midnightUTC(date: string): Date { + const match = localDate.exec(date) + if (match === null) throw new Error(`not a YYYY-MM-DD date: "${date}"`) + const t = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))) + if (formatDate(t) !== date) throw new Error(`not a real date: "${date}"`) + return t +} + +function formatDate(t: Date): string { + return t.toISOString().slice(0, 10) +} + +// addDays returns date moved by days, which may be negative. +export function addDays(date: string, days: number): string { + return formatDate(new Date(midnightUTC(date).getTime() + days * DAY_MS)) +} + +// weekStart returns the Monday of the week containing date. +export function weekStart(date: string): string { + const sinceMonday = (midnightUTC(date).getUTCDay() + 6) % 7 + return addDays(date, -sinceMonday) +} + +// weekdayKicker is date's day as a tile kicker: "MON" to "SUN". +export function weekdayKicker(date: string): string { + return WEEKDAYS[midnightUTC(date).getUTCDay()]! +} + +// monday reads start as midnight UTC, or throws when it is not a Monday. +function monday(start: string): Date { + const t = midnightUTC(start) + if (t.getUTCDay() !== 1) throw new Error(`week start ${start} is not a Monday`) + return t +} + +// isoWeek is the ISO 8601 week number of the week starting on Monday start: +// the week holding that week's Thursday's year's first Thursday counts as 1. +function isoWeek(start: Date): number { + const thursday = new Date(start.getTime() + 3 * DAY_MS) + const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1) + return Math.floor((thursday.getTime() - yearStart) / DAY_MS / 7) + 1 +} + +const monthDay = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) + +// WeekLabel is the week starting on a Monday as the plan's header shows it: +// "Week 41" over "Oct 5 – 11", or "Sep 28 – Oct 4" when the week spans two +// months. Together they are the API's week_label. +export type WeekLabel = { week: string; dates: string } + +// weekLabel labels the week starting on Monday start. +export function weekLabel(start: string): WeekLabel { + const first = monday(start) + const last = new Date(first.getTime() + 6 * DAY_MS) + const end = last.getUTCMonth() === first.getUTCMonth() ? String(last.getUTCDate()) : monthDay.format(last) + return { week: `Week ${isoWeek(first)}`, dates: `${monthDay.format(first)} – ${end}` } +} + +// localNow is the household-local date (YYYY-MM-DD) and clock (24-hour +// HH:MM) at instant now in IANA zone. +export function localNow(zone: string, now: Date): { date: string; time: string } { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(now) + const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)!.value + return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` } +} + +// defaultWeek is the Monday of the week the plan opens on: next week once +// this week's plan_draft time (day of the week counting from Sunday = 0, +// 24-hour HH:MM, both household-local) has come, otherwise this week. +export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number; plan_draft_time: string }, now: Date): string { + const today = localNow(schedule.timezone, now) + const thisWeek = weekStart(today.date) + const draftDay = addDays(thisWeek, (schedule.plan_draft_dow + 6) % 7) + const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time) + return drafted ? addDays(thisWeek, 7) : thisWeek +} -- 2.55.0 From 87381fa4b9f52704bdbe1732baf2d34f40bf1f9b Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 11:38:54 -0400 Subject: [PATCH 2/4] Hold a succeeded draft until the plan read is the one drafted The Draft board let go of a succeeded draft_week job as soon as the plan was read again, whatever that read returned: a missing plan showed as "Nothing planned yet" and another plan as the new draft. Keep the job's plan_id and clear the job only once the plan read has that id; a missing or different plan is shown as "Couldn't read the draft" with Retry. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 34 +++++++++++++++++++++++++++++++-- web/src/plan/PlanPage.tsx | 35 +++++++++++++++++++++++----------- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index ebb6c00..2e53e0a 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -46,8 +46,10 @@ function planBody(status: string, names = NAMES) { } } -function draftJob(status: string, error: string | null = null) { - const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: 'plan-2' } : null +// draftJob is job j1 drafting the week of Oct 5 into plan planId: a +// redraft keeps the plan's id. +function draftJob(status: string, error: string | null = null, planId = 'plan-1') { + const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: planId } : null return { id: 'j1', kind: 'draft_week', status, result, error } } @@ -293,6 +295,34 @@ describe('PlanPage', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) + it.each([ + ['the week has no plan', null, 'The week has no plan, not draft plan-1.'], + ['the week has another plan', { ...planBody('draft'), id: 'plan-9' }, "The week's plan is not draft plan-1."], + ])('shows a succeeded draft with Retry when %s once read', async (_, landed, message) => { + let plan: unknown = null + let job = draftJob('running') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' })) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + plan = landed + job = draftJob('succeeded') + const alert = await screen.findByRole('alert', {}, { timeout: 3000 }) + expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`) + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + + job = draftJob('running') + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(posts(fetchMock)).toHaveLength(2)) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + it('moves between weeks with the previous and next week buttons', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 670dcf2..8af6e34 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -11,7 +11,7 @@ import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' -import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type PlanDay, type PlanStatus } from './api.ts' +import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts' import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts' import styles from './PlanPage.module.css' @@ -90,31 +90,44 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon const label = weekLabel(weekStart) // The draft's result, once the job has succeeded, or why it cannot be read. - const resultError = useMemo(() => { - if (job.status !== 'succeeded') return null + const result = useMemo((): { draft: DraftResult | null; error: Error | null } => { + if (job.status !== 'succeeded') return { draft: null, error: null } try { - parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result) - return null + return { draft: parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result), error: null } } catch (error) { - return error as Error + return { draft: null, error: error as Error } } }, [job.status, job.result, jobId, weekStart]) // Once the job ends the plan is read again: a succeeded draft is followed - // until that read lands, which then shows it; a failed one stays shown, - // and may have met a plan changed meanwhile. + // until that read lands; a failed one stays shown, and may have met a + // plan changed meanwhile. + const [readJobId, setReadJobId] = useState(null) const ended = job.status === 'succeeded' || job.status === 'failed' - const drafted = job.status === 'succeeded' && resultError === null useEffect(() => { if (!ended) return let active = true void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => { - if (active && drafted) setJobId(null) + if (active) setReadJobId(jobId) }) return () => { active = false } - }, [queryClient, ended, drafted, weekStart]) + }, [queryClient, ended, jobId, weekStart]) + + // The plan read shows once it is the one drafted; no plan, or another + // one, is an error rather than an empty or stale board. + const { draft } = result + const read = draft !== null && readJobId === jobId && plan.isSuccess + const landed = read && plan.data?.id === draft.plan_id + const resultError = + result.error ?? + (read && !landed + ? new Error(plan.data === null ? `The week has no plan, not draft ${draft.plan_id}.` : `The week's plan is not draft ${draft.plan_id}.`) + : null) + useEffect(() => { + if (landed) setJobId(null) + }, [landed]) const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) -- 2.55.0 From 58c415ca1abf9a9b855b69bb791ba838114350ba Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 12:01:07 -0400 Subject: [PATCH 3/4] Land a succeeded draft only on a plan read from the server A redraft keeps the plan's id, so a plan the service worker answered from its cache could pass for the finished rebuild. The API client's apiRead now says whether a GET was the cached copy; the plan carries it, and the Draft board shows the retryable draft-unread error instead of a cached plan with the drafted id. A later read from the server still lands the draft. Co-Authored-By: Claude Opus 5.5 --- web/src/api/client.test.ts | 10 ++++++++ web/src/api/client.ts | 20 +++++++++++++++- web/src/api/index.ts | 2 +- web/src/plan/PlanPage.test.tsx | 42 +++++++++++++++++++++++++++++++++- web/src/plan/PlanPage.tsx | 19 +++++++++++---- web/src/plan/api.ts | 17 ++++++++------ 6 files changed, 96 insertions(+), 14 deletions(-) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index c9bdb84..15390e7 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -225,4 +225,14 @@ describe('network status', () => { await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' }) expect(api.network.offline()).toBe(false) }) + + it('says whether a read is the service worker\'s cached copy', async () => { + const cached = jsonResponse(200, { id: '01J9ZR' }) + cached.headers.set(cacheFallbackHeader, '1') + stubFetch(cached, jsonResponse(200, { id: '01J9ZR' })) + const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) + + await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: true }) + await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: false }) + }) }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index cbfc0f0..1fc884d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -43,11 +43,18 @@ export type ApiRequest = { body?: unknown } +// ApiRead is a GET's decoded response body, and whether the service worker +// answered it from its cache because the server could not be reached in time. +export type ApiRead = { body: T; cached: boolean } + export type ApiClient = { // apiFetch sends a same-origin JSON request to path, which must be under // /api/v1/, and resolves with the decoded response body, or rejects with an // ApiError. apiFetch(path: string, request?: ApiRequest): Promise + // apiRead is apiFetch's GET that also says whether the body is the service + // worker's cached copy rather than the server's answer. + apiRead(path: string): Promise> // loadMe fetches GET /api/v1/me and caches its CSRF token. loadMe(): Promise // forbidden reports whether a request was refused because the signed-in @@ -114,6 +121,16 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s } async function apiFetch(path: string, request: ApiRequest = {}): Promise { + return (await exchange(path, request)).body + } + + function apiRead(path: string): Promise> { + return exchange(path, {}) + } + + // exchange sends request to path and decodes the response, noting whether + // it is the service worker's cached copy. + async function exchange(path: string, request: ApiRequest): Promise> { const method = request.method ?? 'GET' const url = apiURL(location.origin, method, path) // fetch refuses a GET with a body by rejecting with a TypeError, the same @@ -131,7 +148,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s response = await send(method, url, request.body) if (response.status === 403) throw await parseForbidden(method, path, response) } - return decode(method, path, response) + return { body: await decode(method, path, response), cached: response.headers.has(cacheFallbackHeader) } } // parseForbidden reads a 403. Any API error other than csrf means the @@ -179,6 +196,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s return { apiFetch, + apiRead, loadMe, forbidden: () => forbidden, subscribe(listener) { diff --git a/web/src/api/index.ts b/web/src/api/index.ts index cae3e1f..2f59f3a 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -1,5 +1,5 @@ export { ApiProvider, useApi } from './ApiContext.tsx' -export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts' +export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, type ApiLocation, type ApiRequest, type Me } from './client.ts' export { ApiError } from './errors.ts' export { createQueryClient, shouldRetry } from './queryClient.ts' export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts' diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 2e53e0a..9f4e916 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -1,8 +1,9 @@ import { QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { cacheFallbackHeader } from '../sw/routes.ts' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' @@ -323,6 +324,45 @@ describe('PlanPage', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) + it("doesn't land a succeeded draft on the plan cached before it, and lands it once the server is read", async () => { + let plan = planBody('draft') + let cached = false + let job = draftJob('running') + stubServer({ + [`GET ${THIS_WEEK}`]: () => { + const response = jsonResponse(200, plan) + if (cached) response.headers.set(cacheFallbackHeader, '1') + return response + }, + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, job), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' })) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + // The draft succeeds, but the server can't be reached: the service + // worker answers with the plan cached before the rebuild, same id. + cached = true + job = draftJob('succeeded') + const alert = await screen.findByRole('alert', {}, { timeout: 3000 }) + expect(alert).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + + // The server is back: the next read of the plan is the draft. + plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) + cached = false + vi.setSystemTime(new Date('2026-10-07T16:01:00Z')) + act(() => { + window.dispatchEvent(new Event('visibilitychange')) + }) + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + }) + it('moves between weeks with the previous and next week buttons', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 8af6e34..0f1bcd5 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -115,15 +115,23 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon } }, [queryClient, ended, jobId, weekStart]) - // The plan read shows once it is the one drafted; no plan, or another - // one, is an error rather than an empty or stale board. + // The plan read shows once it is the one drafted, read from the server; no + // plan, another one, or a copy cached before the draft (a redraft keeps the + // plan's id) is an error rather than an empty or stale board. A later read + // from the server that is the draft still lands it. const { draft } = result const read = draft !== null && readJobId === jobId && plan.isSuccess - const landed = read && plan.data?.id === draft.plan_id + const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached const resultError = result.error ?? (read && !landed - ? new Error(plan.data === null ? `The week has no plan, not draft ${draft.plan_id}.` : `The week's plan is not draft ${draft.plan_id}.`) + ? new Error( + plan.data === null + ? `The week has no plan, not draft ${draft.plan_id}.` + : plan.data.id !== draft.plan_id + ? `The week's plan is not draft ${draft.plan_id}.` + : `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`, + ) : null) useEffect(() => { if (landed) setJobId(null) @@ -168,6 +176,9 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} } else if (drafting) { body = Drafting your week… + } else if (resultError !== null) { + // An unread draft shows no board: the plan read is not the draft. + body = null } else if (plan.isPending) { body = } else if (plan.data === null) { diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index a726ea3..17b4539 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query' -import { ApiError, useApi } from '../api/index.ts' +import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { oneOf } from '../dinners/api.ts' @@ -32,13 +32,15 @@ export type PlanDay = { } // Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to -// Sunday, and the AI's note on the draft, if any. +// Sunday, and the AI's note on the draft, if any. cached says the service +// worker answered from its cache: the server may have changed the plan since. export type Plan = { id: string week_start: string status: PlanStatus ai_note: string | null days: PlanDay[] + cached: boolean } // DRAFT_JOB_KIND is the kind of the job drafting a week's plan. @@ -62,7 +64,7 @@ function parseDay(where: string, body: unknown): PlanDay { return { date: body.date, dinner: { id: body.dinner.id, name: body.dinner.name }, is_new: body.is_new, start_by: body.start_by } } -function parsePlan(where: string, weekStart: string, body: unknown): Plan { +function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan { if ( !isRecord(body) || typeof body.id !== 'string' || @@ -79,6 +81,7 @@ function parsePlan(where: string, weekStart: string, body: unknown): Plan { status: oneOf(where, 'status', PLAN_STATUSES, body.status), ai_note: body.ai_note, days: body.days.map((day) => parseDay(where, day)), + cached, } } @@ -94,19 +97,19 @@ export function parseDraftResult(where: string, weekStart: string, body: unknown // usePlan is the plan of the week starting on Monday weekStart (GET // /api/v1/plans/{weekStart}), or null when the week has none (404). export function usePlan(weekStart: string) { - const { apiFetch } = useApi() + const { apiRead } = useApi() return useQuery({ queryKey: planKey(weekStart), queryFn: async (): Promise => { const path = planPath(weekStart) - let body: unknown + let read: ApiRead try { - body = await apiFetch(path) + read = await apiRead(path) } catch (error) { if (error instanceof ApiError && error.status === 404) return null throw error } - return parsePlan(`GET ${path}`, weekStart, body) + return parsePlan(`GET ${path}`, weekStart, read.body, read.cached) }, }) } -- 2.55.0 From 870f3462cedceb7d6cf7c66def6522ed9e64aa02 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 12:22:17 -0400 Subject: [PATCH 4/4] Retry an unread draft by reading the plan again A succeeded draft whose plan read is missing, another plan, or a cache fallback now retries the plan GET and keeps the job, instead of starting a new draft. Rebuilding stays on "+ Rebuild week". The tests assert Retry sends no draft POST, re-reads the plan, keeps the error while the read is still a cache fallback, and lands the draft on a live matching read. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 37 ++++++++++++++++++++++++++++------ web/src/plan/PlanPage.tsx | 19 +++++++++-------- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 9f4e916..1ecd898 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -81,6 +81,11 @@ function posts(fetchMock: Mock) { .map(([input, init]) => `${String(input)} ${String(init?.body)}`) } +// planReads counts the reads of this week's plan. +function planReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === THIS_WEEK).length +} + // renderPage loads /me (for the CSRF token every write needs) and renders // the Plan page. async function renderPage() { @@ -299,7 +304,7 @@ describe('PlanPage', () => { it.each([ ['the week has no plan', null, 'The week has no plan, not draft plan-1.'], ['the week has another plan', { ...planBody('draft'), id: 'plan-9' }, "The week's plan is not draft plan-1."], - ])('shows a succeeded draft with Retry when %s once read', async (_, landed, message) => { + ])('shows a succeeded draft with Retry when %s once read, and Retry reads the plan again', async (_, landed, message) => { let plan: unknown = null let job = draftJob('running') const fetchMock = stubServer({ @@ -317,18 +322,30 @@ describe('PlanPage', () => { expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`) expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() - job = draftJob('running') + // Retry reads the plan again rather than drafting again: the read is + // still not the draft. + let reads = planReads(fetchMock) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) - await waitFor(() => expect(posts(fetchMock)).toHaveLength(2)) - expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1)) + expect(await screen.findByRole('alert')).toHaveTextContent(`Couldn't read the draft. ${message}`) + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) + + // The next read is the draft, from the server: it lands. + plan = planBody('draft') + reads = planReads(fetchMock) + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + expect(planReads(fetchMock)).toBe(reads + 1) expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) }) it("doesn't land a succeeded draft on the plan cached before it, and lands it once the server is read", async () => { let plan = planBody('draft') let cached = false let job = draftJob('running') - stubServer({ + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => { const response = jsonResponse(200, plan) if (cached) response.headers.set(cacheFallbackHeader, '1') @@ -347,10 +364,18 @@ describe('PlanPage', () => { job = draftJob('succeeded') const alert = await screen.findByRole('alert', {}, { timeout: 3000 }) expect(alert).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server") - expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument() + // Retry reads the plan again and drafts nothing: still the cache, so the + // draft stays unread. + const reads = planReads(fetchMock) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1)) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server") + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]) + // The server is back: the next read of the plan is the draft. plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) cached = false diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 0f1bcd5..10d8473 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -117,14 +117,14 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon // The plan read shows once it is the one drafted, read from the server; no // plan, another one, or a copy cached before the draft (a redraft keeps the - // plan's id) is an error rather than an empty or stale board. A later read - // from the server that is the draft still lands it. + // plan's id) is an error rather than an empty or stale board. Its Retry + // reads the plan again, keeping the job: a later read from the server that + // is the draft still lands it. const { draft } = result const read = draft !== null && readJobId === jobId && plan.isSuccess const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached - const resultError = - result.error ?? - (read && !landed + const unreadError = + read && !landed ? new Error( plan.data === null ? `The week has no plan, not draft ${draft.plan_id}.` @@ -132,7 +132,8 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon ? `The week's plan is not draft ${draft.plan_id}.` : `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`, ) - : null) + : null + const resultError = result.error ?? unreadError useEffect(() => { if (landed) setJobId(null) }, [landed]) @@ -161,8 +162,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon const failure = startDraft.isError ? ( Couldn't start drafting this week. {startDraft.error.message} - ) : resultError !== null ? ( - Couldn't read the draft. {resultError.message} + ) : result.error !== null ? ( + Couldn't read the draft. {result.error.message} + ) : unreadError !== null ? ( + void plan.refetch()}>Couldn't read the draft. {unreadError.message} ) : job.error === null ? null : job.status === 'failed' ? ( Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message} -- 2.55.0