diff --git a/web/src/dinners/AddToWeekSheet.tsx b/web/src/dinners/AddToWeekSheet.tsx
index f656e30..0998ae2 100644
--- a/web/src/dinners/AddToWeekSheet.tsx
+++ b/web/src/dinners/AddToWeekSheet.tsx
@@ -1,6 +1,6 @@
import { useId, useState } from 'react'
import { Link } from 'react-router'
-import { usePlan, useSwapDay, type PlanDinner, type Swap } from '../plan/api.ts'
+import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts'
import { cooking } from '../plan/PlanPage.tsx'
import swapStyles from '../plan/SwapSheet.module.css'
import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts'
@@ -41,13 +41,14 @@ type WeekDaysProps = {
}
// WeekDays is the seven days of the week starting on week, each with its
-// dinner; choosing one swaps the dinner in through the Plan's own swap
-// (useSwapDay). A week with no plan offers to draft it on the Draft board:
+// dinner; choosing one swaps the dinner in as the Draft board swaps a day
+// (useWeekSwap). A week with no plan offers to draft it on the Draft board:
// nothing here makes a plan up. A dinner already on the week can't be added
-// again, nor can one while a change to the week is still being saved.
+// again, nor can one while a swap of the week is still being saved or the
+// week is being pushed to Calendar.
function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
const plan = usePlan(week)
- const swap = useSwapDay(week)
+ const { swap, push, refused, swapIn } = useWeekSwap(week)
if (plan.isError) {
return void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}
@@ -74,10 +75,10 @@ function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
const days = plan.data.days
const planned = days.find((day) => day.dinner.id === dinner.id)
- const choosable = planned === undefined && !swap.pending
+ const choosable = planned === undefined && !refused
function add(date: string) {
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
- swap.mutate(chosen)
+ swapIn(chosen)
onAdd({ week, which, swap: chosen })
}
return (
@@ -86,8 +87,10 @@ function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) {
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
+ ) : swap.pending ? (
+ A change to {weekNames[which]} is still being saved.
) : (
- swap.pending && A change to {weekNames[which]} is still being saved.
+ push.pending && The plan for {weekNames[which]} is being pushed to Calendar.
)}
{days.map((day) => (
diff --git a/web/src/dinners/DinnerDetailActions.test.tsx b/web/src/dinners/DinnerDetailActions.test.tsx
index f77d155..dc967a4 100644
--- a/web/src/dinners/DinnerDetailActions.test.tsx
+++ b/web/src/dinners/DinnerDetailActions.test.tsx
@@ -7,6 +7,7 @@ import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
+import { PlanPage } from '../plan/PlanPage.tsx'
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
const DINNER = '/api/v1/dinners/d1'
@@ -66,7 +67,7 @@ function planBody(weekStart: string, names: string[], ids = names.map((_, i) =>
week_start: weekStart,
status: 'draft',
ai_note: null,
- calendar_synced_at: null,
+ calendar_synced_at: null as string | null,
days: names.map((name, i) => ({
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' },
@@ -127,14 +128,15 @@ function deferred() {
}
// renderPage loads /me (for the CSRF token every write needs) and renders
-// the bowls' detail screen, with a probe for the Plan.
-async function renderPage() {
+// the bowls' detail screen, with plan for the Plan: a probe unless told
+// otherwise. It answers with the router.
+async function renderPage(plan = Plan probe
) {
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter(
[
{ path: '/dinner/:id', element: },
- { path: '/plan', element: Plan probe
},
+ { path: '/plan', element: plan },
],
{ initialEntries: ['/dinner/d1'] },
)
@@ -146,6 +148,7 @@ async function renderPage() {
,
)
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
+ return router
}
// openSheet opens Add to this week and waits for the week's days.
@@ -289,6 +292,70 @@ describe('Add to this week', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`])
})
+
+ it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => {
+ let plan = planBody('2026-10-05', THIS_NAMES)
+ const fetchMock = stubServer({
+ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
+ [`POST ${THIS_WEEK}/push-calendar`]: () => {
+ plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' }
+ return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } })
+ },
+ [`PUT ${THURSDAY}`]: () => {
+ plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' }
+ return jsonResponse(200, plan)
+ },
+ 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
+ })
+ const router = await renderPage()
+
+ // The week is pushed on the Plan, which says its Supply run was made.
+ await act(() => router.navigate('/plan'))
+ fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
+ expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
+ // Coming back from the dinner with nothing swapped, it still says so.
+ await act(() => router.navigate('/dinner/d1'))
+ await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
+ await act(() => router.navigate('/plan'))
+ expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
+
+ // Thursday is swapped from the dinner's Add to this week.
+ await act(() => router.navigate('/dinner/d1'))
+ await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
+ const { days } = await openSheet()
+ fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ }))
+ await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.'))
+
+ // The Plan shows the swapped plan, not changed since its push, and no
+ // longer says the earlier plan's Supply run was made.
+ await act(() => router.navigate('/plan'))
+ expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
+ expect(screen.getByText('Korean beef bowls')).toBeInTheDocument()
+ expect(screen.queryByRole('status')).not.toBeInTheDocument()
+ expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument()
+ expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`])
+ expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`])
+ })
+
+ it('refuses a swap while the week is being pushed to Calendar', async () => {
+ const push = deferred()
+ const fetchMock = stubServer({
+ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)),
+ [`POST ${THIS_WEEK}/push-calendar`]: () => push.response,
+ 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'),
+ })
+ const router = await renderPage()
+ await act(() => router.navigate('/plan'))
+ fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
+ await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1))
+
+ await act(() => router.navigate('/dinner/d1'))
+ await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
+ const { sheet, days } = await openSheet()
+ expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument()
+ expect(within(days).queryAllByRole('button')).toHaveLength(0)
+ expect(sent(fetchMock, 'PUT')).toEqual([])
+ })
})
describe('the favorite heart', () => {
diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx
index c68a270..140236c 100644
--- a/web/src/dinners/DinnerDetailPage.tsx
+++ b/web/src/dinners/DinnerDetailPage.tsx
@@ -3,7 +3,7 @@ import { ChevronLeft, Heart } from 'lucide-react'
import { useEffect, useState } from 'react'
import { Link, useLocation, useNavigate, useParams } from 'react-router'
import { ApiError } from '../api/errors.ts'
-import { planKey, usePlan, useSwapDay } from '../plan/api.ts'
+import { planKey, usePlan, useWeekSwap } from '../plan/api.ts'
import { duration } from '../plan/PlanPage.tsx'
import { weekdayName } from '../plan/week.ts'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
@@ -128,13 +128,14 @@ function Recipe({ dinner }: { dinner: DinnerDetail }) {
}
// AddedStatus says what became of a dinner added to a day: being saved,
-// added, or why it couldn't be, with Retry, which sends the same swap again.
-// The week's plan stays read while it shows, so a retry finds the day to
-// swap; once added, the plan is read again from the server.
+// added, or why it couldn't be, with Retry, which sends the same swap again
+// as the Draft board would (useWeekSwap), and is not offered while that is
+// refused. The week's plan stays read while it shows, so a retry finds the
+// day to swap; once added, the plan is read again from the server.
function AddedStatus({ added }: { added: Added }) {
const queryClient = useQueryClient()
usePlan(added.week)
- const swap = useSwapDay(added.week)
+ const { swap, refused, swapIn } = useWeekSwap(added.week)
const { date } = added.swap
const done = swap.succeeded === added.swap
const failed = swap.failed?.swap === added.swap ? swap.failed.error : null
@@ -145,7 +146,7 @@ function AddedStatus({ added }: { added: Added }) {
if (failed !== null) {
return (
- swap.mutate(added.swap)}>
+ swapIn(added.swap)}>
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx
index 87124ba..f6828b8 100644
--- a/web/src/plan/PlanPage.tsx
+++ b/web/src/plan/PlanPage.tsx
@@ -23,9 +23,8 @@ import {
parseDraftResult,
planKey,
usePlan,
- usePushToCalendar,
useStartDraft,
- useSwapDay,
+ useWeekSwap,
type DraftResult,
type Plan,
type PlanDay,
@@ -266,8 +265,7 @@ function WeekBoard({
const [jobId, setJobId] = useState(null)
const job = useJob(DRAFT_JOB_KIND, jobId)
const [confirming, setConfirming] = useState(false)
- const swap = useSwapDay(weekStart)
- const push = usePushToCalendar(weekStart)
+ const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
const run = useSupplyRun(weekStart)
// Makes the week's Supply run again after a push could not make it.
const generate = useGenerateSupplyRun(weekStart)
@@ -431,25 +429,18 @@ function WeekBoard({
void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}
)
- // swapIn sends the swap chosen in the sheet and closes it.
+ // swapIn sends the swap chosen in the sheet, dropped on a day or retried
+ // (useWeekSwap) and closes the sheet. The Supply run made again on the
+ // Plan after a push is left behind with the push.
function swapIn(chosen: Swap) {
setSwappingDate(null)
- push.reset()
generate.reset()
- swap.mutate(chosen)
+ sendSwap(chosen)
}
- // dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
- // nor dropped, while a swap of the week is pending.
- function dropIn(chosen: Swap) {
- swap.dismiss()
- swapIn(chosen)
- }
- // 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. Nor is
- // one sent while the week is being pushed to Calendar.
+ // openSwap opens the swap sheet on a day, unless a swap is refused now
+ // (useWeekSwap). A drag is not picked up, nor dropped, then either.
function openSwap(date: string) {
- if (swap.pending || pushing) return
+ if (refused) return
swap.dismiss()
setSwappingDate(date)
}
@@ -488,7 +479,7 @@ function WeekBoard({
body =
layout !== 'mobile' ? (
<>
-
+
{days.map((day) => (
void
+}
+
+// useWeekSwap swaps a day of the week starting on weekStart, from the Draft
+// board or a dinner's Add to this week. 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. Nor is one sent
+// while the week is being pushed to Calendar: refused says so, and swapIn
+// then throws. A swap leaves the week's settled swaps behind, and its
+// pushes: what a push said of Calendar and the Supply run was said of the
+// plan before the swap, not the one swapped.
+export function useWeekSwap(weekStart: string): WeekSwap {
+ const swap = useSwapDay(weekStart)
+ const push = usePushToCalendar(weekStart)
+ const refused = swap.pending || push.pending
+ function swapIn(chosen: Swap) {
+ if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`)
+ swap.dismiss()
+ push.reset()
+ swap.mutate(chosen)
+ }
+ return { swap, push, refused, swapIn }
+}
+
// settleUnmadePushes is called as the Supply run of the week starting on
// weekStart starts being made, from the Plan or the Supply run screen
// (useGenerateSupplyRun). The function it answers with is called once the