import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router' 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 { SupplyRunPage } from '../shopping/SupplyRunPage.tsx' import { cacheFallbackHeader } from '../sw/routes.ts' import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { mockViewport } from '../test/matchMedia.ts' import { ago, cooking, duration, PlanPage } from './PlanPage.tsx' const THIS_WEEK = '/api/v1/plans/2026-10-05' const NEXT_WEEK = '/api/v1/plans/2026-10-12' const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28' const DRAFT = `${THIS_WEEK}/draft` const PUSH = `${THIS_WEEK}/push-calendar` 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. Monday's tacos have a stored photo. A plan // pushed to Calendar was pushed two minutes before the tests' noon. 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-07T15:58:00Z', days: DAYS.map((date, i) => ({ date, dinner: { id: `d${i}`, name: names[i], photo: i === 0 ? '/media/photos/tacos-400.jpg' : 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', } } // syncedNow is the plan of the week of Oct 5 as a push answers it: synced // at the tests' noon, with what became of the week's Supply run, made // unless told otherwise. function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) { return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun } } // The Supply run outcomes a push answers with, as the server sends them. const GENERATED = { status: 'generated', error: null } const ARCHIVED = { status: 'archived', error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false }, } const PLAN_CHANGED = { status: 'failed', error: { code: 'plan_changed', message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again', retryable: true, }, } const SUPPLY_RUN_FAILED = { status: 'failed', error: { code: 'supply_run_failed', message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run', retryable: true, }, } // deferred is a response the test sends when it chooses. function deferred() { let send!: (response: Response) => void const response = new Promise((resolve) => { send = resolve }) return { response, send } } // 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 } } type Responder = (init: RequestInit | undefined) => Response | Promise const QUEST_LOG = '/api/v1/dinners?status=quest_log' const REGULARS = '/api/v1/dinners?status=regular' // dinner is a dinner as the dinners API lists it. function dinner(id: string, name: string, difficulty: string | null, minutes: number | null) { return { id, name, method: 'stovetop', minutes_total: minutes, difficulty, is_favorite: false, photo_url: null, grounding_status: 'grounded', } } // The Quest log has lemon orzo; the Regulars are Monday's tacos, already // this week, and fish tacos. const lists = { [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25)] }), [`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }), } // SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}. const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/ // stubServer answers "METHOD path" requests from responders, with /me and // the household settings, and no Supply run (404) for a week given none; // 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] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : 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)}`) } // 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 at entry, over queryClient, with shopping as the Supply run // screen. async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = , entry = '/plan') { const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( } /> , ) } // Opened stands in for the screen a link opened, saying where it is, with // a link back to the Plan. function Opened() { const { pathname, search } = useLocation() return ( <>

Opened {pathname + search}

Back to the Plan ) } // Searched shows the Plan's search, the week it keeps in ?week=. function Searched() { return

