From 5267e8a2a7b7a00e3a44abae40bc72bcf3fbcb54 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 20:52:41 -0400 Subject: [PATCH 1/2] Wire Add to this week and the favorite heart on the dinner detail Add to this week opens a sheet of the household's current week (or next week by the toggle), Monday to Sunday in the household timezone, each day with its current dinner. Choosing a day swaps the dinner in through the Plan's own useSwapDay (PUT /api/v1/plans/{weekStart}/days/{date}); the screen then says it is being added, added, or why not with Retry, and the week's plan query is invalidated once added. A week with no plan offers "Draft this week first", linking to /plan. A dinner already on the week cannot be added again. The heart toggles is_favorite through the existing useToggleFavorite, which now also updates the detail cache optimistically, rolls it back on failure, and takes only is_favorite from the list-shaped answer so the detail keeps its recipe. It exposes aria-pressed; a failure shows the error with Retry. useSwapDay now also reports its latest swap when it succeeded. Maestro task 07423ba7-9f91-4ec5-8ab8-1fb92e3c7d2e. Co-Authored-By: Claude Opus 5.5 --- web/src/dinners/AddToWeekSheet.module.css | 7 + web/src/dinners/AddToWeekSheet.tsx | 170 ++++++++++ web/src/dinners/DinnerDetailActions.test.tsx | 339 +++++++++++++++++++ web/src/dinners/DinnerDetailPage.module.css | 21 +- web/src/dinners/DinnerDetailPage.test.tsx | 7 +- web/src/dinners/DinnerDetailPage.tsx | 100 +++++- web/src/dinners/api.ts | 47 ++- web/src/dinners/detail.ts | 3 +- web/src/plan/api.ts | 5 +- 9 files changed, 661 insertions(+), 38 deletions(-) create mode 100644 web/src/dinners/AddToWeekSheet.module.css create mode 100644 web/src/dinners/AddToWeekSheet.tsx create mode 100644 web/src/dinners/DinnerDetailActions.test.tsx diff --git a/web/src/dinners/AddToWeekSheet.module.css b/web/src/dinners/AddToWeekSheet.module.css new file mode 100644 index 0000000..80ffbfc --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.module.css @@ -0,0 +1,7 @@ +/* A week with no plan: why there's no day, over the link to draft it. */ +.noPlan { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; +} diff --git a/web/src/dinners/AddToWeekSheet.tsx b/web/src/dinners/AddToWeekSheet.tsx new file mode 100644 index 0000000..f656e30 --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.tsx @@ -0,0 +1,170 @@ +import { useId, useState } from 'react' +import { Link } from 'react-router' +import { usePlan, useSwapDay, 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' +import { useHouseholdSettings } from '../settings/api.ts' +import buttonStyles from '../ui/Button.module.css' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import type { DinnerDetail } from './detail.ts' +import styles from './AddToWeekSheet.module.css' + +// PLAN is the Draft board, where a week with no plan is drafted. +export const PLAN = '/plan' + +// Which is the week a dinner is added to: the household's current week or +// the one after. +export type Which = 'this' | 'next' + +export const weekNames: Record = { this: 'this week', next: 'next week' } + +// Added is a swap sent from the sheet: the week it went to and the swap. +export type Added = { week: string; which: Which; swap: Swap } + +// planDinner is the dinner as it is planned on a day. +function planDinner(dinner: DinnerDetail): PlanDinner { + return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method } +} + +type WeekDaysProps = { + dinner: DinnerDetail + week: string + which: Which + onAdd: (added: Added) => void +} + +// 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: +// 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. +function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) { + const plan = usePlan(week) + const swap = useSwapDay(week) + + if (plan.isError) { + return void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message} + } + if (plan.isPending) { + return ( +
+ {Array.from({ length: 7 }, (_, i) => ( + + ))} +
+ ) + } + if (plan.data === null) { + return ( +
+

There's no plan for {weekNames[which]} yet, so there's no day to add it to.

+ + Draft {weekNames[which]} first + +
+ ) + } + + const days = plan.data.days + const planned = days.find((day) => day.dinner.id === dinner.id) + const choosable = planned === undefined && !swap.pending + function add(date: string) { + const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' } + swap.mutate(chosen) + onAdd({ week, which, swap: chosen }) + } + return ( + <> + {planned !== undefined ? ( +

+ {dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}. +

+ ) : ( + swap.pending &&

A change to {weekNames[which]} is still being saved.

+ )} +
    + {days.map((day) => ( +
  • +
  • + ))} +
