Hold a succeeded draft until the plan read is the one drafted
The Draft board let go of a succeeded draft_week job as soon as the plan was read again, whatever that read returned: a missing plan showed as "Nothing planned yet" and another plan as the new draft. Keep the job's plan_id and clear the job only once the plan read has that id; a missing or different plan is shown as "Couldn't read the draft" with Retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,10 @@ function planBody(status: string, names = NAMES) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftJob(status: string, error: string | null = null) {
|
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
|
||||||
const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: 'plan-2' } : null
|
// redraft keeps the plan's id.
|
||||||
|
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
|
||||||
|
const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: planId } : null
|
||||||
return { id: 'j1', kind: 'draft_week', status, result, error }
|
return { id: 'j1', kind: 'draft_week', status, result, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +295,34 @@ describe('PlanPage', () => {
|
|||||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
let plan: unknown = null
|
||||||
|
let job = draftJob('running')
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)),
|
||||||
|
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||||
|
[`GET ${JOB}`]: () => jsonResponse(200, job),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' }))
|
||||||
|
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
|
||||||
|
plan = landed
|
||||||
|
job = draftJob('succeeded')
|
||||||
|
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
|
||||||
|
expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`)
|
||||||
|
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
job = draftJob('running')
|
||||||
|
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()
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('moves between weeks with the previous and next week buttons', async () => {
|
it('moves between weeks with the previous and next week buttons', async () => {
|
||||||
stubServer({
|
stubServer({
|
||||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
|||||||
+24
-11
@@ -11,7 +11,7 @@ import { Icon } from '../ui/Icon.tsx'
|
|||||||
import { IconButton } from '../ui/IconButton.tsx'
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import { Tag } from '../ui/Tag.tsx'
|
import { Tag } from '../ui/Tag.tsx'
|
||||||
import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type PlanDay, type PlanStatus } from './api.ts'
|
import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts'
|
||||||
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts'
|
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts'
|
||||||
import styles from './PlanPage.module.css'
|
import styles from './PlanPage.module.css'
|
||||||
|
|
||||||
@@ -90,31 +90,44 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
const label = weekLabel(weekStart)
|
const label = weekLabel(weekStart)
|
||||||
|
|
||||||
// The draft's result, once the job has succeeded, or why it cannot be read.
|
// The draft's result, once the job has succeeded, or why it cannot be read.
|
||||||
const resultError = useMemo(() => {
|
const result = useMemo((): { draft: DraftResult | null; error: Error | null } => {
|
||||||
if (job.status !== 'succeeded') return null
|
if (job.status !== 'succeeded') return { draft: null, error: null }
|
||||||
try {
|
try {
|
||||||
parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result)
|
return { draft: parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result), error: null }
|
||||||
return null
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return error as Error
|
return { draft: null, error: error as Error }
|
||||||
}
|
}
|
||||||
}, [job.status, job.result, jobId, weekStart])
|
}, [job.status, job.result, jobId, weekStart])
|
||||||
|
|
||||||
// Once the job ends the plan is read again: a succeeded draft is followed
|
// Once the job ends the plan is read again: a succeeded draft is followed
|
||||||
// until that read lands, which then shows it; a failed one stays shown,
|
// until that read lands; a failed one stays shown, and may have met a
|
||||||
// and may have met a plan changed meanwhile.
|
// plan changed meanwhile.
|
||||||
|
const [readJobId, setReadJobId] = useState<string | null>(null)
|
||||||
const ended = job.status === 'succeeded' || job.status === 'failed'
|
const ended = job.status === 'succeeded' || job.status === 'failed'
|
||||||
const drafted = job.status === 'succeeded' && resultError === null
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ended) return
|
if (!ended) return
|
||||||
let active = true
|
let active = true
|
||||||
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
|
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
|
||||||
if (active && drafted) setJobId(null)
|
if (active) setReadJobId(jobId)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [queryClient, ended, drafted, weekStart])
|
}, [queryClient, ended, jobId, weekStart])
|
||||||
|
|
||||||
|
// The plan read shows once it is the one drafted; no plan, or another
|
||||||
|
// one, is an error rather than an empty or stale board.
|
||||||
|
const { draft } = result
|
||||||
|
const read = draft !== null && readJobId === jobId && plan.isSuccess
|
||||||
|
const landed = read && plan.data?.id === draft.plan_id
|
||||||
|
const resultError =
|
||||||
|
result.error ??
|
||||||
|
(read && !landed
|
||||||
|
? new Error(plan.data === null ? `The week has no plan, not draft ${draft.plan_id}.` : `The week's plan is not draft ${draft.plan_id}.`)
|
||||||
|
: null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (landed) setJobId(null)
|
||||||
|
}, [landed])
|
||||||
|
|
||||||
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
|
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user