From e903e5e5261f7f7d1c3622cef79e5271ed632368 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Thu, 8 Oct 2026 14:50:38 -0400 Subject: [PATCH] Remediate feature-review HIGH: report the Supply run outcome after Push to Calendar (#141) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/plan/PlanPage.module.css | 10 ++ web/src/plan/PlanPage.test.tsx | 299 +++++++++++++++++++++++++++++-- web/src/plan/PlanPage.tsx | 52 +++++- web/src/plan/api.test.tsx | 56 +++++- web/src/plan/api.ts | 131 ++++++++++++-- web/src/shopping/api.ts | 10 +- 6 files changed, 529 insertions(+), 29 deletions(-) diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 9cac0e3..86298fd 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -414,6 +414,16 @@ text-align: center; } +/* What became of the week's Supply run after a push. */ +.pushed { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + text-align: center; + color: var(--color-neutral-700); +} + /* The way to the week's Supply run under the Draft board on mobile. */ .shoppingLink { display: inline-flex; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 6f4ec8c..1f027a9 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -1,9 +1,10 @@ -import { QueryClientProvider } from '@tanstack/react-query' +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +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' @@ -52,9 +53,33 @@ function planBody(status: string, names = NAMES) { } // syncedNow is the plan of the week of Oct 5 as a push answers it: synced -// at the tests' noon. -function syncedNow() { - return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' } +// 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. @@ -135,17 +160,17 @@ function planReads(fetchMock: Mock) { } // renderPage loads /me (for the CSRF token every write needs) and renders -// the Plan page. -async function renderPage() { +// the Plan page, over queryClient, with shopping as the Supply run screen. +async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = ) { const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) await api.loadMe() return render( - + } /> - } /> + @@ -153,10 +178,32 @@ async function renderPage() { ) } -// Opened stands in for the screen a link opened, saying where it is. +// 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}

+ return ( + <> +

Opened {pathname + search}

+ Back to the Plan + + ) +} + +// 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 @@ -420,6 +467,236 @@ describe('PlanPage', () => { 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') diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 8f97f99..87124ba 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -7,7 +7,7 @@ import { QUEST_LOG_KEY } from '../discover/api.ts' import { Confirm } from '../settings/LeaveGuard.tsx' import { methodLabels } from '../settings/RegularsSettingsPage.tsx' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' -import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts' +import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' import { Button, type ButtonVariant } from '../ui/Button.tsx' import buttonStyles from '../ui/Button.module.css' @@ -31,6 +31,7 @@ import { type PlanDay, type PlanDinner, type PlanStatus, + type PushedSupplyRun, type Swap, } from './api.ts' import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx' @@ -88,6 +89,13 @@ const syncStatuses: Record = { changed: 'Changes not pushed to Google Calendar', } +// supplyRunOutcomes says what became of the week's Supply run after a push +// that made it or left it as it was. +const supplyRunOutcomes: Record, string> = { + generated: "Made this week's Supply run from the plan.", + archived: "This week's Supply run is in the Supply archive, so it was left as it was.", +} + // duration is minutes as the plan manager's cards say it: "45 min", "6 hr", // "1 hr 30 min", with no-break spaces so a narrow card wraps the line // between the method and the duration, never inside the duration. @@ -238,7 +246,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre // // Push to Calendar, on either layout, sends the plan shown and shows it as // synced once the server answers; a failure says why, linking to the Google -// Calendar settings when only they can fix it. +// Calendar settings when only they can fix it. What became of the week's +// Supply run is said too: made, left as it was in the Supply archive, or +// not made, with the way to make it. function WeekBoard({ weekStart, timezone, @@ -259,6 +269,8 @@ function WeekBoard({ const swap = useSwapDay(weekStart) const push = usePushToCalendar(weekStart) const run = useSupplyRun(weekStart) + // Makes the week's Supply run again after a push could not make it. + const generate = useGenerateSupplyRun(weekStart) const pushing = push.pending // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) @@ -328,6 +340,7 @@ function WeekBoard({ if (!canPush) return swap.dismiss() push.reset() + generate.reset() push.mutate() } @@ -339,6 +352,7 @@ function WeekBoard({ setJobId(null) swap.dismiss() push.reset() + generate.reset() mutate( { weekStart, confirm }, { @@ -357,8 +371,33 @@ function WeekBoard({ else start(false) } + const retryPush = cached ? () => void plan.refetch() : pushToCalendar + // makeShoppingList makes the week's Supply run again after a push could + // not make it. Once made, here or on the Supply run screen, the push is + // settled (useGenerateSupplyRun). A failure is the mutation's state, which + // generate.error reports. + function makeShoppingList() { + generate.mutate() + } + // A push's Supply run made again on the Plan stands for the push's, which + // is settled. + const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun const failure = push.error !== null ? ( - void plan.refetch() : pushToCalendar} /> + + ) : supplyRun?.status === 'failed' ? ( + supplyRun.error.code === 'plan_changed' ? ( + + Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push + it again. + + ) : ( + + Pushed to Calendar, but this week's Supply run couldn't be made. + {generate.isPending + ? ' Making the shopping list…' + : generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`} + + ) ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} @@ -376,6 +415,11 @@ function WeekBoard({ ) : ( Couldn't check on the draft. {job.error.message} ) + const pushed = supplyRun !== null && supplyRun.status !== 'failed' && ( +

