Remediate feature-review HIGH: report the Supply run outcome after Push to Calendar #141
@@ -414,6 +414,16 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* What became of the week's Supply run after a push. */
|
||||
.pushed {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The way to the week's Supply run under the Draft board on mobile. */
|
||||
.shoppingLink {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -52,9 +52,33 @@ function planBody(status: string, names = NAMES) {
|
||||
}
|
||||
|
||||
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
|
||||
// at the tests' noon.
|
||||
function syncedNow() {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
// at the tests' noon, with what became of the week's Supply run, made
|
||||
// unless told otherwise.
|
||||
function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun }
|
||||
}
|
||||
|
||||
// The Supply run outcomes a push answers with, as the server sends them.
|
||||
const GENERATED = { status: 'generated', error: null }
|
||||
const ARCHIVED = {
|
||||
status: 'archived',
|
||||
error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false },
|
||||
}
|
||||
const PLAN_CHANGED = {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'plan_changed',
|
||||
message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
const SUPPLY_RUN_FAILED = {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'supply_run_failed',
|
||||
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
@@ -420,6 +444,104 @@ describe('PlanPage', () => {
|
||||
expect(planReads(fetchMock)).toBe(2)
|
||||
})
|
||||
|
||||
it("reads the week's Supply run again once a push has made it, and says it was made", async () => {
|
||||
let run = () => errorResponse(404, 'not_found')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => {
|
||||
run = () => jsonResponse(200, supplyRun(28))
|
||||
return jsonResponse(200, syncedNow(GENERATED))
|
||||
},
|
||||
'GET /api/v1/shopping/2026-10-05': () => run(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
// The list made is read at once, not after the cached 404 goes stale.
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says a Supply run in the Supply archive was left as it was', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => {
|
||||
let plan = planBody('draft')
|
||||
let push = () => {
|
||||
plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)])
|
||||
return jsonResponse(200, syncedNow(PLAN_CHANGED))
|
||||
}
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`POST ${PUSH}`]: () => push(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(
|
||||
'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.',
|
||||
)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
// The plan edited meanwhile is the one shown, not the one pushed.
|
||||
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
|
||||
expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||
expect(planReads(fetchMock)).toBe(2)
|
||||
|
||||
push = () => jsonResponse(200, syncedNow(GENERATED))
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
|
||||
})
|
||||
|
||||
it('says the Supply run could not be made after the push, and makes the shopping list again', async () => {
|
||||
let generate = () => errorResponse(500, 'internal')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
// Calendar has the plan, but the push is not reported a quiet success.
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(
|
||||
await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] })
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([
|
||||
`${PUSH} undefined`,
|
||||
'/api/v1/shopping/2026-10-05/generate undefined',
|
||||
'/api/v1/shopping/2026-10-05/generate undefined',
|
||||
])
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useSupplyRun } from '../shopping/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type PlanDay,
|
||||
type PlanDinner,
|
||||
type PlanStatus,
|
||||
type PushedSupplyRun,
|
||||
type Swap,
|
||||
} from './api.ts'
|
||||
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
|
||||
@@ -88,6 +89,13 @@ const syncStatuses: Record<PlanStatus, string> = {
|
||||
changed: 'Changes not pushed to Google Calendar',
|
||||
}
|
||||
|
||||
// supplyRunOutcomes says what became of the week's Supply run after a push
|
||||
// that made it or left it as it was.
|
||||
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
|
||||
generated: "Made this week's Supply run from the plan.",
|
||||
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
|
||||
}
|
||||
|
||||
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
|
||||
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
|
||||
// between the method and the duration, never inside the duration.
|
||||
@@ -238,7 +246,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
|
||||
//
|
||||
// Push to Calendar, on either layout, sends the plan shown and shows it as
|
||||
// synced once the server answers; a failure says why, linking to the Google
|
||||
// Calendar settings when only they can fix it.
|
||||
// Calendar settings when only they can fix it. What became of the week's
|
||||
// Supply run is said too: made, left as it was in the Supply archive, or
|
||||
// not made, with the way to make it.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
@@ -259,6 +269,8 @@ function WeekBoard({
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const run = useSupplyRun(weekStart)
|
||||
// Makes the week's Supply run again after a push could not make it.
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
const pushing = push.pending
|
||||
// The date of the day being swapped, while the swap sheet is open.
|
||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||
@@ -328,6 +340,7 @@ function WeekBoard({
|
||||
if (!canPush) return
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
generate.reset()
|
||||
push.mutate()
|
||||
}
|
||||
|
||||
@@ -357,8 +370,25 @@ function WeekBoard({
|
||||
else start(false)
|
||||
}
|
||||
|
||||
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
|
||||
// A push's Supply run made again on the Plan stands for the push's.
|
||||
const supplyRun: PushedSupplyRun | null = push.supplyRun && (generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun)
|
||||
const failure = push.error !== null ? (
|
||||
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
|
||||
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
|
||||
) : supplyRun?.status === 'failed' ? (
|
||||
supplyRun.error.code === 'plan_changed' ? (
|
||||
<ErrorBanner onRetry={retryPush}>
|
||||
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
|
||||
it again.
|
||||
</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner onRetry={generate.isPending ? undefined : () => generate.mutate()} retryLabel="Make the shopping list">
|
||||
Pushed to Calendar, but this week's Supply run couldn't be made.
|
||||
{generate.isPending
|
||||
? ' Making the shopping list…'
|
||||
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
|
||||
</ErrorBanner>
|
||||
)
|
||||
) : swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
@@ -376,6 +406,11 @@ function WeekBoard({
|
||||
) : (
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
||||
)
|
||||
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
|
||||
<p role="status" className={styles.pushed}>
|
||||
{supplyRunOutcomes[supplyRun.status]}
|
||||
</p>
|
||||
)
|
||||
// A failed read of the week's Supply run is shown, with Retry, after the
|
||||
// Draft board's Shopping list link or under the plan manager's board; the
|
||||
// Shopping list action still opens the Supply run. The count is that of a
|
||||
@@ -562,6 +597,7 @@ function WeekBoard({
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
{pushed}
|
||||
{layout !== 'mobile' && supplyRunFailure}
|
||||
<Confirm
|
||||
open={confirming}
|
||||
|
||||
@@ -10,6 +10,21 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
|
||||
|
||||
const WEEK = '2026-10-05'
|
||||
const PLAN = `/api/v1/plans/${WEEK}`
|
||||
|
||||
// GENERATED is a push answer's Supply run when the push made it.
|
||||
const GENERATED = { supply_run: { status: 'generated', error: null } }
|
||||
|
||||
// SUPPLY_RUN_FAILED is a push answer's Supply run when it could not be made.
|
||||
const SUPPLY_RUN_FAILED = {
|
||||
supply_run: {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'supply_run_failed',
|
||||
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const MONDAY = `${PLAN}/days/2026-10-05`
|
||||
const FRIDAY = `${PLAN}/days/2026-10-09`
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
@@ -318,7 +333,7 @@ describe('week mutations', () => {
|
||||
|
||||
// The push succeeds while the third week is shown: it is left behind,
|
||||
// and the week's pending swap is not.
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
|
||||
|
||||
@@ -338,6 +353,43 @@ describe('week mutations', () => {
|
||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
||||
const nextPush = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }),
|
||||
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
|
||||
})
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), {
|
||||
wrapper,
|
||||
initialProps: { week: NEXT },
|
||||
})
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
|
||||
// The push is pushed to Calendar while another week is shown, but its
|
||||
// Supply run is not made: it stays, though it succeeded.
|
||||
act(() => hook.result.current.push.mutate())
|
||||
hook.rerender({ week: WEEK })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success']))
|
||||
|
||||
// Coming back mounts the week's board again, as the Plan's does.
|
||||
hook.unmount()
|
||||
const back = renderHook(() => usePushToCalendar(NEXT), { wrapper })
|
||||
expect(back.result.current.error).toBeNull()
|
||||
expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run)
|
||||
})
|
||||
|
||||
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
|
||||
const friday = deferred()
|
||||
const monday = deferred()
|
||||
@@ -394,7 +446,7 @@ describe('week mutations', () => {
|
||||
// The push succeeds: the plan as synced is recorded, and the push is
|
||||
// left behind.
|
||||
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
|
||||
await act(async () => push.send(jsonResponse(200, synced)))
|
||||
await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
|
||||
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
|
||||
})
|
||||
|
||||
+81
-6
@@ -5,6 +5,7 @@ import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { noteGoogleFailure } from '../settings/calendarApi.ts'
|
||||
import { supplyRunKey } from '../shopping/api.ts'
|
||||
|
||||
export const PLANS_PATH = '/api/v1/plans'
|
||||
|
||||
@@ -114,6 +115,56 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
|
||||
}
|
||||
}
|
||||
|
||||
// SupplyRunError is the API error a Push to Calendar's Supply run carries
|
||||
// when it was not generated.
|
||||
export type SupplyRunError<C extends string> = { code: C; message: string; retryable: boolean }
|
||||
|
||||
// PushedSupplyRun says whether a Push to Calendar made the week's Supply
|
||||
// run: generated when it did; archived when the week's Supply run is in the
|
||||
// Supply archive and was left as it was; or failed when it was not made.
|
||||
// A failed one's error is plan_changed when the plan was edited after it
|
||||
// was pushed, and pushing again makes it, or supply_run_failed when it
|
||||
// could not be made, and making the shopping list again does.
|
||||
export type PushedSupplyRun =
|
||||
| { status: 'generated'; error: null }
|
||||
| { status: 'archived'; error: SupplyRunError<'archived'> }
|
||||
| { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> }
|
||||
|
||||
// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer:
|
||||
// the plan as synced, and what became of the week's Supply run.
|
||||
export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun }
|
||||
|
||||
const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const
|
||||
const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const
|
||||
|
||||
function parseSupplyRunError<C extends string>(where: string, codes: readonly C[], body: unknown): SupplyRunError<C> {
|
||||
if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') {
|
||||
throw new Error(`${where}: supply_run has an error that is not an API error`)
|
||||
}
|
||||
return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable }
|
||||
}
|
||||
|
||||
function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun {
|
||||
if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`)
|
||||
const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status)
|
||||
switch (status) {
|
||||
case 'generated':
|
||||
if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`)
|
||||
return { status, error: null }
|
||||
case 'archived':
|
||||
return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) }
|
||||
case 'failed':
|
||||
return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) }
|
||||
}
|
||||
}
|
||||
|
||||
function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan {
|
||||
return {
|
||||
plan: parsePlan(where, weekStart, body, false),
|
||||
supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDraftResult checks a draft_week job's result, which useJob passes on
|
||||
// unread, is the draft of the week starting on weekStart.
|
||||
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
|
||||
@@ -178,6 +229,14 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// failed says whether a swap or push failed: a push whose Supply run was
|
||||
// not made failed too, though Calendar has the plan.
|
||||
function failed(mutation: Mutation): boolean {
|
||||
if (mutation.state.status === 'error') return true
|
||||
const data = mutation.state.data as PushedPlan | undefined
|
||||
return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed'
|
||||
}
|
||||
|
||||
// weekMutation is the kind and week of a swap or push of a week the mutation
|
||||
// cache keeps for it, or null for any other mutation.
|
||||
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
|
||||
@@ -225,7 +284,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
||||
const latest = week.at(-1)
|
||||
for (const mutation of week) {
|
||||
if (settled && mutation === latest && mutation.state.status === 'error') continue
|
||||
if (settled && mutation === latest && failed(mutation)) continue
|
||||
mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
@@ -363,10 +422,12 @@ function pushKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// whether it is pending, why the latest failed, if it failed, and what
|
||||
// became of the week's Supply run, if it succeeded.
|
||||
export type PushToCalendar = {
|
||||
pending: boolean
|
||||
error: Error | null
|
||||
supplyRun: PushedSupplyRun | null
|
||||
mutate: () => void
|
||||
reset: () => void
|
||||
}
|
||||
@@ -380,6 +441,13 @@ export type PushToCalendar = {
|
||||
// the one shown. One Google refused the grant for reads the Google Calendar
|
||||
// connection again, which is now reconnect required.
|
||||
//
|
||||
// Once pushed, the server makes the week's Supply run from the plan pushed
|
||||
// and answers with its outcome, which supplyRun reports: a failed one is
|
||||
// never a quiet success. The week's Supply run is read again, whatever the
|
||||
// outcome, so the list shown is the server's. A Supply run not made because
|
||||
// the plan was edited after it was pushed reads the plan again too, as a
|
||||
// push refused for that does.
|
||||
//
|
||||
// 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. Settled
|
||||
@@ -395,12 +463,12 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
// The week's pushes, in the order sent.
|
||||
const pushes = useMutationState({
|
||||
filters: { mutationKey },
|
||||
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
|
||||
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error, data: mutation.state.data as PushedPlan | undefined }),
|
||||
})
|
||||
const latest = pushes.at(-1)
|
||||
|
||||
function mutate() {
|
||||
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
|
||||
const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until reset or settled in a week not shown (keepWeekMutations):
|
||||
// a push pending in a week left must still be found when it is shown
|
||||
@@ -408,9 +476,15 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
gcTime: Infinity,
|
||||
mutationFn: async () => {
|
||||
const path = `${planPath(weekStart)}/push-calendar`
|
||||
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
|
||||
return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: async ({ plan, supply_run }) => {
|
||||
queryClient.setQueryData<Plan>(key, plan)
|
||||
void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) })
|
||||
if (supply_run.error?.code === 'plan_changed') {
|
||||
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
|
||||
}
|
||||
},
|
||||
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
|
||||
onError: async (error) => {
|
||||
noteGoogleFailure(queryClient, error)
|
||||
if (error instanceof ApiError && error.code === 'plan_changed') {
|
||||
@@ -433,6 +507,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
return {
|
||||
pending: pushes.some((push) => push.status === 'pending'),
|
||||
error: latest?.status === 'error' ? latest.error! : null,
|
||||
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
|
||||
mutate,
|
||||
reset,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user