diff --git a/web/src/discover/QuestLogSheet.tsx b/web/src/discover/QuestLogSheet.tsx index cafdfa7..9ccd8b6 100644 --- a/web/src/discover/QuestLogSheet.tsx +++ b/web/src/discover/QuestLogSheet.tsx @@ -20,7 +20,7 @@ export type QuestLogSheetProps = { // details is a Quest-log dinner's "difficulty · minutes" line, or null when // neither is known. -function details(dinner: QuestLogDinner): string | null { +export function details(dinner: QuestLogDinner): string | null { const parts = [ dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty], dinner.minutes_total !== null && `${dinner.minutes_total} min`, diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 3803b71..8080ba8 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -95,22 +95,38 @@ .tile { box-sizing: border-box; display: flex; - flex-direction: column; - justify-content: space-between; - gap: 6px; min-height: 86px; - padding: 12px; border-radius: 22px; background: var(--color-surface); color: var(--color-text); } +/* The whole tile is the button that opens the swap sheet. */ +.tileButton { + box-sizing: border-box; + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + gap: 6px; + padding: 12px; + border: 0; + border-radius: inherit; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + /* A Quest-log pick. */ .newTile { background: var(--color-accent-2-300); } .dayKicker { + display: block; margin: 0; font-family: var(--font-body); font-size: 10px; @@ -125,6 +141,7 @@ } .dinner { + display: block; margin: 0; font-family: var(--font-heading); font-size: 15px; @@ -134,6 +151,7 @@ } .startBy { + display: block; margin: 4px 0 0; font-family: var(--font-body); font-size: 11px; @@ -145,6 +163,22 @@ color: var(--color-accent-2-900); } +/* The day being swapped, while the swap sheet is open: the page background + ringed in grape, its name muted. */ +.swapping { + background: var(--color-bg); + box-shadow: inset 0 0 0 2px var(--color-accent); +} + +.swapping .dayKicker { + color: var(--color-accent-700); +} + +.swapping .dinner, +.swapping .startBy { + color: var(--color-neutral-600); +} + .rebuildCell { display: flex; } @@ -168,10 +202,15 @@ cursor: pointer; } -.rebuild:hover { +.rebuild:hover:not(:disabled) { background: var(--color-neutral-100); } +.rebuild:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .loading { display: flex; flex-direction: column; diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 1ecd898..d583bc8 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -54,7 +54,7 @@ function draftJob(status: string, error: string | null = null, planId = 'plan-1' return { id: 'j1', kind: 'draft_week', status, result, error } } -type Responder = (init: RequestInit | undefined) => Response +type Responder = (init: RequestInit | undefined) => Response | Promise // stubServer answers "METHOD path" requests from responders, with /me and // the household settings; any other request fails the test. @@ -142,7 +142,7 @@ describe('PlanPage', () => { expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))') const items = within(grid).getAllByRole('listitem') expect(items).toHaveLength(8) - expect(items.slice(0, 7).map((item) => item.firstChild!.textContent)).toEqual([ + expect(items.slice(0, 7).map((item) => item.querySelector('button')!.firstChild!.textContent)).toEqual([ 'MON', 'TUE', 'WED · NEW', @@ -421,4 +421,263 @@ describe('PlanPage', () => { expect(await screen.findByRole('alert', {}, { timeout: 3000 })).toHaveTextContent("Couldn't load this week's plan. internal message") }) + + describe('swap sheet', () => { + const QUEST_LOG = '/api/v1/dinners?status=quest_log' + const REGULARS = '/api/v1/dinners?status=regular' + const FRIDAY = `${THIS_WEEK}/days/2026-10-09` + + // dinner is a dinner as the dinners API lists it. + function dinner(id: string, name: string, difficulty: string | null, minutes: number | null) { + return { + id, + name, + method: 'stovetop', + minutes_total: minutes, + difficulty, + is_favorite: false, + photo_url: null, + grounding_status: 'grounded', + } + } + + // The Quest log has lemon orzo; the Regulars are Monday's tacos, already + // this week, and fish tacos. + const lists = { + [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25)] }), + [`GET ${REGULARS}`]: () => + jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }), + } + + // swappedBody is the plan as the server answers a swap of Friday to + // lemon orzo, a Quest-log pick. + function swappedBody() { + const body = planBody('draft') + body.days[4] = { ...body.days[4]!, dinner: { ...body.days[4]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true } + return body + } + + // puts lists the PUTs sent, as "path body". + function puts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === 'PUT') + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) + } + + async function openFriday() { + fireEvent.click(within(await waitFor(() => tile('FRI'))).getByRole('button')) + return screen.findByRole('dialog', { name: 'Swap Friday' }) + } + + it('opens on a tapped day, puts it in the swapping state, and closes again', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const sheet = await openFriday() + expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument() + + // The tile: page background, a 2 px grape inset ring, the SWAPPING… + // kicker and its name muted. + const friday = tile('FRI · SWAPPING…') + expect(declaredStyle(friday).background).toBe('var(--color-bg)') + expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)') + expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)') + expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)') + + // "Quest log · N | Regulars · N", on the Quest log first. + expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument() + expect(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument() + const row = within(within(sheet).getByRole('list', { name: 'Quest log' })).getByRole('button', { name: /Lemon orzo/ }) + expect(row).toHaveTextContent('Easy · 25 min') + const photo = within(sheet).getByRole('img', { hidden: true, name: 'Lemon orzo' }) + expect(declaredStyle(photo).width).toBe('48px') + + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(tile('FRI')).toBeInTheDocument() + expect(screen.queryByText('FRI · SWAPPING…')).not.toBeInTheDocument() + }) + + it('swaps the day at once, closing the sheet, and lands the plan the server answers', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) + + // Optimistic: the sheet is closed and Friday is the Quest-log pick, NEW. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await screen.findByText('FRI · NEW') + expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() + expect(declaredStyle(tile('FRI · NEW')).background).toBe('var(--color-accent-2-300)') + await waitFor(() => expect(puts(fetchMock)).toEqual([`${FRIDAY} {"dinner_id":"q1"}`])) + + await act(async () => answer(jsonResponse(200, swappedBody()))) + expect(within(tile('FRI · NEW')).getByText('Lemon orzo')).toBeInTheDocument() + expect(screen.queryByText('Pizza night')).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + // Once the swap has settled the sheet opens again, and reads the Quest + // log again: the server leaves a dinner on the plan out of it. + await waitFor(() => { + fireEvent.click(within(tile('FRI · NEW')).getByRole('button')) + expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toHaveTextContent('Replaces Lemon orzo') + }) + await waitFor(() => expect(fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG)).toHaveLength(2)) + }) + + it('takes the NEW highlight off a day swapped to a Regular', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${THIS_WEEK}/days/2026-10-07`]: () => { + const body = planBody('draft') + body.days[2] = { ...body.days[2]!, dinner: { ...body.days[2]!.dinner, id: 'r1', name: 'Fish tacos' }, is_new: false, start_by: null } + return jsonResponse(200, body) + }, + }) + await renderPage() + + fireEvent.click(within(await waitFor(() => tile('WED · NEW'))).getByRole('button')) + const sheet = await screen.findByRole('dialog', { name: 'Swap Wednesday' }) + fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) + fireEvent.click(within(sheet).getByRole('button', { name: /Fish tacos/ })) + + await screen.findByText('Fish tacos') + expect(within(tile('WED')).getByText('Fish tacos')).toBeInTheDocument() + expect(declaredStyle(tile('WED')).background).toBe('var(--color-surface)') + await waitFor(() => expect(puts(fetchMock)).toEqual([`${THIS_WEEK}/days/2026-10-07 {"dinner_id":"r1"}`])) + expect(screen.queryByText('Start by 1:30 pm')).not.toBeInTheDocument() + }) + + it('puts the day back and shows the failure, with Retry, when the swap is refused', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, swappedBody())), + }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + expect(screen.queryByText('Lemon orzo')).not.toBeInTheDocument() + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Lemon orzo')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(puts(fetchMock)).toHaveLength(2) + }) + + it('marks dinners already this week, which cannot be chosen', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const sheet = await openFriday() + fireEvent.click(await within(sheet).findByRole('radio', { name: 'Regulars · 2' })) + const regulars = within(sheet).getByRole('list', { name: 'Regulars' }) + const [tacos, fish] = within(regulars).getAllByRole('listitem') + expect(within(tacos!).getByText('Already this week')).toBeInTheDocument() + expect(within(tacos!).queryByRole('button')).not.toBeInTheDocument() + expect(within(fish!).queryByText('Already this week')).not.toBeInTheDocument() + expect(within(fish!).getByRole('button', { name: /Fish tacos/ })).toBeInTheDocument() + + fireEvent.click(within(tacos!).getByText('Beef tacos')) + expect(screen.getByRole('dialog', { name: 'Swap Friday' })).toBeInTheDocument() + expect(puts(fetchMock)).toEqual([]) + }) + + // leaveAndReturn goes to next week and back, remounting this week's board. + async function leaveAndReturn() { + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + await screen.findByText('FRI · NEW') + } + + it('sends one swap at a time even after leaving the week and coming back', async () => { + let answer!: (response: Response) => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (answer = resolve)), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + await waitFor(() => expect(puts(fetchMock)).toHaveLength(1)) + await leaveAndReturn() + + // Friday's swap is still pending: Monday does not open. + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + + await act(async () => answer(jsonResponse(200, swappedBody()))) + await waitFor(() => expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeEnabled()) + fireEvent.click(within(tile('MON')).getByRole('button')) + expect(screen.getByRole('dialog', { name: 'Swap Monday' })).toBeInTheDocument() + expect(puts(fetchMock)).toHaveLength(1) + }) + + it('shows the failure of a swap that ended while the week was left', async () => { + let refuse!: () => void + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => refuse()) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + }) + + it('waits for a pending swap before Rebuild week, keeping its failure', async () => { + let refuse!: () => void + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise((resolve) => (refuse = () => resolve(errorResponse(409, 'dinner_not_plannable')))), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + }) + await renderPage() + + fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ })) + await screen.findByText('FRI · NEW') + const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) + expect(rebuild).toBeDisabled() + fireEvent.click(rebuild) + expect(posts(fetchMock)).toEqual([]) + + await act(async () => refuse()) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message") + expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument() + + // Once it has failed, Rebuild week drafts, leaving the failure behind. + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) + expect(await screen.findByText('Drafting your week…')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + }) }) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 10d8473..19519fc 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -11,8 +11,20 @@ import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' -import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts' -import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts' +import { + DRAFT_JOB_KIND, + parseDraftResult, + planKey, + usePlan, + useStartDraft, + useSwapDay, + type DraftResult, + type PlanDay, + type PlanStatus, + type Swap, +} from './api.ts' +import { SwapSheet } from './SwapSheet.tsx' +import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel } from './week.ts' import styles from './PlanPage.module.css' // statusTags is the tag beside the week's title for each plan status. @@ -44,18 +56,23 @@ function pushedToCalendar(status: PlanStatus): boolean { // DayTile is one day of the Draft board: a Regular on the surface, a // Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's -// start-by time in the household's zone. -function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) { +// start-by time in the household's zone. Tapping it opens the swap sheet; +// while that is open the tile is swapping: on the page background, ringed in +// grape, its name muted. +function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) { const kicker = weekdayKicker(day.date) + const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ') return ( -
  • -

    {day.is_new ? `${kicker} · NEW` : kicker}

    -
    -

    {day.dinner.name}

    - {day.start_by !== null && ( -

    Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}

    - )} -
    +
  • +
  • ) } @@ -87,6 +104,9 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon const [jobId, setJobId] = useState(null) const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) + const swap = useSwapDay(weekStart) + // The date of the day being swapped, while the swap sheet is open. + const [swappingDate, setSwappingDate] = useState(null) const label = weekLabel(weekStart) // The draft's result, once the job has succeeded, or why it cannot be read. @@ -143,8 +163,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon const { mutate } = startDraft function start(confirm: boolean) { setConfirming(false) - // A draft asked for again leaves the last one's failure behind. + // A draft asked for again leaves the last one's failure, and a failed + // swap's, behind. setJobId(null) + swap.dismiss() mutate( { weekStart, confirm }, { @@ -155,12 +177,19 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon ) } // rebuild drafts the week, asking first over a plan pushed to Calendar. + // It waits for a swap of the week still pending, which may yet fail: the + // Rebuild week tile is disabled meanwhile. function rebuild() { + if (swap.pending) return if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true) else start(false) } - const failure = startDraft.isError ? ( + const failure = swap.failed !== null ? ( + swapIn(swap.failed!.swap)}> + Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message} + + ) : startDraft.isError ? ( Couldn't start drafting this week. {startDraft.error.message} ) : result.error !== null ? ( Couldn't read the draft. {result.error.message} @@ -174,6 +203,20 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon Couldn't check on the draft. {job.error.message} ) + // swapIn sends the swap chosen in the sheet and closes it. + function swapIn(chosen: Swap) { + setSwappingDate(null) + swap.mutate(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. + function openSwap(date: string) { + if (swap.pending) return + swap.dismiss() + setSwappingDate(date) + } + let body if (plan.isError) { body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} @@ -192,6 +235,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon ) } else { const { ai_note, days } = plan.data + const swappingDay = days.find((day) => day.date === swappingDate) body = ( <> {ai_note !== null && ( @@ -204,16 +248,30 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon )}
      {days.map((day) => ( - + openSwap(day.date)} + /> ))}
    • -
    + {swappingDay !== undefined && ( + day.dinner.id))} + onSwap={swapIn} + onClose={() => setSwappingDate(null)} + /> + )} ) } diff --git a/web/src/plan/SwapSheet.module.css b/web/src/plan/SwapSheet.module.css new file mode 100644 index 0000000..5df5b8c --- /dev/null +++ b/web/src/plan/SwapSheet.module.css @@ -0,0 +1,57 @@ +/* The mobile swap sheet: the Quest log and Regulars of desktop's plan panel + (mock 2d) as a bottom sheet. */ +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.replaces { + margin: 2px 0 0; + font-family: var(--font-body); + font-size: 13px; + font-weight: 600; + color: var(--color-neutral-700); +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +/* The 48 px washed photo; Photo applies the washed filter. */ +.photo { + width: 48px; + height: 48px; +} + +/* A dinner already this week: shown, muted, and not a button. */ +.already { + color: var(--color-neutral-600); +} + +.skeletonRow { + height: 72px; + border-radius: var(--radius-md); +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} diff --git a/web/src/plan/SwapSheet.tsx b/web/src/plan/SwapSheet.tsx new file mode 100644 index 0000000..b2e0e49 --- /dev/null +++ b/web/src/plan/SwapSheet.tsx @@ -0,0 +1,122 @@ +import { useId, useState } from 'react' +import { useRegulars } from '../dinners/api.ts' +import { useQuestLog, type QuestLogDinner } from '../discover/api.ts' +import { details } from '../discover/QuestLogSheet.tsx' +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 { PlanDay, Swap } from './api.ts' +import { weekdayName } from './week.ts' +import styles from './SwapSheet.module.css' + +type Source = 'quest_log' | 'regulars' + +const sourceNames: Record = { quest_log: 'Quest log', regulars: 'Regulars' } + +const emptyLists: Record = { + quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.', + regulars: 'You have no Regulars yet.', +} + +export type SwapSheetProps = { + // The day being swapped. + day: PlanDay + // The ids of the dinners already on the week's plan. + planned: ReadonlySet + onSwap: (swap: Swap) => void + onClose: () => void +} + +// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as +// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N", +// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a +// dinner swaps it into the day; a dinner already this week can't be chosen. +export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) { + const titleId = useId() + const questLog = useQuestLog() + const regulars = useRegulars() + const [source, setSource] = useState('quest_log') + const lists = { quest_log: questLog, regulars } + const list = lists[source] + + const label = (s: Source) => { + const dinners = lists[s].data + return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}` + } + + let body + if (list.isError) { + body = ( + void list.refetch()}> + Couldn't load your {sourceNames[source]}. {list.error.message} + + ) + } else if (list.isPending) { + body = ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) + } else if (list.data.length === 0) { + body =

    {emptyLists[source]}

    + } else { + body = ( +
      + {list.data.map((dinner: QuestLogDinner) => { + const already = planned.has(dinner.id) + return ( +
    • +
    • + ) + })} +
    + ) + } + + return ( + +
    +
    +

    + Swap {weekdayName(day.date)} +

    +

    Replaces {day.dinner.name}

    +
    + + {body} +
    +
    + ) +} diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx new file mode 100644 index 0000000..87b3e57 --- /dev/null +++ b/web/src/plan/api.test.tsx @@ -0,0 +1,165 @@ +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { planKey, usePlan, useSwapDay, type Plan } from './api.ts' + +const WEEK = '2026-10-05' +const PLAN = `/api/v1/plans/${WEEK}` +const MONDAY = `${PLAN}/days/2026-10-05` +const FRIDAY = `${PLAN}/days/2026-10-09` +const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] + +// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days +// changed as given by index. +function planBody(changes: Record = {}) { + return { + id: 'plan-1', + week_start: WEEK, + status: 'draft', + ai_note: null, + days: DAYS.map((date, i) => { + const change = changes[i] + return { + date, + dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}` }, + is_new: change?.is_new ?? false, + start_by: change?.start_by ?? null, + } + }), + } +} + +// deferred is a reply the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const reply = new Promise((resolve) => (send = resolve)) + return { reply, send } +} + +// stubServer answers "METHOD path" requests from responders, with /me and +// the Quest log; any other request fails the test. +function stubServer(responders: Record Response | Promise>) { + const all: Record Response | Promise> = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }), + ...responders, + } + vi.stubGlobal( + 'fetch', + vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond()) + }), + ) +} + +// renderSwaps renders the week's plan and swaps over a query client. +async function renderSwaps() { + const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const queryClient: QueryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper }) + await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true)) + return { ...hook, queryClient, plan: () => queryClient.getQueryData(planKey(WEEK))! } +} + +const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo' }, fromQuestLog: true } +const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos' }, fromQuestLog: false } + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('useSwapDay', () => { + // Friday's swap is sent first, then Monday's; the server answers Monday's + // with both swapped, Friday's with only Friday. + const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } }) + const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } }) + + it.each([ + ['the later swap answered first', ['monday', 'friday'] as const], + ['the earlier swap answered first', ['friday', 'monday'] as const], + ])('keeps the plan of the later of two swaps, %s', async (_, order) => { + const replies = { friday: deferred(), monday: deferred() } + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => replies.friday.reply, + [`PUT ${MONDAY}`]: () => replies.monday.reply, + }) + const { result, plan, queryClient } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + act(() => result.current.swap.mutate(FISH)) + await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos')) + + // Whichever reply settles, Monday stays swapped: Friday's plan, the + // earlier one, never lands over it. + for (const [settled, day] of order.entries()) { + await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan))) + await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled)) + expect(plan().days[0]!.dinner.name).toBe('Fish tacos') + } + expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name)) + }) + + it("does not take an earlier swap's failure for the week's", async () => { + const friday = deferred() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), + [`PUT ${FRIDAY}`]: () => friday.reply, + [`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })), + }) + const { result, plan } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + act(() => result.current.swap.mutate(FISH)) + await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos')) + await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable'))) + + await waitFor(() => expect(result.current.swap.pending).toBe(false)) + expect(result.current.swap.failed).toBeNull() + }) + + it('leaves a day a later plan read landed when the swap of it fails', async () => { + const friday = deferred() + // The read finds Friday already lemon orzo, starting by 2 pm. + let read = planBody() + stubServer({ + [`GET ${PLAN}`]: () => jsonResponse(200, read), + [`PUT ${FRIDAY}`]: () => friday.reply, + }) + const { result, plan, queryClient } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null })) + read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } }) + await act(() => result.current.plan.refetch()) + await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable'))) + + await waitFor(() => expect(queryClient.isMutating()).toBe(0)) + expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' }) + expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message') + }) + + it('refuses to dismiss a pending swap', async () => { + const friday = deferred() + stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply }) + const { result } = await renderSwaps() + + act(() => result.current.swap.mutate(ORZO)) + await waitFor(() => expect(result.current.swap.pending).toBe(true)) + expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.') + await act(async () => friday.send(jsonResponse(200, planBody()))) + }) +}) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 17b4539..4d14cb4 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -1,7 +1,8 @@ -import { useMutation, useQuery } from '@tanstack/react-query' +import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, useApi, type ApiRead } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { oneOf } from '../dinners/api.ts' +import { QUEST_LOG_KEY } from '../discover/api.ts' export const PLANS_PATH = '/api/v1/plans' @@ -130,3 +131,113 @@ export function useStartDraft() { }, }) } + +// Swap is one day's swap: the day, and the dinner chosen for it with whether +// it was chosen from the Quest log. +export type Swap = { date: string; dinner: { id: string; name: string }; fromQuestLog: boolean } + +// sameDay says whether two plan days are the same in every field. +function sameDay(a: PlanDay, b: PlanDay): boolean { + return ( + a.date === b.date && a.dinner.id === b.dinner.id && a.dinner.name === b.dinner.name && a.is_new === b.is_new && a.start_by === b.start_by + ) +} + +// swapKey keys the swaps of the week starting on weekStart in the mutation +// cache, where they outlive the Draft board that sent them. +function swapKey(weekStart: string) { + return ['plans', weekStart, 'swap'] as const +} + +// SwapDay is the swaps of one week, as the mutation cache holds them: +// whether one is pending, and the failure of the latest, if it failed. +export type SwapDay = { + pending: boolean + failed: { swap: Swap; error: Error } | null + mutate: (chosen: Swap) => void + dismiss: () => void +} + +// useSwapDay swaps the dinner of one day of the week starting on weekStart +// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at +// once, NEW when it came from the Quest log, with no start-by time until the +// server answers with the plan as swapped, which then replaces it. A refused +// swap puts the day back as it was, unless it has changed again meanwhile. +// However the swap ends, the Quest log is read again: the server leaves a +// dinner on the plan out of it. +// +// The week's swaps live in the mutation cache, not in the component, so +// leaving the week and coming back keeps them: pending and failed are read +// from there. Only the latest swap of the week speaks for it: the plan an +// earlier one is answered with is not written over a later one, and its +// failure is not the week's. dismiss leaves settled swaps behind; it is +// refused while one is pending, so a later swap is always in the cache for +// an earlier, pending one to see. +export function useSwapDay(weekStart: string): SwapDay { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + const key = planKey(weekStart) + const mutationKey = swapKey(weekStart) + const mutations = queryClient.getMutationCache() + // The week's swaps, in the order sent. + const swaps = useMutationState({ + filters: { mutationKey }, + select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }), + }) + const latest = swaps.at(-1) + + function mutate(chosen: Swap) { + // isLatest says whether no swap of the week was sent after this one. + const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId) + const mutation = mutations.build(queryClient, { + mutationKey, + // Kept until dismissed: a later swap must stay in the cache while an + // earlier one is pending. + gcTime: Infinity, + mutationFn: async ({ date, dinner }) => { + const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}` + const body = await apiFetch(path, { method: 'PUT', body: { dinner_id: dinner.id } }) + return parsePlan(`PUT ${path}`, weekStart, body, false) + }, + onMutate: async ({ date, dinner, fromQuestLog }) => { + await queryClient.cancelQueries({ queryKey: key }) + const plan = queryClient.getQueryData(key) + const previous = plan?.days.find((day) => day.date === date) + if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`) + const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null } + queryClient.setQueryData(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }) + return { previous, swapped } + }, + onSuccess: (plan) => { + if (isLatest()) queryClient.setQueryData(key, plan) + }, + // The day goes back only while it is still, field for field, the day + // this swap wrote: a later swap or plan read that replaced it is left + // alone. + onError: (_error, _swap, context) => { + if (context === undefined) return + queryClient.setQueryData(key, (plan) => + plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) }, + ) + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + }) + // A failure is the mutation's state, which failed reports. + mutation.execute(chosen).catch(() => undefined) + } + + function dismiss() { + const week = mutations.findAll({ mutationKey }) + if (week.some((mutation) => mutation.state.status === 'pending')) { + throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`) + } + for (const mutation of week) mutations.remove(mutation) + } + + return { + pending: swaps.some((swap) => swap.status === 'pending'), + failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null, + mutate, + dismiss, + } +} diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index 211d211..0854764 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel, weekStart } from './week.ts' +import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' // SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York. const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' } @@ -31,6 +31,11 @@ describe('week helpers', () => { expect(weekdayKicker('2026-10-11')).toBe('SUN') }) + it('names the day of a date in full', () => { + expect(weekdayName('2026-10-09')).toBe('Friday') + expect(weekdayName('2026-10-11')).toBe('Sunday') + }) + it('refuses a week start that is not a Monday, and a date that is not real', () => { expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday') expect(() => addDays('2026-02-30', 1)).toThrow('not a real date') diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index d0ecb54..7241135 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -40,6 +40,13 @@ export function weekdayKicker(date: string): string { return WEEKDAYS[midnightUTC(date).getUTCDay()]! } +const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' }) + +// weekdayName is date's day in full: "Monday" to "Sunday". +export function weekdayName(date: string): string { + return weekday.format(midnightUTC(date)) +} + // monday reads start as midnight UTC, or throws when it is not a Monday. function monday(start: string): Date { const t = midnightUTC(start)