From cc8a08aadc4fec3181b62b9418ee796f1f1daae2 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 01:03:40 -0400 Subject: [PATCH] Hold a plan_changed push until the changed plan is shown; announce reconnect A push refused with plan_changed now stays pending until the plan has been read again, so Push and Retry stay disabled until the changed plan is on screen and nothing unreviewed can be pushed. A test holds that read back and checks that no second push is sent meanwhile, and that Retry pushes once it lands. The reconnect banner's reason and required action are now an alert, as ErrorBanner's message is, so a calendar request that turns the screen to reconnect required is announced. Task: 677261bc-1a7c-4d96-b8f9-7601caf2186d Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.test.tsx | 35 +++++++++++++++++++ web/src/plan/api.ts | 10 +++--- .../settings/CalendarSettingsPage.module.css | 6 ++++ .../settings/CalendarSettingsPage.test.tsx | 9 ++++- web/src/settings/CalendarSettingsPage.tsx | 9 +++-- 5 files changed, 61 insertions(+), 8 deletions(-) diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 6f8584d..3f2904e 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -392,6 +392,41 @@ describe('PlanPage', () => { expect(planReads(fetchMock)).toBe(2) }) + 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('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { let plan = planBody('draft') let job = draftJob('running') diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 6477089..491efda 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -280,8 +280,10 @@ export function useSwapDay(weekStart: string): SwapDay { // chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The // server answers with the plan as synced, which replaces the one shown, as a // swap's answer does. A push refused because the plan changed meanwhile -// reads the plan again; one Google refused the grant for reads the Google -// Calendar connection again, which is now reconnect required. +// reads the plan again, and stays pending until that read lands: nothing is +// pushed again before the changed plan is shown. One Google refused the +// grant for reads the Google Calendar connection again, which is now +// reconnect required. export function usePushToCalendar(weekStart: string) { const { apiFetch } = useApi() const queryClient = useQueryClient() @@ -292,9 +294,9 @@ export function usePushToCalendar(weekStart: string) { return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) }, onSuccess: (plan) => queryClient.setQueryData(key, plan), - onError: (error) => { - if (error instanceof ApiError && error.code === 'plan_changed') void queryClient.invalidateQueries({ queryKey: key }) + onError: async (error) => { noteGoogleFailure(queryClient, error) + if (error instanceof ApiError && error.code === 'plan_changed') await queryClient.invalidateQueries({ queryKey: key }) }, }) } diff --git a/web/src/settings/CalendarSettingsPage.module.css b/web/src/settings/CalendarSettingsPage.module.css index ccfbc5f..3c6f7bf 100644 --- a/web/src/settings/CalendarSettingsPage.module.css +++ b/web/src/settings/CalendarSettingsPage.module.css @@ -129,6 +129,12 @@ font-weight: 400; } +.reconnectStatus { + display: flex; + flex-direction: column; + gap: 10px; +} + .reconnectText { margin: 0; font-family: var(--font-body); diff --git a/web/src/settings/CalendarSettingsPage.test.tsx b/web/src/settings/CalendarSettingsPage.test.tsx index 4baa7b1..6803202 100644 --- a/web/src/settings/CalendarSettingsPage.test.tsx +++ b/web/src/settings/CalendarSettingsPage.test.tsx @@ -279,7 +279,14 @@ describe('CalendarSettingsPage', () => { }) await renderPage() - expect(await screen.findByRole('region', { name: 'Reconnect Google Calendar' })).toBeInTheDocument() + const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' }) + // Why and what to do are announced as the screen turns to the banner. + const alert = screen.getByRole('alert') + expect(banner).toContainElement(alert) + expect(alert).toHaveTextContent( + 'Google access was revoked or expired — reconnect' + 'Nothing can be pushed to Calendar until you connect again.', + ) + expect(within(alert).queryByRole('link')).not.toBeInTheDocument() expect(screen.queryByRole('combobox')).not.toBeInTheDocument() expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument() }) diff --git a/web/src/settings/CalendarSettingsPage.tsx b/web/src/settings/CalendarSettingsPage.tsx index 40af793..ffb78b0 100644 --- a/web/src/settings/CalendarSettingsPage.tsx +++ b/web/src/settings/CalendarSettingsPage.tsx @@ -96,7 +96,8 @@ function Connection({ connection }: { connection: GoogleConnection }) { } // ReconnectBanner is the grape banner of a grant Google refused, with why -// and Reconnect. +// and Reconnect. Why and what to do are an alert, as ErrorBanner's message +// is: a calendar request that meets the refusal turns the screen to it. function ReconnectBanner({ error }: { error: string | null }) { const titleId = useId() return ( @@ -104,8 +105,10 @@ function ReconnectBanner({ error }: { error: string | null }) {

Reconnect Google Calendar

- {error !== null &&

{error}

} -

Nothing can be pushed to Calendar until you connect again.

+
+ {error !== null &&

{error}

} +

Nothing can be pushed to Calendar until you connect again.

+
Reconnect )