Keep a week's pending push across leaving the week and coming back
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:
@@ -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')
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user