Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #118.
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Response>
|
||||
|
||||
// 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<typeof fetch>) {
|
||||
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<Response>((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<Response>((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<Response>((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<Response>((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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+74
-16
@@ -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 (
|
||||
<li className={[styles.tile, day.is_new && styles.newTile].filter(Boolean).join(' ')}>
|
||||
<p className={styles.dayKicker}>{day.is_new ? `${kicker} · NEW` : kicker}</p>
|
||||
<div>
|
||||
<p className={styles.dinner}>{day.dinner.name}</p>
|
||||
{day.start_by !== null && (
|
||||
<p className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
|
||||
)}
|
||||
</div>
|
||||
<li className={classes}>
|
||||
<button type="button" className={styles.tileButton} onClick={onSwap}>
|
||||
<span className={styles.dayKicker}>{swapping ? `${kicker} · SWAPPING…` : day.is_new ? `${kicker} · NEW` : kicker}</span>
|
||||
<span>
|
||||
<span className={styles.dinner}>{day.dinner.name}</span>
|
||||
{day.start_by !== null && (
|
||||
<span className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -87,6 +104,9 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(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<string | null>(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 ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
) : startDraft.isError ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||
) : result.error !== null ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
|
||||
@@ -174,6 +203,20 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
// 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 = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
|
||||
@@ -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
|
||||
)}
|
||||
<ul className={styles.grid} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayTile key={day.date} day={day} timezone={timezone} />
|
||||
<DayTile
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
<li className={styles.rebuildCell}>
|
||||
<button type="button" className={styles.rebuild} onClick={rebuild}>
|
||||
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
||||
<Icon icon={Plus} size="inline" />
|
||||
Rebuild week
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<PushToCalendar />
|
||||
{swappingDay !== undefined && (
|
||||
<SwapSheet
|
||||
day={swappingDay}
|
||||
planned={new Set(days.map((day) => day.dinner.id))}
|
||||
onSwap={swapIn}
|
||||
onClose={() => setSwappingDate(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
|
||||
|
||||
const emptyLists: Record<Source, string> = {
|
||||
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<string>
|
||||
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<Source>('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 = (
|
||||
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
} else if (list.isPending) {
|
||||
body = (
|
||||
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.rows}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (list.data.length === 0) {
|
||||
body = <p className={styles.empty}>{emptyLists[source]}</p>
|
||||
} else {
|
||||
body = (
|
||||
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||
{list.data.map((dinner: QuestLogDinner) => {
|
||||
const already = planned.has(dinner.id)
|
||||
return (
|
||||
<li key={dinner.id}>
|
||||
<ListRow
|
||||
className={already ? styles.already : undefined}
|
||||
leading={
|
||||
// The row's name already says what the photo shows.
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={dinner.name}
|
||||
meta={details(dinner)}
|
||||
// A dinner already this week is no button: it can't be chosen.
|
||||
onClick={
|
||||
already
|
||||
? undefined
|
||||
: () => onSwap({ date: day.date, dinner: { id: dinner.id, name: dinner.name }, fromQuestLog: source === 'quest_log' })
|
||||
}
|
||||
trailing={already && <Tag>Already this week</Tag>}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<div>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Swap {weekdayName(day.date)}
|
||||
</h2>
|
||||
<p className={styles.replaces}>Replaces {day.dinner.name}</p>
|
||||
</div>
|
||||
<Segmented
|
||||
label="Swap in from"
|
||||
options={[
|
||||
{ value: 'quest_log', label: label('quest_log') },
|
||||
{ value: 'regulars', label: label('regulars') },
|
||||
]}
|
||||
value={source}
|
||||
onValueChange={setSource}
|
||||
/>
|
||||
{body}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -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<number, { id: string; name: string; is_new?: boolean; start_by?: string | null }> = {}) {
|
||||
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<Response>((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<string, () => Response | Promise<Response>>) {
|
||||
const all: Record<string, () => Response | Promise<Response>> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||
...responders,
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
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())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
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<Plan>(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())))
|
||||
})
|
||||
})
|
||||
+112
-1
@@ -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<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(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<unknown>(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<Plan | null>(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<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
||||
return { previous, swapped }
|
||||
},
|
||||
onSuccess: (plan) => {
|
||||
if (isLatest()) queryClient.setQueryData<Plan>(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<Plan | null>(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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user