+ {supplyRunOutcomes[supplyRun.status]} +

+ ) // A failed read of the week's Supply run is shown, with Retry, after the // Draft board's Shopping list link or under the plan manager's board; the // Shopping list action still opens the Supply run. The count is that of a @@ -391,6 +435,7 @@ function WeekBoard({ function swapIn(chosen: Swap) { setSwappingDate(null) push.reset() + generate.reset() swap.mutate(chosen) } // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, @@ -562,6 +607,7 @@ function WeekBoard({ {header} {body} {failure} + {pushed} {layout !== 'mobile' && supplyRunFailure} { // The push succeeds while the third week is shown: it is left behind, // and the week's pending swap is not. - await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT }))) + await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED }))) await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([])) expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success']) @@ -338,6 +353,43 @@ describe('week mutations', () => { expect(weekMutations(queryClient, LATER)).toEqual([]) }) + it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => { + const nextPush = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }), + [`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply, + }) + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), { + wrapper, + initialProps: { week: NEXT }, + }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + + // The push is pushed to Calendar while another week is shown, but its + // Supply run is not made: it stays, though it succeeded. + act(() => hook.result.current.push.mutate()) + hook.rerender({ week: WEEK }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } + await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED }))) + await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success'])) + + // Coming back mounts the week's board again, as the Plan's does. + hook.unmount() + const back = renderHook(() => usePushToCalendar(NEXT), { wrapper }) + expect(back.result.current.error).toBeNull() + expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run) + }) + it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => { const friday = deferred() const monday = deferred() @@ -394,7 +446,7 @@ describe('week mutations', () => { // The push succeeds: the plan as synced is recorded, and the push is // left behind. const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' } - await act(async () => push.send(jsonResponse(200, synced))) + await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED }))) await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([])) expect(queryClient.getQueryData(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') }) }) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 0a8ee4a..e6094fc 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -5,6 +5,7 @@ import { isRecord } from '../api/errors.ts' import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' import { noteGoogleFailure } from '../settings/calendarApi.ts' +import { supplyRunKey } from '../shopping/api.ts' export const PLANS_PATH = '/api/v1/plans' @@ -114,6 +115,56 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool } } +// SupplyRunError is the API error a Push to Calendar's Supply run carries +// when it was not generated. +export type SupplyRunError = { code: C; message: string; retryable: boolean } + +// PushedSupplyRun says whether a Push to Calendar made the week's Supply +// run: generated when it did; archived when the week's Supply run is in the +// Supply archive and was left as it was; or failed when it was not made. +// A failed one's error is plan_changed when the plan was edited after it +// was pushed, and pushing again makes it, or supply_run_failed when it +// could not be made, and making the shopping list again does. +export type PushedSupplyRun = + | { status: 'generated'; error: null } + | { status: 'archived'; error: SupplyRunError<'archived'> } + | { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> } + +// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer: +// the plan as synced, and what became of the week's Supply run. +export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun } + +const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const +const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const + +function parseSupplyRunError(where: string, codes: readonly C[], body: unknown): SupplyRunError { + if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') { + throw new Error(`${where}: supply_run has an error that is not an API error`) + } + return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable } +} + +function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun { + if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`) + const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status) + switch (status) { + case 'generated': + if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`) + return { status, error: null } + case 'archived': + return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) } + case 'failed': + return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) } + } +} + +function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan { + return { + plan: parsePlan(where, weekStart, body, false), + supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined), + } +} + // parseDraftResult checks a draft_week job's result, which useJob passes on // unread, is the draft of the week starting on weekStart. export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult { @@ -178,6 +229,19 @@ function sameDay(a: PlanDay, b: PlanDay): boolean { ) } +// failed says whether a swap or push failed: a push whose Supply run was +// not made failed too, though Calendar has the plan. +function failed(mutation: Mutation): boolean { + return mutation.state.status === 'error' || unmade(mutation) +} + +// unmade says whether a push succeeded but its Supply run was not made: the +// week is on Calendar with no shopping list until one is made. +function unmade(mutation: Mutation): boolean { + const data = mutation.state.data as PushedPlan | undefined + return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed' +} + // weekMutation is the kind and week of a swap or push of a week the mutation // cache keeps for it, or null for any other mutation. function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null { @@ -203,9 +267,13 @@ const weeksShown = new WeakMap() // earlier pending one must see, stay across leaving its week and coming // back. When one settles, the same is done, except that a week's latest, // when it failed, stays until a week is next shown, so that coming straight -// back to it shows the failure. The cache holds at most the weeks shown's, -// those of weeks left pending, and one failure of each week whose last -// pending one settled since a week was last shown. +// back to it shows the failure. A week's latest push whose Supply run was +// not made stays whatever is shown: the week has no shopping list until one +// is made, which settles the push, or a push sent since replaces it, and +// making it may still be under way when the week is left. The cache holds +// at most the weeks shown's, those of weeks left pending, one push of each +// week whose Supply run was not made, and one failure of each week whose +// last pending one settled since a week was last shown. function keepWeekMutations(queryClient: QueryClient): WeeksShown { const kept = weeksShown.get(queryClient) if (kept !== undefined) return kept @@ -225,7 +293,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown { if (week.some((mutation) => mutation.state.status === 'pending')) continue const latest = week.at(-1) for (const mutation of week) { - if (settled && mutation === latest && mutation.state.status === 'error') continue + if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue mutations.remove(mutation) } } @@ -363,10 +431,12 @@ function pushKey(weekStart: string) { } // PushToCalendar is the push of one week, as the mutation cache holds it: -// whether it is pending, and why the latest failed, if it failed. +// whether it is pending, why the latest failed, if it failed, and what +// became of the week's Supply run, if it succeeded. export type PushToCalendar = { pending: boolean error: Error | null + supplyRun: PushedSupplyRun | null mutate: () => void reset: () => void } @@ -380,10 +450,19 @@ export type PushToCalendar = { // the one shown. One Google refused the grant for reads the Google Calendar // connection again, which is now reconnect required. // +// Once pushed, the server makes the week's Supply run from the plan pushed +// and answers with its outcome, which supplyRun reports: a failed one is +// never a quiet success. The week's Supply run is read again, whatever the +// outcome, so the list shown is the server's. A Supply run not made because +// the plan was edited after it was pushed reads the plan again too, as a +// push refused for that does. +// // The week's pushes live in the mutation cache, not in the component, as its // swaps do, so leaving the week and coming back keeps one pending. reset -// leaves settled pushes behind; it is refused while one is pending. Settled -// pushes of a week no longer shown are left behind too +// leaves settled pushes behind; it is refused while one is pending. A push +// whose Supply run was not made is left behind once the list is made +// (settleUnmadePushes). Settled pushes of a week no longer shown are left +// behind too, but for the latest when its Supply run was not made // (keepWeekMutations). export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() @@ -395,12 +474,17 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { // The week's pushes, in the order sent. const pushes = useMutationState({ filters: { mutationKey }, - select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }), + select: (mutation) => ({ + mutationId: mutation.mutationId, + status: mutation.state.status, + error: mutation.state.error, + data: mutation.state.data as PushedPlan | undefined, + }), }) const latest = pushes.at(-1) function mutate() { - const mutation = mutations.build(queryClient, { + const mutation = mutations.build(queryClient, { mutationKey, // Kept until reset or settled in a week not shown (keepWeekMutations): // a push pending in a week left must still be found when it is shown @@ -408,9 +492,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { gcTime: Infinity, mutationFn: async () => { const path = `${planPath(weekStart)}/push-calendar` - return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) + return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' })) + }, + onSuccess: async ({ plan, supply_run }) => { + queryClient.setQueryData(key, plan) + void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }) + if (supply_run.error?.code === 'plan_changed') { + await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + } }, - onSuccess: (plan) => queryClient.setQueryData(key, plan), onError: async (error) => { noteGoogleFailure(queryClient, error) if (error instanceof ApiError && error.code === 'plan_changed') { @@ -433,7 +523,26 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { return { pending: pushes.some((push) => push.status === 'pending'), error: latest?.status === 'error' ? latest.error! : null, + supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null, mutate, reset, } } + +// settleUnmadePushes is called as the Supply run of the week starting on +// weekStart starts being made, from the Plan or the Supply run screen +// (useGenerateSupplyRun). The function it answers with is called once the +// list is made: it leaves behind the week's pushes sent before whose Supply +// run was not made, so the Plan does not say again that it is unmade, even +// if the Plan was left meanwhile. A push sent since is kept, and a list not +// made leaves them as they are. +export function settleUnmadePushes(queryClient: QueryClient, weekStart: string): () => void { + const mutations = queryClient.getMutationCache() + const mutationKey = pushKey(weekStart) + const sent = new Set(mutations.findAll({ mutationKey })) + return () => { + for (const mutation of mutations.findAll({ mutationKey })) { + if (sent.has(mutation) && unmade(mutation)) mutations.remove(mutation) + } + } +} diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 6119f03..09fc0a3 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -12,6 +12,7 @@ import { ApiError, useApi, type ApiClient } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { meQueryKey } from '../AuthBootstrap.tsx' import { oneOf } from '../dinners/api.ts' +import { settleUnmadePushes } from '../plan/api.ts' import type { NetworkStatus } from '../pwa/networkStatus.ts' import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts' import { @@ -187,16 +188,21 @@ export function useSupplyRun(weekStart: string) { // useGenerateSupplyRun makes the week's Supply run from its plan (POST // /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is -// the one shown. +// the one shown. Once made, from whichever screen, a push of the week whose +// Supply run was not made is settled (settleUnmadePushes). export function useGenerateSupplyRun(weekStart: string) { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ + onMutate: () => settleUnmadePushes(queryClient, weekStart), mutationFn: async (): Promise => { const path = `${supplyRunPath(weekStart)}/generate` return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' })) }, - onSuccess: (run) => queryClient.setQueryData(supplyRunKey(weekStart), run), + onSuccess: (run, _, settle) => { + queryClient.setQueryData(supplyRunKey(weekStart), run) + settle() + }, }) }