Hold a plan_changed push until the changed plan is shown; announce reconnect
CI / ci (push) Successful in 4m21s
CI / ci (pull_request) Successful in 4m25s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 01:03:40 -04:00
co-authored by Claude Opus 5.5
parent 2b5350bd62
commit cc8a08aadc
5 changed files with 61 additions and 8 deletions
+35
View File
@@ -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<Response> = () => 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')
+6 -4
View File
@@ -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<unknown>(path, { method: 'POST' }), false)
},
onSuccess: (plan) => queryClient.setQueryData<Plan>(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 })
},
})
}
@@ -129,6 +129,12 @@
font-weight: 400;
}
.reconnectStatus {
display: flex;
flex-direction: column;
gap: 10px;
}
.reconnectText {
margin: 0;
font-family: var(--font-body);
@@ -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()
})
+6 -3
View File
@@ -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 }) {
<h2 id={titleId} className={styles.reconnectTitle}>
Reconnect Google Calendar
</h2>
{error !== null && <p className={styles.reconnectText}>{error}</p>}
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
<div role="alert" className={styles.reconnectStatus}>
{error !== null && <p className={styles.reconnectText}>{error}</p>}
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
</div>
<ConnectLink icon={RefreshCw}>Reconnect</ConnectLink>
</section>
)