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..ea13869 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; } diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 1ecd898..6a6f84d 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,180 @@ 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([]) + }) + }) }) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 10d8473..1d4a444 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.reset() mutate( { weekStart, confirm }, { @@ -160,7 +182,11 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon else start(false) } - const failure = startDraft.isError ? ( + const failure = swap.isError ? ( + swapIn(swap.variables)}> + Couldn't swap {weekdayName(swap.variables.date)}'s dinner. {swap.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 +200,19 @@ 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 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 if (plan.isError) { body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} @@ -192,6 +231,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,7 +244,13 @@ 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.ts b/web/src/plan/api.ts index 17b4539..bded587 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, 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,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 => { + 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) => { + queryClient.setQueryData(key, plan) + }, + onError: (_error, { date }, context) => { + if (context === undefined) return + queryClient.setQueryData(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 }), + }) +} 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)