Retry an unread draft by reading the plan again
CI / ci (push) Successful in 3m13s
CI / ci (pull_request) Successful in 3m13s

A succeeded draft whose plan read is missing, another plan, or a cache
fallback now retries the plan GET and keeps the job, instead of starting
a new draft. Rebuilding stays on "+ Rebuild week". The tests assert Retry
sends no draft POST, re-reads the plan, keeps the error while the read is
still a cache fallback, and lands the draft on a live matching read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:22:17 -04:00
co-authored by Claude Opus 5.5
parent 58c415ca1a
commit 870f3462ce
2 changed files with 42 additions and 14 deletions
+31 -6
View File
@@ -81,6 +81,11 @@ function posts(fetchMock: Mock<typeof fetch>) {
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
// planReads counts the reads of this week's plan.
function planReads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === THIS_WEEK).length
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page.
async function renderPage() {
@@ -299,7 +304,7 @@ describe('PlanPage', () => {
it.each([
['the week has no plan', null, 'The week has no plan, not draft plan-1.'],
['the week has another plan', { ...planBody('draft'), id: 'plan-9' }, "The week's plan is not draft plan-1."],
])('shows a succeeded draft with Retry when %s once read', async (_, landed, message) => {
])('shows a succeeded draft with Retry when %s once read, and Retry reads the plan again', async (_, landed, message) => {
let plan: unknown = null
let job = draftJob('running')
const fetchMock = stubServer({
@@ -317,18 +322,30 @@ describe('PlanPage', () => {
expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`)
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
job = draftJob('running')
// Retry reads the plan again rather than drafting again: the read is
// still not the draft.
let reads = planReads(fetchMock)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(posts(fetchMock)).toHaveLength(2))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
expect(await screen.findByRole('alert')).toHaveTextContent(`Couldn't read the draft. ${message}`)
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
// The next read is the draft, from the server: it lands.
plan = planBody('draft')
reads = planReads(fetchMock)
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(reads + 1)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
})
it("doesn't land a succeeded draft on the plan cached before it, and lands it once the server is read", async () => {
let plan = planBody('draft')
let cached = false
let job = draftJob('running')
stubServer({
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => {
const response = jsonResponse(200, plan)
if (cached) response.headers.set(cacheFallbackHeader, '1')
@@ -347,10 +364,18 @@ describe('PlanPage', () => {
job = draftJob('succeeded')
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
expect(alert).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
// Retry reads the plan again and drafts nothing: still the cache, so the
// draft stays unread.
const reads = planReads(fetchMock)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
// The server is back: the next read of the plan is the draft.
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
cached = false
+11 -8
View File
@@ -117,14 +117,14 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
// The plan read shows once it is the one drafted, read from the server; no
// plan, another one, or a copy cached before the draft (a redraft keeps the
// plan's id) is an error rather than an empty or stale board. A later read
// from the server that is the draft still lands it.
// plan's id) is an error rather than an empty or stale board. Its Retry
// reads the plan again, keeping the job: a later read from the server that
// is the draft still lands it.
const { draft } = result
const read = draft !== null && readJobId === jobId && plan.isSuccess
const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached
const resultError =
result.error ??
(read && !landed
const unreadError =
read && !landed
? new Error(
plan.data === null
? `The week has no plan, not draft ${draft.plan_id}.`
@@ -132,7 +132,8 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
? `The week's plan is not draft ${draft.plan_id}.`
: `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`,
)
: null)
: null
const resultError = result.error ?? unreadError
useEffect(() => {
if (landed) setJobId(null)
}, [landed])
@@ -161,8 +162,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
const failure = startDraft.isError ? (
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
) : resultError !== null ? (
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {resultError.message}</ErrorBanner>
) : result.error !== null ? (
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
) : unreadError !== null ? (
<ErrorBanner onRetry={() => void plan.refetch()}>Couldn't read the draft. {unreadError.message}</ErrorBanner>
) : job.error === null ? null : job.status === 'failed' ? (
<ErrorBanner onRetry={rebuild}>
Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message}