Keep a week's pending push across leaving the week and coming back
CI / ci (push) Successful in 4m21s
CI / ci (pull_request) Successful in 4m25s

A push to Calendar now lives in the query client's mutation cache, keyed by
week, as a swap does, instead of in the Draft board's own useMutation. A
board remounted by leaving the week and coming back therefore still sees a
push pending, including one held open by the plan_changed reread, and keeps
Push and Retry disabled until that read lands. The reread now refetches the
week's plan even while it is not shown, so a return finds the changed plan
in the cache rather than the stale one.

Tests leave the week and come back with the reread held open (no second
POST; the changed plan, then Retry, push once), and let the reread land
while the week is left (the board returns on the changed plan).

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:23:01 -04:00
co-authored by Claude Opus 5.5
parent cc8a08aadc
commit f77960ad05
3 changed files with 144 additions and 18 deletions
+71
View File
@@ -427,6 +427,77 @@ describe('PlanPage', () => {
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<Response> = () => 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('shows the changed plan read while the week was left, and pushes it', async () => {
let read: () => Response | Promise<Response> = () => 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')
+5 -3
View File
@@ -243,7 +243,7 @@ function WeekBoard({
const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const pushing = push.isPending
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
const label = weekLabel(weekStart)
@@ -302,11 +302,13 @@ function WeekBoard({
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
// A push sends the plan shown: it waits for a draft or a swap of the week
// to settle, and neither starts while it runs.
// to settle, and neither starts while it runs, even across leaving the
// week and coming back.
const canPush = plan.isSuccess && plan.data !== null && !drafting && !swap.pending && !pushing
function pushToCalendar() {
if (!canPush) return
swap.dismiss()
push.reset()
push.mutate()
}
@@ -336,7 +338,7 @@ function WeekBoard({
else start(false)
}
const failure = push.isError ? (
const failure = push.error !== null ? (
<PushFailure error={push.error} onRetry={pushToCalendar} />
) : swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
+68 -15
View File
@@ -276,27 +276,80 @@ export function useSwapDay(weekStart: string): SwapDay {
}
}
// pushKey keys the pushes of the week starting on weekStart in the mutation
// cache, where they outlive the Draft board that sent them.
function pushKey(weekStart: string) {
return ['plans', weekStart, 'push'] as const
}
// 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.
export type PushToCalendar = {
pending: boolean
error: Error | null
mutate: () => void
reset: () => void
}
// usePushToCalendar pushes the plan of the week starting on weekStart to the
// 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, 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) {
// reads the plan again, whether or not the week is shown, and stays pending
// until that read lands: nothing is pushed again before the changed plan is
// the one shown. One Google refused the grant for reads the Google Calendar
// connection again, which is now reconnect required.
//
// 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.
export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const key = planKey(weekStart)
return useMutation({
mutationFn: async (): Promise<Plan> => {
const path = `${planPath(weekStart)}/push-calendar`
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
},
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
onError: async (error) => {
noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') await queryClient.invalidateQueries({ queryKey: key })
},
const mutationKey = pushKey(weekStart)
const mutations = queryClient.getMutationCache()
// The week's pushes, in the order sent.
const pushes = useMutationState({
filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
})
const latest = pushes.at(-1)
function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
mutationKey,
// Kept until reset: a push pending in a week left must still be found
// when it is shown again.
gcTime: Infinity,
mutationFn: async () => {
const path = `${planPath(weekStart)}/push-calendar`
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
},
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
onError: async (error) => {
noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') {
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
}
},
})
// A failure is the mutation's state, which error reports.
mutation.execute().catch(() => undefined)
}
function reset() {
const week = mutations.findAll({ mutationKey })
if (week.some((mutation) => mutation.state.status === 'pending')) {
throw new Error(`The week of ${weekStart} has a push pending, which cannot be reset.`)
}
for (const mutation of week) mutations.remove(mutation)
}
return {
pending: pushes.some((push) => push.status === 'pending'),
error: latest?.status === 'error' ? latest.error! : null,
mutate,
reset,
}
}