+ + ) +} + +export type AddToWeekSheetProps = { + dinner: DinnerDetail + onAdd: (added: Added) => void + onClose: () => void +} + +// AddToWeekSheet is the sheet Add to this week opens: the seven days of the +// household's current week, Monday to Sunday in its timezone, each with its +// dinner, and a toggle to next week. Choosing a day replaces its dinner. +export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) { + const titleId = useId() + const settings = useHouseholdSettings() + const [which, setWhich] = useState('this') + + let body + if (settings.isError) { + body = ( + void settings.refetch()}> + Couldn't load the household settings. {settings.error.message} + + ) + } else if (settings.isPending) { + body = + } else { + const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date) + const week = which === 'this' ? thisWeek : addDays(thisWeek, 7) + body = ( + <> +

+ {weekLabel(week).dates} · replaces the day's dinner +

+ + + + ) + } + + return ( + +
+

+ Add {dinner.name} +

+ {body} +
+
+ ) +} diff --git a/web/src/dinners/DinnerDetailActions.test.tsx b/web/src/dinners/DinnerDetailActions.test.tsx new file mode 100644 index 0000000..f77d155 --- /dev/null +++ b/web/src/dinners/DinnerDetailActions.test.tsx @@ -0,0 +1,339 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +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 { DinnerDetailPage } from './DinnerDetailPage.tsx' + +const DINNER = '/api/v1/dinners/d1' +const FAVORITE = `${DINNER}/favorite` +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const THURSDAY = `${THIS_WEEK}/days/2026-10-08` + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }], + steps: ['Brown the beef with the sauce.'], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + ...overrides, + } +} + +// listed is the bowls as the dinners list, and so the favorite toggle, +// answers: no recipe. +function listed(is_favorite: boolean) { + return { + id: 'd1', + name: 'Korean beef bowls', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + is_favorite, + photo_url: null, + grounding_status: 'grounded', + } +} + +const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon'] +const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice'] + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with +// dinner ids p0 to p6 unless ids says otherwise. +function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: 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' }, + is_new: false, + start_by: null, + })), + } +} + +// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it. +function thursdaySwapped() { + const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`)) + const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name)) + const plan = planBody('2026-10-05', names, ids) + plan.days[3]!.is_new = true + return plan +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me, the +// household settings and the dinner; any other request fails the test. +function stubServer(responders: Record = {}) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [`GET ${DINNER}`]: () => jsonResponse(200, detail()), + ...responders, + } + const fetchMock = 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(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests sent with method, as "path body". +function sent(fetchMock: Mock, method: string) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === method) + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length +} + +// deferred is a response the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const response = new Promise((resolve) => { + send = resolve + }) + return { response, send } +} + +// 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() { + 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

}, + ], + { initialEntries: ['/dinner/d1'] }, + ) + render( + + + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) +} + +// openSheet opens Add to this week and waits for the week's days. +async function openSheet(listName = 'Days of this week') { + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const days = await within(sheet).findByRole('list', { name: listName }) + return { sheet, days } +} + +// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min", +// with plain spaces. +function dayRows(days: HTMLElement) { + return within(days) + .getAllByRole('listitem') + .map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' ')) +} + +beforeEach(() => { + mockViewport(390) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('Add to this week', () => { + it('lists the seven days of this week with their dinners, and next week by the toggle', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)), + }) + await renderPage() + // Nothing about the week is read until the sheet opens. + expect(reads(fetchMock, THIS_WEEK)).toBe(0) + + const { sheet, days } = await openSheet() + expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(days)).toEqual([ + 'Monday 5 · Beef tacos · Stovetop · 30 min', + 'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min', + 'Wednesday 7 · Lemon orzo · Stovetop · 30 min', + 'Thursday 8 · Chicken stir-fry · Stovetop · 30 min', + 'Friday 9 · Pizza night · Stovetop · 30 min', + 'Saturday 10 · Fish tacos · Stovetop · 30 min', + 'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min', + ]) + expect(within(days).getAllByRole('button')).toHaveLength(7) + + fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' })) + const next = await within(sheet).findByRole('list', { name: 'Days of next week' }) + expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min') + expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min') + + fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' })) + expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument() + }) + + it("takes this week in the household's timezone, not the device's UTC date", async () => { + // Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC. + vi.setSystemTime(new Date('2026-10-12T02:00:00Z')) + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) }) + await renderPage() + + const { days } = await openSheet() + expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min') + expect(reads(fetchMock, THIS_WEEK)).toBe(1) + expect(reads(fetchMock, NEXT_WEEK)).toBe(0) + }) + + it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => { + const swap = deferred() + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`PUT ${THURSDAY}`]: () => swap.response, + }) + await renderPage() + const { days } = await openSheet() + + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1)) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…') + + plan = thursdaySwapped() + await act(async () => swap.send(jsonResponse(200, plan))) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + // The plan query is invalidated: the week is read again from the server. + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2)) + + // The sheet opened again shows the plan as swapped. + const again = await openSheet() + expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner') + expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument() + expect(within(again.days).queryAllByRole('button')).toHaveLength(0) + }) + + it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') }) + await renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' }) + expect(draft).toHaveAttribute('href', '/plan') + expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(draft) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) + + it('shows a refused swap with the error, puts the day back and retries it', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())), + }) + await renderPage() + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message") + const reopened = await openSheet() + expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min') + fireEvent.keyDown(reopened.sheet, { key: 'Escape' }) + + refuse = false + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`]) + }) +}) + +describe('the favorite heart', () => { + it('fills at once, keeps the recipe and takes the server answer', async () => { + const favorite = deferred() + const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true')) + expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`]) + + await act(async () => favorite.send(jsonResponse(200, listed(true)))) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + expect(heart).toHaveAttribute('aria-pressed', 'true') + // The list-shaped answer did not replace the detail: the recipe stays. + expect(screen.getByText('Ground beef')).toBeInTheDocument() + expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('empties again with the error when the server refuses, and retries', async () => { + const refused = deferred() + let refuse = true + const fetchMock = stubServer({ + [`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })), + [`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))), + }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await act(async () => refused.send(errorResponse(500, 'internal'))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message") + expect(heart).toHaveAttribute('aria-pressed', 'true') + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(fetchMock, 'POST')).toHaveLength(2) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css index dd65df2..d88d3e6 100644 --- a/web/src/dinners/DinnerDetailPage.module.css +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -207,6 +207,7 @@ bottom: 0; z-index: 1; display: flex; + flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 24px; @@ -214,16 +215,26 @@ background: var(--color-bg); } -.bar > :first-child { - flex: 1; -} - .add { - width: 100%; + flex: 1; min-height: 52px; font-size: 16px; } +/* What became of an add or a favorite: a full row above the buttons. */ +.status { + order: -1; + flex-basis: 100%; +} + +.added { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + color: var(--color-accent-2-800); +} + .filled { color: var(--color-accent); fill: currentColor; diff --git a/web/src/dinners/DinnerDetailPage.test.tsx b/web/src/dinners/DinnerDetailPage.test.tsx index 5e93d46..52ee959 100644 --- a/web/src/dinners/DinnerDetailPage.test.tsx +++ b/web/src/dinners/DinnerDetailPage.test.tsx @@ -194,15 +194,14 @@ describe('DinnerDetailPage', () => { expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() }) - it('holds Add to this week and the heart at the bottom, at rest until the next update', async () => { + it('holds Add to this week and the heart at the bottom', async () => { serve(detail({ is_favorite: true })) renderAt(['/dinner/d1']) const add = await screen.findByRole('button', { name: 'Add to this week' }) - expect(add).toHaveAttribute('aria-disabled', 'true') - expect(add).toHaveAccessibleDescription('Adding to this week and the favorite heart arrive in the next update.') + expect(add).toBeEnabled() const heart = screen.getByRole('button', { name: 'Favorite' }) - expect(heart).toBeDisabled() + expect(heart).toBeEnabled() expect(heart).toHaveAttribute('aria-pressed', 'true') const bar = heart.parentElement! expect(bar).toContainElement(add) diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx index afbf78e..c68a270 100644 --- a/web/src/dinners/DinnerDetailPage.tsx +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -1,8 +1,13 @@ +import { useQueryClient } from '@tanstack/react-query' 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 { duration, Unavailable } from '../plan/PlanPage.tsx' +import { planKey, usePlan, useSwapDay } from '../plan/api.ts' +import { duration } from '../plan/PlanPage.tsx' +import { weekdayName } from '../plan/week.ts' import { useBreakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' import buttonStyles from '../ui/Button.module.css' import { EmptyState } from '../ui/EmptyState.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' @@ -11,17 +16,14 @@ import { IconButton } from '../ui/IconButton.tsx' import { Photo } from '../ui/Photo.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' -import type { Difficulty } from './api.ts' +import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx' +import { useToggleFavorite, type Difficulty } from './api.ts' import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts' import styles from './DinnerDetailPage.module.css' // REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed. export const REGULAR_DINNERS = '/settings/regulars' -// ADD_HINT is why Add to this week and the heart are unavailable. -// TODO(07423ba7): the add-to-week sheet and the favorite heart. -const ADD_HINT = 'Adding to this week and the favorite heart arrive in the next update.' - const difficultyLabels: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } // BackButton goes back in the app's history, or, opened from a link with @@ -125,20 +127,90 @@ 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. +function AddedStatus({ added }: { added: Added }) { + const queryClient = useQueryClient() + usePlan(added.week) + const swap = useSwapDay(added.week) + const { date } = added.swap + const done = swap.succeeded === added.swap + const failed = swap.failed?.swap === added.swap ? swap.failed.error : null + useEffect(() => { + if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) }) + }, [done, queryClient, added.week]) + + if (failed !== null) { + return ( +
+ swap.mutate(added.swap)}> + Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message} + +
+ ) + } + return ( +

+ {done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`} +

+ ) +} + +// AddToWeek is Add to this week: it opens the sheet of the week's days, and +// says what became of the dinner added. +function AddToWeek({ dinner }: { dinner: DinnerDetail }) { + const [open, setOpen] = useState(false) + const [added, setAdded] = useState(null) + function add(chosen: Added) { + setOpen(false) + setAdded(chosen) + } + return ( + <> + {added !== null && } + + {open && setOpen(false)} />} + + ) +} + +// FavoriteHeart toggles whether the dinner is a favorite, at once, changing +// back with the error and Retry if the server refuses. One toggle is sent at +// a time. +function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) { + const favorite = useToggleFavorite() + function toggle() { + if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite }) + } + return ( + <> + {favorite.isError && ( +
+ + Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message} + +
+ )} + + + + + ) +} + // ActionBar is the bar at the bottom of the screen, the page's last child: // the page fills the scroll area so a short recipe still leaves it at the // bottom, and it sticks there over a long one. It holds Add to this week -// and the favorite heart, showing whether the dinner is a favorite. Both -// rest unavailable until the next update wires them. +// and the favorite heart, with what became of either above them. function ActionBar({ dinner }: { dinner: DinnerDetail }) { return (
- - Add to this week - - - - + +
) } diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 2a3b2b7..2c0c081 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -1,6 +1,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' +import type { DinnerDetail } from './detail.ts' export const DINNERS_PATH = '/api/v1/dinners' export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` @@ -146,6 +147,11 @@ export function dinnerPath(id: string): string { return `${DINNERS_PATH}/${encodeURIComponent(id)}` } +// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail). +export function dinnerDetailKey(id: string) { + return ['dinners', 'detail', id] as const +} + // useRegulars is the household's Regulars, in the server's order. While any // of them is still finding its recipe it is read again every // GROUNDING_POLL_MS, except while any change is in flight (whose settling @@ -275,28 +281,43 @@ export function useEditRegular() { }) } -// useToggleFavorite toggles a Regular's favorite heart (POST -// /api/v1/dinners/{id}/favorite). The heart changes at once and changes -// back if the server refuses. +// Favorite is the dinner whose heart is toggled, as it was before the toggle. +export type Favorite = { id: string; is_favorite: boolean } + +// setFavorite shows dinner id's heart as is_favorite wherever it is cached: +// in the Regulars and in its detail. A cache not yet read is left unread. +function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => + list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)), + ) + queryClient.setQueryData(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite }) +} + +// useToggleFavorite toggles a dinner's favorite heart (POST +// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail +// screen. The heart changes at once and changes back if the server refuses. +// The server answers with the dinner as the dinners list sends it, which +// replaces the Regular; the detail keeps its recipe and takes only the heart. export function useToggleFavorite() { const { apiFetch } = useApi() const queryClient = useQueryClient() - const set = (id: string, is_favorite: boolean) => - setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r))) return useMutation({ mutationKey: REGULARS_MUTATION_KEY, - mutationFn: async (regular: Regular) => + mutationFn: async (dinner: Favorite) => parseDinner( `POST ${DINNERS_PATH}/{id}/favorite`, - await apiFetch(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }), + await apiFetch(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }), ), - onMutate: async (regular) => { - await changeOptimistically(queryClient, (list) => - list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)), - ) + onMutate: async (dinner) => { + await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) }) + await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY }) + setFavorite(queryClient, dinner.id, !dinner.is_favorite) }, - onSuccess: (dinner) => replace(queryClient, dinner), - onError: (_error, regular) => set(regular.id, regular.is_favorite), + onSuccess: (dinner) => { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r))) + queryClient.setQueryData(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite }) + }, + onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite), onSettled: () => settle(queryClient), }) } diff --git a/web/src/dinners/detail.ts b/web/src/dinners/detail.ts index 7e8ff2f..48ef513 100644 --- a/web/src/dinners/detail.ts +++ b/web/src/dinners/detail.ts @@ -4,6 +4,7 @@ import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { DIFFICULTIES, + dinnerDetailKey, dinnerPath, DINNERS_PATH, GROUNDING_POLL_MS, @@ -158,7 +159,7 @@ export function useDinnerDetail(id: string) { const { apiRead, network } = useApi() const offline = useSyncExternalStore(network.subscribe, network.offline) const query = useQuery({ - queryKey: ['dinners', 'detail', id], + queryKey: dinnerDetailKey(id), queryFn: async () => { const read = await apiRead(dinnerPath(id)) return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index e6094fc..8541d2d 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -329,9 +329,11 @@ function swapKey(weekStart: string) { } // 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. +// whether one is pending, the latest if it succeeded, and the failure of the +// latest, if it failed. export type SwapDay = { pending: boolean + succeeded: Swap | null failed: { swap: Swap; error: Error } | null mutate: (chosen: Swap) => void dismiss: () => void @@ -418,6 +420,7 @@ export function useSwapDay(weekStart: string): SwapDay { return { pending: swaps.some((swap) => swap.status === 'pending'), + succeeded: latest?.status === 'success' ? latest.swap : null, failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null, mutate, dismiss, -- 2.55.0 From bbd66e49f264e95694fd918e2bad0ff322859973 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Thu, 8 Oct 2026 21:04:16 -0400 Subject: [PATCH 2/2] Share the Draft board's swap step with Add to this week useWeekSwap is now the one way a day is swapped: it refuses while a swap or push of the week is pending, and leaves the week's settled swaps and pushes behind before sending the swap. The Draft board (sheet, drop and retry) and the dinner's Add to this week sheet and its Retry all use it, so a swap from the dinner no longer leaves the Plan saying the earlier plan's Supply run was made. Co-Authored-By: Claude Opus 5.5 --- web/src/dinners/AddToWeekSheet.tsx | 19 ++--- web/src/dinners/DinnerDetailActions.test.tsx | 75 ++++++++++++++++++-- web/src/dinners/DinnerDetailPage.tsx | 13 ++-- web/src/plan/PlanPage.tsx | 29 +++----- web/src/plan/api.ts | 31 ++++++++ 5 files changed, 130 insertions(+), 37 deletions(-) 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 -- 2.55.0