Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #117.
This commit is contained in:
+19
-18
@@ -30,6 +30,7 @@ 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,
|
||||
@@ -165,19 +166,19 @@ describe('App first-run redirect', () => {
|
||||
it('opens the routes once onboarding is finished', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/plan')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).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('/plan')
|
||||
renderAt('/quick')
|
||||
|
||||
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: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,12 +186,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('/plan')
|
||||
renderAt('/quick')
|
||||
|
||||
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: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
@@ -258,7 +259,7 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/plan', 'Plan'],
|
||||
['/quick', 'Quick dinners'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
@@ -282,7 +283,7 @@ describe('App offline bootstrap', () => {
|
||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
)
|
||||
renderAt('/plan')
|
||||
renderAt('/quick')
|
||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||
onLine = false
|
||||
act(() => {
|
||||
@@ -290,7 +291,7 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
act(() => failMe())
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).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()
|
||||
@@ -299,15 +300,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('/plan')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).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('/plan')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
@@ -316,12 +317,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('/plan')
|
||||
const { location, unmount } = renderAt('/quick')
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||
unmount()
|
||||
|
||||
goOffline()
|
||||
renderAt('/plan')
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -329,13 +330,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('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<InDataRouter path="/plan">
|
||||
<InDataRouter path="/quick">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||
@@ -350,6 +351,6 @@ describe('App offline bootstrap', () => {
|
||||
]),
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -36,6 +37,7 @@ 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 />,
|
||||
|
||||
@@ -225,4 +225,14 @@ 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 })
|
||||
})
|
||||
})
|
||||
|
||||
+19
-1
@@ -43,11 +43,18 @@ 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
|
||||
@@ -114,6 +121,16 @@ 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
|
||||
@@ -131,7 +148,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 decode<T>(method, path, response)
|
||||
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
|
||||
}
|
||||
|
||||
// parseForbidden reads a 403. Any API error other than csrf means the
|
||||
@@ -179,6 +196,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
|
||||
return {
|
||||
apiFetch,
|
||||
apiRead,
|
||||
loadMe,
|
||||
forbidden: () => forbidden,
|
||||
subscribe(listener) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { ApiProvider, useApi } from './ApiContext.tsx'
|
||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, 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'
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user