Keep plan manager columns usable from 768 px; show method and sync time
CI / ci (push) Successful in 3m11s
CI / ci (pull_request) Successful in 3m14s

The week's columns are never narrower than 112 px, which holds a
dinner's words whole and the tag beside the swap icon. Where seven do not
fit (768-1100 px tablets, desktop up to about 1430 px beside the panel)
the week scrolls sideways inside the content area; at 1180 and 1440 px
the grid is unchanged. Names no longer hyphenate or break mid-word, and
the card footer no longer wraps the swap icon under the tag.

Each card now says how the dinner is cooked as well as how long:
"Crock pot · 6 hr", "Stovetop · 45 min", or the method alone when the
minutes are unknown. The plan API's method is parsed (and carried by a
swap from the Quest log or Regulars), and an unknown method is refused.

The synced status line says how long ago the plan was pushed, from
calendar_synced_at ("Synced to Google Calendar 2 min ago"), kept current
minute by minute.

Maestro task 4657cba1-c709-420e-8b1e-18761e9e0e27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 14:08:00 -04:00
co-authored by Claude Opus 5.5
parent a9038845a3
commit 8d46b58f11
7 changed files with 238 additions and 43 deletions
+8 -6
View File
@@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
// it: photo, name, difficulty and minutes.
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
// it: photo, name, difficulty and minutes, with the method a swap carries
// onto the plan.
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
// DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
@@ -127,8 +128,8 @@ export function useQuestLog() {
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => {
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, minutes_total }
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, method, minutes_total }
})
},
})
@@ -250,13 +251,14 @@ export function useDecide() {
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
? dinners
: // The deck does not say the minutes; the Quest log read after the
// decision does.
: // The deck does not say the method or minutes; the Quest log
// read after the decision does.
dinners.concat({
id: quest.dinner_id,
name: quest.name,
photo_id: quest.photo_id,
difficulty: quest.difficulty,
method: null,
minutes_total: null,
}),
)
+12 -10
View File
@@ -418,14 +418,20 @@
white-space: nowrap;
}
/* The week: seven columns, each a day label over its card. */
/* The week: seven columns, each a day label over its card. A column is
never narrower than 112 px, which holds a dinner's words whole and the
tag beside the swap icon; where seven of those do not fit (tablet's
narrow end, desktop beside the panel) the week scrolls sideways. Its 8 px
padding, taken back by the margin, keeps the swap icons' hit boxes and
focus rings inside the scroll area. */
.week {
flex: 1;
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-columns: repeat(7, minmax(112px, 1fr));
gap: 12px;
margin: 0;
padding: 0;
margin: -8px;
padding: 8px;
overflow-x: auto;
list-style: none;
}
@@ -494,8 +500,7 @@
font-family: var(--font-heading);
font-size: 17px;
line-height: 1.15;
hyphens: auto;
overflow-wrap: anywhere;
overflow-wrap: break-word;
}
.tablet .cardName {
@@ -522,16 +527,13 @@
.cardFooter {
margin-top: auto;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 4px;
}
/* The swap icon sits bare on the card, beside the tag: its 44 px hit box
keeps the 16 px footprint of its icon, reaching over the card's padding
and the tag. Where even that does not fit, it wraps under the tag, at the
right. */
and the tag. */
.cardFooter .swapButton {
margin: -14px;
}
+97 -11
View File
@@ -9,7 +9,7 @@ import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
import { PlanPage } from './PlanPage.tsx'
import { ago, cooking, duration, PlanPage } from './PlanPage.tsx'
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
@@ -23,7 +23,8 @@ const NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Crock pot tikka masala', 'C
// planBody is the plan of the week of Oct 5 as GET /api/v1/plans/{weekStart}
// sends it: Wednesday's crock-pot tikka masala and Saturday's bowls are
// Quest-log picks, and the tikka masala must start by 1:30 pm New York time
// to be ready for 5:30 pm dinner. Monday's tacos have a stored photo.
// to be ready for 5:30 pm dinner. Monday's tacos have a stored photo. A plan
// pushed to Calendar was pushed two minutes before the tests' noon.
function planBody(status: string, names = NAMES) {
return {
id: 'plan-1',
@@ -31,7 +32,7 @@ function planBody(status: string, names = NAMES) {
week_label: 'Week 41 · Oct 5 – 11',
status,
ai_note: 'I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.',
calendar_synced_at: status === 'draft' ? null : '2026-10-04T15:00:00Z',
calendar_synced_at: status === 'draft' ? null : '2026-10-07T15:58:00Z',
days: DAYS.map((date, i) => ({
date,
dinner: {
@@ -688,6 +689,7 @@ describe('PlanPage', () => {
})
describe.each([
{ width: 768, layout: 'tablet', photoHeight: '96px', nameSize: '16px' },
{ width: 1180, layout: 'tablet', photoHeight: '96px', nameSize: '16px' },
{ width: 1440, layout: 'desktop', photoHeight: '110px', nameSize: '17px' },
])('PlanPage at $width px (mocks 2c, 2d)', ({ width, layout, photoHeight, nameSize }) => {
@@ -736,7 +738,7 @@ describe.each([
// Seven columns, Monday to Sunday, 12 px apart.
const grid = screen.getByRole('list', { name: 'Dinners this week' })
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(0, 1fr))')
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))')
expect(declaredStyle(grid).gap).toBe('12px')
expect(within(grid).getAllByRole('listitem').map((li) => li.getAttribute('aria-label'))).toEqual([
'Monday',
@@ -748,8 +750,8 @@ describe.each([
'Sunday',
])
// Monday: day label, the washed photo, the name in Bagel, minutes and
// the Regular tag, on the surface.
// Monday: day label, the washed photo, the name in Bagel, method and
// minutes, and the Regular tag, on the surface.
expect(column('Monday')).toHaveTextContent(/^MON5/)
const monday = card('Monday', 'Beef tacos')
expect(declaredStyle(monday).background).toBe('var(--color-surface)')
@@ -759,25 +761,38 @@ describe.each([
const name = within(monday).getByText('Beef tacos')
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(name).fontSize).toBe(nameSize)
expect(within(monday).getByText('30 min')).toBeInTheDocument()
// Testing Library reads the duration's no-break spaces as spaces.
expect(within(monday).getByText('Stovetop · 30 min')).toBeInTheDocument()
expect(declaredStyle(within(monday).getByText('Regular')).background).toBe('var(--color-neutral-100)')
expect(within(monday).queryByText(/Start by/)).not.toBeInTheDocument()
// Wednesday: a crock-pot Side quest on teal-200, with its start-by time.
const wednesday = card('Wednesday', 'Crock pot tikka masala')
expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-200)')
expect(within(wednesday).getByText('240 min')).toBeInTheDocument()
expect(within(wednesday).getByText('Crock pot · 4 hr')).toBeInTheDocument()
expect(within(wednesday).getByText('Start by 1:30 pm')).toBeInTheDocument()
expect(within(wednesday).getByText('Side quest')).toBeInTheDocument()
expect(within(column('Wednesday')).getByRole('button', { name: 'Swap Wednesday' })).toBeInTheDocument()
})
it('says a synced plan is on Google Calendar', async () => {
it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), ...lists })
await renderPage()
const status = await screen.findByText('Synced to Google Calendar')
const status = await screen.findByText('Synced to Google Calendar 2 min ago')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
// The time is kept current.
act(() => vi.advanceTimersByTime(58 * 60_000))
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
})
it('says changes to a pushed plan are not on Google Calendar', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
await renderPage()
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
})
if (layout === 'desktop') {
@@ -912,7 +927,7 @@ describe.each([
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
const friday = await waitFor(() => card('Friday', 'Lemon orzo'))
expect(within(friday).getByText('Side quest')).toBeInTheDocument()
expect(within(friday).getByText('25 min')).toBeInTheDocument()
expect(within(friday).getByText('Stovetop · 25 min')).toBeInTheDocument()
expect(declaredStyle(friday).background).toBe('var(--color-accent-2-200)')
await waitFor(() => expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PUT')).toHaveLength(1))
// Rebuild week waits for the swap.
@@ -958,3 +973,74 @@ describe.each([
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]))
})
})
// At tablet's narrow end seven usable columns do not fit beside the rail:
// the week keeps its columns usable and scrolls sideways.
describe('PlanPage at 768 px', () => {
beforeEach(() => mockViewport(768))
it('keeps each day column usable and scrolls the week sideways', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
await renderPage()
expect(await screen.findByText('Spaghetti bolognese')).toBeInTheDocument()
const grid = screen.getByRole('list', { name: 'Dinners this week' })
// Columns no narrower than 112 px; the week scrolls when seven do not
// fit, its padding keeping the swap icons' hit boxes inside.
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(112px, 1fr))')
expect(declaredStyle(grid).overflowX).toBe('auto')
expect(declaredStyle(grid).padding).toBe('8px')
expect(declaredStyle(grid).margin).toBe('-8px')
const tuesday = within(grid).getByRole('listitem', { name: 'Tuesday' })
// The name wraps between words, only breaking a word wider than the card.
const name = within(tuesday).getByText('Spaghetti bolognese')
expect(declaredStyle(name).overflowWrap).toBe('break-word')
expect(declaredStyle(name).hyphens).toBe('')
// The swap icon stays beside the tag.
const footer = within(tuesday).getByRole('button', { name: 'Swap Tuesday' }).parentElement!
expect(within(footer).getByText('Regular')).toBeInTheDocument()
expect(declaredStyle(footer).display).toBe('flex')
expect(declaredStyle(footer).flexWrap).toBe('')
// Every piece of the card is still there.
expect(within(tuesday).getByRole('img', { hidden: true, name: 'Spaghetti bolognese' })).toBeInTheDocument()
expect(within(tuesday).getByText('Stovetop · 30 min')).toBeInTheDocument()
expect(within(within(grid).getByRole('listitem', { name: 'Wednesday' })).getByText('Start by 1:30 pm')).toBeInTheDocument()
})
})
describe('duration', () => {
it.each([
[25, '25\u00a0min'],
[60, '1\u00a0hr'],
[90, '1\u00a0hr\u00a030\u00a0min'],
[360, '6\u00a0hr'],
])('says %i minutes as %j', (minutes, said) => {
expect(duration(minutes)).toBe(said)
})
})
describe('cooking', () => {
const dinner = { id: 'd1', name: 'Tikka masala', photo_id: undefined }
it.each([
[{ method: 'crockpot' as const, minutes: 360 }, 'Crock pot · 6\u00a0hr'],
[{ method: 'crockpot' as const, minutes: null }, 'Crock pot'],
[{ method: null, minutes: 45 }, '45\u00a0min'],
[{ method: null, minutes: null }, null],
])('says %o as %j', (known, said) => {
expect(cooking({ ...dinner, ...known })).toBe(said)
})
})
describe('ago', () => {
const now = new Date('2026-10-07T16:00:00Z')
it.each([
['2026-10-07T15:59:30Z', 'just now'],
['2026-10-07T15:58:00Z', '2 min ago'],
['2026-10-07T13:00:00Z', '3 hr ago'],
['2026-10-06T15:00:00Z', '1 day ago'],
['2026-10-04T15:00:00Z', '3 days ago'],
])('says %s as %j', (at, said) => {
expect(ago(new Date(at), now)).toBe(said)
})
})
+50 -7
View File
@@ -3,6 +3,7 @@ import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, Shoppi
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { useJob } from '../api/index.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
import { Button, type ButtonVariant } from '../ui/Button.tsx'
@@ -21,7 +22,9 @@ import {
useStartDraft,
useSwapDay,
type DraftResult,
type Plan,
type PlanDay,
type PlanDinner,
type PlanStatus,
type Swap,
} from './api.ts'
@@ -71,6 +74,37 @@ const syncStatuses: Record<PlanStatus, string> = {
changed: 'Changes not pushed to Google Calendar',
}
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
// between the method and the duration, never inside the duration.
export function duration(minutes: number): string {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
if (hours === 0) return `${rest}\u00a0min`
return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min`
}
// cooking is a card's "method · duration" line, "Crock pot · 6 hr", with
// whichever of the two is known, or null when neither is.
export function cooking(dinner: PlanDinner): string | null {
const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter(
(part): part is string => typeof part === 'string',
)
return parts.length === 0 ? null : parts.join(' · ')
}
// ago is how long before now the instant at was: "just now", "2 min ago",
// "3 hr ago", "2 days ago".
export function ago(at: Date, now: Date): string {
const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes} min ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} hr ago`
const days = Math.floor(hours / 24)
return days === 1 ? '1 day ago' : `${days} days ago`
}
// pushedToCalendar says whether drafting over a plan of this status needs
// confirm: it was pushed to Calendar.
function pushedToCalendar(status: PlanStatus): boolean {
@@ -101,12 +135,13 @@ function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone:
}
// DayColumn is one day of the plan manager's 7-column grid: its day label
// over a card with the dinner's washed photo, name, minutes, a crock-pot
// dinner's start-by time in the household's zone, and its Regular or Side
// quest tag, a Side quest's card in teal. The swap icon opens the swap
// over a card with the dinner's washed photo, name, method and time, a
// crock-pot dinner's start-by time in the household's zone, and its Regular
// or Side quest tag, a Side quest's card in teal. The swap icon opens the swap
// sheet; while that is open the card is swapping, ringed in grape.
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
const { dinner } = day
const time = cooking(dinner)
const classes = [styles.card, day.is_new && styles.sideQuestCard, swapping && styles.swapping].filter(Boolean).join(' ')
return (
<li className={styles.column} aria-label={weekdayName(day.date)}>
@@ -120,7 +155,7 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
<Photo id={dinner.photo_id} name={dinner.name} radius="18px" sizes="160px" className={styles.cardPhoto} />
</span>
<p className={styles.cardName}>{dinner.name}</p>
{dinner.minutes !== null && <p className={styles.cardMinutes}>{dinner.minutes} min</p>}
{time !== null && <p className={styles.cardMinutes}>{time}</p>}
{day.start_by !== null && (
<p className={styles.cardStartBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
)}
@@ -396,7 +431,7 @@ function WeekBoard({
</div>
</div>
<div className={styles.actionBar}>
{plan.data ? <SyncStatus status={plan.data.status} /> : <span className={styles.status} />}
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
<div className={styles.actions}>
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
<Icon icon={ShoppingCart} size="inline" />
@@ -452,12 +487,20 @@ function WeekBoard({
}
// SyncStatus is the plan manager's status line: whether the plan is on
// Google Calendar.
function SyncStatus({ status }: { status: PlanStatus }) {
// Google Calendar and, once synced, how long ago it was pushed, kept current
// minute by minute.
function SyncStatus({ plan }: { plan: Plan }) {
const { status, calendar_synced_at: syncedAt } = plan
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 60_000)
return () => clearInterval(timer)
}, [])
return (
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
{status === 'synced' && <Icon icon={Check} size="inline" />}
{syncStatuses[status]}
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
</p>
)
}
+7 -1
View File
@@ -90,7 +90,13 @@ export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
: () =>
onSwap({
date: day.date,
dinner: { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total },
dinner: {
id: dinner.id,
name: dinner.name,
photo_id: dinner.photo_id,
minutes: dinner.minutes_total,
method: dinner.method,
},
fromQuestLog: source === 'quest_log',
})
}
+43 -3
View File
@@ -21,11 +21,12 @@ function planBody(changes: Record<number, { id: string; name: string; is_new?: b
week_start: WEEK,
status: 'draft',
ai_note: null,
calendar_synced_at: null as string | null,
days: DAYS.map((date, i) => {
const change = changes[i]
return {
date,
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null, origin: 'regular' },
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null as string | null, origin: 'regular' },
is_new: change?.is_new ?? false,
start_by: change?.start_by ?? null,
}
@@ -74,13 +75,52 @@ async function renderSwaps() {
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
}
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null }, fromQuestLog: true }
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null }, fromQuestLog: false }
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null, method: null }, fromQuestLog: true }
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null, method: null }, fromQuestLog: false }
afterEach(() => {
vi.unstubAllGlobals()
})
// renderPlan renders the week's plan read over a query client.
async function renderPlan() {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(() => usePlan(WEEK), { wrapper })
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
return hook.result.current
}
describe('usePlan', () => {
it("reads each dinner's method and when the plan was pushed to Calendar", async () => {
const body = planBody()
body.days[2]!.dinner.method = 'crockpot'
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, { ...body, status: 'synced', calendar_synced_at: '2026-10-07T15:58:00Z' }) })
const plan = await renderPlan()
expect(plan.data?.calendar_synced_at).toEqual(new Date('2026-10-07T15:58:00Z'))
expect(plan.data?.days.map((day) => day.dinner.method)).toEqual([null, null, 'crockpot', null, null, null, null])
})
it.each([
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
])('refuses a plan with %s', async (_, change, said) => {
const body = planBody()
change(body)
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, body) })
const plan = await renderPlan()
expect(plan.error?.message).toContain(said)
})
})
describe('useSwapDay', () => {
// Friday's swap is sent first, then Monday's; the server answers Monday's
// with both swapped, Friday's with only Friday.
+21 -5
View File
@@ -1,7 +1,7 @@
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, photoId } from '../dinners/api.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
export const PLANS_PATH = '/api/v1/plans'
@@ -23,8 +23,9 @@ export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
export type PlanStatus = (typeof PLAN_STATUSES)[number]
// PlanDinner is a planned dinner: photo_id is its stored photo's id, from
// photo, and minutes its minutes from prep to table, when known.
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | null }
// photo, minutes its minutes from prep to table and method how it is
// cooked, each when known.
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | null; method: Method | null }
// PlanDay is one day of a plan: its household-local date, the dinner
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot
@@ -37,13 +38,15 @@ export type PlanDay = {
}
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
// Sunday, and the AI's note on the draft, if any. cached says the service
// worker answered from its cache: the server may have changed the plan since.
// Sunday, the AI's note on the draft, if any, and when it was last pushed to
// Google Calendar, if ever. cached says the service worker answered from its
// cache: the server may have changed the plan since.
export type Plan = {
id: string
week_start: string
status: PlanStatus
ai_note: string | null
calendar_synced_at: Date | null
days: PlanDay[]
cached: boolean
}
@@ -72,10 +75,21 @@ function parseDay(where: string, body: unknown): PlanDay {
name: body.dinner.name,
photo_id: photoId(where, body.dinner.photo),
minutes: body.dinner.minutes as number | null,
method: body.dinner.method === null ? null : oneOf(where, 'method', METHODS, body.dinner.method),
}
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
}
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
function syncedAt(where: string, value: unknown): Date | null {
if (value === null) return null
const at = typeof value === 'string' ? new Date(value) : null
if (at === null || Number.isNaN(at.getTime())) {
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
}
return at
}
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
if (
!isRecord(body) ||
@@ -92,6 +106,7 @@ function parsePlan(where: string, weekStart: string, body: unknown, cached: bool
week_start: weekStart,
status: oneOf(where, 'status', PLAN_STATUSES, body.status),
ai_note: body.ai_note,
calendar_synced_at: syncedAt(where, body.calendar_synced_at),
days: body.days.map((day) => parseDay(where, day)),
cached,
}
@@ -155,6 +170,7 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
a.dinner.name === b.dinner.name &&
a.dinner.photo_id === b.dinner.photo_id &&
a.dinner.minutes === b.dinner.minutes &&
a.dinner.method === b.dinner.method &&
a.is_new === b.is_new &&
a.start_by === b.start_by
)