Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #119.
This commit is contained in:
@@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
|
||||
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
||||
|
||||
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
|
||||
// it: photo, name, difficulty and minutes.
|
||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
|
||||
// it: photo, name, difficulty and minutes, with the method a swap carries
|
||||
// onto the plan.
|
||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
|
||||
|
||||
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
||||
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
||||
@@ -127,8 +128,8 @@ export function useQuestLog() {
|
||||
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||
return body.dinners.map((dinner) => {
|
||||
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
|
||||
return { id, name, photo_id, difficulty, minutes_total }
|
||||
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
|
||||
return { id, name, photo_id, difficulty, method, minutes_total }
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -250,13 +251,14 @@ export function useDecide() {
|
||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
|
||||
? dinners
|
||||
: // The deck does not say the minutes; the Quest log read after the
|
||||
// decision does.
|
||||
: // The deck does not say the method or minutes; the Quest log
|
||||
// read after the decision does.
|
||||
dinners.concat({
|
||||
id: quest.dinner_id,
|
||||
name: quest.name,
|
||||
photo_id: quest.photo_id,
|
||||
difficulty: quest.difficulty,
|
||||
method: null,
|
||||
minutes_total: null,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+392
-29
@@ -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<Response>
|
||||
|
||||
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<string, Responder>) {
|
||||
@@ -94,7 +122,9 @@ async function renderPage() {
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<PlanPage />
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<PlanPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
@@ -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<Response>(() => 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)
|
||||
})
|
||||
})
|
||||
|
||||
+287
-64
@@ -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<string, string> = {
|
||||
// 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<PlanStatus, string> = {
|
||||
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 (
|
||||
<li className={styles.column} aria-label={weekdayName(day.date)}>
|
||||
<p className={styles.dayLabel} aria-hidden="true">
|
||||
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
|
||||
<span className={styles.dayNumber}>{dayOfMonth(day.date)}</span>
|
||||
</p>
|
||||
<div className={classes}>
|
||||
{/* The card's name already says what the photo shows. */}
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="18px" sizes="160px" className={styles.cardPhoto} />
|
||||
</span>
|
||||
<p className={styles.cardName}>{dinner.name}</p>
|
||||
{time !== null && <p className={styles.cardMinutes}>{time}</p>}
|
||||
{day.start_by !== null && (
|
||||
<p className={styles.cardStartBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
|
||||
)}
|
||||
<div className={styles.cardFooter}>
|
||||
<Tag tone={day.is_new ? 'grape' : 'neutral'} className={styles.cardTag}>{day.is_new ? 'Side quest' : 'Regular'}</Tag>
|
||||
<IconButton label={`Swap ${weekdayName(day.date)}`} className={styles.swapButton} onClick={onSwap}>
|
||||
<Icon icon={RefreshCw} size="inline" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<section aria-busy="true" aria-label={label} className={styles.loading}>
|
||||
<div className={styles.grid}>
|
||||
<div className={wide ? `${styles.week} ${styles[layout]}` : styles.grid}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Skeleton key={i} data-testid="skeleton-tile" className={styles.skeletonTile} />
|
||||
<Skeleton key={i} data-testid="skeleton-tile" className={wide ? styles.skeletonCard : styles.skeletonTile} />
|
||||
))}
|
||||
</div>
|
||||
{children !== undefined && <p className={styles.drafting}>{children}</p>}
|
||||
@@ -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 = <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load this week's plan. {plan.error.message}</ErrorBanner>
|
||||
} else if (drafting) {
|
||||
body = <SkeletonTiles label="Drafting this week">Drafting your week…</SkeletonTiles>
|
||||
body = (
|
||||
<SkeletonTiles label="Drafting this week" layout={layout}>
|
||||
Drafting your week…
|
||||
</SkeletonTiles>
|
||||
)
|
||||
} else if (resultError !== null) {
|
||||
// An unread draft shows no board: the plan read is not the draft.
|
||||
body = null
|
||||
} else if (plan.isPending) {
|
||||
body = <SkeletonTiles label="Plan loading" />
|
||||
body = <SkeletonTiles label="Plan loading" layout={layout} />
|
||||
} else if (plan.data === null) {
|
||||
body = (
|
||||
<EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}>
|
||||
@@ -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 && (
|
||||
<div className={styles.assistant}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
Q
|
||||
</span>
|
||||
<p className={styles.bubble}>{ai_note}</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.grid} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayTile
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
<li className={styles.rebuildCell}>
|
||||
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
||||
<Icon icon={Plus} size="inline" />
|
||||
Rebuild week
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<PushToCalendar />
|
||||
{swappingDay !== undefined && (
|
||||
<SwapSheet
|
||||
day={swappingDay}
|
||||
planned={new Set(days.map((day) => day.dinner.id))}
|
||||
onSwap={swapIn}
|
||||
onClose={() => setSwappingDate(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
const sheet = swappingDay !== undefined && (
|
||||
<SwapSheet
|
||||
day={swappingDay}
|
||||
planned={new Set(days.map((day) => day.dinner.id))}
|
||||
onSwap={swapIn}
|
||||
onClose={() => setSwappingDate(null)}
|
||||
/>
|
||||
)
|
||||
body =
|
||||
layout !== 'mobile' ? (
|
||||
<>
|
||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{sheet}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ai_note !== null && (
|
||||
<div className={styles.assistant}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
Q
|
||||
</span>
|
||||
<p className={styles.bubble}>{ai_note}</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.grid} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayTile
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
<li className={styles.rebuildCell}>
|
||||
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
||||
<Icon icon={Plus} size="inline" />
|
||||
Rebuild week
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
|
||||
Looks good — push to Calendar
|
||||
</Unavailable>
|
||||
{sheet}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tag = plan.data ? statusTags[plan.data.status] : null
|
||||
return (
|
||||
<>
|
||||
const header =
|
||||
layout !== 'mobile' ? (
|
||||
<header className={styles.wideHeader}>
|
||||
<div className={styles.titleBar}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>{label.week} · Plan</p>
|
||||
<h1 className={styles.wideTitle}>{label.dates}</h1>
|
||||
</div>
|
||||
<div className={styles.weekNav}>
|
||||
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
|
||||
<Icon icon={ChevronLeft} size="inline" />
|
||||
</IconButton>
|
||||
{layout === 'desktop' && (
|
||||
<button type="button" className={styles.thisWeek} onClick={() => onWeek(weekOf(localNow(timezone, new Date()).date))}>
|
||||
This week
|
||||
</button>
|
||||
)}
|
||||
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
|
||||
<Icon icon={ChevronRight} size="inline" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actionBar}>
|
||||
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
|
||||
<div className={styles.actions}>
|
||||
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
Shopping list
|
||||
</Unavailable>
|
||||
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
|
||||
<Icon icon={RefreshCw} size="inline" />
|
||||
Rebuild week
|
||||
</Button>
|
||||
<Unavailable hint={PUSH_HINT} below className={styles.action}>
|
||||
<Icon icon={CalendarDays} size="inline" />
|
||||
Push to Calendar
|
||||
</Unavailable>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>{label.week}</p>
|
||||
@@ -296,6 +466,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
<Confirm
|
||||
@@ -312,27 +486,68 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
)
|
||||
}
|
||||
|
||||
// PushToCalendar is "Looks good — push to Calendar", unavailable until
|
||||
// Google Calendar can be connected: it stays focusable, so a tap or focus
|
||||
// shows the tooltip saying why.
|
||||
function PushToCalendar() {
|
||||
// SyncStatus is the plan manager's status line: whether the plan is on
|
||||
// Google Calendar and, once synced, how long ago it was pushed, kept current
|
||||
// minute by minute.
|
||||
function SyncStatus({ plan }: { plan: Plan }) {
|
||||
const { status, calendar_synced_at: syncedAt } = plan
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
return (
|
||||
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
|
||||
{status === 'synced' && <Icon icon={Check} size="inline" />}
|
||||
{syncStatuses[status]}
|
||||
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={styles.push}>
|
||||
<Button className={styles.pushButton} aria-disabled="true" aria-describedby={hintId}>
|
||||
Looks good — push to Calendar
|
||||
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
|
||||
<Button
|
||||
variant={variant}
|
||||
className={[className, styles[variant]].filter(Boolean).join(' ')}
|
||||
aria-disabled="true"
|
||||
aria-describedby={hintId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<span id={hintId} role="tooltip" className={styles.hint}>
|
||||
{PUSH_HINT}
|
||||
{hint}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<string | null>(null)
|
||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||
@@ -343,9 +558,17 @@ export function PlanPage() {
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
)
|
||||
} else if (week === null || !settings.data) {
|
||||
content = <SkeletonTiles label="Plan loading" />
|
||||
content = <SkeletonTiles label="Plan loading" layout={layout} />
|
||||
} else {
|
||||
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} onWeek={setWeek} />
|
||||
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
|
||||
}
|
||||
return <div className={styles.page}>{content}</div>
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||
<div className={styles.board}>
|
||||
{content}
|
||||
{layout === 'tablet' && <QuestLogStrip />}
|
||||
</div>
|
||||
{layout === 'desktop' && <PlanPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof useQuestLog>
|
||||
|
||||
// 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 (
|
||||
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (list.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.loading}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeleton} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (list.data.length === 0) return <p className={styles.empty}>{emptyLists[source]}</p>
|
||||
return children(list.data)
|
||||
}
|
||||
|
||||
// Grip is the 6-dot drag handle of a panel row.
|
||||
function Grip() {
|
||||
return (
|
||||
<span className={styles.grip} aria-hidden="true">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<span key={i} className={styles.dot} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// PlanPanel is desktop's right panel (mock 2d): the Quest log and the
|
||||
// Regulars, segmented "Quest log · N | Regulars · N", each dinner with its
|
||||
// 48 px photo, name, difficulty and grip, and a link to swipe more Side
|
||||
// quests.
|
||||
export function PlanPanel() {
|
||||
const questLog = useQuestLog()
|
||||
const regulars = useRegulars()
|
||||
const [source, setSource] = useState<Source>('quest_log')
|
||||
const lists = { quest_log: questLog, regulars }
|
||||
|
||||
const label = (s: Source) => {
|
||||
const dinners = lists[s].data
|
||||
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
||||
}
|
||||
|
||||
return (
|
||||
<aside aria-label="Quest log and Regulars" className={styles.panel}>
|
||||
<Segmented
|
||||
label="Dinners to swap in"
|
||||
options={[
|
||||
{ value: 'quest_log', label: label('quest_log') },
|
||||
{ value: 'regulars', label: label('regulars') },
|
||||
]}
|
||||
value={source}
|
||||
onValueChange={setSource}
|
||||
/>
|
||||
<p className={styles.hint}>{DRAG_HINT}</p>
|
||||
<ListBody source={source} list={lists[source]}>
|
||||
{(dinners) => (
|
||||
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||
{dinners.map((dinner) => (
|
||||
<li key={dinner.id} className={styles.row}>
|
||||
{/* The row's name already says what the photo shows. */}
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
|
||||
</span>
|
||||
<span className={styles.rowText}>
|
||||
<span className={styles.rowName}>{dinner.name}</span>
|
||||
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
|
||||
</span>
|
||||
<Grip />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ListBody>
|
||||
<Link to="/discover" className={styles.more}>
|
||||
<Icon icon={Compass} size="inline" />
|
||||
Swipe more side quests
|
||||
</Link>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each
|
||||
// dinner a chip with its 28 px photo and name.
|
||||
export function QuestLogStrip() {
|
||||
const questLog = useQuestLog()
|
||||
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
|
||||
return (
|
||||
<section aria-label={sourceNames.quest_log} className={styles.strip}>
|
||||
<div className={styles.stripHeader}>
|
||||
<h2 className={styles.stripTitle}>{title}</h2>
|
||||
<p className={styles.stripHint}>{DRAG_HINT}</p>
|
||||
</div>
|
||||
<ListBody source="quest_log" list={questLog}>
|
||||
{(dinners) => (
|
||||
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
|
||||
{dinners.map((dinner) => (
|
||||
<li key={dinner.id} className={styles.chip}>
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
|
||||
</span>
|
||||
{dinner.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ListBody>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -13,11 +13,12 @@ import type { PlanDay, Swap } from './api.ts'
|
||||
import { weekdayName } from './week.ts'
|
||||
import styles from './SwapSheet.module.css'
|
||||
|
||||
type Source = 'quest_log' | 'regulars'
|
||||
// Source is a list a dinner is swapped in from.
|
||||
export type Source = 'quest_log' | 'regulars'
|
||||
|
||||
const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
|
||||
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
|
||||
|
||||
const emptyLists: Record<Source, string> = {
|
||||
export const emptyLists: Record<Source, string> = {
|
||||
quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.',
|
||||
regulars: 'You have no Regulars yet.',
|
||||
}
|
||||
@@ -86,7 +87,18 @@ export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
|
||||
onClick={
|
||||
already
|
||||
? undefined
|
||||
: () => onSwap({ date: day.date, dinner: { id: dinner.id, name: dinner.name }, fromQuestLog: source === 'quest_log' })
|
||||
: () =>
|
||||
onSwap({
|
||||
date: day.date,
|
||||
dinner: {
|
||||
id: dinner.id,
|
||||
name: dinner.name,
|
||||
photo_id: dinner.photo_id,
|
||||
minutes: dinner.minutes_total,
|
||||
method: dinner.method,
|
||||
},
|
||||
fromQuestLog: source === 'quest_log',
|
||||
})
|
||||
}
|
||||
trailing={already && <Tag>Already this week</Tag>}
|
||||
/>
|
||||
|
||||
@@ -21,11 +21,12 @@ function planBody(changes: Record<number, { id: string; name: string; is_new?: b
|
||||
week_start: WEEK,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null as string | null,
|
||||
days: DAYS.map((date, i) => {
|
||||
const change = changes[i]
|
||||
return {
|
||||
date,
|
||||
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}` },
|
||||
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null as string | null, origin: 'regular' },
|
||||
is_new: change?.is_new ?? false,
|
||||
start_by: change?.start_by ?? null,
|
||||
}
|
||||
@@ -74,13 +75,52 @@ async function renderSwaps() {
|
||||
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
|
||||
}
|
||||
|
||||
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo' }, fromQuestLog: true }
|
||||
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos' }, fromQuestLog: false }
|
||||
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null, method: null }, fromQuestLog: true }
|
||||
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null, method: null }, fromQuestLog: false }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// renderPlan renders the week's plan read over a query client.
|
||||
async function renderPlan() {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(() => usePlan(WEEK), { wrapper })
|
||||
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
||||
return hook.result.current
|
||||
}
|
||||
|
||||
describe('usePlan', () => {
|
||||
it("reads each dinner's method and when the plan was pushed to Calendar", async () => {
|
||||
const body = planBody()
|
||||
body.days[2]!.dinner.method = 'crockpot'
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, { ...body, status: 'synced', calendar_synced_at: '2026-10-07T15:58:00Z' }) })
|
||||
const plan = await renderPlan()
|
||||
|
||||
expect(plan.data?.calendar_synced_at).toEqual(new Date('2026-10-07T15:58:00Z'))
|
||||
expect(plan.data?.days.map((day) => day.dinner.method)).toEqual([null, null, 'crockpot', null, null, null, null])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||
])('refuses a plan with %s', async (_, change, said) => {
|
||||
const body = planBody()
|
||||
change(body)
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, body) })
|
||||
const plan = await renderPlan()
|
||||
|
||||
expect(plan.error?.message).toContain(said)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSwapDay', () => {
|
||||
// Friday's swap is sent first, then Monday's; the server answers Monday's
|
||||
// with both swapped, Friday's with only Friday.
|
||||
|
||||
+40
-7
@@ -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 } 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'
|
||||
@@ -22,24 +22,31 @@ export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
|
||||
// synced plan makes it changed.
|
||||
export type PlanStatus = (typeof PLAN_STATUSES)[number]
|
||||
|
||||
// PlanDinner is a planned dinner: photo_id is its stored photo's id, from
|
||||
// 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
|
||||
// dinner of known minutes, the instant it must start by.
|
||||
export type PlanDay = {
|
||||
date: string
|
||||
dinner: { id: string; name: string }
|
||||
dinner: PlanDinner
|
||||
is_new: boolean
|
||||
start_by: string | null
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -57,12 +64,30 @@ function parseDay(where: string, body: unknown): PlanDay {
|
||||
!isRecord(body.dinner) ||
|
||||
typeof body.dinner.id !== 'string' ||
|
||||
typeof body.dinner.name !== 'string' ||
|
||||
(body.dinner.minutes !== null && !Number.isInteger(body.dinner.minutes)) ||
|
||||
typeof body.is_new !== 'boolean' ||
|
||||
(body.start_by !== null && typeof body.start_by !== 'string')
|
||||
) {
|
||||
throw new Error(`${where}: response has a day that is not a plan day`)
|
||||
}
|
||||
return { date: body.date, dinner: { id: body.dinner.id, name: body.dinner.name }, is_new: body.is_new, start_by: body.start_by }
|
||||
const dinner: PlanDinner = {
|
||||
id: body.dinner.id,
|
||||
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 {
|
||||
@@ -81,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,
|
||||
}
|
||||
@@ -134,12 +160,19 @@ export function useStartDraft() {
|
||||
|
||||
// Swap is one day's swap: the day, and the dinner chosen for it with whether
|
||||
// it was chosen from the Quest log.
|
||||
export type Swap = { date: string; dinner: { id: string; name: string }; fromQuestLog: boolean }
|
||||
export type Swap = { date: string; dinner: PlanDinner; fromQuestLog: boolean }
|
||||
|
||||
// sameDay says whether two plan days are the same in every field.
|
||||
function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||
return (
|
||||
a.date === b.date && a.dinner.id === b.dinner.id && a.dinner.name === b.dinner.name && a.is_new === b.is_new && a.start_by === b.start_by
|
||||
a.date === b.date &&
|
||||
a.dinner.id === b.dinner.id &&
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
@@ -36,6 +36,12 @@ describe('week helpers', () => {
|
||||
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
||||
})
|
||||
|
||||
it('reads the day of the month of a date', () => {
|
||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||
expect(() => dayOfMonth('2026-10-32')).toThrow('not a real date')
|
||||
})
|
||||
|
||||
it('refuses a week start that is not a Monday, and a date that is not real', () => {
|
||||
expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday')
|
||||
expect(() => addDays('2026-02-30', 1)).toThrow('not a real date')
|
||||
|
||||
@@ -40,6 +40,11 @@ export function weekdayKicker(date: string): string {
|
||||
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
|
||||
}
|
||||
|
||||
// dayOfMonth is date's day of the month: 1 to 31.
|
||||
export function dayOfMonth(date: string): number {
|
||||
return midnightUTC(date).getUTCDate()
|
||||
}
|
||||
|
||||
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' })
|
||||
|
||||
// weekdayName is date's day in full: "Monday" to "Sunday".
|
||||
|
||||
@@ -19,3 +19,8 @@
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,9 @@ describe('Tag', () => {
|
||||
render(<Tag tone="teal">Synced</Tag>)
|
||||
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
|
||||
})
|
||||
|
||||
it('supports the neutral tone', () => {
|
||||
render(<Tag tone="neutral">Regular</Tag>)
|
||||
expect(screen.getByText('Regular')).toHaveClass(styles.neutral!)
|
||||
})
|
||||
})
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Tag.module.css'
|
||||
|
||||
export type TagTone = 'grape' | 'teal'
|
||||
export type TagTone = 'grape' | 'teal' | 'neutral'
|
||||
|
||||
export type TagProps = ComponentPropsWithRef<'span'> & {
|
||||
tone?: TagTone
|
||||
}
|
||||
|
||||
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
|
||||
// Static label such as "Draft", "Side quest", "Synced" or "Regular". Not
|
||||
// interactive.
|
||||
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
|
||||
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
|
||||
return <span className={classes} {...rest} />
|
||||
|
||||
Reference in New Issue
Block a user