From dba4b9328056f16d28488ced2c06eb4f7502d31d Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Wed, 7 Oct 2026 14:22:48 -0400 Subject: [PATCH] Tablet and desktop plan manager (2c, 2d) (#119) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/discover/api.ts | 14 +- web/src/plan/PlanPage.module.css | 293 ++++++++++++++++++++- web/src/plan/PlanPage.test.tsx | 421 ++++++++++++++++++++++++++++-- web/src/plan/PlanPage.tsx | 351 ++++++++++++++++++++----- web/src/plan/PlanPanel.module.css | 171 ++++++++++++ web/src/plan/PlanPanel.tsx | 138 ++++++++++ web/src/plan/SwapSheet.tsx | 20 +- web/src/plan/api.test.tsx | 46 +++- web/src/plan/api.ts | 47 +++- 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 +- 14 files changed, 1403 insertions(+), 126 deletions(-) create mode 100644 web/src/plan/PlanPanel.module.css create mode 100644 web/src/plan/PlanPanel.tsx 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 8080ba8..f75cf9d 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,246 @@ 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. 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(112px, 1fr)); + gap: 12px; + margin: -8px; + padding: 8px; + overflow-x: auto; + 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; + overflow-wrap: break-word; +} + +.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; + 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. */ +.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..fe23d57 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' @@ -8,10 +9,11 @@ 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' +const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28' const DRAFT = `${THIS_WEEK}/draft` const JOB = '/api/v1/jobs/j1' @@ -21,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. +// 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', @@ -29,13 +32,13 @@ 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: { 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 +59,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 +122,9 @@ async function renderPage() { return render( - + + + , ) @@ -423,32 +453,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 +687,360 @@ 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 }) => { + 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(112px, 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, 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)') + 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) + // 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('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, 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 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') { + 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('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. + 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} {}`])) + }) +}) + +// 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 19519fc..f543439 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -1,14 +1,17 @@ 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 { methodLabels } from '../settings/RegularsSettingsPage.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 { @@ -19,12 +22,24 @@ import { useStartDraft, useSwapDay, type DraftResult, + type Plan, type PlanDay, + type PlanDinner, 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 +63,48 @@ 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', +} + +// 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 { @@ -77,14 +134,51 @@ 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, 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 ( +
  • + +
    + {/* The card's name already says what the photo shows. */} + +

    {dinner.name}

    + {time !== null &&

    {time}

    } + {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 +191,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 +329,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 +348,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 +466,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
    + ) + return ( + <> + {header} {body} {failure} 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)}`} +

    + ) +} + +// 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 +558,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 ( +