diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index ea13869..8080ba8 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -202,10 +202,15 @@ cursor: pointer; } -.rebuild:hover { +.rebuild:hover:not(:disabled) { background: var(--color-neutral-100); } +.rebuild:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .loading { display: flex; flex-direction: column; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 6a6f84d..d583bc8 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -596,5 +596,88 @@ describe('PlanPage', () => { expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument() 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((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((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((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() + }) }) }) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 1d4a444..19519fc 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -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 // swap's, behind. setJobId(null) - swap.reset() + swap.dismiss() mutate( { 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. + // It waits for a swap of the week still pending, which may yet fail: the + // Rebuild week tile is disabled meanwhile. function rebuild() { + if (swap.pending) return if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) else start(false) } - const failure = swap.isError ? ( - swapIn(swap.variables)}> - Couldn't swap {weekdayName(swap.variables.date)}'s dinner. {swap.error.message} + const failure = swap.failed !== null ? ( + swapIn(swap.failed!.swap)}> + Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} ) : startDraft.isError ? ( Couldn't start drafting this week. {startDraft.error.message} @@ -205,11 +208,12 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon setSwappingDate(null) swap.mutate(chosen) } - // openSwap opens the swap sheet on a day. One swap is sent at a time: the - // plan the server answers it with must not overwrite another still pending. + // openSwap opens the swap sheet on a day. One swap of the week is sent at + // 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) { - if (swap.isPending) return - swap.reset() + if (swap.pending) return + swap.dismiss() setSwappingDate(date) } @@ -253,7 +257,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon /> ))}
  • - diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx new file mode 100644 index 0000000..87b3e57 --- /dev/null +++ b/web/src/plan/api.test.tsx @@ -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 = {}) { + 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((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 Response | Promise>) { + const all: Record Response | Promise> = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }), + ...responders, + } + vi.stubGlobal( + 'fetch', + vi.fn((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 }) => ( + + {children} + + ) + 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(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()))) + }) +}) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index bded587..4d14cb4 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -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 { isRecord } from '../api/errors.ts' import { oneOf } from '../dinners/api.ts' @@ -136,6 +136,28 @@ export function useStartDraft() { // it was chosen from the Quest log. 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 // (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 @@ -143,37 +165,79 @@ 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. // However the swap ends, the Quest log is read again: the server leaves a // 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 queryClient = useQueryClient() const key = planKey(weekStart) - return useMutation({ - mutationFn: async ({ date, dinner }: Swap): Promise => { - const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}` - const body = await apiFetch(path, { method: 'PUT', body: { dinner_id: dinner.id } }) - return parsePlan(`PUT ${path}`, weekStart, body, false) - }, - onMutate: async ({ date, dinner, fromQuestLog }) => { - await queryClient.cancelQueries({ queryKey: key }) - const plan = queryClient.getQueryData(key) - const previous = plan?.days.find((day) => day.date === date) - if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`) - const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null } - queryClient.setQueryData(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }) - return { previous, swapped } - }, - onSuccess: (plan) => { - queryClient.setQueryData(key, plan) - }, - onError: (_error, { date }, context) => { - if (context === undefined) return - queryClient.setQueryData(key, (plan) => - plan && { - ...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 }), + const mutationKey = swapKey(weekStart) + 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(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 body = await apiFetch(path, { method: 'PUT', body: { dinner_id: dinner.id } }) + return parsePlan(`PUT ${path}`, weekStart, body, false) + }, + onMutate: async ({ date, dinner, fromQuestLog }) => { + await queryClient.cancelQueries({ queryKey: key }) + const plan = queryClient.getQueryData(key) + const previous = plan?.days.find((day) => day.date === date) + if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`) + const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null } + queryClient.setQueryData(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }) + return { previous, swapped } + }, + onSuccess: (plan) => { + if (isLatest()) queryClient.setQueryData(key, plan) + }, + // 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 + queryClient.setQueryData(key, (plan) => + plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) }, + ) + }, + 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, + } }