From a9038845a3f62949f49760ced0d13a1f6cb7a3a5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 13:40:39 -0400 Subject: [PATCH 1/2] Add tablet and desktop plan manager (mocks 2c, 2d) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 768 px up /plan renders the plan manager: "Week N · Plan" kicker, the week's dates, previous/next buttons and, on desktop, a "This week" pill; the sync status line; "Shopping list" and "Push to Calendar" unavailable with tooltips saying why (Features 17 and 16), and a working outlined "Rebuild week" that confirms over a plan pushed to Calendar; and a 7-column grid of day cards with washed photo, name, minutes, crock-pot start-by time, Regular / Side quest tag (Side quests on teal-200) and a swap icon that opens the existing swap sheet. Desktop adds the 290 px right panel: "Quest log · N | Regulars · N", the drag helper, rows with photo, name, difficulty and a 6-dot grip, and "Swipe more side quests". Tablet shows the Quest log as a chip strip under the grid. Dragging is presentational here; it lands in task 5c2842a8. The plan day now reads the photo and minutes the plan API already sends, and an optimistic swap carries them from the chosen dinner. Tag gains a neutral tone for "Regular". Maestro task 4657cba1-c709-420e-8b1e-18761e9e0e27. Co-Authored-By: Claude Opus 5.5 --- web/src/plan/PlanPage.module.css | 291 +++++++++++++++++++++++++- web/src/plan/PlanPage.test.tsx | 331 +++++++++++++++++++++++++++--- web/src/plan/PlanPage.tsx | 308 +++++++++++++++++++++------ web/src/plan/PlanPanel.module.css | 171 +++++++++++++++ web/src/plan/PlanPanel.tsx | 138 +++++++++++++ web/src/plan/SwapSheet.tsx | 14 +- web/src/plan/api.test.tsx | 6 +- web/src/plan/api.ts | 27 ++- web/src/plan/week.test.ts | 8 +- web/src/plan/week.ts | 5 + web/src/ui/Tag.module.css | 5 + web/src/ui/Tag.test.tsx | 5 + web/src/ui/Tag.tsx | 5 +- 13 files changed, 1198 insertions(+), 116 deletions(-) create mode 100644 web/src/plan/PlanPanel.module.css create mode 100644 web/src/plan/PlanPanel.tsx diff --git a/web/src/plan/PlanPage.module.css b/web/src/plan/PlanPage.module.css index 8080ba8..f70c683 100644 --- a/web/src/plan/PlanPage.module.css +++ b/web/src/plan/PlanPage.module.css @@ -1,13 +1,40 @@ -/* Plan · Draft board (mock 1f). */ +/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */ .page { box-sizing: border-box; - max-width: 440px; +} + +.board { + box-sizing: border-box; + min-width: 0; display: flex; flex-direction: column; gap: 18px; +} + +.mobilePage { + max-width: 440px; padding-top: 8px; } +/* The grid fills the screen below the header: the main area's 32 px top and + bottom padding (AppShell) is taken back off the viewport height. */ +.tabletPage .board { + min-height: calc(100dvh - 64px); +} + +/* Desktop: the board beside the 290 px right panel, which runs the full + height of the screen to its right edge over the main area's padding. */ +.desktopPage { + display: grid; + grid-template-columns: minmax(0, 1fr) 290px; + min-height: 100dvh; + margin: -32px -36px -32px 0; +} + +.desktopPage .board { + padding: 32px 36px 32px 0; +} + .header { display: flex; align-items: flex-end; @@ -231,9 +258,10 @@ color: var(--color-neutral-700); } -/* "Looks good — push to Calendar", unavailable until Feature 16: it keeps - focus, so hovering, tapping or focusing it shows the tooltip. */ -.push { +/* An action with nothing behind it yet (Push to Calendar until Feature 16, + the shopping list until Feature 17): it keeps focus, so hovering, tapping + or focusing it shows the tooltip. */ +.unavailable { position: relative; display: flex; flex-direction: column; @@ -243,18 +271,24 @@ min-height: 50px; } -.pushButton[aria-disabled='true'] { +.unavailable [aria-disabled='true'] { opacity: 0.45; cursor: not-allowed; } /* Unavailable, it neither darkens nor presses. */ -.push .pushButton[aria-disabled='true']:hover, -.push .pushButton[aria-disabled='true']:active { +.unavailable .primary[aria-disabled='true']:hover, +.unavailable .primary[aria-disabled='true']:active { background: var(--color-accent); transform: none; } +.unavailable .outline[aria-disabled='true']:hover, +.unavailable .outline[aria-disabled='true']:active { + background: transparent; + transform: none; +} + .hint { position: absolute; bottom: calc(100% + 8px); @@ -275,7 +309,244 @@ pointer-events: none; } -.push:hover .hint, -.pushButton:focus + .hint { +/* In the plan manager's header the tooltip opens below, at the action's + right edge. */ +.below .hint { + top: calc(100% + 8px); + bottom: auto; + left: auto; + right: 0; + transform: none; + width: 240px; + max-width: none; + z-index: 1; +} + +.unavailable:hover .hint, +.unavailable [aria-disabled='true']:focus + .hint { visibility: visible; } + +/* The plan manager's header: kicker and title beside the week buttons, over + the sync status and the actions. */ +.wideHeader { + display: flex; + flex-direction: column; + gap: 14px; +} + +.titleBar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.wideTitle { + margin: 4px 0 0; + font-family: var(--font-heading); + font-size: 40px; + font-weight: 400; + line-height: 1.1; +} + +.tabletPage .wideTitle { + font-size: 36px; +} + +.wideHeader .kicker { + font-size: 12px; +} + +.wideHeader .weekNav { + align-items: center; + gap: 12px; +} + +.thisWeek { + min-height: 44px; + padding: 10px 16px; + border: 0; + border-radius: var(--radius-pill); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + cursor: pointer; +} + +.thisWeek:hover { + background: var(--color-neutral-100); +} + +.actionBar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} + +/* The status keeps to one line; the actions wrap under it when the row is + too narrow for both. */ +.status { + flex: 1 0 auto; + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.synced { + color: var(--color-accent-2-800); +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.action { + min-height: 44px; + padding: 0 18px; + white-space: nowrap; +} + +/* The week: seven columns, each a day label over its card. */ +.week { + flex: 1; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.column { + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.dayLabel { + display: flex; + align-items: baseline; + gap: 6px; + margin: 0; + padding: 0 4px; +} + +.dayName { + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.dayNumber { + font-family: var(--font-heading); + font-size: 22px; + color: var(--color-text); +} + +.card { + box-sizing: border-box; + flex: 1; + display: flex; + flex-direction: column; + gap: 9px; + padding: 12px; + border-radius: 26px; + background: var(--color-surface); + color: var(--color-text); +} + +.tablet .card { + gap: 8px; + padding: 10px; + border-radius: 24px; +} + +/* A Side quest's card. */ +.sideQuestCard { + background: var(--color-accent-2-200); +} + +.cardPhoto { + width: 100%; + height: 110px; +} + +.tablet .cardPhoto { + height: 96px; +} + +.cardName { + margin: 0; + font-family: var(--font-heading); + font-size: 17px; + line-height: 1.15; + hyphens: auto; + overflow-wrap: anywhere; +} + +.tablet .cardName { + font-size: 16px; +} + +.cardMinutes, +.cardStartBy { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-800); +} + +.cardStartBy { + font-weight: 700; +} + +.sideQuestCard .cardMinutes, +.sideQuestCard .cardStartBy { + color: var(--color-accent-2-900); +} + +.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. */ +.cardFooter .swapButton { + margin: -14px; +} + +.cardFooter .cardTag { + margin-right: auto; + padding: 3px 8px; +} + +.swapButton > span { + background: transparent; + color: var(--color-neutral-700); +} + +.skeletonCard { + height: 300px; + border-radius: 26px; +} diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index d583bc8..96ce87e 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -1,5 +1,6 @@ import { QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' @@ -12,6 +13,7 @@ import { PlanPage } from './PlanPage.tsx' const THIS_WEEK = '/api/v1/plans/2026-10-05' const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28' const DRAFT = `${THIS_WEEK}/draft` const JOB = '/api/v1/jobs/j1' @@ -21,7 +23,7 @@ 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. +// to be ready for 5:30 pm dinner. Monday's tacos have a stored photo. function planBody(status: string, names = NAMES) { return { id: 'plan-1', @@ -35,7 +37,7 @@ function planBody(status: string, names = NAMES) { dinner: { id: `d${i}`, name: names[i], - photo: null, + photo: i === 0 ? '/media/photos/tacos-400.jpg' : null, minutes: i === 2 ? 240 : 30, method: i === 2 ? 'crockpot' : 'stovetop', origin: i === 2 || i === 5 ? 'side_quest' : 'regular', @@ -56,6 +58,31 @@ function draftJob(status: string, error: string | null = null, planId = 'plan-1' type Responder = (init: RequestInit | undefined) => Response | Promise +const QUEST_LOG = '/api/v1/dinners?status=quest_log' +const REGULARS = '/api/v1/dinners?status=regular' + +// 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)] }), +} + // stubServer answers "METHOD path" requests from responders, with /me and // the household settings; any other request fails the test. function stubServer(responders: Record) { @@ -94,7 +121,9 @@ async function renderPage() { return render( - + + + , ) @@ -423,32 +452,8 @@ describe('PlanPage', () => { }) 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() { @@ -681,3 +686,275 @@ describe('PlanPage', () => { }) }) }) + +describe.each([ + { 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 }) => { + beforeEach(() => mockViewport(width)) + + // column is the grid's column of a day, by its name: "Monday". + function column(day: string) { + return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day }) + } + // card is the card in a day's column, holding its dinner. + function card(day: string, dinnerName: string) { + return within(column(day)).getByText(dinnerName).parentElement! + } + + it('renders the plan manager: header, actions, sync status and the 7-column grid', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + expect(screen.getByText('Week 41 · Plan')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Previous week' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop') + + // Actions: the shopping list and Push to Calendar unavailable, saying + // why; Rebuild week outlined and working. + const shopping = screen.getByRole('button', { name: 'Shopping list' }) + expect(shopping).toHaveAttribute('aria-disabled', 'true') + expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.') + const push = screen.getByRole('button', { name: 'Push to Calendar' }) + expect(push).toHaveAttribute('aria-disabled', 'true') + expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.') + expect(declaredStyle(push).background).toBe('var(--color-accent)') + const rebuild = screen.getByRole('button', { name: 'Rebuild week' }) + expect(rebuild).toBeEnabled() + expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)') + fireEvent.click(shopping) + fireEvent.click(push) + expect(posts(fetchMock)).toEqual([]) + // Mobile's own pieces are not shown. + expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument() + expect(screen.queryByText('Draft')).not.toBeInTheDocument() + + expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument() + + // 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).gap).toBe('12px') + expect(within(grid).getAllByRole('listitem').map((li) => li.getAttribute('aria-label'))).toEqual([ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', + ]) + + // Monday: day label, the washed photo, the name in Bagel, 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)') + const photo = within(monday).getByRole('img', { hidden: true, name: 'Beef tacos' }) + expect(photo).toHaveAttribute('src', '/media/photos/tacos-400.jpg') + expect(declaredStyle(photo).height).toBe(photoHeight) + 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() + 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('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 () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), ...lists }) + await renderPage() + + const status = await screen.findByText('Synced to Google Calendar') + expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)') + }) + + if (layout === 'desktop') { + it('shows the Quest log and Regulars in the 290 px right panel', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const panel = screen.getByRole('complementary', { name: 'Quest log and Regulars' }) + expect(declaredStyle(panel).borderLeft).toBe('1px solid var(--color-divider)') + expect(declaredStyle(panel.parentElement!).gridTemplateColumns).toBe('minmax(0, 1fr) 290px') + expect(await within(panel).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument() + expect(await within(panel).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument() + expect(within(panel).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument() + + const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem') + expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy') + expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px') + // The 6-dot grip. + expect(orzo.lastElementChild!.children).toHaveLength(6) + + fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' })) + const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem') + expect(regulars).toHaveLength(2) + expect(within(regulars[0]!).getByText('Beef tacos').nextElementSibling).toHaveTextContent('Easy') + expect(within(regulars[1]!).getByText('Fish tacos').nextElementSibling).toHaveTextContent('Medium') + + expect(within(panel).getByRole('link', { name: 'Swipe more side quests' })).toHaveAttribute('href', '/discover') + expect(screen.queryByRole('region', { name: 'Quest log' })).not.toBeInTheDocument() + }) + } else { + it('shows the Quest log as a strip of chips under the grid', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists }) + await renderPage() + + const strip = screen.getByRole('region', { name: 'Quest log' }) + expect(await within(strip).findByRole('heading', { name: 'Quest log · 1' })).toBeInTheDocument() + expect(within(strip).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument() + const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem') + expect(chip).toHaveTextContent('Lemon orzo') + expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px') + // The strip follows the grid. + const grid = await screen.findByRole('list', { name: 'Dinners this week' }) + expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(screen.queryByRole('complementary')).not.toBeInTheDocument() + }) + } + + it('moves between weeks with the previous and next week buttons', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + expect(screen.getByText('Week 42 · Plan')).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument() + expect(screen.getByText('Week 40 · Plan')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + }) + + if (layout === 'desktop') { + it('comes back to this week with the "This week" pill', async () => { + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + }) + await renderPage() + + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + const thisWeek = screen.getByRole('button', { name: 'This week' }) + expect(declaredStyle(thisWeek).background).toBe('var(--color-surface)') + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'This week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + expect(await screen.findByText('Beef tacos')).toBeInTheDocument() + }) + + it("comes back to this week even when the plan opened on next week's", async () => { + vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York + stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + ...lists, + }) + await renderPage() + + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'This week' })) + expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument() + }) + } + + it("opens the swap sheet from a day's swap icon and swaps the dinner in", async () => { + const FRIDAY = `${THIS_WEEK}/days/2026-10-09` + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + ...lists, + [`PUT ${FRIDAY}`]: () => new Promise(() => undefined), + }) + await renderPage() + + fireEvent.click(within(await waitFor(() => column('Friday'))).getByRole('button', { name: 'Swap Friday' })) + const sheet = await screen.findByRole('dialog', { name: 'Swap Friday' }) + expect(within(sheet).getByText('Replaces Pizza night')).toBeInTheDocument() + // The card being swapped is ringed in grape. + expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)') + + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(declaredStyle(card('Friday', 'Pizza night')).boxShadow).toBe('') + + fireEvent.click(within(column('Friday')).getByRole('button', { name: 'Swap Friday' })) + const again = await screen.findByRole('dialog', { name: 'Swap Friday' }) + fireEvent.click(await within(again).findByRole('button', { name: /Lemon orzo/ })) + + // Optimistic: the sheet is closed and Friday is the Side quest, on teal. + 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(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. + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + }) + + it.each(['synced', 'changed'])('asks before rebuilding a %s plan, and sends confirm', async (status) => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' }) + fireEvent.click(within(dialog).getByRole('button', { name: 'Keep this plan' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(posts(fetchMock)).toEqual([]) + + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Rebuild week' })) + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {"confirm":true}`])) + expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7) + expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled() + }) + + it('rebuilds a draft at once', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), + [`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }), + [`GET ${JOB}`]: () => jsonResponse(200, draftJob('running')), + ...lists, + }) + await renderPage() + + await screen.findByText('Beef tacos') + fireEvent.click(screen.getByRole('button', { name: 'Rebuild week' })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])) + }) +}) diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 19519fc..083d422 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -1,14 +1,16 @@ import { useQueryClient } from '@tanstack/react-query' -import { ChevronLeft, ChevronRight, Plus } from 'lucide-react' -import { useEffect, useId, useMemo, useState } from 'react' +import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react' +import { useEffect, useId, useMemo, useState, type ReactNode } from 'react' import { useJob } from '../api/index.ts' import { Confirm } from '../settings/LeaveGuard.tsx' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' -import { Button } from '../ui/Button.tsx' +import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' +import { Button, type ButtonVariant } from '../ui/Button.tsx' import { EmptyState } from '../ui/EmptyState.tsx' import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { IconButton } from '../ui/IconButton.tsx' +import { Photo } from '../ui/Photo.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' import { @@ -23,8 +25,18 @@ import { type PlanStatus, type Swap, } from './api.ts' +import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx' import { SwapSheet } from './SwapSheet.tsx' -import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel } from './week.ts' +import { + addDays, + dayOfMonth, + defaultWeek, + localNow, + weekdayKicker, + weekdayName, + weekLabel, + weekStart as weekOf, +} from './week.ts' import styles from './PlanPage.module.css' // statusTags is the tag beside the week's title for each plan status. @@ -48,6 +60,17 @@ const draftFailures: Record = { // TODO(Feature 16): connect Push to Calendar once Google Calendar is. const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.' +// SHOPPING_HINT is why the shopping list is unavailable. +// TODO(Feature 17): open the Supply run, with its count, once it is built. +const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.' + +// syncStatuses is the plan manager's line on whether the plan is on Calendar. +const syncStatuses: Record = { + draft: 'Not pushed to Google Calendar yet', + synced: 'Synced to Google Calendar', + changed: 'Changes not pushed to Google Calendar', +} + // pushedToCalendar says whether drafting over a plan of this status needs // confirm: it was pushed to Calendar. function pushedToCalendar(status: PlanStatus): boolean { @@ -77,14 +100,50 @@ 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 +// 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 classes = [styles.card, day.is_new && styles.sideQuestCard, swapping && styles.swapping].filter(Boolean).join(' ') + return ( +
  • + +
    + {/* The card's name already says what the photo shows. */} + +

    {dinner.name}

    + {dinner.minutes !== null &&

    {dinner.minutes} min

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

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

    + )} +
    + {day.is_new ? 'Side quest' : 'Regular'} + + + +
    +
    +
  • + ) +} + // SkeletonTiles stands in for the seven days while the plan loads or the AI -// drafts it. -function SkeletonTiles({ label, children }: { label: string; children?: string }) { +// drafts it, as two columns of tiles on mobile and seven columns wider. +function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) { + const wide = layout !== 'mobile' return (
    -
    +
    {Array.from({ length: 7 }, (_, i) => ( - + ))}
    {children !== undefined &&

    {children}

    } @@ -97,7 +156,21 @@ function SkeletonTiles({ label, children }: { label: string; children?: string } // starts the draft_week job and shows skeleton tiles until the job ends and // the plan is read again. Rebuilding a plan pushed to Calendar asks first, // and sends confirm. A failed draft is shown with Retry; no plan is made up. -function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezone: string; onWeek: (weekStart: string) => void }) { +// +// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the +// unavailable Shopping list and Push to Calendar are actions in the header, +// over the plan's sync status and the 7-column grid. +function WeekBoard({ + weekStart, + timezone, + layout, + onWeek, +}: { + weekStart: string + timezone: string + layout: Breakpoint + onWeek: (weekStart: string) => void +}) { const queryClient = useQueryClient() const plan = usePlan(weekStart) const startDraft = useStartDraft() @@ -221,12 +294,16 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon if (plan.isError) { body = void plan.refetch()}>Couldn't load this week's plan. {plan.error.message} } else if (drafting) { - body = Drafting your week… + body = ( + + Drafting your week… + + ) } else if (resultError !== null) { // An unread draft shows no board: the plan read is not the draft. body = null } else if (plan.isPending) { - body = + body = } else if (plan.data === null) { body = ( @@ -236,49 +313,107 @@ 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 && ( -
    - -

    {ai_note}

    -
    - )} -
      - {days.map((day) => ( - openSwap(day.date)} - /> - ))} -
    • - -
    • -
    - - {swappingDay !== undefined && ( - day.dinner.id))} - onSwap={swapIn} - onClose={() => setSwappingDate(null)} - /> - )} - + const sheet = swappingDay !== undefined && ( + day.dinner.id))} + onSwap={swapIn} + onClose={() => setSwappingDate(null)} + /> ) + body = + layout !== 'mobile' ? ( + <> +
      + {days.map((day) => ( + openSwap(day.date)} + /> + ))} +
    + {sheet} + + ) : ( + <> + {ai_note !== null && ( +
    + +

    {ai_note}

    +
    + )} +
      + {days.map((day) => ( + openSwap(day.date)} + /> + ))} +
    • + +
    • +
    + + Looks good — push to Calendar + + {sheet} + + ) } const tag = plan.data ? statusTags[plan.data.status] : null - return ( - <> + const header = + layout !== 'mobile' ? ( +
    +
    +
    +

    {label.week} · Plan

    +

    {label.dates}

    +
    +
    + onWeek(addDays(weekStart, -7))}> + + + {layout === 'desktop' && ( + + )} + onWeek(addDays(weekStart, 7))}> + + +
    +
    +
    + {plan.data ? : } +
    + + + Shopping list + + + + + Push to Calendar + +
    +
    +
    + ) : (

    {label.week}

    @@ -296,6 +431,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
    + ) + return ( + <> + {header} {body} {failure} + {status === 'synced' && } + {syncStatuses[status]} +

    + ) +} + +// Unavailable is an action with nothing behind it yet (Push to Calendar, the +// shopping list): it stays focusable, so a tap or focus shows the tooltip +// saying why, above it or, at the top of the page, below. +function Unavailable({ + hint, + variant = 'primary', + below = false, + className, + children, +}: { + hint: string + variant?: ButtonVariant + below?: boolean + className?: string + children: ReactNode +}) { const hintId = useId() return ( -
    - - {PUSH_HINT} + {hint}
    ) } -// PlanPage is mock 1f, the mobile Draft board, on the week chosen with the -// previous / next week buttons. It opens on next week once this week's -// plan_draft time has come in the household's zone, otherwise on this week. +// PlanPage is the Draft board on the week chosen with the previous / next +// week buttons: mock 1f on mobile; from tablet up the plan manager, with the +// Quest log as a strip under the grid on tablet (2c) and the Quest log and +// Regulars in a right panel on desktop (2d). It opens on next week once this +// week's plan_draft time has come in the household's zone, otherwise on this +// week. The board keeps its place in the tree across layouts, so crossing a +// breakpoint keeps a draft being followed. export function PlanPage() { + const layout = useBreakpoint() const settings = useHouseholdSettings() const [week, setWeek] = useState(null) if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date())) @@ -343,9 +515,17 @@ export function PlanPage() { void settings.refetch()}>Couldn't load the household settings. {settings.error.message} ) } else if (week === null || !settings.data) { - content = + content = } else { - content = + content = } - return
    {content}
    + return ( +
    +
    + {content} + {layout === 'tablet' && } +
    + {layout === 'desktop' && } +
    + ) } diff --git a/web/src/plan/PlanPanel.module.css b/web/src/plan/PlanPanel.module.css new file mode 100644 index 0000000..9243782 --- /dev/null +++ b/web/src/plan/PlanPanel.module.css @@ -0,0 +1,171 @@ +/* Desktop's right panel (mock 2d) and tablet's Quest-log strip (mock 2c). */ +.panel { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 16px; + padding: 32px 22px; + border-left: 1px solid var(--color-divider); +} + +.hint { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + color: var(--color-neutral-800); +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; +} + +.row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + border-radius: 22px; + background: var(--color-surface); +} + +/* The 48 px washed photo; Photo applies the washed filter. The placeholder's + "dish photo" caption does not fit, so only its stripes show. */ +.rowPhoto { + width: 48px; + height: 48px; + font-size: 0; +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + font-family: var(--font-body); +} + +.rowName { + font-size: 14px; + font-weight: 700; + overflow-wrap: anywhere; +} + +.rowMeta { + font-size: 12px; + color: var(--color-neutral-700); +} + +/* Two columns of three 4 px dots. */ +.grip { + flex: none; + display: grid; + grid-template-columns: 4px 4px; + gap: 3px; +} + +.dot { + width: 4px; + height: 4px; + border-radius: var(--radius-pill); + background: var(--color-neutral-500); +} + +.more { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 44px; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-accent-700); + text-decoration: none; +} + +.more:hover { + text-decoration: underline; +} + +.loading { + display: flex; + flex-direction: column; + gap: 12px; +} + +.skeleton { + height: 68px; + border-radius: 22px; +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +/* Tablet: the Quest log as chips on a surface card under the grid. */ +.strip { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 18px; + border-radius: var(--radius-lg); + background: var(--color-surface); +} + +.stripHeader { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.stripTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 18px; + font-weight: 400; +} + +.stripHint { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.chips { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.chip { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 14px 5px 5px; + border-radius: var(--radius-pill); + background: var(--color-bg); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} + +/* As the row's photo, at 28 px. */ +.chipPhoto { + width: 28px; + height: 28px; + font-size: 0; +} diff --git a/web/src/plan/PlanPanel.tsx b/web/src/plan/PlanPanel.tsx new file mode 100644 index 0000000..0386d4c --- /dev/null +++ b/web/src/plan/PlanPanel.tsx @@ -0,0 +1,138 @@ +import { Compass } from 'lucide-react' +import { useState, type ReactNode } from 'react' +import { Link } from 'react-router' +import { useRegulars } from '../dinners/api.ts' +import { useQuestLog, type QuestLogDinner } from '../discover/api.ts' +import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { emptyLists, sourceNames, type Source } from './SwapSheet.tsx' +import styles from './PlanPanel.module.css' + +// DRAG_HINT says how a dinner is swapped in from the panel or the strip. +// TODO(5c2842a8): drag-and-drop and its keyboard alternative; until then the +// rows and chips are not draggable. +const DRAG_HINT = 'Drag a dinner onto a day to swap it in.' + +type List = ReturnType + +// ListBody is a list of the panel or the strip once it has loaded, or why it +// has not: its error with Retry, skeletons while loading, or that it is +// empty. +function ListBody({ source, list, children }: { source: Source; list: List; children: (dinners: QuestLogDinner[]) => ReactNode }) { + if (list.isError) { + return ( + void list.refetch()}> + Couldn't load your {sourceNames[source]}. {list.error.message} + + ) + } + if (list.isPending) { + return ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) + } + if (list.data.length === 0) return

    {emptyLists[source]}

    + return children(list.data) +} + +// Grip is the 6-dot drag handle of a panel row. +function Grip() { + return ( +
    - {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 ) -- 2.55.0