Keep plan manager columns usable from 768 px; show method and sync time
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:
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user