From 8d46b58f11c4aae4d799a1507e3de5079ac77926 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 14:08:00 -0400 Subject: [PATCH] Keep plan manager columns usable from 768 px; show method and sync time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/discover/api.ts | 14 ++-- web/src/plan/PlanPage.module.css | 22 ++++--- web/src/plan/PlanPage.test.tsx | 108 +++++++++++++++++++++++++++---- web/src/plan/PlanPage.tsx | 57 ++++++++++++++-- web/src/plan/SwapSheet.tsx | 8 ++- web/src/plan/api.test.tsx | 46 ++++++++++++- web/src/plan/api.ts | 26 ++++++-- 7 files changed, 238 insertions(+), 43 deletions(-) diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index ea8d070..c65726f 100644 --- a/web/src/discover/api.ts +++ b/web/src/discover/api.ts @@ -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 +// it: photo, name, difficulty and minutes, with the method a swap carries +// onto the plan. +export type QuestLogDinner = Pick // DECISION_STATUS is the status a suggestion has once each choice is saved. const DECISION_STATUS: Record = { try: 'accepted', pass: 'declined' } @@ -127,8 +128,8 @@ export function useQuestLog() { const body = await apiFetch(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(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, }), ) diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index f70c683..f75cf9d 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -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; } diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 96ce87e..fe23d57 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -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) + }) +}) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 083d422..f543439 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -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 = { 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 (
  • @@ -120,7 +155,7 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone

    {dinner.name}

    - {dinner.minutes !== null &&

    {dinner.minutes} min

    } + {time !== null &&

    {time}

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

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

    )} @@ -396,7 +431,7 @@ function WeekBoard({
    - {plan.data ? : } + {plan.data ? : }
    @@ -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 (

    {status === 'synced' && } {syncStatuses[status]} + {status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}

    ) } diff --git a/web/src/plan/SwapSheet.tsx b/web/src/plan/SwapSheet.tsx index 7326cb6..0f12d09 100644 --- a/web/src/plan/SwapSheet.tsx +++ b/web/src/plan/SwapSheet.tsx @@ -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', }) } diff --git a/web/src/plan/api.test.tsx b/web/src/plan/api.test.tsx index 2202e6b..438c2de 100644 --- a/web/src/plan/api.test.tsx +++ b/web/src/plan/api.test.tsx @@ -21,11 +21,12 @@ function planBody(changes: Record { 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(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 }) => ( + + {children} + + ) + 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) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'], + ['no method', (body: ReturnType) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'], + ['a sync time that is no instant', (body: ReturnType) => (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. diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 0a9bf1d..bec55f3 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -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 )