{useLocation().search}

} // pushesKept counts the pushes of the week of Oct 5 the mutation cache of // queryClient keeps. function pushesKept(queryClient: QueryClient) { return queryClient.getMutationCache().findAll({ mutationKey: ['plans', '2026-10-05', 'push'] }).length } // SupplyRunScreen is the Supply run screen, with a link back to the Plan. function SupplyRunScreen() { return ( <> Back to the Plan ) } // supplyRun is the Supply run of the week of Oct 5 with needed of its 32 // items still to buy. function supplyRun(needed: number) { return { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', title: null, status: 'current', sent_to_chat_at: null, chat_send_unconfirmed_at: null, needed_count: needed, total_count: 32, groups: [], } } // 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.querySelector('button')!.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('pushes the plan to Calendar, showing progress, then the plan as synced', async () => { const answer = deferred() const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => answer.response, }) await renderPage() expect(await screen.findByText('Not pushed to Google Calendar yet')).toBeInTheDocument() const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) expect(declaredStyle(push).minHeight).toBe('50px') fireEvent.click(push) await waitFor(() => expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])) const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST')! expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) // In progress: the button says so and is disabled, as is Rebuild week, // and no day can be swapped. const pushing = await screen.findByRole('button', { name: 'Pushing to Calendar…' }) expect(pushing).toBeDisabled() expect(pushing).toHaveAttribute('aria-busy', 'true') expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() fireEvent.click(within(tile('MON')).getByRole('button')) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() answer.send(jsonResponse(200, syncedNow())) const status = await screen.findByText('Synced to Google Calendar just now') expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') expect(status.querySelector('svg')).not.toBeNull() expect(screen.getByText('Synced')).toBeInTheDocument() expect(screen.queryByText('Draft')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() expect(screen.queryByRole('alert')).not.toBeInTheDocument() // The plan shown is the one the push answered with: no second read. expect(planReads(fetchMock)).toBe(1) }) it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')) }) await renderPage() const status = await screen.findByText('Synced to Google Calendar 2 min ago') expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') expect(status.querySelector('svg')).not.toBeNull() }) it('says changes to a pushed plan are not pushed, and pushes them', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow()), }) await renderPage() expect(await screen.findByText('Changes not pushed')).toBeInTheDocument() const status = screen.getByText('Changes not pushed to Google Calendar') expect(declaredStyle(status).color).toBe('var(--color-neutral-700)') fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(screen.queryByText('Changes not pushed')).not.toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) }) it.each([ ['google_reconnect_required', 'Google access was revoked or expired.', 'Reconnect Google Calendar'], ['no_calendar', 'No calendar is chosen for dinners yet.', 'Choose a calendar in Settings'], ['not_connected', 'Google Calendar is not connected.', 'Connect Google Calendar'], ['calendar_unavailable', 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', 'Choose another calendar'], ])('sends a %s push failure to the Google Calendar settings', async (code, reason, fix) => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => errorResponse(409, code), }) await renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent(`Couldn't push to Calendar. ${reason} ${fix}`) expect(within(alert).getByRole('link', { name: fix })).toHaveAttribute('href', '/settings/calendar') expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() // The plan is as it was: not synced. expect(screen.getByText('Draft')).toBeInTheDocument() expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() }) it('marks the Google Calendar connection to be read again when Google refused the grant', async () => { const connection = { status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners', connected_at: null, last_sync_error: null } const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) const queryClient = createQueryClient() queryClient.setQueryData(['integrations', 'google'], connection) const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'), }) await api.loadMe() render( , ) fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByRole('link', { name: 'Reconnect Google Calendar' })).toBeInTheDocument() // The settings screen reads it again as it opens: it is now reconnect // required. expect(queryClient.getQueryState(['integrations', 'google'])?.isInvalidated).toBe(true) expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) }) it('says why any other push failed, and pushes again on Retry', async () => { let push = () => errorResponse(502, 'google_failed') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => push(), }) await renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't push to Calendar. google_failed message") expect(within(alert).queryByRole('link')).not.toBeInTheDocument() push = () => jsonResponse(200, syncedNow()) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) it('reads the plan again when it changed while being pushed', async () => { let plan = planBody('draft') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), [`POST ${PUSH}`]: () => errorResponse(409, 'plan_changed'), }) await renderPage() await screen.findByText('Beef tacos') plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)]) fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByRole('alert')).toHaveTextContent( "Couldn't push to Calendar. The plan changed while it was being pushed; push it again.", ) expect(await screen.findByText('Lasagna')).toBeInTheDocument() expect(planReads(fetchMock)).toBe(2) }) it("reads the week's Supply run again once a push has made it, and says it was made", async () => { let run = () => errorResponse(404, 'not_found') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => { run = () => jsonResponse(200, supplyRun(28)) return jsonResponse(200, syncedNow(GENERATED)) }, 'GET /api/v1/shopping/2026-10-05': () => run(), }) await renderPage() expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") // The list made is read at once, not after the cached 404 goes stale. expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2) expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('says a Supply run in the Supply archive was left as it was', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)), }) await renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.") expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => { let plan = planBody('draft') let push = () => { plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)]) return jsonResponse(200, syncedNow(PLAN_CHANGED)) } const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), [`POST ${PUSH}`]: () => push(), }) await renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent( 'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.', ) expect(screen.queryByRole('status')).not.toBeInTheDocument() // The plan edited meanwhile is the one shown, not the one pushed. expect(await screen.findByText('Lasagna')).toBeInTheDocument() expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument() expect(planReads(fetchMock)).toBe(2) push = () => jsonResponse(200, syncedNow(GENERATED)) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) it('says the Supply run could not be made after the push, and makes the shopping list again', async () => { let generate = () => errorResponse(500, 'internal') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), 'POST /api/v1/shopping/2026-10-05/generate': () => generate(), }) await renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) // Calendar has the plan, but the push is not reported a quiet success. expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") expect(screen.queryByRole('status')).not.toBeInTheDocument() fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' })) expect( await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"), ).toBeInTheDocument() generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] }) fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() expect(posts(fetchMock)).toEqual([ `${PUSH} undefined`, '/api/v1/shopping/2026-10-05/generate undefined', '/api/v1/shopping/2026-10-05/generate undefined', ]) }) it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), }) await renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") // A Supply run never made is still said after leaving the Plan and // coming back. fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' })) expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") // Once made, it is not said again to be unmade. fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' })) fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' })) expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() }) it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => { const made = deferred() stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), 'POST /api/v1/shopping/2026-10-05/generate': () => made.response, }) await renderPage() fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…') // The week is left while the shopping list is being made, which fails. fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() await act(async () => made.send(errorResponse(500, 'internal'))) // Coming back, the Supply run is still said to be unmade, and can be // made again. fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled() }) it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => { const made = deferred() stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), 'POST /api/v1/shopping/2026-10-05/generate': () => made.response, }) const queryClient = createQueryClient() await renderPage(queryClient) fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' })) expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…') // The push is kept while the week is left, until the list is made: it // is then settled, not merely swept with the week. fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() expect(pushesKept(queryClient)).toBe(1) await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] }))) expect(pushesKept(queryClient)).toBe(0) fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) expect(await screen.findByText('Beef tacos')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() }) it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), 'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }), }) const queryClient = createQueryClient() await renderPage(queryClient, ) fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") // The list is made on the Supply run screen, not from the Plan's alert. fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' })) await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()) expect(pushesKept(queryClient)).toBe(0) fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' })) expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument() }) it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)), 'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'), }) const queryClient = createQueryClient() await renderPage(queryClient, ) fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") fireEvent.click(screen.getByRole('link', { name: 'Shopping list' })) fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' })) expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.") expect(pushesKept(queryClient)).toBe(1) fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.") expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled() }) it('pushes nothing again until the changed plan is read and shown', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => read(), [`POST ${PUSH}`]: () => push(), }) await renderPage() await screen.findByText('Beef tacos') const reread = deferred() read = () => reread.response const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) fireEvent.click(pushButton) await waitFor(() => expect(planReads(fetchMock)).toBe(2)) // While the changed plan is read, the push stays busy: neither Push // nor Retry sends it again. expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled() expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' })) expect(screen.getByText('Beef tacos')).toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") expect(screen.getByText('Lasagna')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() push = () => jsonResponse(200, syncedNow()) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) it('pushes nothing again until the changed plan is shown, even after leaving the week and coming back', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => read(), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), [`POST ${PUSH}`]: () => push(), }) await renderPage() await screen.findByText('Beef tacos') const reread = deferred() read = () => reread.response fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) await waitFor(() => expect(planReads(fetchMock)).toBe(2)) // Leaving the week and coming back while the changed plan is read // remounts the board: the push is still busy, and sends nothing again. fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) await screen.findByText('Beef tacos') expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled() expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' })) expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") expect(screen.getByText('Lasagna')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() push = () => jsonResponse(200, syncedNow()) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) it("pushes nothing again while the changed plan's read is answered from the cache", async () => { let read = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => read(), [`POST ${PUSH}`]: () => push(), }) await renderPage() await screen.findByText('Beef tacos') // The server can't be reached: the service worker answers the read of // the changed plan with the plan it cached before. read = () => { const response = jsonResponse(200, planBody('draft')) response.headers.set(cacheFallbackHeader, '1') return response } fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) const cachedCopy = "The plan changed while it was being pushed. The plan shown came from this device's cache, not the server; read it again before pushing." const failed = await screen.findByRole('alert') expect(planReads(fetchMock)).toBe(2) expect(screen.getByText('Beef tacos')).toBeInTheDocument() const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) expect(pushButton).toBeDisabled() fireEvent.click(pushButton) expect(failed).toHaveTextContent(cachedCopy) // Retry reads the plan again rather than pushing: still the cache, so // nothing is pushed. fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) await waitFor(() => expect(planReads(fetchMock)).toBe(3)) expect(await screen.findByRole('alert')).toHaveTextContent(cachedCopy) expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeDisabled() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) // The server is back: Retry shows the changed plan, and only then may it // be pushed. read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])) fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) expect(await screen.findByText('Lasagna')).toBeInTheDocument() const alert = screen.getByRole('alert') expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) push = () => jsonResponse(200, syncedNow()) fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) it('shows the changed plan read while the week was left, and pushes it', async () => { let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) let push = () => errorResponse(409, 'plan_changed') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => read(), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), [`POST ${PUSH}`]: () => push(), }) await renderPage() await screen.findByText('Beef tacos') const reread = deferred() read = () => reread.response fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) await waitFor(() => expect(planReads(fetchMock)).toBe(2)) fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])) fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) // The board comes back on the changed plan: Push sends that one. expect(await screen.findByText('Lasagna')).toBeInTheDocument() expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() expect(screen.getByRole('alert')).toHaveTextContent('The plan changed while it was being pushed; push it again.') push = () => jsonResponse(200, syncedNow()) fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) 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.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, and Retry reads the plan again', 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() // 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(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') const fetchMock = 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(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 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("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)), }) await renderPage() const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' }) expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)') // It follows the push button and the sync status. const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() fireEvent.click(shopping) expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() }) it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => { let failing = true const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))), }) await renderPage() const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message") expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') // Retry reads it again. failing = false fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' }) expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2) fireEvent.click(shopping) expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() }) it("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => { let failing = false stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))), }) await renderPage() expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument() // Coming back to the page once the Supply run is stale reads it again, // and that read fails. failing = true vi.setSystemTime(new Date('2026-10-07T16:01:00Z')) act(() => { window.dispatchEvent(new Event('visibilitychange')) }) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message") expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument() // Retry reads it again, and the count is back. failing = false fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('links to the Supply run with no count while the week has no shopping list', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) }) await renderPage() expect(await screen.findByText('Beef tacos')).toBeInTheDocument() await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05')) const shopping = screen.getByRole('link', { name: 'Shopping list' }) expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') // No list is not a failure. expect(screen.queryByRole('alert')).not.toBeInTheDocument() fireEvent.click(shopping) 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(), , '/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(), , '/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')), [`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") }) describe('swap sheet', () => { const FRIDAY = `${THIS_WEEK}/days/2026-10-09` // swappedBody is the plan as the server answers a swap of Friday to // lemon orzo, a Quest-log pick. function swappedBody() { const body = planBody('draft') body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } return body } // puts lists the PUTs sent, as "path body". function puts(fetchMock: Mock) { return fetchMock.mock.calls .filter(([, init]) => init?.method === 'PUT') .map(([input, init]) => `${String(input)} ${String(init?.body)}`) } async function openFriday() { fireEvent.click(within(await waitFor(() => tile('FRI'))).getByRole('button')) return screen.findByRole('dialog', { name: 'Swap Friday' }) } it('opens on a tapped day, puts it in the swapping state, and closes again', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) await renderPage() const sheet = await openFriday() expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument() // The tile: page background, a 2 px grape inset ring, the SWAPPING… // kicker and its name muted. const friday = tile('FRI · SWAPPING…') expect(declaredStyle(friday).background).toBe('var(--color-bg)') expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)') expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)') expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-700)') // "Quest log · N | Regulars · N", on the Quest log first. expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument() expect(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument() const row = within(within(sheet).getByRole('list', { name: 'Quest log' })).getByRole('button', { name: /Lemon orzo/ }) expect(row).toHaveTextContent('Easy · 25 min') const photo = within(sheet).getByRole('img', { hidden: true, name: 'Lemon orzo' }) expect(declaredStyle(photo).width).toBe('48px') fireEvent.keyDown(sheet, { key: 'Escape' }) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(tile('FRI')).toBeInTheDocument() expect(screen.queryByText('FRI · SWAPPING…')).not.toBeInTheDocument() }) it('swaps the day at once, closing the sheet, and lands the plan the server answers', async () => { let answer!: (response: Response) => void const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists, [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), }) await renderPage() const sheet = await openFriday() fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) // Optimistic: the sheet is closed and Friday is the Quest-log pick, NEW. expect(screen.queryByRole('dialog')).not.toBeInTheDocument() await screen.findByText('FRI · NEW') expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() expect(declaredStyle(tile('FRI · NEW')).background).toBe('var(--color-accent-2-300)') await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) await act(async () => answer(jsonResponse(200, swappedBody()))) expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() expect(screen.queryByText('Pizza night')).not.toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() // Once the swap has settled the sheet opens again, and reads the Quest // log again: the server leaves a dinner on the plan out of it. await waitFor(() => { fireEvent.click(within(tile('FRI · NEW')).getByRole('button')) expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toHaveTextContent('Replaces Lemon orzo') }) await waitFor(() => expect(fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG)).toHaveLength(2)) }) it('takes the NEW highlight off a day swapped to a Regular', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists, [`PUT ${THIS_WEEK}/days/2026-10-07`]: () => { const body = planBody('draft') body.days[2] = { ...body.days[2]!, dinner: { ...body.days[2]!.dinner, id: 'r1', name: 'Fish tacos' }, is_new: false, start_by: null } return jsonResponse(200, body) }, }) await renderPage() fireEvent.click(within(await waitFor(() => tile('WED · NEW'))).getByRole('button')) const sheet = await screen.findByRole('dialog', { name: 'Swap Wednesday' }) fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) fireEvent.click(within(sheet).getByRole('button', { name: /Fish tacos/ })) await screen.findByText('Fish tacos') expect(within(tile('WED')).getByText('Fish tacos')).toBeInTheDocument() expect(declaredStyle(tile('WED')).background).toBe('var(--color-surface)') await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`])) expect(screen.queryByText('Start by 1:30 pm')).not.toBeInTheDocument() }) it('puts the day back and shows the failure, with Retry, when the swap is refused', async () => { let refuse = true const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists, [`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, swappedBody())), }) await renderPage() const sheet = await openFriday() fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() expect(screen.queryByText('Lemon orzo')).not.toBeInTheDocument() refuse = false fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) expect(await screen.findByText('Lemon orzo')).toBeInTheDocument() await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) expect(puts(fetchMock)).toHaveLength(2) }) it('marks dinners already this week, which cannot be chosen', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) await renderPage() const sheet = await openFriday() fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) const regulars = within(sheet).getByRole('list', { name: 'Regulars' }) const [tacos, fish] = within(regulars).getAllByRole('listitem') expect(within(tacos!).getByText('Already this week')).toBeInTheDocument() expect(within(tacos!).queryByRole('button')).not.toBeInTheDocument() expect(within(fish!).queryByText('Already this week')).not.toBeInTheDocument() expect(within(fish!).getByRole('button', { name: /Fish tacos/ })).toBeInTheDocument() fireEvent.click(within(tacos!).getByText('Beef tacos')) expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument() expect(puts(fetchMock)).toEqual([]) }) // leaveAndReturn goes to next week and back, remounting this week's board. async function leaveAndReturn() { fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) await screen.findByText('FRI · NEW') } it('sends one swap at a time even after leaving the week and coming back', async () => { let answer!: (response: Response) => void const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), ...lists, [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), }) await renderPage() fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) await waitFor(() => expect(puts(fetchMock)).toHaveLength(1)) await leaveAndReturn() // Friday's swap is still pending: Monday does not open. fireEvent.click(within(tile('MON')).getByRole('button')) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() await act(async () => answer(jsonResponse(200, swappedBody()))) await waitFor(() => expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeEnabled()) fireEvent.click(within(tile('MON')).getByRole('button')) expect(screen.getByRole('dialog', { name: 'Swap Monday' })).toBeInTheDocument() expect(puts(fetchMock)).toHaveLength(1) }) it('shows the failure of a swap that ended while the week was left', async () => { let refuse!: () => void stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), ...lists, [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), }) await renderPage() fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() await act(async () => refuse()) fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() }) it("still shows a swap's failure on coming back to the week, through another, and asks for the swap again only on Retry", async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), [`GET /api/v1/plans/2026-10-19`]: () => errorResponse(404, 'not_found'), ...lists, [`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_not_plannable'), }) await renderPage() fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Next week' })) await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-19')) expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-12')) fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() expect(puts(fetchMock)).toHaveLength(1) }) it('reads the plan again when the dinner is already on another day, and offers to choose another rather than send it again', async () => { // Meanwhile lemon orzo was planned on Wednesday. let read = planBody('draft') const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, read), ...lists, [`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'), }) await renderPage() const sheet = await openFriday() read = planBody('draft') read.days[2] = { ...read.days[2]!, dinner: { ...read.days[2]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. Lemon orzo is already on another day this week.") expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() await waitFor(() => expect(within(tile('WED · NEW')).getByText('Lemon orzo')).toBeInTheDocument()) expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() // Choosing again opens Friday's sheet on the plan read, where lemon // orzo is already this week; nothing is sent again. fireEvent.click(within(alert).getByRole('button', { name: 'Choose another dinner' })) const again = screen.getByRole('dialog', { name: 'Swap Friday' }) expect(screen.queryByRole('alert')).not.toBeInTheDocument() const orzo = within(within(again).getByRole('list', { name: 'Quest log' })).getByRole('listitem') expect(within(orzo).getByText('Already this week')).toBeInTheDocument() expect(within(orzo).queryByRole('button')).not.toBeInTheDocument() expect(puts(fetchMock)).toHaveLength(1) }) it('waits for a pending swap before Rebuild week, keeping its failure', async () => { let refuse!: () => void const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists, [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), }) await renderPage() fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) await screen.findByText('FRI · NEW') const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) expect(rebuild).toBeDisabled() fireEvent.click(rebuild) expect(posts(fetchMock)).toEqual([]) await act(async () => refuse()) expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() // Once it has failed, Rebuild week drafts, leaving the failure behind. fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) expect(await screen.findByText('Drafting your week…')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) }) }) describe.each([ { width: 768, layout: 'tablet', photoHeight: '96px', nameSize: '16px' }, { width: 1180, layout: 'tablet', photoHeight: '96px', nameSize: '16px' }, { width: 1440, layout: 'desktop', photoHeight: '110px', nameSize: '17px' }, ])('PlanPage at $width px (mocks 2c, 2d)', ({ width, layout, photoHeight, nameSize }) => { beforeEach(() => mockViewport(width)) // column is the grid's column of a day, by its name: "Monday". function column(day: string) { return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day }) } // card is the card in a day's column, holding its dinner. function card(day: string, dinnerName: string) { return within(column(day)).getByText(dinnerName).parentElement! } it('renders the plan manager: header, actions, sync status and the 7-column grid', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) await renderPage() expect(await screen.findByText('Beef tacos')).toBeInTheDocument() expect(screen.getByText('Week 41 · Plan')).toBeInTheDocument() expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Previous week' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop') // Actions: the shopping list, with no count while the week has no // Supply run, and Rebuild week outlined; Push to Calendar primary. const shopping = await screen.findByRole('link', { name: 'Shopping list' }) expect(shopping).not.toHaveAttribute('aria-disabled') expect(shopping).not.toHaveAccessibleDescription() const push = screen.getByRole('button', { name: 'Push to Calendar' }) expect(push).toBeEnabled() expect(push).not.toHaveAttribute('aria-disabled') expect(declaredStyle(push).background).toBe('var(--color-accent)') const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) expect(rebuild).toBeEnabled() expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') expect(posts(fetchMock)).toEqual([]) // Mobile's own pieces are not shown. expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() expect(screen.queryByText('Draft')).not.toBeInTheDocument() expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument() // Seven columns, Monday to Sunday, 12 px apart. const grid = screen.getByRole('list', { name: 'Dinners this week' }) expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))') expect(declaredStyle(grid).gap).toBe('12px') expect(within(grid).getAllByRole('listitem').map((li) => li.getAttribute('aria-label'))).toEqual([ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', ]) // Monday: day label, the washed photo, the name in Bagel, method and // minutes, and the Regular tag, on the surface. expect(column('Monday')).toHaveTextContent(/^MON5/) const monday = card('Monday', 'Beef tacos') expect(declaredStyle(monday).background).toBe('var(--color-surface)') const photo = within(monday).getByRole('img', { hidden: true, name: 'Beef tacos' }) expect(photo).toHaveAttribute('src', '/media/photos/tacos-400.jpg') expect(declaredStyle(photo).height).toBe(photoHeight) const name = within(monday).getByText('Beef tacos') expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)') expect(declaredStyle(name).fontSize).toBe(nameSize) // Testing Library reads the duration's no-break spaces as spaces. expect(within(monday).getByText('Stovetop · 30 min')).toBeInTheDocument() expect(declaredStyle(within(monday).getByText('Regular')).background).toBe('var(--color-neutral-100)') expect(within(monday).queryByText(/Start by/)).not.toBeInTheDocument() // Wednesday: a crock-pot Side quest on teal-200, with its start-by time. const wednesday = card('Wednesday', 'Crock pot tikka masala') expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-200)') expect(within(wednesday).getByText('Crock pot · 4 hr')).toBeInTheDocument() expect(within(wednesday).getByText('Start by 1:30 pm')).toBeInTheDocument() expect(within(wednesday).getByText('Side quest')).toBeInTheDocument() expect(within(column('Wednesday')).getByRole('button', { name: 'Swap Wednesday' })).toBeInTheDocument() }) it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => { vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }) vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), ...lists }) await renderPage() const status = await screen.findByText('Synced to Google Calendar 2 min ago') expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') // The time is kept current. act(() => vi.advanceTimersByTime(58 * 60_000)) expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago') }) it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => { let failing = true stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), 'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))), ...lists, }) await renderPage() const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message") expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') failing = false fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' }) expect(screen.queryByRole('alert')).not.toBeInTheDocument() fireEvent.click(shopping) expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() }) it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), 'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)), ...lists, }) await renderPage() const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' }) expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05') expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)') // Another week's action opens that week's, which has no list yet. fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12') fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' })) expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument() }) it('says changes to a pushed plan are not on Google Calendar', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists }) await renderPage() expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() }) it('pushes the plan from the header, showing progress, then the plan as synced', async () => { const answer = deferred() stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), [`POST ${PUSH}`]: () => answer.response, ...lists, }) await renderPage() await screen.findByText('Beef tacos') fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' })) const pushing = await screen.findByRole('button', { name: 'Pushing…' }) expect(pushing).toBeDisabled() expect(pushing).toHaveAttribute('aria-busy', 'true') expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() answer.send(jsonResponse(200, syncedNow())) const status = await screen.findByText('Synced to Google Calendar just now') expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') expect(screen.getByRole('button', { name: 'Push to Calendar' })).toBeEnabled() }) it('links a google_reconnect_required push failure to the Google Calendar settings', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), [`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'), ...lists, }) await renderPage() await screen.findByText('Beef tacos') fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' })) const alert = await screen.findByRole('alert') expect(within(alert).getByRole('link', { name: 'Reconnect Google Calendar' })).toHaveAttribute('href', '/settings/calendar') expect(screen.getByText('Synced to Google Calendar 2 min ago')).toBeInTheDocument() }) if (layout === 'desktop') { it('shows the Quest log and Regulars in the 290 px right panel', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) await renderPage() const panel = screen.getByRole('complementary', { name: 'Quest log and Regulars' }) expect(declaredStyle(panel).borderLeft).toBe('1px solid var(--color-divider)') expect(declaredStyle(panel.parentElement!).gridTemplateColumns).toBe('minmax(0, 1fr) 290px') expect(await within(panel).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument() expect(await within(panel).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument() expect(within(panel).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument() const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem') expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy') expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px') // The 6-dot grip, last in the row's button. expect(within(orzo).getByRole('button').lastElementChild!.children).toHaveLength(6) fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' })) const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem') expect(regulars).toHaveLength(2) expect(within(regulars[0]!).getByText('Beef tacos').nextElementSibling).toHaveTextContent('Easy') expect(within(regulars[1]!).getByText('Fish tacos').nextElementSibling).toHaveTextContent('Medium') expect(within(panel).getByRole('link', { name: 'Swipe more side quests' })).toHaveAttribute('href', '/discover') expect(screen.queryByRole('region', { name: 'Quest log' })).not.toBeInTheDocument() }) } else { it('shows the Quest log as a strip of chips under the grid', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) await renderPage() const strip = screen.getByRole('region', { name: 'Quest log' }) expect(await within(strip).findByRole('heading', { name: 'Quest log · 1' })).toBeInTheDocument() expect(within(strip).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument() const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem') expect(chip).toHaveTextContent('Lemon orzo') expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px') // The 28 px photo makes a 38 px chip; the chip is a button, so it // keeps the 44 px tap minimum. expectMinHitArea(within(chip).getByRole('button', { name: /Lemon orzo/ })) // The strip follows the grid. const grid = await screen.findByRole('list', { name: 'Dinners this week' }) expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(screen.queryByRole('complementary')).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'), [`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'), ...lists, }) 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 · Plan')).toBeInTheDocument() expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument() expect(screen.getByText('Week 40 · Plan')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() expect(await screen.findByText('Beef tacos')).toBeInTheDocument() }) if (layout === 'desktop') { it('comes back to this week with the "This week" pill', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), ...lists, }) await renderPage() expect(await screen.findByText('Beef tacos')).toBeInTheDocument() const thisWeek = screen.getByRole('button', { name: 'This week' }) expect(declaredStyle(thisWeek).background).toBe('var(--color-surface)') fireEvent.click(screen.getByRole('button', { name: 'Next week' })) expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'This week' })) expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() expect(await screen.findByText('Beef tacos')).toBeInTheDocument() }) it("comes back to this week even when the plan opened on next week's", async () => { vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), ...lists, }) await renderPage() expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'This week' })) expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() }) } it("opens the swap sheet from a day's swap icon and swaps the dinner in", async () => { const FRIDAY = `${THIS_WEEK}/days/2026-10-09` const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists, [`PUT ${FRIDAY}`]: () => new Promise(() => undefined), }) await renderPage() fireEvent.click(within(await waitFor(() => column('Friday'))).getByRole('button', { name: 'Swap Friday' })) const sheet = await screen.findByRole('dialog', { name: 'Swap Friday' }) expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument() // The card being swapped is ringed in grape. expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)') fireEvent.keyDown(sheet, { key: 'Escape' }) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('') fireEvent.click(within(column('Friday')).getByRole('button', { name: 'Swap Friday' })) const again = await screen.findByRole('dialog', { name: 'Swap Friday' }) fireEvent.click(await within(again).findByRole('button', { name: /Lemon orzo/ })) // Optimistic: the sheet is closed and Friday is the Side quest, on teal. expect(screen.queryByRole('dialog')).not.toBeInTheDocument() const friday = await waitFor(() => card('Friday', 'Lemon orzo')) expect(within(friday).getByText('Side quest')).toBeInTheDocument() expect(within(friday).getByText('Stovetop · 25 min')).toBeInTheDocument() expect(declaredStyle(friday).background).toBe('var(--color-accent-2-200)') await waitFor(() => expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1)) // Rebuild week waits for the swap. expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() }) 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')), ...lists, }) await renderPage() await screen.findByText('Beef tacos') fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' }) 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() expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7) expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() }) it('rebuilds a draft at once', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), ...lists, }) await renderPage() await screen.findByText('Beef tacos') fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) }) it('reads the Quest log again once a draft finishes, dropping the dinners it planned', async () => { let plan = planBody('draft') let job = draftJob('running') let questLog = [dinner('q1', 'Lemon orzo', 'easy', 25)] stubServer({ ...lists, [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), [`GET ${JOB}`]: () => jsonResponse(200, job), [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }), }) await renderPage() const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' }) expect(await within(list).findByText('Lemon orzo')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() plan = planBody('draft', ['Lemon orzo', ...NAMES.slice(1)]) questLog = [] job = draftJob('succeeded') const week = await screen.findByRole('list', { name: 'Dinners this week' }, { timeout: 3000 }) expect(await within(week).findByText('Lemon orzo', {}, { timeout: 3000 })).toBeInTheDocument() await waitFor(() => expect(within(list).queryByText('Lemon orzo')).not.toBeInTheDocument()) }) }) // Dragging a dinner from desktop's panel or tablet's strip onto a day swaps // it in, by pointer or keyboard. describe.each([ { width: 1180, layout: 'tablet' }, { width: 1440, layout: 'desktop' }, ])('PlanPage drag and drop at $width px', ({ width, layout }) => { const FRIDAY = `${THIS_WEEK}/days/2026-10-09` let capture: Mock<(pointerId: number) => void> // The element document.elementFromPoint finds under the pointer. let hovered: Element | null = null // jsdom has no pointer capture and no layout to hit-test. beforeEach(() => { mockViewport(width) capture = vi.fn() Element.prototype.setPointerCapture = capture hovered = null document.elementFromPoint = () => hovered }) afterEach(() => { Reflect.deleteProperty(Element.prototype, 'setPointerCapture') Reflect.deleteProperty(document, 'elementFromPoint') }) // The Quest log has lemon orzo and miso salmon. const dragLists = { ...lists, [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25), dinner('q2', 'Miso salmon', 'medium', 30)] }), } // swappedBody is the plan as the server answers a swap of Friday to lemon // orzo, a Quest-log pick. function swappedBody() { const body = planBody('draft') body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } return body } function puts(fetchMock: Mock) { return fetchMock.mock.calls .filter(([, init]) => init?.method === 'PUT') .map(([input, init]) => `${String(input)} ${String(init?.body)}`) } function column(day: string) { return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day }) } // quest is the Quest-log dinner's draggable: a row of the panel on // desktop, a chip of the strip on tablet. async function quest(name: string) { const list = await screen.findByRole('list', { name: layout === 'desktop' ? 'Quest log' : 'Quest log dinners' }) return within(list).getByRole('button', { name: new RegExp(name) }) } const announcer = () => screen.getByTestId('drag-announcer') const float = () => screen.queryByTestId('drag-float') // press, move and release drive pointer 3 on the draggable: down at // (100, 100), moved over an element (or none) at (160, 140) or the given // point, and let go there. function press(draggable: Element) { fireEvent.pointerDown(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 100, clientY: 100 }) } function move(draggable: Element, over: Element | null, [clientX, clientY] = [160, 140]) { hovered = over fireEvent.pointerMove(draggable, { pointerId: 3, isPrimary: true, clientX, clientY }) } function release(draggable: Element) { fireEvent.pointerUp(draggable, { pointerId: 3, isPrimary: true, button: 0, clientX: 160, clientY: 140 }) } it('drops a dinner dragged onto a day, swapping it in at once and landing the plan the server answers', async () => { let answer!: (response: Response) => void const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists, [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), }) await renderPage() await screen.findByText('Pizza night') const orzo = await quest('Lemon orzo') expect(declaredStyle(orzo).getPropertyValue('touch-action')).toBe('none') expect(orzo).toHaveAccessibleDescription( 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.', ) press(orzo) expect(capture).toHaveBeenCalledExactlyOnceWith(3) expect(capture.mock.contexts[0]).toBe(orzo) // A press alone is no drag. expect(float()).not.toBeInTheDocument() // Over Friday: the source a dashed ghost at 55%, its copy floating with // the large shadow at -3deg, and Friday the drop target. move(orzo, within(column('Friday')).getByText('Pizza night')) expect(declaredStyle(orzo).opacity).toBe('0.55') expect(declaredStyle(orzo).borderStyle).toBe('dashed') const copy = float()! expect(copy).toHaveTextContent('Lemon orzo') expect(copy).toHaveAttribute('aria-hidden', 'true') expect(declaredStyle(copy).boxShadow).toBe('var(--shadow-lg)') expect(declaredStyle(copy).transform).toBe('rotate(-3deg)') expect(declaredStyle(copy).pointerEvents).toBe('none') // Offset 48 px below right of the pointer at (160, 140), clear of the // day's text under it. expect(copy.style.left).toBe('208px') expect(copy.style.top).toBe('188px') expect(announcer()).toHaveTextContent('Picked up Lemon orzo.') const target = within(column('Friday')).getByText('Drop to swap').parentElement! expect(target).toHaveTextContent('Drop to swapReplaces Pizza night') expect(declaredStyle(target).border).toBe('2.5px dashed var(--color-accent)') expect(declaredStyle(target).background).toBe('var(--color-accent-100)') expect(within(column('Thursday')).queryByText('Drop to swap')).not.toBeInTheDocument() // Moving on to Thursday moves the target. move(orzo, column('Thursday')) expect(within(column('Friday')).queryByText('Drop to swap')).not.toBeInTheDocument() expect(within(column('Thursday')).getByText('Replaces Chicken stir-fry')).toBeInTheDocument() move(orzo, within(column('Friday')).getByRole('button', { name: 'Swap Friday' })) release(orzo) expect(float()).not.toBeInTheDocument() expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() expect(declaredStyle(orzo).opacity).toBe('') expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.') // Optimistic: Friday is the Side quest at once. const friday = await waitFor(() => within(column('Friday')).getByText('Lemon orzo').parentElement!) expect(within(friday).getByText('Side quest')).toBeInTheDocument() await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) await act(async () => answer(jsonResponse(200, swappedBody()))) expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument() expect(screen.queryByText('Pizza night')).not.toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(puts(fetchMock)).toHaveLength(1) }) it('flips the floating copy to the left of and above the pointer where it would leave the viewport', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) await renderPage() await screen.findByText('Pizza night') const orzo = await quest('Lemon orzo') vi.spyOn(orzo, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ x: 90, y: 90, width: 240, height: 60 })) press(orzo) const [x, y] = [window.innerWidth - 100, window.innerHeight - 40] move(orzo, document.body, [x, y]) const copy = float()! expect(copy.style.width).toBe('240px') expect(copy.style.left).toBe(`${x - 48 - 240}px`) expect(copy.style.top).toBe(`${y - 48 - 60}px`) release(orzo) }) it('cancels a drag let go over no day, or cancelled, with no swap', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) await renderPage() await screen.findByText('Pizza night') const orzo = await quest('Lemon orzo') press(orzo) move(orzo, column('Friday')) expect(within(column('Friday')).getByText('Drop to swap')).toBeInTheDocument() // Off the grid, onto the page around it. move(orzo, document.body) expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() expect(float()).toBeInTheDocument() release(orzo) expect(float()).not.toBeInTheDocument() expect(declaredStyle(orzo).opacity).toBe('') expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') // A cancelled pointer, over a day, is no drop either. press(orzo) move(orzo, column('Monday')) expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument() fireEvent.pointerCancel(orzo, { pointerId: 3, isPrimary: true }) expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() expect(float()).not.toBeInTheDocument() // Nor is a drag Escape ends. press(orzo) move(orzo, column('Monday')) fireEvent.keyDown(orzo, { key: 'Escape' }) expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() release(orzo) expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument() expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument() expect(puts(fetchMock)).toEqual([]) }) it('puts the day back and shows the failure when the dropped swap is refused', async () => { let refuse!: () => void const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists, [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), }) await renderPage() await screen.findByText('Pizza night') const orzo = await quest('Lemon orzo') press(orzo) move(orzo, column('Friday')) release(orzo) await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()) await waitFor(() => expect(puts(fetchMock)).toHaveLength(1)) // While the swap is pending no other drag starts, by pointer or keyboard. const salmon = await quest('Miso salmon') expect(salmon).toHaveAttribute('aria-disabled', 'true') press(salmon) move(salmon, column('Monday')) expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() expect(float()).not.toBeInTheDocument() release(salmon) fireEvent.keyDown(salmon, { key: 'Enter' }) expect(announcer()).toHaveTextContent('A swap is still being saved. Pick Miso salmon up once it is done.') expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() await act(async () => refuse()) const alert = await screen.findByRole('alert') expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") expect(within(column('Friday')).getByText('Pizza night')).toBeInTheDocument() expect(within(column('Friday')).queryByText('Lemon orzo')).not.toBeInTheDocument() expect(puts(fetchMock)).toHaveLength(1) await waitFor(() => expect(salmon).not.toHaveAttribute('aria-disabled')) }) it('swaps a dinner in with the keyboard, announcing each step', async () => { let answer!: (response: Response) => void const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists, [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), }) await renderPage() await screen.findByText('Pizza night') const orzo = await quest('Lemon orzo') expect(announcer()).toHaveAttribute('aria-live', 'polite') // Enter picks it up onto Monday. orzo.focus() fireEvent.keyDown(orzo, { key: 'Enter' }) expect(announcer()).toHaveTextContent( 'Picked up Lemon orzo. Monday: replaces Beef tacos. Use the arrow keys to choose a day, Enter to drop, Escape to cancel.', ) expect(declaredStyle(orzo).opacity).toBe('0.55') expect(within(column('Monday')).getByText('Replaces Beef tacos')).toBeInTheDocument() // No pointer, so no floating copy. expect(float()).not.toBeInTheDocument() // The arrow keys choose the day, going no further than Monday. fireEvent.keyDown(orzo, { key: 'ArrowLeft' }) expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument() fireEvent.keyDown(orzo, { key: 'ArrowRight' }) expect(announcer()).toHaveTextContent('Tuesday: replaces Spaghetti bolognese.') expect(within(column('Monday')).queryByText('Drop to swap')).not.toBeInTheDocument() fireEvent.keyDown(orzo, { key: 'ArrowDown' }) fireEvent.keyDown(orzo, { key: 'ArrowRight' }) fireEvent.keyDown(orzo, { key: 'ArrowRight' }) fireEvent.keyDown(orzo, { key: 'ArrowRight' }) fireEvent.keyDown(orzo, { key: 'ArrowUp' }) expect(announcer()).toHaveTextContent('Friday: replaces Pizza night.') expect(within(column('Friday')).getByText('Replaces Pizza night')).toBeInTheDocument() expect(puts(fetchMock)).toEqual([]) // Enter drops it, swapping it in at once; focus moves to the day. fireEvent.keyDown(orzo, { key: 'Enter' }) expect(announcer()).toHaveTextContent('Dropped Lemon orzo on Friday, replacing Pizza night.') expect(screen.getByRole('button', { name: 'Swap Friday' })).toHaveFocus() expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() await waitFor(() => expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument()) await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) await act(async () => answer(jsonResponse(200, swappedBody()))) expect(within(column('Friday')).getByText('Lemon orzo')).toBeInTheDocument() expect(puts(fetchMock)).toHaveLength(1) }) it('cancels a keyboard drag with Escape, or by leaving it', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists }) await renderPage() await screen.findByText('Pizza night') const orzo = await quest('Lemon orzo') // Space picks it up too, but does not drop it. orzo.focus() fireEvent.keyDown(orzo, { key: ' ' }) fireEvent.keyDown(orzo, { key: 'ArrowRight' }) fireEvent.keyDown(orzo, { key: ' ' }) expect(within(column('Tuesday')).getByText('Drop to swap')).toBeInTheDocument() fireEvent.keyDown(orzo, { key: 'Escape' }) expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() expect(declaredStyle(orzo).opacity).toBe('') expect(orzo).toHaveFocus() // The arrow keys do nothing until it is picked up again; Enter does. fireEvent.keyDown(orzo, { key: 'ArrowRight' }) expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() fireEvent.keyDown(orzo, { key: 'Enter' }) expect(within(column('Monday')).getByText('Drop to swap')).toBeInTheDocument() // Tabbing away cancels. fireEvent.blur(orzo) expect(announcer()).toHaveTextContent('Cancelled. Lemon orzo was not swapped in.') expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() expect(within(column('Monday')).getByText('Beef tacos')).toBeInTheDocument() expect(puts(fetchMock)).toEqual([]) }) if (layout === 'desktop') { it('does not pick up a Regular already this week', async () => { const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...dragLists, [`PUT ${THIS_WEEK}/days/2026-10-07`]: () => new Promise(() => undefined), }) await renderPage() await screen.findByText('Pizza night') fireEvent.click(await screen.findByRole('radio', { name: 'Regulars · 2' })) const regulars = screen.getByRole('list', { name: 'Regulars' }) const tacos = within(regulars).getByRole('button', { name: /Beef tacos/ }) expect(tacos).toHaveAttribute('aria-disabled', 'true') fireEvent.keyDown(tacos, { key: 'Enter' }) expect(announcer()).toHaveTextContent('Beef tacos is already this week.') expect(screen.queryByText('Drop to swap')).not.toBeInTheDocument() // A Regular not this week is dragged in as one, not NEW. const fish = within(regulars).getByRole('button', { name: /Fish tacos/ }) expect(fish).not.toHaveAttribute('aria-disabled') press(fish) move(fish, column('Wednesday')) release(fish) await waitFor(() => expect(within(column('Wednesday')).getByText('Fish tacos').parentElement).toHaveTextContent('Regular')) await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`])) }) } }) // Mobile's Draft board has no drag: its tiles open the swap sheet. describe('PlanPage drag and drop on mobile', () => { it('has no draggables, drop targets or drag announcements', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) const { container } = await renderPage() await screen.findByText('Pizza night') expect(container.querySelector('[data-drop-date]')).toBeNull() expect(screen.queryByTestId('drag-announcer')).not.toBeInTheDocument() expect(screen.queryByText('Drag a dinner onto a day to swap it in.')).not.toBeInTheDocument() }) }) // At tablet's narrow end seven usable columns do not fit beside the rail: // the week keeps its columns usable and scrolls sideways. describe('PlanPage at 768 px', () => { beforeEach(() => mockViewport(768)) it('keeps each day column usable and scrolls the week sideways', async () => { stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) await renderPage() expect(await screen.findByText('Spaghetti bolognese')).toBeInTheDocument() const grid = screen.getByRole('list', { name: 'Dinners this week' }) // Columns no narrower than 112 px; the week scrolls when seven do not // fit, its padding keeping the swap icons' hit boxes inside. expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))') expect(declaredStyle(grid).overflowX).toBe('auto') expect(declaredStyle(grid).padding).toBe('8px') expect(declaredStyle(grid).margin).toBe('-8px') const tuesday = within(grid).getByRole('listitem', { name: 'Tuesday' }) // The name wraps between words, only breaking a word wider than the card. const name = within(tuesday).getByText('Spaghetti bolognese') expect(declaredStyle(name).overflowWrap).toBe('break-word') expect(declaredStyle(name).hyphens).toBe('') // The swap icon stays beside the tag. const footer = within(tuesday).getByRole('button', { name: 'Swap Tuesday' }).parentElement! expect(within(footer).getByText('Regular')).toBeInTheDocument() expect(declaredStyle(footer).display).toBe('flex') expect(declaredStyle(footer).flexWrap).toBe('') // Every piece of the card is still there. expect(within(tuesday).getByRole('img', { hidden: true, name: 'Spaghetti bolognese' })).toBeInTheDocument() expect(within(tuesday).getByText('Stovetop · 30 min')).toBeInTheDocument() expect(within(within(grid).getByRole('listitem', { name: 'Wednesday' })).getByText('Start by 1:30 pm')).toBeInTheDocument() }) }) describe('duration', () => { it.each([ [25, '25\u00a0min'], [60, '1\u00a0hr'], [90, '1\u00a0hr\u00a030\u00a0min'], [360, '6\u00a0hr'], ])('says %i minutes as %j', (minutes, said) => { expect(duration(minutes)).toBe(said) }) }) describe('cooking', () => { const dinner = { id: 'd1', name: 'Tikka masala', photo_id: undefined } it.each([ [{ method: 'crockpot' as const, minutes: 360 }, 'Crock pot · 6\u00a0hr'], [{ method: 'crockpot' as const, minutes: null }, 'Crock pot'], [{ method: null, minutes: 45 }, '45\u00a0min'], [{ method: null, minutes: null }, null], ])('says %o as %j', (known, said) => { expect(cooking({ ...dinner, ...known })).toBe(said) }) }) describe('ago', () => { const now = new Date('2026-10-07T16:00:00Z') it.each([ ['2026-10-07T15:59:30Z', 'just now'], ['2026-10-07T15:58:00Z', '2 min ago'], ['2026-10-07T13:00:00Z', '3 hr ago'], ['2026-10-06T15:00:00Z', '1 day ago'], ['2026-10-04T15:00:00Z', '3 days ago'], ])('says %s as %j', (at, said) => { expect(ago(new Date(at), now)).toBe(said) }) })