Keep a week's swaps in the mutation cache, across remounts
The Draft board's swap guard was its mounted useMutation observer, so leaving the week and coming back let a second swap start while the first PUT was still pending, and Rebuild week's reset() detached a pending swap, hiding its failure. The week's swaps now live in the mutation cache, keyed by week: the guard, the failure shown and the success handler all read them from there. Only the latest swap of the week writes the plan it is answered with, and only its failure is the week's. Rebuild week waits for a pending swap (its tile is disabled meanwhile), and dismissing settled swaps is refused while one is pending. A failed swap puts its day back only while the day is still, field for field, the day it wrote, so a newer plan read survives. Maestro task f231813c-992e-4a3b-a1a4-868cbad47415. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -202,10 +202,15 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rebuild:hover {
|
.rebuild:hover:not(:disabled) {
|
||||||
background: var(--color-neutral-100);
|
background: var(--color-neutral-100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rebuild:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -596,5 +596,88 @@ describe('PlanPage', () => {
|
|||||||
expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument()
|
expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument()
|
||||||
expect(puts(fetchMock)).toEqual([])
|
expect(puts(fetchMock)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// leaveAndReturn goes to next week and back, remounting this week's board.
|
||||||
|
async function leaveAndReturn() {
|
||||||
|
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('FRI · NEW')
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends one swap at a time even after leaving the week and coming back', async () => {
|
||||||
|
let answer!: (response: Response) => void
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||||
|
...lists,
|
||||||
|
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (answer = resolve)),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
|
||||||
|
await waitFor(() => expect(puts(fetchMock)).toHaveLength(1))
|
||||||
|
await leaveAndReturn()
|
||||||
|
|
||||||
|
// Friday's swap is still pending: Monday does not open.
|
||||||
|
fireEvent.click(within(tile('MON')).getByRole('button'))
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
|
||||||
|
|
||||||
|
await act(async () => answer(jsonResponse(200, swappedBody())))
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeEnabled())
|
||||||
|
fireEvent.click(within(tile('MON')).getByRole('button'))
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Swap Monday' })).toBeInTheDocument()
|
||||||
|
expect(puts(fetchMock)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the failure of a swap that ended while the week was left', async () => {
|
||||||
|
let refuse!: () => void
|
||||||
|
stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||||
|
...lists,
|
||||||
|
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||||
|
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||||
|
await act(async () => refuse())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||||
|
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
|
||||||
|
let refuse!: () => void
|
||||||
|
const fetchMock = stubServer({
|
||||||
|
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||||
|
...lists,
|
||||||
|
[`PUT ${FRIDAY}`]: () => new Promise<Response>((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))),
|
||||||
|
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||||
|
[`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
|
||||||
|
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
|
||||||
|
await screen.findByText('FRI · NEW')
|
||||||
|
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
||||||
|
expect(rebuild).toBeDisabled()
|
||||||
|
fireEvent.click(rebuild)
|
||||||
|
expect(posts(fetchMock)).toEqual([])
|
||||||
|
|
||||||
|
await act(async () => refuse())
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||||
|
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||||
|
|
||||||
|
// Once it has failed, Rebuild week drafts, leaving the failure behind.
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' }))
|
||||||
|
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]))
|
||||||
|
expect(await screen.findByText('Drafting your week…')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
// A draft asked for again leaves the last one's failure, and a failed
|
// A draft asked for again leaves the last one's failure, and a failed
|
||||||
// swap's, behind.
|
// swap's, behind.
|
||||||
setJobId(null)
|
setJobId(null)
|
||||||
swap.reset()
|
swap.dismiss()
|
||||||
mutate(
|
mutate(
|
||||||
{ weekStart, confirm },
|
{ weekStart, confirm },
|
||||||
{
|
{
|
||||||
@@ -177,14 +177,17 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// rebuild drafts the week, asking first over a plan pushed to Calendar.
|
// rebuild drafts the week, asking first over a plan pushed to Calendar.
|
||||||
|
// It waits for a swap of the week still pending, which may yet fail: the
|
||||||
|
// Rebuild week tile is disabled meanwhile.
|
||||||
function rebuild() {
|
function rebuild() {
|
||||||
|
if (swap.pending) return
|
||||||
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
||||||
else start(false)
|
else start(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = swap.isError ? (
|
const failure = swap.failed !== null ? (
|
||||||
<ErrorBanner onRetry={() => swapIn(swap.variables)}>
|
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||||
Couldn't swap {weekdayName(swap.variables.date)}'s dinner. {swap.error.message}
|
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||||
</ErrorBanner>
|
</ErrorBanner>
|
||||||
) : startDraft.isError ? (
|
) : startDraft.isError ? (
|
||||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||||
@@ -205,11 +208,12 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
setSwappingDate(null)
|
setSwappingDate(null)
|
||||||
swap.mutate(chosen)
|
swap.mutate(chosen)
|
||||||
}
|
}
|
||||||
// openSwap opens the swap sheet on a day. One swap is sent at a time: the
|
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
||||||
// plan the server answers it with must not overwrite another still pending.
|
// a time, even across leaving the week and coming back: the plan the
|
||||||
|
// server answers it with must not overwrite another still pending.
|
||||||
function openSwap(date: string) {
|
function openSwap(date: string) {
|
||||||
if (swap.isPending) return
|
if (swap.pending) return
|
||||||
swap.reset()
|
swap.dismiss()
|
||||||
setSwappingDate(date)
|
setSwappingDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +257,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<li className={styles.rebuildCell}>
|
<li className={styles.rebuildCell}>
|
||||||
<button type="button" className={styles.rebuild} onClick={rebuild}>
|
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
||||||
<Icon icon={Plus} size="inline" />
|
<Icon icon={Plus} size="inline" />
|
||||||
Rebuild week
|
Rebuild week
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
|
import { planKey, usePlan, useSwapDay, type Plan } from './api.ts'
|
||||||
|
|
||||||
|
const WEEK = '2026-10-05'
|
||||||
|
const PLAN = `/api/v1/plans/${WEEK}`
|
||||||
|
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']
|
||||||
|
|
||||||
|
// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days
|
||||||
|
// changed as given by index.
|
||||||
|
function planBody(changes: Record<number, { id: string; name: string; is_new?: boolean; start_by?: string | null }> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'plan-1',
|
||||||
|
week_start: WEEK,
|
||||||
|
status: 'draft',
|
||||||
|
ai_note: null,
|
||||||
|
days: DAYS.map((date, i) => {
|
||||||
|
const change = changes[i]
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}` },
|
||||||
|
is_new: change?.is_new ?? false,
|
||||||
|
start_by: change?.start_by ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deferred is a reply the test sends when it chooses.
|
||||||
|
function deferred() {
|
||||||
|
let send!: (response: Response) => void
|
||||||
|
const reply = new Promise<Response>((resolve) => (send = resolve))
|
||||||
|
return { reply, send }
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||||
|
// the Quest log; any other request fails the test.
|
||||||
|
function stubServer(responders: Record<string, () => Response | Promise<Response>>) {
|
||||||
|
const all: Record<string, () => Response | Promise<Response>> = {
|
||||||
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||||
|
...responders,
|
||||||
|
}
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
|
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
return Promise.resolve(respond())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSwaps renders the week's plan and swaps over a query client.
|
||||||
|
async function renderSwaps() {
|
||||||
|
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||||
|
await api.loadMe()
|
||||||
|
const queryClient: QueryClient = createQueryClient()
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
)
|
||||||
|
const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper })
|
||||||
|
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||||
|
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo' }, fromQuestLog: true }
|
||||||
|
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos' }, fromQuestLog: false }
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useSwapDay', () => {
|
||||||
|
// Friday's swap is sent first, then Monday's; the server answers Monday's
|
||||||
|
// with both swapped, Friday's with only Friday.
|
||||||
|
const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||||
|
const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['the later swap answered first', ['monday', 'friday'] as const],
|
||||||
|
['the earlier swap answered first', ['friday', 'monday'] as const],
|
||||||
|
])('keeps the plan of the later of two swaps, %s', async (_, order) => {
|
||||||
|
const replies = { friday: deferred(), monday: deferred() }
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||||
|
[`PUT ${FRIDAY}`]: () => replies.friday.reply,
|
||||||
|
[`PUT ${MONDAY}`]: () => replies.monday.reply,
|
||||||
|
})
|
||||||
|
const { result, plan, queryClient } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
act(() => result.current.swap.mutate(FISH))
|
||||||
|
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||||
|
|
||||||
|
// Whichever reply settles, Monday stays swapped: Friday's plan, the
|
||||||
|
// earlier one, never lands over it.
|
||||||
|
for (const [settled, day] of order.entries()) {
|
||||||
|
await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan)))
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled))
|
||||||
|
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||||
|
}
|
||||||
|
expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not take an earlier swap's failure for the week's", async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||||
|
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||||
|
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||||
|
})
|
||||||
|
const { result, plan } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
act(() => result.current.swap.mutate(FISH))
|
||||||
|
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||||
|
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.swap.pending).toBe(false))
|
||||||
|
expect(result.current.swap.failed).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a day a later plan read landed when the swap of it fails', async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
// The read finds Friday already lemon orzo, starting by 2 pm.
|
||||||
|
let read = planBody()
|
||||||
|
stubServer({
|
||||||
|
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||||
|
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||||
|
})
|
||||||
|
const { result, plan, queryClient } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }))
|
||||||
|
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } })
|
||||||
|
await act(() => result.current.plan.refetch())
|
||||||
|
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||||
|
expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' })
|
||||||
|
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to dismiss a pending swap', async () => {
|
||||||
|
const friday = deferred()
|
||||||
|
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||||
|
const { result } = await renderSwaps()
|
||||||
|
|
||||||
|
act(() => result.current.swap.mutate(ORZO))
|
||||||
|
await waitFor(() => expect(result.current.swap.pending).toBe(true))
|
||||||
|
expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.')
|
||||||
|
await act(async () => friday.send(jsonResponse(200, planBody())))
|
||||||
|
})
|
||||||
|
})
|
||||||
+74
-10
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { oneOf } from '../dinners/api.ts'
|
import { oneOf } from '../dinners/api.ts'
|
||||||
@@ -136,6 +136,28 @@ export function useStartDraft() {
|
|||||||
// it was chosen from the Quest log.
|
// it was chosen from the Quest log.
|
||||||
export type Swap = { date: string; dinner: { id: string; name: string }; fromQuestLog: boolean }
|
export type Swap = { date: string; dinner: { id: string; name: string }; fromQuestLog: boolean }
|
||||||
|
|
||||||
|
// sameDay says whether two plan days are the same in every field.
|
||||||
|
function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||||
|
return (
|
||||||
|
a.date === b.date && a.dinner.id === b.dinner.id && a.dinner.name === b.dinner.name && a.is_new === b.is_new && a.start_by === b.start_by
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||||
|
// cache, where they outlive the Draft board that sent them.
|
||||||
|
function swapKey(weekStart: string) {
|
||||||
|
return ['plans', weekStart, 'swap'] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
||||||
|
// whether one is pending, and the failure of the latest, if it failed.
|
||||||
|
export type SwapDay = {
|
||||||
|
pending: boolean
|
||||||
|
failed: { swap: Swap; error: Error } | null
|
||||||
|
mutate: (chosen: Swap) => void
|
||||||
|
dismiss: () => void
|
||||||
|
}
|
||||||
|
|
||||||
// useSwapDay swaps the dinner of one day of the week starting on weekStart
|
// useSwapDay swaps the dinner of one day of the week starting on weekStart
|
||||||
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||||
// once, NEW when it came from the Quest log, with no start-by time until the
|
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||||
@@ -143,12 +165,36 @@ export type Swap = { date: string; dinner: { id: string; name: string }; fromQue
|
|||||||
// swap puts the day back as it was, unless it has changed again meanwhile.
|
// swap puts the day back as it was, unless it has changed again meanwhile.
|
||||||
// However the swap ends, the Quest log is read again: the server leaves a
|
// However the swap ends, the Quest log is read again: the server leaves a
|
||||||
// dinner on the plan out of it.
|
// dinner on the plan out of it.
|
||||||
export function useSwapDay(weekStart: string) {
|
//
|
||||||
|
// The week's swaps live in the mutation cache, not in the component, so
|
||||||
|
// leaving the week and coming back keeps them: pending and failed are read
|
||||||
|
// from there. Only the latest swap of the week speaks for it: the plan an
|
||||||
|
// earlier one is answered with is not written over a later one, and its
|
||||||
|
// failure is not the week's. dismiss leaves settled swaps behind; it is
|
||||||
|
// refused while one is pending, so a later swap is always in the cache for
|
||||||
|
// an earlier, pending one to see.
|
||||||
|
export function useSwapDay(weekStart: string): SwapDay {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const key = planKey(weekStart)
|
const key = planKey(weekStart)
|
||||||
return useMutation({
|
const mutationKey = swapKey(weekStart)
|
||||||
mutationFn: async ({ date, dinner }: Swap): Promise<Plan> => {
|
const mutations = queryClient.getMutationCache()
|
||||||
|
// The week's swaps, in the order sent.
|
||||||
|
const swaps = useMutationState({
|
||||||
|
filters: { mutationKey },
|
||||||
|
select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }),
|
||||||
|
})
|
||||||
|
const latest = swaps.at(-1)
|
||||||
|
|
||||||
|
function mutate(chosen: Swap) {
|
||||||
|
// isLatest says whether no swap of the week was sent after this one.
|
||||||
|
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||||
|
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||||
|
mutationKey,
|
||||||
|
// Kept until dismissed: a later swap must stay in the cache while an
|
||||||
|
// earlier one is pending.
|
||||||
|
gcTime: Infinity,
|
||||||
|
mutationFn: async ({ date, dinner }) => {
|
||||||
const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}`
|
const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}`
|
||||||
const body = await apiFetch<unknown>(path, { method: 'PUT', body: { dinner_id: dinner.id } })
|
const body = await apiFetch<unknown>(path, { method: 'PUT', body: { dinner_id: dinner.id } })
|
||||||
return parsePlan(`PUT ${path}`, weekStart, body, false)
|
return parsePlan(`PUT ${path}`, weekStart, body, false)
|
||||||
@@ -163,17 +209,35 @@ export function useSwapDay(weekStart: string) {
|
|||||||
return { previous, swapped }
|
return { previous, swapped }
|
||||||
},
|
},
|
||||||
onSuccess: (plan) => {
|
onSuccess: (plan) => {
|
||||||
queryClient.setQueryData<Plan>(key, plan)
|
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||||
},
|
},
|
||||||
onError: (_error, { date }, context) => {
|
// The day goes back only while it is still, field for field, the day
|
||||||
|
// this swap wrote: a later swap or plan read that replaced it is left
|
||||||
|
// alone.
|
||||||
|
onError: (_error, _swap, context) => {
|
||||||
if (context === undefined) return
|
if (context === undefined) return
|
||||||
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||||
plan && {
|
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||||
...plan,
|
|
||||||
days: plan.days.map((day) => (day.date === date && day.dinner.id === context.swapped.dinner.id ? context.previous : day)),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||||
})
|
})
|
||||||
|
// A failure is the mutation's state, which failed reports.
|
||||||
|
mutation.execute(chosen).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
const week = mutations.findAll({ mutationKey })
|
||||||
|
if (week.some((mutation) => mutation.state.status === 'pending')) {
|
||||||
|
throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`)
|
||||||
|
}
|
||||||
|
for (const mutation of week) mutations.remove(mutation)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: swaps.some((swap) => swap.status === 'pending'),
|
||||||
|
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||||
|
mutate,
|
||||||
|
dismiss,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user