Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 bbd66e49f2 Share the Draft board's swap step with Add to this week
CI / ci (push) Successful in 4m57s
CI / ci (pull_request) Successful in 5m0s
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 <noreply@anthropic.com>
2026-10-08 21:04:16 -04:00
dev_agentandClaude Opus 5.5 5267e8a2a7 Wire Add to this week and the favorite heart on the dinner detail
CI / ci (push) Successful in 5m11s
CI / ci (pull_request) Successful in 5m6s
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 <noreply@anthropic.com>
2026-10-08 20:52:41 -04:00
10 changed files with 773 additions and 57 deletions
@@ -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;
}
+173
View File
@@ -0,0 +1,173 @@
import { useId, useState } from 'react'
import { Link } from 'react-router'
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'
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<Which, string> = { 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 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 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, push, refused, swapIn } = useWeekSwap(week)
if (plan.isError) {
return <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
}
if (plan.isPending) {
return (
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} className={swapStyles.skeletonRow} />
))}
</div>
)
}
if (plan.data === null) {
return (
<div className={styles.noPlan}>
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
Draft {weekNames[which]} first
</Link>
</div>
)
}
const days = plan.data.days
const planned = days.find((day) => day.dinner.id === dinner.id)
const choosable = planned === undefined && !refused
function add(date: string) {
const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' }
swapIn(chosen)
onAdd({ week, which, swap: chosen })
}
return (
<>
{planned !== undefined ? (
<p className={swapStyles.empty}>
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
</p>
) : swap.pending ? (
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
) : (
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
)}
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
{days.map((day) => (
<li key={day.date}>
<ListRow
className={choosable ? undefined : swapStyles.already}
leading={
// The row's dinner names what the photo shows.
<span aria-hidden="true">
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
</span>
}
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
onClick={choosable ? () => add(day.date) : undefined}
trailing={day === planned && <Tag>This dinner</Tag>}
/>
</li>
))}
</ul>
</>
)
}
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<Which>('this')
let body
if (settings.isError) {
body = (
<ErrorBanner onRetry={() => void settings.refetch()}>
Couldn't load the household settings. {settings.error.message}
</ErrorBanner>
)
} else if (settings.isPending) {
body = <Skeleton className={swapStyles.skeletonRow} />
} else {
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
body = (
<>
<p className={swapStyles.replaces}>
{weekLabel(week).dates} · replaces the day's dinner
</p>
<Segmented
label="Week"
options={[
{ value: 'this', label: 'This week' },
{ value: 'next', label: 'Next week' },
]}
value={which}
onValueChange={setWhich}
/>
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
</>
)
}
return (
<Sheet open onClose={onClose} aria-labelledby={titleId}>
<div className={swapStyles.sheet}>
<h2 id={titleId} className={swapStyles.title}>
Add {dinner.name}
</h2>
{body}
</div>
</Sheet>
)
}
@@ -0,0 +1,406 @@
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 { PlanPage } from '../plan/PlanPage.tsx'
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<string, unknown> = {}) {
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 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' },
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<Response>
// 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<string, Responder> = {}) {
const all: Record<string, Responder> = {
'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<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(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests sent with method, as "path body".
function sent(fetchMock: Mock<typeof fetch>, method: string) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === method)
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
function reads(fetchMock: Mock<typeof fetch>, 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<Response>((resolve) => {
send = resolve
})
return { response, send }
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the bowls' detail screen, with plan for the Plan: a probe unless told
// otherwise. It answers with the router.
async function renderPage(plan = <h1>Plan probe</h1>) {
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const router = createMemoryRouter(
[
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
{ path: '/plan', element: plan },
],
{ initialEntries: ['/dinner/d1'] },
)
render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<RouterProvider router={router} />
</QueryClientProvider>
</ApiProvider>,
)
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.
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"}`])
})
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(<PlanPage />)
// 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(<PlanPage />)
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', () => {
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)
})
})
+16 -5
View File
@@ -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;
+3 -4
View File
@@ -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)
+87 -14
View File
@@ -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, useWeekSwap } 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<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// BackButton goes back in the app's history, or, opened from a link with
@@ -125,20 +127,91 @@ 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
// 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, 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
useEffect(() => {
if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) })
}, [done, queryClient, added.week])
if (failed !== null) {
return (
<div className={styles.status}>
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
</ErrorBanner>
</div>
)
}
return (
<p role="status" className={[styles.status, styles.added].join(' ')}>
{done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`}
</p>
)
}
// 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<Added | null>(null)
function add(chosen: Added) {
setOpen(false)
setAdded(chosen)
}
return (
<>
{added !== null && <AddedStatus key={`${added.week} ${added.swap.date}`} added={added} />}
<Button className={styles.add} onClick={() => setOpen(true)}>
Add to this week
</Button>
{open && <AddToWeekSheet dinner={dinner} onAdd={add} onClose={() => 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 && (
<div className={styles.status}>
<ErrorBanner onRetry={toggle}>
Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message}
</ErrorBanner>
</div>
)}
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} onClick={toggle}>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
</>
)
}
// 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 (
<div className={styles.bar}>
<Unavailable hint={ADD_HINT} className={styles.add}>
Add to this week
</Unavailable>
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} disabled>
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
</IconButton>
<AddToWeek dinner={dinner} />
<FavoriteHeart dinner={dinner} />
</div>
)
}
+34 -13
View File
@@ -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<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
)
queryClient.setQueryData<DinnerDetail>(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<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
await apiFetch<unknown>(`${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<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
},
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
onSettled: () => settle(queryClient),
})
}
+2 -1
View File
@@ -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<unknown>(dinnerPath(id))
return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached)
+10 -19
View File
@@ -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<string | null>(null)
const job = useJob<unknown>(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({
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
)
// 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' ? (
<>
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
<DropBoard days={days} pending={refused} drop={swapIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
+35 -1
View File
@@ -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,
@@ -529,6 +532,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
}
}
// WeekSwap is the one way a day of a week is swapped, wherever the swap is
// chosen: the week's swaps and pushes, whether a swap is refused now, and
// swapIn to send one.
export type WeekSwap = {
swap: SwapDay
push: PushToCalendar
refused: boolean
swapIn: (chosen: Swap) => 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