Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 cca6a82810 Keep a dropped dinner's name immutable and 404 a never-checked dinner
CI / ci (push) Successful in 3m37s
CI / ci (pull_request) Successful in 3m14s
R-01: a Rotation check decided drop now records the dinner's name at the
decision in rotation_reviews.name (migration 0014, a table rebuild as 0012
did for suggestions; checks dropped before it take their dinner's name).
ListPassedNames and ListRecentPassedNames read that name instead of the
mutable dinners.name, so renaming the archived dinner no longer lets the
dropped dish back into a Side quest batch. A CHECK keeps a name on drops
only.

R-02: deciding on a dinner that has never had a Rotation check is now
ErrReviewNotFound, 404 not_found; a dinner whose checks are all decided,
such as a second decision, stays 409 no_pending_review.

Tests: TestLaterBatchNeverResuggestsRenamedDrop (drop, rename, next batch
still refuses the original name), TestMigrateRotationReviewName (0013 to
0014 upgrade and constraints), TestPostRotationReviewNeverChecked (404),
and TestDecideReview/TestDecideReviewRefused updated.

Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:05 -04:00
dev_agentandClaude Opus 5.5 f97b4c7b8c Add the Rotation check: MarkTried and keep/drop decisions
CI / ci (push) Successful in 3m28s
CI / ci (pull_request) Successful in 3m7s
Per D-021, plan.MarkTried(date) moves every Quest log dinner planned on
a household-local date (any plan status) to tried and opens a pending
rotation_reviews row; a dinner already checked for the date is left as
it is, so it is idempotent and never re-marks a dinner returned to the
Quest log.

GET /api/v1/rotation-reviews lists the pending checks as
{dinner {id, name, photo}, plan_date}. POST
/api/v1/dinners/{id}/rotation-review {decision} applies keep (Regular,
origin kept), drop ("Not for us", archived) or not_made ("We didn't
make it", back to the Quest log), closing the check with decided_at and
the session's updated_by; a second decision is 409 no_pending_review.

A dropped dinner's name joins the passed names: ListDeclinedSuggestionNames
and ListRecentDeclinedNames become ListPassedNames and
ListRecentPassedNames, which also read Rotation checks decided drop, so
the Side quest dedupe and household context never suggest it again.

A static test guards that keep is the only path to Regular: every query
writing dinners.status and every use of the regular status is reviewed.

Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:14:01 -04:00
11 changed files with 20 additions and 1299 deletions
+18 -19
View File
@@ -30,7 +30,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/plan': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
@@ -166,19 +165,19 @@ describe('App first-run redirect', () => {
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
@@ -186,12 +185,12 @@ describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/quick')
renderAt('/plan')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
@@ -259,7 +258,7 @@ describe('App offline bootstrap', () => {
})
it.each([
['/quick', 'Quick dinners'],
['/plan', 'Plan'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
@@ -283,7 +282,7 @@ describe('App offline bootstrap', () => {
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
).mockRejectedValue(new TypeError('Failed to fetch')),
)
renderAt('/quick')
renderAt('/plan')
await vi.waitFor(() => expect(failMe).toBeDefined())
onLine = false
act(() => {
@@ -291,7 +290,7 @@ describe('App offline bootstrap', () => {
})
act(() => failMe())
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -300,15 +299,15 @@ describe('App offline bootstrap', () => {
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
})
it('reports the failure offline when this device never verified a session', async () => {
goOffline()
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
@@ -317,12 +316,12 @@ describe('App offline bootstrap', () => {
it('reports the failure offline after the session was refused', async () => {
await visitOnline()
stubFetch(errorResponse(401, 'unauthenticated'))
const { location, unmount } = renderAt('/quick')
const { location, unmount } = renderAt('/plan')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
@@ -330,13 +329,13 @@ describe('App offline bootstrap', () => {
it('loads /me again on reconnect and refuses writes until it has', async () => {
await visitOnline()
goOffline()
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path="/quick">
<InDataRouter path="/plan">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
@@ -351,6 +350,6 @@ describe('App offline bootstrap', () => {
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
-2
View File
@@ -7,7 +7,6 @@ import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { PlanPage } from './plan/PlanPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
@@ -37,7 +36,6 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
-10
View File
@@ -225,14 +225,4 @@ describe('network status', () => {
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
expect(api.network.offline()).toBe(false)
})
it('says whether a read is the service worker\'s cached copy', async () => {
const cached = jsonResponse(200, { id: '01J9ZR' })
cached.headers.set(cacheFallbackHeader, '1')
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: true })
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: false })
})
})
+1 -19
View File
@@ -43,18 +43,11 @@ export type ApiRequest = {
body?: unknown
}
// ApiRead is a GET's decoded response body, and whether the service worker
// answered it from its cache because the server could not be reached in time.
export type ApiRead<T> = { body: T; cached: boolean }
export type ApiClient = {
// apiFetch sends a same-origin JSON request to path, which must be under
// /api/v1/, and resolves with the decoded response body, or rejects with an
// ApiError.
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
// apiRead is apiFetch's GET that also says whether the body is the service
// worker's cached copy rather than the server's answer.
apiRead<T>(path: string): Promise<ApiRead<T>>
// loadMe fetches GET /api/v1/me and caches its CSRF token.
loadMe(): Promise<Me>
// forbidden reports whether a request was refused because the signed-in
@@ -121,16 +114,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
}
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
return (await exchange<T>(path, request)).body
}
function apiRead<T>(path: string): Promise<ApiRead<T>> {
return exchange<T>(path, {})
}
// exchange sends request to path and decodes the response, noting whether
// it is the service worker's cached copy.
async function exchange<T>(path: string, request: ApiRequest): Promise<ApiRead<T>> {
const method = request.method ?? 'GET'
const url = apiURL(location.origin, method, path)
// fetch refuses a GET with a body by rejecting with a TypeError, the same
@@ -148,7 +131,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
response = await send(method, url, request.body)
if (response.status === 403) throw await parseForbidden(method, path, response)
}
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
return decode<T>(method, path, response)
}
// parseForbidden reads a 403. Any API error other than csrf means the
@@ -196,7 +179,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
return {
apiFetch,
apiRead,
loadMe,
forbidden: () => forbidden,
subscribe(listener) {
+1 -1
View File
@@ -1,5 +1,5 @@
export { ApiProvider, useApi } from './ApiContext.tsx'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { ApiError } from './errors.ts'
export { createQueryClient, shouldRetry } from './queryClient.ts'
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
-242
View File
@@ -1,242 +0,0 @@
/* Plan · Draft board (mock 1f). */
.page {
box-sizing: border-box;
max-width: 440px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 8px;
}
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
}
.heading {
min-width: 0;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.titleRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 26px;
font-weight: 400;
line-height: 1.1;
}
.weekNav {
flex: none;
display: flex;
gap: 8px;
}
/* The assistant: a 32 px grape "Q" avatar beside its note. */
.assistant {
display: flex;
align-items: flex-start;
gap: 10px;
}
.avatar {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-pill);
background: var(--color-accent);
color: var(--on-accent);
font-family: var(--font-heading);
font-size: 16px;
line-height: 1;
}
.bubble {
margin: 0;
padding: 12px 14px;
border-radius: 4px 18px 18px 18px;
background: var(--color-surface);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
}
/* Two columns of 86 px day tiles; the 8th cell is "+ Rebuild week". */
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.tile {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 6px;
min-height: 86px;
padding: 12px;
border-radius: 22px;
background: var(--color-surface);
color: var(--color-text);
}
/* A Quest-log pick. */
.newTile {
background: var(--color-accent-2-300);
}
.dayKicker {
margin: 0;
font-family: var(--font-body);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-neutral-700);
}
.newTile .dayKicker {
color: var(--color-accent-2-900);
}
.dinner {
margin: 0;
font-family: var(--font-heading);
font-size: 15px;
font-weight: 400;
line-height: 1.15;
overflow-wrap: anywhere;
}
.startBy {
margin: 4px 0 0;
font-family: var(--font-body);
font-size: 11px;
font-weight: 700;
color: var(--color-neutral-700);
}
.newTile .startBy {
color: var(--color-accent-2-900);
}
.rebuildCell {
display: flex;
}
.rebuild {
box-sizing: border-box;
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 86px;
padding: 12px;
border: 2px dashed var(--color-neutral-400);
border-radius: 22px;
background: transparent;
color: var(--color-neutral-700);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.rebuild:hover {
background: var(--color-neutral-100);
}
.loading {
display: flex;
flex-direction: column;
gap: 12px;
}
.skeletonTile {
height: 86px;
border-radius: 22px;
}
.drafting {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
text-align: center;
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 {
position: relative;
display: flex;
flex-direction: column;
}
.pushButton {
min-height: 50px;
}
.pushButton[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 {
background: var(--color-accent);
transform: none;
}
.hint {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--color-text);
color: var(--color-bg);
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
text-align: center;
box-shadow: var(--shadow-md);
visibility: hidden;
pointer-events: none;
}
.push:hover .hint,
.pushButton:focus + .hint {
visibility: visible;
}
-424
View File
@@ -1,424 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
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'
import { cacheFallbackHeader } from '../sw/routes.ts'
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'
const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
const DRAFT = `${THIS_WEEK}/draft`
const JOB = '/api/v1/jobs/j1'
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
const NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Crock pot tikka masala', 'Chicken stir-fry', 'Pizza night', 'Korean beef bowls', 'Sheet-pan salmon']
// 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.
function planBody(status: string, names = NAMES) {
return {
id: 'plan-1',
week_start: '2026-10-05',
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',
days: DAYS.map((date, i) => ({
date,
dinner: {
id: `d${i}`,
name: names[i],
photo: null,
minutes: i === 2 ? 240 : 30,
method: i === 2 ? 'crockpot' : 'stovetop',
origin: i === 2 || i === 5 ? 'side_quest' : 'regular',
},
is_new: i === 2 || i === 5,
start_by: i === 2 ? '2026-10-07T13:30:00-04:00' : null,
})),
updated_at: '2026-10-04T15:00:00Z',
}
}
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
// redraft keeps the plan's id.
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
const result = status === 'succeeded' ? { week_start: '2026-10-05', plan_id: planId } : null
return { id: 'j1', kind: 'draft_week', status, result, error }
}
type Responder = (init: RequestInit | undefined) => Response
// 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>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// posts lists the POSTs sent, as "path body".
function posts(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => init?.method === 'POST')
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
}
// planReads counts the reads of this week's plan.
function planReads(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === THIS_WEEK).length
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the Plan page.
async function renderPage() {
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<PlanPage />
</QueryClientProvider>
</ApiProvider>,
)
}
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
// the page opens on this week.
beforeEach(() => {
mockViewport(390)
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
function tile(kicker: string) {
return screen.getByText(kicker).closest('li')!
}
describe('PlanPage', () => {
it('renders mock 1f for a draft: title, Draft tag, the assistant note and the 2-column grid', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
const title = screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })
expect(declaredStyle(title).fontSize).toBe('26px')
expect(screen.getByText('Week 41')).toBeInTheDocument()
const tag = screen.getByText('Draft')
expect(declaredStyle(tag).background).toBe('var(--color-accent-100)')
expect(declaredStyle(tag).color).toBe('var(--color-accent-800)')
// The assistant: a 32 px grape "Q" avatar beside the plan's ai_note.
const avatar = screen.getByText('Q')
expect(declaredStyle(avatar).width).toBe('32px')
expect(declaredStyle(avatar).background).toBe('var(--color-accent)')
expect(screen.getByText('I mixed 5 of your regulars with 2 from your Quest log. Tap any day to swap it.')).toBeInTheDocument()
// Seven day tiles, Monday to Sunday, then the "+ Rebuild week" tile.
const grid = screen.getByRole('list', { name: 'Dinners this week' })
expect(declaredStyle(grid).gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
const items = within(grid).getAllByRole('listitem')
expect(items).toHaveLength(8)
expect(items.slice(0, 7).map((item) => item.firstChild!.textContent)).toEqual([
'MON',
'TUE',
'WED · NEW',
'THU',
'FRI',
'SAT · NEW',
'SUN',
])
expect(within(items[7]!).getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
const monday = tile('MON')
expect(declaredStyle(monday).minHeight).toBe('86px')
expect(declaredStyle(monday).borderRadius).toBe('22px')
expect(declaredStyle(monday).background).toBe('var(--color-surface)')
expect(declaredStyle(within(monday).getByText('Beef tacos')).fontFamily).toBe('var(--font-heading)')
expect(declaredStyle(within(monday).getByText('Beef tacos')).fontSize).toBe('15px')
})
it('highlights Quest-log picks in teal with the NEW kicker', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
await screen.findByText('WED · NEW')
const wednesday = tile('WED · NEW')
expect(declaredStyle(wednesday).background).toBe('var(--color-accent-2-300)')
expect(declaredStyle(within(wednesday).getByText('WED · NEW')).color).toBe('var(--color-accent-2-900)')
expect(declaredStyle(tile('SAT · NEW')).background).toBe('var(--color-accent-2-300)')
// A Regular stays on the surface, with no NEW.
expect(declaredStyle(tile('THU')).background).toBe('var(--color-surface)')
expect(screen.queryByText('THU · NEW')).not.toBeInTheDocument()
})
it('shows a crock-pot dinner its start-by time in the household zone, and no other dinner one', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Start by 1:30 pm')).toBeInTheDocument()
expect(within(tile('WED · NEW')).getByText('Start by 1:30 pm')).toBeInTheDocument()
expect(screen.getAllByText(/^Start by/)).toHaveLength(1)
})
it.each([
['synced', 'Synced', 'var(--color-accent-2-200)'],
['changed', 'Changes not pushed', 'var(--color-accent-100)'],
])('tags a %s plan "%s"', async (status, label, background) => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody(status)) })
await renderPage()
const tag = await screen.findByText(label)
expect(declaredStyle(tag).background).toBe(background)
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
})
it('offers Push to Calendar unavailable, with a tooltip saying why', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' })
expect(push).toHaveAttribute('aria-disabled', 'true')
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.')
expect(declaredStyle(push).minHeight).toBe('50px')
fireEvent.click(push)
expect(posts(fetchMock)).toEqual([])
})
it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => {
let plan = planBody('draft')
let job = draftJob('running')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`]))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toHaveAttribute('aria-busy', 'true')
expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7)
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
job = draftJob('succeeded')
expect(await screen.findByText('Lasagna', {}, { timeout: 3000 })).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
})
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')),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' }))
const dialog = await screen.findByRole('dialog', { name: 'Rebuild this week?' })
expect(dialog).toHaveTextContent('already pushed to Calendar')
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()
})
it('offers "Draft this week" with no plan, and shows skeleton tiles while it drafts', async () => {
let plan: unknown = null
let job = draftJob('queued')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
expect(await screen.findByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Draft this week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-tile')).toHaveLength(7)
expect(screen.getByText('Drafting your week…')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
plan = planBody('draft')
job = draftJob('succeeded')
expect(await screen.findByText('Beef tacos', {}, { timeout: 3000 })).toBeInTheDocument()
expect(screen.getByText('Draft')).toBeInTheDocument()
})
it('shows a failed draft with Retry and makes up no plan', async () => {
let job = draftJob('failed', 'ai_failed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' }))
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
expect(alert).toHaveTextContent("Couldn't draft this week. The AI assistant couldn't draft it.")
expect(screen.getByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
job = draftJob('running')
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(posts(fetchMock)).toEqual([`${DRAFT} {}`, `${DRAFT} {}`]))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it.each([
['the week has no plan', null, 'The week has no plan, not draft plan-1.'],
['the week has another plan', { ...planBody('draft'), id: 'plan-9' }, "The week's plan is not draft plan-1."],
])('shows a succeeded draft with Retry when %s once read, and Retry reads the plan again', async (_, landed, message) => {
let plan: unknown = null
let job = draftJob('running')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => (plan === null ? errorResponse(404, 'not_found') : jsonResponse(200, plan)),
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Draft this week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
plan = landed
job = draftJob('succeeded')
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
expect(alert).toHaveTextContent(`Couldn't read the draft. ${message}`)
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
// Retry reads the plan again rather than drafting again: the read is
// still not the draft.
let reads = planReads(fetchMock)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
expect(await screen.findByRole('alert')).toHaveTextContent(`Couldn't read the draft. ${message}`)
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
// The next read is the draft, from the server: it lands.
plan = planBody('draft')
reads = planReads(fetchMock)
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(reads + 1)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
})
it("doesn't land a succeeded draft on the plan cached before it, and lands it once the server is read", async () => {
let plan = planBody('draft')
let cached = false
let job = draftJob('running')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => {
const response = jsonResponse(200, plan)
if (cached) response.headers.set(cacheFallbackHeader, '1')
return response
},
[`POST ${DRAFT}`]: () => jsonResponse(202, { jobId: 'j1' }),
[`GET ${JOB}`]: () => jsonResponse(200, job),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Rebuild week' }))
expect(await screen.findByRole('region', { name: 'Drafting this week' })).toBeInTheDocument()
// The draft succeeds, but the server can't be reached: the service
// worker answers with the plan cached before the rebuild, same id.
cached = true
job = draftJob('succeeded')
const alert = await screen.findByRole('alert', {}, { timeout: 3000 })
expect(alert).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
// Retry reads the plan again and drafts nothing: still the cache, so the
// draft stays unread.
const reads = planReads(fetchMock)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(reads + 1))
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the draft. The plan came from this device's cache, not the server")
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${DRAFT} {}`])
// The server is back: the next read of the plan is the draft.
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
cached = false
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
act(() => {
window.dispatchEvent(new Event('visibilitychange'))
})
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tile')).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'),
})
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')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 5 – 11' })).toBeInTheDocument()
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
})
it("opens on next week once this week's plan_draft time has come", async () => {
vi.setSystemTime(new Date('2026-10-09T13:00:00Z')) // Friday 09:00 in New York
stubServer({ [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found') })
await renderPage()
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
})
it("reports a plan that can't be read, with Retry", async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(500, 'internal') })
await renderPage()
expect(await screen.findByRole('alert', {}, { timeout: 3000 })).toHaveTextContent("Couldn't load this week's plan. internal message")
})
})
-293
View File
@@ -1,293 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'
import { useEffect, useId, useMemo, useState } 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 { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { DRAFT_JOB_KIND, parseDraftResult, planKey, usePlan, useStartDraft, type DraftResult, type PlanDay, type PlanStatus } from './api.ts'
import { addDays, defaultWeek, localNow, weekdayKicker, weekLabel } from './week.ts'
import styles from './PlanPage.module.css'
// statusTags is the tag beside the week's title for each plan status.
const statusTags: Record<PlanStatus, { label: string; tone: 'grape' | 'teal' }> = {
draft: { label: 'Draft', tone: 'grape' },
synced: { label: 'Synced', tone: 'teal' },
changed: { label: 'Changes not pushed', tone: 'grape' },
}
// draftFailures says why a draft_week job failed, by its failure code. Any
// other code is shown as it is.
const draftFailures: Record<string, string> = {
not_enough_dinners: 'There are fewer than seven Regulars and Quest-log dinners to draft from.',
ai_failed: "The AI assistant couldn't draft it.",
confirm_required: 'The plan was pushed to Calendar while it was drafting.',
plan_changed: 'The plan was edited while it was drafting.',
internal_error: 'Something went wrong on the server.',
}
// PUSH_HINT is why Push to Calendar is unavailable.
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
// pushedToCalendar says whether drafting over a plan of this status needs
// confirm: it was pushed to Calendar.
function pushedToCalendar(status: PlanStatus): boolean {
return status !== 'draft'
}
// DayTile is one day of the Draft board: a Regular on the surface, a
// Quest-log pick in teal with its "NEW" kicker, and a crock-pot dinner's
// start-by time in the household's zone.
function DayTile({ day, timezone }: { day: PlanDay; timezone: string }) {
const kicker = weekdayKicker(day.date)
return (
<li className={[styles.tile, day.is_new && styles.newTile].filter(Boolean).join(' ')}>
<p className={styles.dayKicker}>{day.is_new ? `${kicker} · NEW` : kicker}</p>
<div>
<p className={styles.dinner}>{day.dinner.name}</p>
{day.start_by !== null && (
<p className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
)}
</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 }) {
return (
<section aria-busy="true" aria-label={label} className={styles.loading}>
<div className={styles.grid}>
{Array.from({ length: 7 }, (_, i) => (
<Skeleton key={i} data-testid="skeleton-tile" className={styles.skeletonTile} />
))}
</div>
{children !== undefined && <p className={styles.drafting}>{children}</p>}
</section>
)
}
// WeekBoard is the Draft board of the week starting on weekStart. Drafting
// it — the empty state's "Draft this week" or the "+ Rebuild week" tile —
// 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 }) {
const queryClient = useQueryClient()
const plan = usePlan(weekStart)
const startDraft = useStartDraft()
const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
const [confirming, setConfirming] = useState(false)
const label = weekLabel(weekStart)
// The draft's result, once the job has succeeded, or why it cannot be read.
const result = useMemo((): { draft: DraftResult | null; error: Error | null } => {
if (job.status !== 'succeeded') return { draft: null, error: null }
try {
return { draft: parseDraftResult(`GET /api/v1/jobs/${jobId}`, weekStart, job.result), error: null }
} catch (error) {
return { draft: null, error: error as Error }
}
}, [job.status, job.result, jobId, weekStart])
// Once the job ends the plan is read again: a succeeded draft is followed
// until that read lands; a failed one stays shown, and may have met a
// plan changed meanwhile.
const [readJobId, setReadJobId] = useState<string | null>(null)
const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => {
if (!ended) return
let active = true
void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }).then(() => {
if (active) setReadJobId(jobId)
})
return () => {
active = false
}
}, [queryClient, ended, jobId, weekStart])
// The plan read shows once it is the one drafted, read from the server; no
// plan, another one, or a copy cached before the draft (a redraft keeps the
// plan's id) is an error rather than an empty or stale board. Its Retry
// reads the plan again, keeping the job: a later read from the server that
// is the draft still lands it.
const { draft } = result
const read = draft !== null && readJobId === jobId && plan.isSuccess
const landed = read && plan.data?.id === draft.plan_id && !plan.data.cached
const unreadError =
read && !landed
? new Error(
plan.data === null
? `The week has no plan, not draft ${draft.plan_id}.`
: plan.data.id !== draft.plan_id
? `The week's plan is not draft ${draft.plan_id}.`
: `The plan came from this device's cache, not the server, so it may not be draft ${draft.plan_id} yet.`,
)
: null
const resultError = result.error ?? unreadError
useEffect(() => {
if (landed) setJobId(null)
}, [landed])
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
const { mutate } = startDraft
function start(confirm: boolean) {
setConfirming(false)
// A draft asked for again leaves the last one's failure behind.
setJobId(null)
mutate(
{ weekStart, confirm },
{
onSuccess: setJobId,
// A plan pushed to Calendar meanwhile needs confirm: read it again.
onError: () => void queryClient.invalidateQueries({ queryKey: planKey(weekStart) }),
},
)
}
// rebuild drafts the week, asking first over a plan pushed to Calendar.
function rebuild() {
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
else start(false)
}
const failure = startDraft.isError ? (
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
) : result.error !== null ? (
<ErrorBanner onRetry={rebuild}>Couldn't read the draft. {result.error.message}</ErrorBanner>
) : unreadError !== null ? (
<ErrorBanner onRetry={() => void plan.refetch()}>Couldn't read the draft. {unreadError.message}</ErrorBanner>
) : job.error === null ? null : job.status === 'failed' ? (
<ErrorBanner onRetry={rebuild}>
Couldn't draft this week. {draftFailures[job.error.message] ?? job.error.message}
</ErrorBanner>
) : (
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
)
let body
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>
} 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" />
} else if (plan.data === null) {
body = (
<EmptyState title="Nothing planned yet" action={{ label: 'Draft this week', onClick: rebuild }}>
I'll draft seven dinners from your Regulars and Quest log.
</EmptyState>
)
} else {
const { ai_note, days } = plan.data
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} />
))}
<li className={styles.rebuildCell}>
<button type="button" className={styles.rebuild} onClick={rebuild}>
<Icon icon={Plus} size="inline" />
Rebuild week
</button>
</li>
</ul>
<PushToCalendar />
</>
)
}
const tag = plan.data ? statusTags[plan.data.status] : null
return (
<>
<header className={styles.header}>
<div className={styles.heading}>
<p className={styles.kicker}>{label.week}</p>
<div className={styles.titleRow}>
<h1 className={styles.title}>{label.dates}</h1>
{tag !== null && <Tag tone={tag.tone}>{tag.label}</Tag>}
</div>
</div>
<div className={styles.weekNav}>
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
<Icon icon={ChevronLeft} size="inline" />
</IconButton>
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
<Icon icon={ChevronRight} size="inline" />
</IconButton>
</div>
</header>
{body}
{failure}
<Confirm
open={confirming}
title="Rebuild this week?"
confirmLabel="Rebuild week"
cancelLabel="Keep this plan"
onCancel={() => setConfirming(false)}
onConfirm={() => start(true)}
>
This week is already pushed to Calendar. The AI assistant drafts a new plan in its place.
</Confirm>
</>
)
}
// 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() {
const hintId = useId()
return (
<div className={styles.push}>
<Button className={styles.pushButton} aria-disabled="true" aria-describedby={hintId}>
Looks good — push to Calendar
</Button>
<span id={hintId} role="tooltip" className={styles.hint}>
{PUSH_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.
export function PlanPage() {
const settings = useHouseholdSettings()
const [week, setWeek] = useState<string | null>(null)
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
let content
if (settings.isError) {
content = (
<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" />
} else {
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} onWeek={setWeek} />
}
return <div className={styles.page}>{content}</div>
}
-132
View File
@@ -1,132 +0,0 @@
import { useMutation, useQuery } 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'
export const PLANS_PATH = '/api/v1/plans'
// planPath is the plan of the week starting on Monday weekStart.
export function planPath(weekStart: string): string {
return `${PLANS_PATH}/${encodeURIComponent(weekStart)}`
}
// planKey is the query of the plan of the week starting on weekStart.
export function planKey(weekStart: string) {
return ['plans', weekStart] as const
}
export const PLAN_STATUSES = ['draft', 'synced', 'changed'] as const
// PlanStatus is draft until Push to Calendar syncs the plan; an edit of a
// synced plan makes it changed.
export type PlanStatus = (typeof PLAN_STATUSES)[number]
// 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 }
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.
export type Plan = {
id: string
week_start: string
status: PlanStatus
ai_note: string | null
days: PlanDay[]
cached: boolean
}
// DRAFT_JOB_KIND is the kind of the job drafting a week's plan.
export const DRAFT_JOB_KIND = 'draft_week'
// DraftResult is the draft_week job's result: the week drafted and its plan.
export type DraftResult = { week_start: string; plan_id: string }
function parseDay(where: string, body: unknown): PlanDay {
if (
!isRecord(body) ||
typeof body.date !== 'string' ||
!isRecord(body.dinner) ||
typeof body.dinner.id !== 'string' ||
typeof body.dinner.name !== 'string' ||
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 }
}
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
body.week_start !== weekStart ||
(body.ai_note !== null && typeof body.ai_note !== 'string') ||
!Array.isArray(body.days) ||
body.days.length !== 7
) {
throw new Error(`${where}: response is not the plan of the week of ${weekStart}`)
}
return {
id: body.id,
week_start: weekStart,
status: oneOf(where, 'status', PLAN_STATUSES, body.status),
ai_note: body.ai_note,
days: body.days.map((day) => parseDay(where, day)),
cached,
}
}
// parseDraftResult checks a draft_week job's result, which useJob passes on
// unread, is the draft of the week starting on weekStart.
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
if (!isRecord(body) || body.week_start !== weekStart || typeof body.plan_id !== 'string') {
throw new Error(`${where}: result is not the draft of the week of ${weekStart}`)
}
return { week_start: weekStart, plan_id: body.plan_id }
}
// usePlan is the plan of the week starting on Monday weekStart (GET
// /api/v1/plans/{weekStart}), or null when the week has none (404).
export function usePlan(weekStart: string) {
const { apiRead } = useApi()
return useQuery({
queryKey: planKey(weekStart),
queryFn: async (): Promise<Plan | null> => {
const path = planPath(weekStart)
let read: ApiRead<unknown>
try {
read = await apiRead<unknown>(path)
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null
throw error
}
return parsePlan(`GET ${path}`, weekStart, read.body, read.cached)
},
})
}
// useStartDraft asks the AI to draft the week starting on weekStart (POST
// /api/v1/plans/{weekStart}/draft) and resolves with the id of the
// draft_week job, which the server answers with the draft already running
// when there is one. Drafting over a plan pushed to Calendar (synced or
// changed) needs confirm.
export function useStartDraft() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async ({ weekStart, confirm }: { weekStart: string; confirm: boolean }): Promise<string> => {
const path = `${planPath(weekStart)}/draft`
const body = await apiFetch<unknown>(path, { method: 'POST', body: confirm ? { confirm: true } : {} })
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${path}: response has no jobId`)
return body.jobId
},
})
}
-59
View File
@@ -1,59 +0,0 @@
import { describe, expect, it } from 'vitest'
import { addDays, defaultWeek, localNow, weekdayKicker, 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' }
describe('week helpers', () => {
it('finds the Monday of a week and moves by days across months and years', () => {
expect(weekStart('2026-10-07')).toBe('2026-10-05')
expect(weekStart('2026-10-05')).toBe('2026-10-05')
expect(weekStart('2026-10-11')).toBe('2026-10-05')
expect(addDays('2026-10-05', 7)).toBe('2026-10-12')
expect(addDays('2026-10-05', -7)).toBe('2026-09-28')
expect(addDays('2026-12-28', 7)).toBe('2027-01-04')
})
it('crosses a DST change without losing a day', () => {
expect(addDays('2026-10-26', 7)).toBe('2026-11-02')
expect(addDays('2027-03-08', 7)).toBe('2027-03-15')
})
it('labels weeks as the API does, by ISO week', () => {
expect(weekLabel('2026-10-05')).toEqual({ week: 'Week 41', dates: 'Oct 5 – 11' })
expect(weekLabel('2026-09-28')).toEqual({ week: 'Week 40', dates: 'Sep 28 – Oct 4' })
expect(weekLabel('2026-12-28')).toEqual({ week: 'Week 53', dates: 'Dec 28 – Jan 3' })
expect(weekLabel('2027-01-04')).toEqual({ week: 'Week 1', dates: 'Jan 4 – 10' })
})
it('names the day of a date as a tile kicker', () => {
expect(weekdayKicker('2026-10-05')).toBe('MON')
expect(weekdayKicker('2026-10-11')).toBe('SUN')
})
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')
expect(() => weekStart('Oct 5')).toThrow('not a YYYY-MM-DD date')
})
it('reads the household-local date and clock in its zone', () => {
// 03:30 UTC on Thursday is still Wednesday evening in New York.
expect(localNow('America/New_York', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-07', time: '23:30' })
expect(localNow('Asia/Tokyo', new Date('2026-10-08T03:30:00Z'))).toEqual({ date: '2026-10-08', time: '12:30' })
})
it('opens on this week until the plan_draft time, then on next week', () => {
expect(defaultWeek(SCHEDULE, new Date('2026-10-07T16:00:00Z'))).toBe('2026-10-05') // Wed noon
expect(defaultWeek(SCHEDULE, new Date('2026-10-09T12:59:00Z'))).toBe('2026-10-05') // Fri 08:59
expect(defaultWeek(SCHEDULE, new Date('2026-10-09T13:00:00Z'))).toBe('2026-10-12') // Fri 09:00
expect(defaultWeek(SCHEDULE, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-12') // Sat
expect(defaultWeek(SCHEDULE, new Date('2026-10-12T04:30:00Z'))).toBe('2026-10-12') // the next Mon, 00:30
})
it('counts a Sunday plan_draft day as the end of the Monday-start week', () => {
const sunday = { ...SCHEDULE, plan_draft_dow: 0, plan_draft_time: '18:00' }
expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat
expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00
})
})
-98
View File
@@ -1,98 +0,0 @@
// Household-local calendar helpers for the weekly plan, mirroring
// internal/household/week.go. Weeks are ISO weeks, Monday to Sunday. Dates
// are household-local YYYY-MM-DD strings; arithmetic is done on UTC
// midnights, which have no DST, so a date plus seven days is always the same
// weekday a week later.
const localDate = /^(\d{4})-(\d{2})-(\d{2})$/
const DAY_MS = 24 * 60 * 60 * 1000
const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const
// midnightUTC reads date as midnight UTC, or throws when it is not a real
// YYYY-MM-DD date.
function midnightUTC(date: string): Date {
const match = localDate.exec(date)
if (match === null) throw new Error(`not a YYYY-MM-DD date: "${date}"`)
const t = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
if (formatDate(t) !== date) throw new Error(`not a real date: "${date}"`)
return t
}
function formatDate(t: Date): string {
return t.toISOString().slice(0, 10)
}
// addDays returns date moved by days, which may be negative.
export function addDays(date: string, days: number): string {
return formatDate(new Date(midnightUTC(date).getTime() + days * DAY_MS))
}
// weekStart returns the Monday of the week containing date.
export function weekStart(date: string): string {
const sinceMonday = (midnightUTC(date).getUTCDay() + 6) % 7
return addDays(date, -sinceMonday)
}
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
export function weekdayKicker(date: string): string {
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
}
// monday reads start as midnight UTC, or throws when it is not a Monday.
function monday(start: string): Date {
const t = midnightUTC(start)
if (t.getUTCDay() !== 1) throw new Error(`week start ${start} is not a Monday`)
return t
}
// isoWeek is the ISO 8601 week number of the week starting on Monday start:
// the week holding that week's Thursday's year's first Thursday counts as 1.
function isoWeek(start: Date): number {
const thursday = new Date(start.getTime() + 3 * DAY_MS)
const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1)
return Math.floor((thursday.getTime() - yearStart) / DAY_MS / 7) + 1
}
const monthDay = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
// WeekLabel is the week starting on a Monday as the plan's header shows it:
// "Week 41" over "Oct 5 – 11", or "Sep 28 – Oct 4" when the week spans two
// months. Together they are the API's week_label.
export type WeekLabel = { week: string; dates: string }
// weekLabel labels the week starting on Monday start.
export function weekLabel(start: string): WeekLabel {
const first = monday(start)
const last = new Date(first.getTime() + 6 * DAY_MS)
const end = last.getUTCMonth() === first.getUTCMonth() ? String(last.getUTCDate()) : monthDay.format(last)
return { week: `Week ${isoWeek(first)}`, dates: `${monthDay.format(first)} – ${end}` }
}
// localNow is the household-local date (YYYY-MM-DD) and clock (24-hour
// HH:MM) at instant now in IANA zone.
export function localNow(zone: string, now: Date): { date: string; time: string } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)!.value
return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` }
}
// defaultWeek is the Monday of the week the plan opens on: next week once
// this week's plan_draft time (day of the week counting from Sunday = 0,
// 24-hour HH:MM, both household-local) has come, otherwise this week.
export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number; plan_draft_time: string }, now: Date): string {
const today = localNow(schedule.timezone, now)
const thisWeek = weekStart(today.date)
const draftDay = addDays(thisWeek, (schedule.plan_draft_dow + 6) % 7)
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
return drafted ? addDays(thisWeek, 7) : thisWeek
}