Add tablet and desktop plan manager (mocks 2c, 2d)
From 768 px up /plan renders the plan manager: "Week N · Plan" kicker, the week's dates, previous/next buttons and, on desktop, a "This week" pill; the sync status line; "Shopping list" and "Push to Calendar" unavailable with tooltips saying why (Features 17 and 16), and a working outlined "Rebuild week" that confirms over a plan pushed to Calendar; and a 7-column grid of day cards with washed photo, name, minutes, crock-pot start-by time, Regular / Side quest tag (Side quests on teal-200) and a swap icon that opens the existing swap sheet. Desktop adds the 290 px right panel: "Quest log · N | Regulars · N", the drag helper, rows with photo, name, difficulty and a 6-dot grip, and "Swipe more side quests". Tablet shows the Quest log as a chip strip under the grid. Dragging is presentational here; it lands in task 5c2842a8. The plan day now reads the photo and minutes the plan API already sends, and an optimistic swap carries them from the chosen dinner. Tag gains a neutral tone for "Regular". Maestro task 4657cba1-c709-420e-8b1e-18761e9e0e27. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,40 @@
|
||||
/* Plan · Draft board (mock 1f). */
|
||||
/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.board {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mobilePage {
|
||||
max-width: 440px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* The grid fills the screen below the header: the main area's 32 px top and
|
||||
bottom padding (AppShell) is taken back off the viewport height. */
|
||||
.tabletPage .board {
|
||||
min-height: calc(100dvh - 64px);
|
||||
}
|
||||
|
||||
/* Desktop: the board beside the 290 px right panel, which runs the full
|
||||
height of the screen to its right edge over the main area's padding. */
|
||||
.desktopPage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 290px;
|
||||
min-height: 100dvh;
|
||||
margin: -32px -36px -32px 0;
|
||||
}
|
||||
|
||||
.desktopPage .board {
|
||||
padding: 32px 36px 32px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -231,9 +258,10 @@
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* "Looks good — push to Calendar", unavailable until Feature 16: it keeps
|
||||
focus, so hovering, tapping or focusing it shows the tooltip. */
|
||||
.push {
|
||||
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
|
||||
the shopping list until Feature 17): it keeps focus, so hovering, tapping
|
||||
or focusing it shows the tooltip. */
|
||||
.unavailable {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -243,18 +271,24 @@
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.pushButton[aria-disabled='true'] {
|
||||
.unavailable [aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Unavailable, it neither darkens nor presses. */
|
||||
.push .pushButton[aria-disabled='true']:hover,
|
||||
.push .pushButton[aria-disabled='true']:active {
|
||||
.unavailable .primary[aria-disabled='true']:hover,
|
||||
.unavailable .primary[aria-disabled='true']:active {
|
||||
background: var(--color-accent);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.unavailable .outline[aria-disabled='true']:hover,
|
||||
.unavailable .outline[aria-disabled='true']:active {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
@@ -275,7 +309,244 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.push:hover .hint,
|
||||
.pushButton:focus + .hint {
|
||||
/* In the plan manager's header the tooltip opens below, at the action's
|
||||
right edge. */
|
||||
.below .hint {
|
||||
top: calc(100% + 8px);
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
width: 240px;
|
||||
max-width: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.unavailable:hover .hint,
|
||||
.unavailable [aria-disabled='true']:focus + .hint {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* The plan manager's header: kicker and title beside the week buttons, over
|
||||
the sync status and the actions. */
|
||||
.wideHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.titleBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.wideTitle {
|
||||
margin: 4px 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 40px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tabletPage .wideTitle {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.wideHeader .kicker {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wideHeader .weekNav {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.thisWeek {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thisWeek:hover {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* The status keeps to one line; the actions wrap under it when the row is
|
||||
too narrow for both. */
|
||||
.status {
|
||||
flex: 1 0 auto;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.synced {
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The week: seven columns, each a day label over its card. */
|
||||
.week {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.dayName {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.dayNumber {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tablet .card {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
/* A Side quest's card. */
|
||||
.sideQuestCard {
|
||||
background: var(--color-accent-2-200);
|
||||
}
|
||||
|
||||
.cardPhoto {
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.tablet .cardPhoto {
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 17px;
|
||||
line-height: 1.15;
|
||||
hyphens: auto;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tablet .cardName {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cardMinutes,
|
||||
.cardStartBy {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.cardStartBy {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sideQuestCard .cardMinutes,
|
||||
.sideQuestCard .cardStartBy {
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* The swap icon sits bare on the card, beside the tag: its 44 px hit box
|
||||
keeps the 16 px footprint of its icon, reaching over the card's padding
|
||||
and the tag. Where even that does not fit, it wraps under the tag, at the
|
||||
right. */
|
||||
.cardFooter .swapButton {
|
||||
margin: -14px;
|
||||
}
|
||||
|
||||
.cardFooter .cardTag {
|
||||
margin-right: auto;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.swapButton > span {
|
||||
background: transparent;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 300px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
+304
-27
@@ -1,5 +1,6 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
@@ -12,6 +13,7 @@ import { PlanPage } from './PlanPage.tsx'
|
||||
|
||||
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
||||
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
||||
const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28'
|
||||
const DRAFT = `${THIS_WEEK}/draft`
|
||||
const JOB = '/api/v1/jobs/j1'
|
||||
|
||||
@@ -21,7 +23,7 @@ const NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Crock pot tikka masala', 'C
|
||||
// planBody is the plan of the week of Oct 5 as GET /api/v1/plans/{weekStart}
|
||||
// sends it: Wednesday's crock-pot tikka masala and Saturday's bowls are
|
||||
// Quest-log picks, and the tikka masala must start by 1:30 pm New York time
|
||||
// to be ready for 5:30 pm dinner.
|
||||
// to be ready for 5:30 pm dinner. Monday's tacos have a stored photo.
|
||||
function planBody(status: string, names = NAMES) {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
@@ -35,7 +37,7 @@ function planBody(status: string, names = NAMES) {
|
||||
dinner: {
|
||||
id: `d${i}`,
|
||||
name: names[i],
|
||||
photo: null,
|
||||
photo: i === 0 ? '/media/photos/tacos-400.jpg' : null,
|
||||
minutes: i === 2 ? 240 : 30,
|
||||
method: i === 2 ? 'crockpot' : 'stovetop',
|
||||
origin: i === 2 || i === 5 ? 'side_quest' : 'regular',
|
||||
@@ -56,6 +58,31 @@ function draftJob(status: string, error: string | null = null, planId = 'plan-1'
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<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 +121,9 @@ async function renderPage() {
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<PlanPage />
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<PlanPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
@@ -423,32 +452,8 @@ describe('PlanPage', () => {
|
||||
})
|
||||
|
||||
describe('swap sheet', () => {
|
||||
const QUEST_LOG = '/api/v1/dinners?status=quest_log'
|
||||
const REGULARS = '/api/v1/dinners?status=regular'
|
||||
const FRIDAY = `${THIS_WEEK}/days/2026-10-09`
|
||||
|
||||
// dinner is a dinner as the dinners API lists it.
|
||||
function dinner(id: string, name: string, difficulty: string | null, minutes: number | null) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
method: 'stovetop',
|
||||
minutes_total: minutes,
|
||||
difficulty,
|
||||
is_favorite: false,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
}
|
||||
}
|
||||
|
||||
// The Quest log has lemon orzo; the Regulars are Monday's tacos, already
|
||||
// this week, and fish tacos.
|
||||
const lists = {
|
||||
[`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: [dinner('q1', 'Lemon orzo', 'easy', 25)] }),
|
||||
[`GET ${REGULARS}`]: () =>
|
||||
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
|
||||
}
|
||||
|
||||
// swappedBody is the plan as the server answers a swap of Friday to
|
||||
// lemon orzo, a Quest-log pick.
|
||||
function swappedBody() {
|
||||
@@ -681,3 +686,275 @@ describe('PlanPage', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe.each([
|
||||
{ width: 1180, layout: 'tablet', photoHeight: '96px', nameSize: '16px' },
|
||||
{ width: 1440, layout: 'desktop', photoHeight: '110px', nameSize: '17px' },
|
||||
])('PlanPage at $width px (mocks 2c, 2d)', ({ width, layout, photoHeight, nameSize }) => {
|
||||
beforeEach(() => mockViewport(width))
|
||||
|
||||
// column is the grid's column of a day, by its name: "Monday".
|
||||
function column(day: string) {
|
||||
return within(screen.getByRole('list', { name: 'Dinners this week' })).getByRole('listitem', { name: day })
|
||||
}
|
||||
// card is the card in a day's column, holding its dinner.
|
||||
function card(day: string, dinnerName: string) {
|
||||
return within(column(day)).getByText(dinnerName).parentElement!
|
||||
}
|
||||
|
||||
it('renders the plan manager: header, actions, sync status and the 7-column grid', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
expect(screen.getByText('Week 41 · Plan')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Previous week' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
|
||||
|
||||
// Actions: the shopping list and Push to Calendar unavailable, saying
|
||||
// why; Rebuild week outlined and working.
|
||||
const shopping = screen.getByRole('button', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
|
||||
const push = screen.getByRole('button', { name: 'Push to Calendar' })
|
||||
expect(push).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
|
||||
expect(declaredStyle(push).background).toBe('var(--color-accent)')
|
||||
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
||||
expect(rebuild).toBeEnabled()
|
||||
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
|
||||
fireEvent.click(shopping)
|
||||
fireEvent.click(push)
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
// Mobile's own pieces are not shown.
|
||||
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
|
||||
|
||||
// Seven columns, Monday to Sunday, 12 px apart.
|
||||
const grid = screen.getByRole('list', { name: 'Dinners this week' })
|
||||
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(7, minmax(0, 1fr))')
|
||||
expect(declaredStyle(grid).gap).toBe('12px')
|
||||
expect(within(grid).getAllByRole('listitem').map((li) => li.getAttribute('aria-label'))).toEqual([
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday',
|
||||
])
|
||||
|
||||
// Monday: day label, the washed photo, the name in Bagel, minutes and
|
||||
// the Regular tag, on the surface.
|
||||
expect(column('Monday')).toHaveTextContent(/^MON5/)
|
||||
const monday = card('Monday', 'Beef tacos')
|
||||
expect(declaredStyle(monday).background).toBe('var(--color-surface)')
|
||||
const photo = within(monday).getByRole('img', { hidden: true, name: 'Beef tacos' })
|
||||
expect(photo).toHaveAttribute('src', '/media/photos/tacos-400.jpg')
|
||||
expect(declaredStyle(photo).height).toBe(photoHeight)
|
||||
const name = within(monday).getByText('Beef tacos')
|
||||
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
|
||||
expect(declaredStyle(name).fontSize).toBe(nameSize)
|
||||
expect(within(monday).getByText('30 min')).toBeInTheDocument()
|
||||
expect(declaredStyle(within(monday).getByText('Regular')).background).toBe('var(--color-neutral-100)')
|
||||
expect(within(monday).queryByText(/Start by/)).not.toBeInTheDocument()
|
||||
|
||||
// Wednesday: a crock-pot Side quest on teal-200, with its start-by time.
|
||||
const wednesday = card('Wednesday', 'Crock pot tikka masala')
|
||||
expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-200)')
|
||||
expect(within(wednesday).getByText('240 min')).toBeInTheDocument()
|
||||
expect(within(wednesday).getByText('Start by 1:30 pm')).toBeInTheDocument()
|
||||
expect(within(wednesday).getByText('Side quest')).toBeInTheDocument()
|
||||
expect(within(column('Wednesday')).getByRole('button', { name: 'Swap Wednesday' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says a synced plan is on Google Calendar', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')), ...lists })
|
||||
await renderPage()
|
||||
|
||||
const status = await screen.findByText('Synced to Google Calendar')
|
||||
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
|
||||
})
|
||||
|
||||
if (layout === 'desktop') {
|
||||
it('shows the Quest log and Regulars in the 290 px right panel', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
|
||||
await renderPage()
|
||||
|
||||
const panel = screen.getByRole('complementary', { name: 'Quest log and Regulars' })
|
||||
expect(declaredStyle(panel).borderLeft).toBe('1px solid var(--color-divider)')
|
||||
expect(declaredStyle(panel.parentElement!).gridTemplateColumns).toBe('minmax(0, 1fr) 290px')
|
||||
expect(await within(panel).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
|
||||
expect(await within(panel).findByRole('radio', { name: 'Regulars · 2' })).toBeInTheDocument()
|
||||
expect(within(panel).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument()
|
||||
|
||||
const orzo = within(within(panel).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
|
||||
expect(within(orzo).getByText('Lemon orzo').nextElementSibling).toHaveTextContent('Easy')
|
||||
expect(declaredStyle(within(orzo).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('48px')
|
||||
// The 6-dot grip.
|
||||
expect(orzo.lastElementChild!.children).toHaveLength(6)
|
||||
|
||||
fireEvent.click(within(panel).getByRole('radio', { name: 'Regulars · 2' }))
|
||||
const regulars = within(within(panel).getByRole('list', { name: 'Regulars' })).getAllByRole('listitem')
|
||||
expect(regulars).toHaveLength(2)
|
||||
expect(within(regulars[0]!).getByText('Beef tacos').nextElementSibling).toHaveTextContent('Easy')
|
||||
expect(within(regulars[1]!).getByText('Fish tacos').nextElementSibling).toHaveTextContent('Medium')
|
||||
|
||||
expect(within(panel).getByRole('link', { name: 'Swipe more side quests' })).toHaveAttribute('href', '/discover')
|
||||
expect(screen.queryByRole('region', { name: 'Quest log' })).not.toBeInTheDocument()
|
||||
})
|
||||
} else {
|
||||
it('shows the Quest log as a strip of chips under the grid', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
|
||||
await renderPage()
|
||||
|
||||
const strip = screen.getByRole('region', { name: 'Quest log' })
|
||||
expect(await within(strip).findByRole('heading', { name: 'Quest log · 1' })).toBeInTheDocument()
|
||||
expect(within(strip).getByText('Drag a dinner onto a day to swap it in.')).toBeInTheDocument()
|
||||
const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem')
|
||||
expect(chip).toHaveTextContent('Lemon orzo')
|
||||
expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px')
|
||||
// The strip follows the grid.
|
||||
const grid = await screen.findByRole('list', { name: 'Dinners this week' })
|
||||
expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(screen.queryByRole('complementary')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET ${PREVIOUS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Week 42 · Plan')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Sep 28 – Oct 4' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Week 40 · Plan')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
if (layout === 'desktop') {
|
||||
it('comes back to this week with the "This week" pill', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
const thisWeek = screen.getByRole('button', { name: 'This week' })
|
||||
expect(declaredStyle(thisWeek).background).toBe('var(--color-surface)')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'This week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("comes back to this week even when the plan opened on next week's", async () => {
|
||||
vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'This week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
|
||||
it("opens the swap sheet from a day's swap icon and swaps the dinner in", async () => {
|
||||
const FRIDAY = `${THIS_WEEK}/days/2026-10-09`
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
...lists,
|
||||
[`PUT ${FRIDAY}`]: () => new Promise<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('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} {}`]))
|
||||
})
|
||||
})
|
||||
|
||||
+244
-64
@@ -1,14 +1,16 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import {
|
||||
@@ -23,8 +25,18 @@ import {
|
||||
type PlanStatus,
|
||||
type Swap,
|
||||
} from './api.ts'
|
||||
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
|
||||
import { SwapSheet } from './SwapSheet.tsx'
|
||||
import { addDays, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel } from './week.ts'
|
||||
import {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
weekLabel,
|
||||
weekStart as weekOf,
|
||||
} from './week.ts'
|
||||
import styles from './PlanPage.module.css'
|
||||
|
||||
// statusTags is the tag beside the week's title for each plan status.
|
||||
@@ -48,6 +60,17 @@ const draftFailures: Record<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',
|
||||
}
|
||||
|
||||
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||
// confirm: it was pushed to Calendar.
|
||||
function pushedToCalendar(status: PlanStatus): boolean {
|
||||
@@ -77,14 +100,50 @@ function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone:
|
||||
)
|
||||
}
|
||||
|
||||
// DayColumn is one day of the plan manager's 7-column grid: its day label
|
||||
// over a card with the dinner's washed photo, name, minutes, a crock-pot
|
||||
// dinner's start-by time in the household's zone, and its Regular or Side
|
||||
// quest tag, a Side quest's card in teal. The swap icon opens the swap
|
||||
// sheet; while that is open the card is swapping, ringed in grape.
|
||||
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
|
||||
const { dinner } = day
|
||||
const classes = [styles.card, day.is_new && styles.sideQuestCard, swapping && styles.swapping].filter(Boolean).join(' ')
|
||||
return (
|
||||
<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>
|
||||
{dinner.minutes !== null && <p className={styles.cardMinutes}>{dinner.minutes} min</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 +156,21 @@ function SkeletonTiles({ label, children }: { label: string; children?: string }
|
||||
// starts the draft_week job and shows skeleton tiles until the job ends and
|
||||
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
|
||||
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
|
||||
function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezone: string; onWeek: (weekStart: string) => void }) {
|
||||
//
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
|
||||
// unavailable Shopping list and Push to Calendar are actions in the header,
|
||||
// over the plan's sync status and the 7-column grid.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
layout,
|
||||
onWeek,
|
||||
}: {
|
||||
weekStart: string
|
||||
timezone: string
|
||||
layout: Breakpoint
|
||||
onWeek: (weekStart: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const plan = usePlan(weekStart)
|
||||
const startDraft = useStartDraft()
|
||||
@@ -221,12 +294,16 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
if (plan.isError) {
|
||||
body = <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 +313,107 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
} else {
|
||||
const { ai_note, days } = plan.data
|
||||
const swappingDay = days.find((day) => day.date === swappingDate)
|
||||
body = (
|
||||
<>
|
||||
{ai_note !== null && (
|
||||
<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 status={plan.data.status} /> : <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 +431,10 @@ function WeekBoard({ weekStart, timezone, onWeek }: { weekStart: string; timezon
|
||||
</IconButton>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
<Confirm
|
||||
@@ -312,27 +451,60 @@ 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.
|
||||
function SyncStatus({ status }: { status: PlanStatus }) {
|
||||
return (
|
||||
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
|
||||
{status === 'synced' && <Icon icon={Check} size="inline" />}
|
||||
{syncStatuses[status]}
|
||||
</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 +515,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,12 @@ 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 },
|
||||
fromQuestLog: source === 'quest_log',
|
||||
})
|
||||
}
|
||||
trailing={already && <Tag>Already this week</Tag>}
|
||||
/>
|
||||
|
||||
@@ -25,7 +25,7 @@ function planBody(changes: Record<number, { id: string; name: string; is_new?: b
|
||||
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, origin: 'regular' },
|
||||
is_new: change?.is_new ?? false,
|
||||
start_by: change?.start_by ?? null,
|
||||
}
|
||||
@@ -74,8 +74,8 @@ 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 }, fromQuestLog: true }
|
||||
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null }, fromQuestLog: false }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
+22
-5
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf } from '../dinners/api.ts'
|
||||
import { oneOf, photoId } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
|
||||
export const PLANS_PATH = '/api/v1/plans'
|
||||
@@ -22,12 +22,16 @@ 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, and minutes its minutes from prep to table, when known.
|
||||
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | 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
|
||||
}
|
||||
@@ -57,12 +61,19 @@ 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,
|
||||
}
|
||||
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||
}
|
||||
|
||||
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
@@ -134,12 +145,18 @@ 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.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