Add the mobile swap sheet to the Draft board
Tapping a day tile puts it in the swapping state and opens a bottom sheet
segmented "Quest log · N | Regulars · N". Choosing a dinner swaps the day
optimistically with PUT /api/v1/plans/{weekStart}/days/{date}, landing the
server's plan on success and rolling the day back with an ErrorBanner and
Retry on failure. Dinners already this week are marked and not selectable.
Maestro task f231813c-992e-4a3b-a1a4-868cbad47415.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
|
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
|
||||||
// neither is known.
|
// neither is known.
|
||||||
function details(dinner: QuestLogDinner): string | null {
|
export function details(dinner: QuestLogDinner): string | null {
|
||||||
const parts = [
|
const parts = [
|
||||||
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
||||||
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
||||||
|
|||||||
@@ -95,22 +95,38 @@
|
|||||||
.tile {
|
.tile {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 6px;
|
|
||||||
min-height: 86px;
|
min-height: 86px;
|
||||||
padding: 12px;
|
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text);
|
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. */
|
/* A Quest-log pick. */
|
||||||
.newTile {
|
.newTile {
|
||||||
background: var(--color-accent-2-300);
|
background: var(--color-accent-2-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dayKicker {
|
.dayKicker {
|
||||||
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -125,6 +141,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dinner {
|
.dinner {
|
||||||
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -134,6 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.startBy {
|
.startBy {
|
||||||
|
display: block;
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -145,6 +163,22 @@
|
|||||||
color: var(--color-accent-2-900);
|
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 {
|
.rebuildCell {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function draftJob(status: string, error: string | null = null, planId = 'plan-1'
|
|||||||
return { id: 'j1', kind: 'draft_week', status, result, error }
|
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
|
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||||
// the household settings; any other request fails the test.
|
// 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))')
|
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
|
||||||
const items = within(grid).getAllByRole('listitem')
|
const items = within(grid).getAllByRole('listitem')
|
||||||
expect(items).toHaveLength(8)
|
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',
|
'MON',
|
||||||
'TUE',
|
'TUE',
|
||||||
'WED · NEW',
|
'WED · NEW',
|
||||||
@@ -421,4 +421,180 @@ describe('PlanPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByRole('alert', {}, { timeout: 3000 })).toHaveTextContent("Couldn't load this week's plan. internal message")
|
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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+69
-15
@@ -11,8 +11,20 @@ import { Icon } from '../ui/Icon.tsx'
|
|||||||
import { IconButton } from '../ui/IconButton.tsx'
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import { Tag } from '../ui/Tag.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 {
|
||||||
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts'
|
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'
|
import styles from './PlanPage.module.css'
|
||||||
|
|
||||||
// statusTags is the tag beside the week's title for each plan status.
|
// 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
|
// 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
|
// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's
|
||||||
// start-by time in the household's zone.
|
// start-by time in the household's zone. Tapping it opens the swap sheet;
|
||||||
function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) {
|
// 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 kicker = weekdayKicker(day.date)
|
||||||
|
const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ')
|
||||||
return (
|
return (
|
||||||
<li className={[styles.tile, day.is_new && styles.newTile].filter(Boolean).join(' ')}>
|
<li className={classes}>
|
||||||
<p className={styles.dayKicker}>{day.is_new ? `${kicker} · NEW` : kicker}</p>
|
<button type="button" className={styles.tileButton} onClick={onSwap}>
|
||||||
<div>
|
<span className={styles.dayKicker}>{swapping ? `${kicker} · SWAPPING…` : day.is_new ? `${kicker} · NEW` : kicker}</span>
|
||||||
<p className={styles.dinner}>{day.dinner.name}</p>
|
<span>
|
||||||
{day.start_by !== null && (
|
<span className={styles.dinner}>{day.dinner.name}</span>
|
||||||
<p className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
|
{day.start_by !== null && (
|
||||||
)}
|
<span className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</span>
|
||||||
</div>
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,6 +104,9 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
const [jobId, setJobId] = useState<string | null>(null)
|
const [jobId, setJobId] = useState<string | null>(null)
|
||||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||||
const [confirming, setConfirming] = useState(false)
|
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)
|
const label = weekLabel(weekStart)
|
||||||
|
|
||||||
// The draft's result, once the job has succeeded, or why it cannot be read.
|
// 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
|
const { mutate } = startDraft
|
||||||
function start(confirm: boolean) {
|
function start(confirm: boolean) {
|
||||||
setConfirming(false)
|
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)
|
setJobId(null)
|
||||||
|
swap.reset()
|
||||||
mutate(
|
mutate(
|
||||||
{ weekStart, confirm },
|
{ weekStart, confirm },
|
||||||
{
|
{
|
||||||
@@ -160,7 +182,11 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
else start(false)
|
else start(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const failure = startDraft.isError ? (
|
const failure = swap.isError ? (
|
||||||
|
<ErrorBanner onRetry={() => swapIn(swap.variables)}>
|
||||||
|
Couldn't swap {weekdayName(swap.variables.date)}'s dinner. {swap.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
) : startDraft.isError ? (
|
||||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||||
) : result.error !== null ? (
|
) : result.error !== null ? (
|
||||||
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
|
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
|
||||||
@@ -174,6 +200,19 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
<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 is sent at a time: the
|
||||||
|
// plan the server answers it with must not overwrite another still pending.
|
||||||
|
function openSwap(date: string) {
|
||||||
|
if (swap.isPending) return
|
||||||
|
swap.reset()
|
||||||
|
setSwappingDate(date)
|
||||||
|
}
|
||||||
|
|
||||||
let body
|
let body
|
||||||
if (plan.isError) {
|
if (plan.isError) {
|
||||||
body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
|
body = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
|
||||||
@@ -192,6 +231,7 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
const { ai_note, days } = plan.data
|
const { ai_note, days } = plan.data
|
||||||
|
const swappingDay = days.find((day) => day.date === swappingDate)
|
||||||
body = (
|
body = (
|
||||||
<>
|
<>
|
||||||
{ai_note !== null && (
|
{ai_note !== null && (
|
||||||
@@ -204,7 +244,13 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
)}
|
)}
|
||||||
<ul className={styles.grid} aria-label="Dinners this week">
|
<ul className={styles.grid} aria-label="Dinners this week">
|
||||||
{days.map((day) => (
|
{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}>
|
<li className={styles.rebuildCell}>
|
||||||
<button type="button" className={styles.rebuild} onClick={rebuild}>
|
<button type="button" className={styles.rebuild} onClick={rebuild}>
|
||||||
@@ -214,6 +260,14 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<PushToCalendar />
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+48
-1
@@ -1,7 +1,8 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { oneOf } from '../dinners/api.ts'
|
import { oneOf } from '../dinners/api.ts'
|
||||||
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
|
|
||||||
export const PLANS_PATH = '/api/v1/plans'
|
export const PLANS_PATH = '/api/v1/plans'
|
||||||
|
|
||||||
@@ -130,3 +131,49 @@ 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 }
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
export function useSwapDay(weekStart: string) {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const key = planKey(weekStart)
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ date, dinner }: Swap): Promise<Plan> => {
|
||||||
|
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) => {
|
||||||
|
queryClient.setQueryData<Plan>(key, plan)
|
||||||
|
},
|
||||||
|
onError: (_error, { date }, context) => {
|
||||||
|
if (context === undefined) return
|
||||||
|
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||||
|
plan && {
|
||||||
|
...plan,
|
||||||
|
days: plan.days.map((day) => (day.date === date && day.dinner.id === context.swapped.dinner.id ? context.previous : day)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
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.
|
// 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' }
|
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')
|
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', () => {
|
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(() => 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')
|
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()]!
|
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.
|
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||||
function monday(start: string): Date {
|
function monday(start: string): Date {
|
||||||
const t = midnightUTC(start)
|
const t = midnightUTC(start)
|
||||||
|
|||||||
Reference in New Issue
Block a user