Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #121.
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'
|
||||
|
||||
@@ -20,7 +20,7 @@ export type QuestLogSheetProps = {
|
||||
|
||||
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
|
||||
// neither is known.
|
||||
function details(dinner: QuestLogDinner): string | null {
|
||||
export function details(dinner: QuestLogDinner): string | null {
|
||||
const parts = [
|
||||
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
|
||||
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
|
||||
|
||||
@@ -51,8 +51,9 @@ export const BATCH_JOB_KIND = 'suggestion_batch'
|
||||
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
|
||||
|
||||
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
|
||||
// it: photo, name, difficulty and minutes.
|
||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'minutes_total'>
|
||||
// it: photo, name, difficulty and minutes, with the method a swap carries
|
||||
// onto the plan.
|
||||
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
|
||||
|
||||
// DECISION_STATUS is the status a suggestion has once each choice is saved.
|
||||
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
|
||||
@@ -127,8 +128,8 @@ export function useQuestLog() {
|
||||
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
|
||||
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
|
||||
return body.dinners.map((dinner) => {
|
||||
const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner)
|
||||
return { id, name, photo_id, difficulty, minutes_total }
|
||||
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
|
||||
return { id, name, photo_id, difficulty, method, minutes_total }
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -250,13 +251,14 @@ export function useDecide() {
|
||||
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
|
||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
|
||||
? dinners
|
||||
: // The deck does not say the minutes; the Quest log read after the
|
||||
// decision does.
|
||||
: // The deck does not say the method or minutes; the Quest log
|
||||
// read after the decision does.
|
||||
dinners.concat({
|
||||
id: quest.dinner_id,
|
||||
name: quest.name,
|
||||
photo_id: quest.photo_id,
|
||||
difficulty: quest.difficulty,
|
||||
method: null,
|
||||
minutes_total: null,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
/* Plan · Draft board (mock 1f) and plan manager (mocks 2c, 2d). */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.board {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mobilePage {
|
||||
max-width: 440px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* The grid fills the screen below the header: the main area's 32 px top and
|
||||
bottom padding (AppShell) is taken back off the viewport height. */
|
||||
.tabletPage .board {
|
||||
min-height: calc(100dvh - 64px);
|
||||
}
|
||||
|
||||
/* Desktop: the board beside the 290 px right panel, which runs the full
|
||||
height of the screen to its right edge over the main area's padding. */
|
||||
.desktopPage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 290px;
|
||||
min-height: 100dvh;
|
||||
margin: -32px -36px -32px 0;
|
||||
}
|
||||
|
||||
.desktopPage .board {
|
||||
padding: 32px 36px 32px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
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;
|
||||
min-height: 86px;
|
||||
border-radius: 22px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* The whole tile is the button that opens the swap sheet. */
|
||||
.tileButton {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A Quest-log pick. */
|
||||
.newTile {
|
||||
background: var(--color-accent-2-300);
|
||||
}
|
||||
|
||||
.dayKicker {
|
||||
display: block;
|
||||
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 {
|
||||
display: block;
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.startBy {
|
||||
display: block;
|
||||
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);
|
||||
}
|
||||
|
||||
/* The day being swapped, while the swap sheet is open: the page background
|
||||
ringed in grape, its name muted. */
|
||||
.swapping {
|
||||
background: var(--color-bg);
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent);
|
||||
}
|
||||
|
||||
.swapping .dayKicker {
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.swapping .dinner,
|
||||
.swapping .startBy {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.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:not(:disabled) {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.rebuild:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
|
||||
the shopping list until Feature 17): it keeps focus, so hovering, tapping
|
||||
or focusing it shows the tooltip. */
|
||||
.unavailable {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pushButton {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.unavailable [aria-disabled='true'] {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Unavailable, it neither darkens nor presses. */
|
||||
.unavailable .primary[aria-disabled='true']:hover,
|
||||
.unavailable .primary[aria-disabled='true']:active {
|
||||
background: var(--color-accent);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.unavailable .outline[aria-disabled='true']:hover,
|
||||
.unavailable .outline[aria-disabled='true']:active {
|
||||
background: transparent;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
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;
|
||||
}
|
||||
|
||||
/* In the plan manager's header the tooltip opens below, at the action's
|
||||
right edge. */
|
||||
.below .hint {
|
||||
top: calc(100% + 8px);
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
right: 0;
|
||||
transform: none;
|
||||
width: 240px;
|
||||
max-width: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.unavailable:hover .hint,
|
||||
.unavailable [aria-disabled='true']:focus + .hint {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* The plan manager's header: kicker and title beside the week buttons, over
|
||||
the sync status and the actions. */
|
||||
.wideHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.titleBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.wideTitle {
|
||||
margin: 4px 0 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 40px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tabletPage .wideTitle {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.wideHeader .kicker {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wideHeader .weekNav {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.thisWeek {
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thisWeek:hover {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* The status keeps to one line; the actions wrap under it when the row is
|
||||
too narrow for both. */
|
||||
.status {
|
||||
flex: 1 0 auto;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.synced {
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The week: seven columns, each a day label over its card. A column is
|
||||
never narrower than 112 px, which holds a dinner's words whole and the
|
||||
tag beside the swap icon; where seven of those do not fit (tablet's
|
||||
narrow end, desktop beside the panel) the week scrolls sideways. Its 8 px
|
||||
padding, taken back by the margin, keeps the swap icons' hit boxes and
|
||||
focus rings inside the scroll area. */
|
||||
.week {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(112px, 1fr));
|
||||
gap: 12px;
|
||||
margin: -8px;
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.dayName {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.dayNumber {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tablet .card {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
/* A Side quest's card. */
|
||||
.sideQuestCard {
|
||||
background: var(--color-accent-2-200);
|
||||
}
|
||||
|
||||
.cardPhoto {
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
}
|
||||
|
||||
.tablet .cardPhoto {
|
||||
height: 96px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 17px;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.tablet .cardName {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cardMinutes,
|
||||
.cardStartBy {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.cardStartBy {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sideQuestCard .cardMinutes,
|
||||
.sideQuestCard .cardStartBy {
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.cardFooter {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* The day a dragged dinner drops on: a dashed 2.5 px grape border on
|
||||
grape-100 over the card, saying which dinner it replaces. */
|
||||
.dropTarget {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 2.5px dashed var(--color-accent);
|
||||
border-radius: inherit;
|
||||
background: var(--color-accent-100);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.dropReplaces {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* The swap icon sits bare on the card, beside the tag: its 44 px hit box
|
||||
keeps the 16 px footprint of its icon, reaching over the card's padding
|
||||
and the tag. */
|
||||
.cardFooter .swapButton {
|
||||
margin: -14px;
|
||||
}
|
||||
|
||||
.cardFooter .cardTag {
|
||||
margin-right: auto;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
.swapButton > span {
|
||||
background: transparent;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 300px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,599 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import {
|
||||
DRAFT_JOB_KIND,
|
||||
parseDraftResult,
|
||||
planKey,
|
||||
usePlan,
|
||||
useStartDraft,
|
||||
useSwapDay,
|
||||
type DraftResult,
|
||||
type Plan,
|
||||
type PlanDay,
|
||||
type PlanDinner,
|
||||
type PlanStatus,
|
||||
type Swap,
|
||||
} from './api.ts'
|
||||
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
|
||||
import { PlanPanel, QuestLogStrip } from './PlanPanel.tsx'
|
||||
import { SwapSheet } from './SwapSheet.tsx'
|
||||
import {
|
||||
addDays,
|
||||
dayOfMonth,
|
||||
defaultWeek,
|
||||
localNow,
|
||||
weekdayKicker,
|
||||
weekdayName,
|
||||
weekLabel,
|
||||
weekStart as weekOf,
|
||||
} from './week.ts'
|
||||
import styles from './PlanPage.module.css'
|
||||
|
||||
// statusTags is the tag beside the week's title for each plan status.
|
||||
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.',
|
||||
pool_changed: 'A dinner it picked was archived, tried or planned for another week 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.'
|
||||
|
||||
// SHOPPING_HINT is why the shopping list is unavailable.
|
||||
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
||||
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
|
||||
|
||||
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
|
||||
const syncStatuses: Record<PlanStatus, string> = {
|
||||
draft: 'Not pushed to Google Calendar yet',
|
||||
synced: 'Synced to Google Calendar',
|
||||
changed: 'Changes not pushed to Google Calendar',
|
||||
}
|
||||
|
||||
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
|
||||
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
|
||||
// between the method and the duration, never inside the duration.
|
||||
export function duration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
if (hours === 0) return `${rest}\u00a0min`
|
||||
return rest === 0 ? `${hours}\u00a0hr` : `${hours}\u00a0hr\u00a0${rest}\u00a0min`
|
||||
}
|
||||
|
||||
// cooking is a card's "method · duration" line, "Crock pot · 6 hr", with
|
||||
// whichever of the two is known, or null when neither is.
|
||||
export function cooking(dinner: PlanDinner): string | null {
|
||||
const parts = [dinner.method && methodLabels[dinner.method], dinner.minutes !== null && duration(dinner.minutes)].filter(
|
||||
(part): part is string => typeof part === 'string',
|
||||
)
|
||||
return parts.length === 0 ? null : parts.join(' · ')
|
||||
}
|
||||
|
||||
// ago is how long before now the instant at was: "just now", "2 min ago",
|
||||
// "3 hr ago", "2 days ago".
|
||||
export function ago(at: Date, now: Date): string {
|
||||
const minutes = Math.floor((now.getTime() - at.getTime()) / 60_000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} hr ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||
// confirm: it was pushed to Calendar.
|
||||
function pushedToCalendar(status: PlanStatus): boolean {
|
||||
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. Tapping it opens the swap sheet;
|
||||
// while that is open the tile is swapping: on the page background, ringed in
|
||||
// grape, its name muted.
|
||||
function DayTile({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
|
||||
const kicker = weekdayKicker(day.date)
|
||||
const classes = [styles.tile, day.is_new && styles.newTile, swapping && styles.swapping].filter(Boolean).join(' ')
|
||||
return (
|
||||
<li className={classes}>
|
||||
<button type="button" className={styles.tileButton} onClick={onSwap}>
|
||||
<span className={styles.dayKicker}>{swapping ? `${kicker} · SWAPPING…` : day.is_new ? `${kicker} · NEW` : kicker}</span>
|
||||
<span>
|
||||
<span className={styles.dinner}>{day.dinner.name}</span>
|
||||
{day.start_by !== null && (
|
||||
<span className={styles.startBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// DayColumn is one day of the plan manager's 7-column grid: its day label
|
||||
// over a card with the dinner's washed photo, name, method and time, a
|
||||
// crock-pot dinner's start-by time in the household's zone, and its Regular
|
||||
// or Side quest tag, a Side quest's card in teal. The swap icon opens the swap
|
||||
// sheet; while that is open the card is swapping, ringed in grape. A dinner
|
||||
// dragged over the day drops on it: the card is swapping, covered by a
|
||||
// dashed grape "Drop to swap" saying which dinner it replaces.
|
||||
function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone: string; swapping: boolean; onSwap: () => void }) {
|
||||
const { dinner } = day
|
||||
const time = cooking(dinner)
|
||||
const target = useDropTarget(day.date) !== null
|
||||
const classes = [styles.card, day.is_new && styles.sideQuestCard, (swapping || target) && styles.swapping].filter(Boolean).join(' ')
|
||||
return (
|
||||
<li className={styles.column} aria-label={weekdayName(day.date)} {...{ [DROP_DATE]: day.date }}>
|
||||
<p className={styles.dayLabel} aria-hidden="true">
|
||||
<span className={styles.dayName}>{weekdayKicker(day.date)}</span>
|
||||
<span className={styles.dayNumber}>{dayOfMonth(day.date)}</span>
|
||||
</p>
|
||||
<div className={classes}>
|
||||
{/* The card's name already says what the photo shows. */}
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="18px" sizes="160px" className={styles.cardPhoto} />
|
||||
</span>
|
||||
<p className={styles.cardName}>{dinner.name}</p>
|
||||
{time !== null && <p className={styles.cardMinutes}>{time}</p>}
|
||||
{day.start_by !== null && (
|
||||
<p className={styles.cardStartBy}>Start by {formatClockTime(localNow(timezone, new Date(day.start_by)).time)}</p>
|
||||
)}
|
||||
<div className={styles.cardFooter}>
|
||||
<Tag tone={day.is_new ? 'grape' : 'neutral'} className={styles.cardTag}>{day.is_new ? 'Side quest' : 'Regular'}</Tag>
|
||||
<IconButton label={`Swap ${weekdayName(day.date)}`} className={styles.swapButton} onClick={onSwap}>
|
||||
<Icon icon={RefreshCw} size="inline" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{target && (
|
||||
<div className={styles.dropTarget}>
|
||||
<p className={styles.dropTitle}>Drop to swap</p>
|
||||
<p className={styles.dropReplaces}>Replaces {dinner.name}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// SkeletonTiles stands in for the seven days while the plan loads or the AI
|
||||
// drafts it, as two columns of tiles on mobile and seven columns wider.
|
||||
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
|
||||
const wide = layout !== 'mobile'
|
||||
return (
|
||||
<section aria-busy="true" aria-label={label} className={styles.loading}>
|
||||
<div className={wide ? `${styles.week} ${styles[layout]}` : styles.grid}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Skeleton key={i} data-testid="skeleton-tile" className={wide ? styles.skeletonCard : 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.
|
||||
//
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
|
||||
// unavailable Shopping list and Push to Calendar are actions in the header,
|
||||
// over the plan's sync status and the 7-column grid.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
layout,
|
||||
onWeek,
|
||||
}: {
|
||||
weekStart: string
|
||||
timezone: string
|
||||
layout: Breakpoint
|
||||
onWeek: (weekStart: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const plan = usePlan(weekStart)
|
||||
const startDraft = useStartDraft()
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
// The date of the day being swapped, while the swap sheet is open.
|
||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||
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. The Quest log is read again too, as after a
|
||||
// swap: the draft may have planned some of it.
|
||||
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: QUEST_LOG_KEY })
|
||||
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, and a failed
|
||||
// swap's, behind.
|
||||
setJobId(null)
|
||||
swap.dismiss()
|
||||
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.
|
||||
// It waits for a swap of the week still pending, which may yet fail: the
|
||||
// Rebuild week tile is disabled meanwhile.
|
||||
function rebuild() {
|
||||
if (swap.pending) return
|
||||
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
|
||||
else start(false)
|
||||
}
|
||||
|
||||
const failure = swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
) : 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>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
swap.mutate(chosen)
|
||||
}
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
// nor dropped, while a swap of the week is pending.
|
||||
function dropIn(chosen: Swap) {
|
||||
swap.dismiss()
|
||||
swapIn(chosen)
|
||||
}
|
||||
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
||||
// a time, even across leaving the week and coming back: the plan the
|
||||
// server answers it with must not overwrite another still pending.
|
||||
function openSwap(date: string) {
|
||||
if (swap.pending) return
|
||||
swap.dismiss()
|
||||
setSwappingDate(date)
|
||||
}
|
||||
|
||||
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" layout={layout}>
|
||||
Drafting your week…
|
||||
</SkeletonTiles>
|
||||
)
|
||||
} else if (resultError !== null) {
|
||||
// An unread draft shows no board: the plan read is not the draft.
|
||||
body = null
|
||||
} else if (plan.isPending) {
|
||||
body = <SkeletonTiles label="Plan loading" layout={layout} />
|
||||
} 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
|
||||
const swappingDay = days.find((day) => day.date === swappingDate)
|
||||
const sheet = swappingDay !== undefined && (
|
||||
<SwapSheet
|
||||
day={swappingDay}
|
||||
planned={new Set(days.map((day) => day.dinner.id))}
|
||||
onSwap={swapIn}
|
||||
onClose={() => setSwappingDate(null)}
|
||||
/>
|
||||
)
|
||||
body =
|
||||
layout !== 'mobile' ? (
|
||||
<>
|
||||
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
|
||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{sheet}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{ai_note !== null && (
|
||||
<div className={styles.assistant}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
Q
|
||||
</span>
|
||||
<p className={styles.bubble}>{ai_note}</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className={styles.grid} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayTile
|
||||
key={day.date}
|
||||
day={day}
|
||||
timezone={timezone}
|
||||
swapping={day.date === swappingDate}
|
||||
onSwap={() => openSwap(day.date)}
|
||||
/>
|
||||
))}
|
||||
<li className={styles.rebuildCell}>
|
||||
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
|
||||
<Icon icon={Plus} size="inline" />
|
||||
Rebuild week
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
|
||||
Looks good — push to Calendar
|
||||
</Unavailable>
|
||||
{sheet}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const tag = plan.data ? statusTags[plan.data.status] : null
|
||||
const header =
|
||||
layout !== 'mobile' ? (
|
||||
<header className={styles.wideHeader}>
|
||||
<div className={styles.titleBar}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>{label.week} · Plan</p>
|
||||
<h1 className={styles.wideTitle}>{label.dates}</h1>
|
||||
</div>
|
||||
<div className={styles.weekNav}>
|
||||
<IconButton label="Previous week" onClick={() => onWeek(addDays(weekStart, -7))}>
|
||||
<Icon icon={ChevronLeft} size="inline" />
|
||||
</IconButton>
|
||||
{layout === 'desktop' && (
|
||||
<button type="button" className={styles.thisWeek} onClick={() => onWeek(weekOf(localNow(timezone, new Date()).date))}>
|
||||
This week
|
||||
</button>
|
||||
)}
|
||||
<IconButton label="Next week" onClick={() => onWeek(addDays(weekStart, 7))}>
|
||||
<Icon icon={ChevronRight} size="inline" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.actionBar}>
|
||||
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
|
||||
<div className={styles.actions}>
|
||||
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
Shopping list
|
||||
</Unavailable>
|
||||
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
|
||||
<Icon icon={RefreshCw} size="inline" />
|
||||
Rebuild week
|
||||
</Button>
|
||||
<Unavailable hint={PUSH_HINT} below className={styles.action}>
|
||||
<Icon icon={CalendarDays} size="inline" />
|
||||
Push to Calendar
|
||||
</Unavailable>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>{label.week}</p>
|
||||
<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>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// SyncStatus is the plan manager's status line: whether the plan is on
|
||||
// Google Calendar and, once synced, how long ago it was pushed, kept current
|
||||
// minute by minute.
|
||||
function SyncStatus({ plan }: { plan: Plan }) {
|
||||
const { status, calendar_synced_at: syncedAt } = plan
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 60_000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
return (
|
||||
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
|
||||
{status === 'synced' && <Icon icon={Check} size="inline" />}
|
||||
{syncStatuses[status]}
|
||||
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable is an action with nothing behind it yet (Push to Calendar, the
|
||||
// shopping list): it stays focusable, so a tap or focus shows the tooltip
|
||||
// saying why, above it or, at the top of the page, below.
|
||||
function Unavailable({
|
||||
hint,
|
||||
variant = 'primary',
|
||||
below = false,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
hint: string
|
||||
variant?: ButtonVariant
|
||||
below?: boolean
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const hintId = useId()
|
||||
return (
|
||||
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
|
||||
<Button
|
||||
variant={variant}
|
||||
className={[className, styles[variant]].filter(Boolean).join(' ')}
|
||||
aria-disabled="true"
|
||||
aria-describedby={hintId}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
<span id={hintId} role="tooltip" className={styles.hint}>
|
||||
{hint}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// PlanPage is the Draft board on the week chosen with the previous / next
|
||||
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
||||
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
||||
// Regulars in a right panel on desktop (2d). It opens on next week once this
|
||||
// week's plan_draft time has come in the household's zone, otherwise on this
|
||||
// week. The board keeps its place in the tree across layouts, so crossing a
|
||||
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [week, setWeek] = useState<string | null>(null)
|
||||
if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date()))
|
||||
|
||||
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" layout={layout} />
|
||||
} else {
|
||||
content = <WeekBoard key={week} weekStart={week} timezone={settings.data.timezone} layout={layout} onWeek={setWeek} />
|
||||
}
|
||||
return (
|
||||
<PlanDragProvider>
|
||||
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||
<div className={styles.board}>
|
||||
{content}
|
||||
{layout === 'tablet' && <QuestLogStrip />}
|
||||
</div>
|
||||
{layout === 'desktop' && <PlanPanel />}
|
||||
{layout !== 'mobile' && <DragAnnouncer />}
|
||||
</div>
|
||||
</PlanDragProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/* Desktop's right panel (mock 2d) and tablet's Quest-log strip (mock 2c). */
|
||||
.panel {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 32px 22px;
|
||||
border-left: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 22px;
|
||||
background: var(--color-surface);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* The 48 px washed photo; Photo applies the washed filter. The placeholder's
|
||||
"dish photo" caption does not fit, so only its stripes show. */
|
||||
.rowPhoto {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Two columns of three 4 px dots. */
|
||||
.grip {
|
||||
flex: none;
|
||||
display: grid;
|
||||
grid-template-columns: 4px 4px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
height: 68px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Tablet: the Quest log as chips on a surface card under the grid. */
|
||||
.strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.stripHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stripTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stripHint {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.chips {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 14px 5px 5px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* As the row's photo, at 28 px. */
|
||||
.chipPhoto {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
/* A row or a chip is a button dragged onto a day: by a pointer (no
|
||||
scrolling or text selection under it) or with the keyboard. Its 2 px
|
||||
border is clear until it is the ghost, inside its padding. */
|
||||
.draggable {
|
||||
margin: 0;
|
||||
border: 2px solid transparent;
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.row.draggable {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chip.draggable {
|
||||
padding: 3px 12px 3px 3px;
|
||||
}
|
||||
|
||||
/* A dinner that cannot be picked up now: no grid to drop on, a swap
|
||||
pending, or already this week. */
|
||||
.draggable[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The row or chip being dragged: a dashed ghost at 55% opacity. */
|
||||
.ghost {
|
||||
border-style: dashed;
|
||||
border-color: var(--color-neutral-500);
|
||||
background: transparent;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* The dragged copy floating below right of the pointer: lifted with the
|
||||
large shadow and tilted -3deg. It lets the pointer through to the day
|
||||
under it. */
|
||||
.floating {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: rotate(-3deg);
|
||||
pointer-events: none;
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { Compass } from 'lucide-react'
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Link } from 'react-router'
|
||||
import { useRegulars } from '../dinners/api.ts'
|
||||
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
|
||||
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useDraggable, type Float } from './drag.tsx'
|
||||
import { emptyLists, planDinner, sourceNames, type Source } from './SwapSheet.tsx'
|
||||
import styles from './PlanPanel.module.css'
|
||||
|
||||
// DRAG_HINT says how a dinner is swapped in from the panel or the strip.
|
||||
const DRAG_HINT = 'Drag a dinner onto a day to swap it in.'
|
||||
|
||||
// KEYS_HINT says how a dinner is swapped in with the keyboard.
|
||||
const KEYS_HINT = 'Press Enter or Space to pick it up, the arrow keys to choose a day, Enter to drop it on that day and Escape to cancel.'
|
||||
|
||||
type List = ReturnType<typeof useQuestLog>
|
||||
|
||||
// ListBody is a list of the panel or the strip once it has loaded, or why it
|
||||
// has not: its error with Retry, skeletons while loading, or that it is
|
||||
// empty.
|
||||
function ListBody({ source, list, children }: { source: Source; list: List; children: (dinners: QuestLogDinner[]) => ReactNode }) {
|
||||
if (list.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (list.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.loading}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeleton} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (list.data.length === 0) return <p className={styles.empty}>{emptyLists[source]}</p>
|
||||
return children(list.data)
|
||||
}
|
||||
|
||||
// Grip is the 6-dot drag handle of a panel row.
|
||||
function Grip() {
|
||||
return (
|
||||
<span className={styles.grip} aria-hidden="true">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<span key={i} className={styles.dot} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Draggable is a dinner of the panel or the strip, dragged onto a day to
|
||||
// swap it in: a button whose content is the row or the chip. While it is
|
||||
// dragged it is a dashed ghost, and with a pointer a copy of it floats
|
||||
// below right of the pointer, lifted and tilted.
|
||||
function Draggable({
|
||||
dinner,
|
||||
fromQuestLog,
|
||||
className,
|
||||
keysHintId,
|
||||
children,
|
||||
}: {
|
||||
dinner: QuestLogDinner
|
||||
fromQuestLog: boolean
|
||||
className: string | undefined
|
||||
keysHintId: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { drag, float, props } = useDraggable({ dinner: planDinner(dinner), fromQuestLog })
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={[className, styles.draggable, drag !== null && styles.ghost].filter(Boolean).join(' ')}
|
||||
aria-describedby={keysHintId}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{float !== null && <Floating float={float} className={className}>{children}</Floating>}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Floating is the dragged copy of a row or a chip, drawn over the page.
|
||||
function Floating({ float, className, children }: { float: Float; className: string | undefined; children: ReactNode }) {
|
||||
return createPortal(
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="drag-float"
|
||||
className={[className, styles.floating].filter(Boolean).join(' ')}
|
||||
style={{ left: float.left, top: float.top, width: float.width }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
// KeysHint is the keyboard's way to swap a dinner in, describing each
|
||||
// draggable.
|
||||
function KeysHint({ id }: { id: string }) {
|
||||
return (
|
||||
<p id={id} hidden>
|
||||
{KEYS_HINT}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// PlanPanel is desktop's right panel (mock 2d): the Quest log and the
|
||||
// Regulars, segmented "Quest log · N | Regulars · N", each dinner with its
|
||||
// 48 px photo, name, difficulty and grip, and a link to swipe more Side
|
||||
// quests.
|
||||
export function PlanPanel() {
|
||||
const questLog = useQuestLog()
|
||||
const regulars = useRegulars()
|
||||
const [source, setSource] = useState<Source>('quest_log')
|
||||
const lists = { quest_log: questLog, regulars }
|
||||
const keysHintId = useId()
|
||||
|
||||
const label = (s: Source) => {
|
||||
const dinners = lists[s].data
|
||||
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
||||
}
|
||||
|
||||
return (
|
||||
<aside aria-label="Quest log and Regulars" className={styles.panel}>
|
||||
<Segmented
|
||||
label="Dinners to swap in"
|
||||
options={[
|
||||
{ value: 'quest_log', label: label('quest_log') },
|
||||
{ value: 'regulars', label: label('regulars') },
|
||||
]}
|
||||
value={source}
|
||||
onValueChange={setSource}
|
||||
/>
|
||||
<p className={styles.hint}>{DRAG_HINT}</p>
|
||||
<KeysHint id={keysHintId} />
|
||||
<ListBody source={source} list={lists[source]}>
|
||||
{(dinners) => (
|
||||
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||
{dinners.map((dinner) => (
|
||||
<Draggable
|
||||
key={dinner.id}
|
||||
dinner={dinner}
|
||||
fromQuestLog={source === 'quest_log'}
|
||||
className={styles.row}
|
||||
keysHintId={keysHintId}
|
||||
>
|
||||
{/* The row's name already says what the photo shows. */}
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="48px" className={styles.rowPhoto} />
|
||||
</span>
|
||||
<span className={styles.rowText}>
|
||||
<span className={styles.rowName}>{dinner.name}</span>
|
||||
{dinner.difficulty !== null && <span className={styles.rowMeta}>{DIFFICULTY_LABEL[dinner.difficulty]}</span>}
|
||||
</span>
|
||||
<Grip />
|
||||
</Draggable>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ListBody>
|
||||
<Link to="/discover" className={styles.more}>
|
||||
<Icon icon={Compass} size="inline" />
|
||||
Swipe more side quests
|
||||
</Link>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// QuestLogStrip is tablet's Quest log under the week's grid (mock 2c): each
|
||||
// dinner a chip with its 28 px photo and name, dragged as the panel's rows
|
||||
// are.
|
||||
export function QuestLogStrip() {
|
||||
const questLog = useQuestLog()
|
||||
const keysHintId = useId()
|
||||
const title = questLog.data === undefined ? sourceNames.quest_log : `${sourceNames.quest_log} · ${questLog.data.length}`
|
||||
return (
|
||||
<section aria-label={sourceNames.quest_log} className={styles.strip}>
|
||||
<div className={styles.stripHeader}>
|
||||
<h2 className={styles.stripTitle}>{title}</h2>
|
||||
<p className={styles.stripHint}>{DRAG_HINT}</p>
|
||||
</div>
|
||||
<KeysHint id={keysHintId} />
|
||||
<ListBody source="quest_log" list={questLog}>
|
||||
{(dinners) => (
|
||||
<ul aria-label={`${sourceNames.quest_log} dinners`} className={styles.chips}>
|
||||
{dinners.map((dinner) => (
|
||||
<Draggable key={dinner.id} dinner={dinner} fromQuestLog className={styles.chip} keysHintId={keysHintId}>
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-pill)" sizes="28px" className={styles.chipPhoto} />
|
||||
</span>
|
||||
{dinner.name}
|
||||
</Draggable>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ListBody>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* The mobile swap sheet: the Quest log and Regulars of desktop's plan panel
|
||||
(mock 2d) as a bottom sheet. */
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.replaces {
|
||||
margin: 2px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.rows {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* The 48 px washed photo; Photo applies the washed filter. */
|
||||
.photo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
/* A dinner already this week: shown, muted, and not a button. */
|
||||
.already {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
height: 72px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useRegulars } from '../dinners/api.ts'
|
||||
import { useQuestLog, type QuestLogDinner } from '../discover/api.ts'
|
||||
import { details } from '../discover/QuestLogSheet.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { ListRow } from '../ui/ListRow.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Sheet } from '../ui/Sheet.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import type { PlanDay, PlanDinner, Swap } from './api.ts'
|
||||
import { weekdayName } from './week.ts'
|
||||
import styles from './SwapSheet.module.css'
|
||||
|
||||
// Source is a list a dinner is swapped in from.
|
||||
export type Source = 'quest_log' | 'regulars'
|
||||
|
||||
export const sourceNames: Record<Source, string> = { quest_log: 'Quest log', regulars: 'Regulars' }
|
||||
|
||||
export const emptyLists: Record<Source, string> = {
|
||||
quest_log: 'Your Quest log is empty — swipe right on a Side quest to add one.',
|
||||
regulars: 'You have no Regulars yet.',
|
||||
}
|
||||
|
||||
// planDinner is a listed dinner as it is planned on a day.
|
||||
export function planDinner(dinner: QuestLogDinner): PlanDinner {
|
||||
return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method }
|
||||
}
|
||||
|
||||
export type SwapSheetProps = {
|
||||
// The day being swapped.
|
||||
day: PlanDay
|
||||
// The ids of the dinners already on the week's plan.
|
||||
planned: ReadonlySet<string>
|
||||
onSwap: (swap: Swap) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// SwapSheet is the mobile swap sheet: the Quest log and the Regulars, as
|
||||
// desktop's plan panel lists them, segmented "Quest log · N | Regulars · N",
|
||||
// each dinner with its 48 px photo, name, difficulty and minutes. Choosing a
|
||||
// dinner swaps it into the day; a dinner already this week can't be chosen.
|
||||
export function SwapSheet({ day, planned, onSwap, onClose }: SwapSheetProps) {
|
||||
const titleId = useId()
|
||||
const questLog = useQuestLog()
|
||||
const regulars = useRegulars()
|
||||
const [source, setSource] = useState<Source>('quest_log')
|
||||
const lists = { quest_log: questLog, regulars }
|
||||
const list = lists[source]
|
||||
|
||||
const label = (s: Source) => {
|
||||
const dinners = lists[s].data
|
||||
return dinners === undefined ? sourceNames[s] : `${sourceNames[s]} · ${dinners.length}`
|
||||
}
|
||||
|
||||
let body
|
||||
if (list.isError) {
|
||||
body = (
|
||||
<ErrorBanner onRetry={() => void list.refetch()}>
|
||||
Couldn't load your {sourceNames[source]}. {list.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
} else if (list.isPending) {
|
||||
body = (
|
||||
<div aria-busy="true" aria-label={`${sourceNames[source]} loading`} className={styles.rows}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} className={styles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (list.data.length === 0) {
|
||||
body = <p className={styles.empty}>{emptyLists[source]}</p>
|
||||
} else {
|
||||
body = (
|
||||
<ul aria-label={sourceNames[source]} className={styles.rows}>
|
||||
{list.data.map((dinner: QuestLogDinner) => {
|
||||
const already = planned.has(dinner.id)
|
||||
return (
|
||||
<li key={dinner.id}>
|
||||
<ListRow
|
||||
className={already ? styles.already : undefined}
|
||||
leading={
|
||||
// The row's name already says what the photo shows.
|
||||
<span aria-hidden="true">
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={dinner.name}
|
||||
meta={details(dinner)}
|
||||
// A dinner already this week is no button: it can't be chosen.
|
||||
onClick={
|
||||
already
|
||||
? undefined
|
||||
: () =>
|
||||
onSwap({ date: day.date, dinner: planDinner(dinner), fromQuestLog: source === 'quest_log' })
|
||||
}
|
||||
trailing={already && <Tag>Already this week</Tag>}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={styles.sheet}>
|
||||
<div>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Swap {weekdayName(day.date)}
|
||||
</h2>
|
||||
<p className={styles.replaces}>Replaces {day.dinner.name}</p>
|
||||
</div>
|
||||
<Segmented
|
||||
label="Swap in from"
|
||||
options={[
|
||||
{ value: 'quest_log', label: label('quest_log') },
|
||||
{ value: 'regulars', label: label('regulars') },
|
||||
]}
|
||||
value={source}
|
||||
onValueChange={setSource}
|
||||
/>
|
||||
{body}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { planKey, usePlan, useSwapDay, type Plan } from './api.ts'
|
||||
|
||||
const WEEK = '2026-10-05'
|
||||
const PLAN = `/api/v1/plans/${WEEK}`
|
||||
const MONDAY = `${PLAN}/days/2026-10-05`
|
||||
const FRIDAY = `${PLAN}/days/2026-10-09`
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
|
||||
// planBody is the plan of the week of Oct 5, dinners d0 to d6, with days
|
||||
// changed as given by index.
|
||||
function planBody(changes: Record<number, { id: string; name: string; is_new?: boolean; start_by?: string | null }> = {}) {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
week_start: WEEK,
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null as string | null,
|
||||
days: DAYS.map((date, i) => {
|
||||
const change = changes[i]
|
||||
return {
|
||||
date,
|
||||
dinner: { id: change?.id ?? `d${i}`, name: change?.name ?? `Dinner ${i}`, photo: null, minutes: null, method: null as string | null, origin: 'regular' },
|
||||
is_new: change?.is_new ?? false,
|
||||
start_by: change?.start_by ?? null,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// deferred is a reply the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const reply = new Promise<Response>((resolve) => (send = resolve))
|
||||
return { reply, send }
|
||||
}
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the Quest log; any other request fails the test.
|
||||
function stubServer(responders: Record<string, () => Response | Promise<Response>>) {
|
||||
const all: Record<string, () => Response | Promise<Response>> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
|
||||
...responders,
|
||||
}
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
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())
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// renderSwaps renders the week's plan and swaps over a query client.
|
||||
async function renderSwaps() {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient: QueryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(() => ({ plan: usePlan(WEEK), swap: useSwapDay(WEEK) }), { wrapper })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
return { ...hook, queryClient, plan: () => queryClient.getQueryData<Plan>(planKey(WEEK))! }
|
||||
}
|
||||
|
||||
const ORZO = { date: '2026-10-09', dinner: { id: 'q1', name: 'Lemon orzo', photo_id: undefined, minutes: null, method: null }, fromQuestLog: true }
|
||||
const FISH = { date: '2026-10-05', dinner: { id: 'r1', name: 'Fish tacos', photo_id: undefined, minutes: null, method: null }, fromQuestLog: false }
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// renderPlan renders the week's plan read over a query client.
|
||||
async function renderPlan() {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(() => usePlan(WEEK), { wrapper })
|
||||
await waitFor(() => expect(hook.result.current.isPending).toBe(false))
|
||||
return hook.result.current
|
||||
}
|
||||
|
||||
describe('usePlan', () => {
|
||||
it("reads each dinner's method and when the plan was pushed to Calendar", async () => {
|
||||
const body = planBody()
|
||||
body.days[2]!.dinner.method = 'crockpot'
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, { ...body, status: 'synced', calendar_synced_at: '2026-10-07T15:58:00Z' }) })
|
||||
const plan = await renderPlan()
|
||||
|
||||
expect(plan.data?.calendar_synced_at).toEqual(new Date('2026-10-07T15:58:00Z'))
|
||||
expect(plan.data?.days.map((day) => day.dinner.method)).toEqual([null, null, 'crockpot', null, null, null, null])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||
])('refuses a plan with %s', async (_, change, said) => {
|
||||
const body = planBody()
|
||||
change(body)
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, body) })
|
||||
const plan = await renderPlan()
|
||||
|
||||
expect(plan.error?.message).toContain(said)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSwapDay', () => {
|
||||
// Friday's swap is sent first, then Monday's; the server answers Monday's
|
||||
// with both swapped, Friday's with only Friday.
|
||||
const fridayPlan = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
const bothPlan = planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
|
||||
it.each([
|
||||
['the later swap answered first', ['monday', 'friday'] as const],
|
||||
['the earlier swap answered first', ['friday', 'monday'] as const],
|
||||
])('keeps the plan of the later of two swaps, %s', async (_, order) => {
|
||||
const replies = { friday: deferred(), monday: deferred() }
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => replies.friday.reply,
|
||||
[`PUT ${MONDAY}`]: () => replies.monday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
act(() => result.current.swap.mutate(FISH))
|
||||
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||
|
||||
// Whichever reply settles, Monday stays swapped: Friday's plan, the
|
||||
// earlier one, never lands over it.
|
||||
for (const [settled, day] of order.entries()) {
|
||||
await act(async () => replies[day].send(jsonResponse(200, day === 'friday' ? fridayPlan : bothPlan)))
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(1 - settled))
|
||||
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||
}
|
||||
expect(plan().days.map((day) => day.dinner.name)).toEqual(bothPlan.days.map((day) => day.dinner.name))
|
||||
})
|
||||
|
||||
it("does not take an earlier swap's failure for the week's", async () => {
|
||||
const friday = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||
})
|
||||
const { result, plan } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
act(() => result.current.swap.mutate(FISH))
|
||||
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||
|
||||
await waitFor(() => expect(result.current.swap.pending).toBe(false))
|
||||
expect(result.current.swap.failed).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a day a later plan read landed when the swap of it fails', async () => {
|
||||
const friday = deferred()
|
||||
// The read finds Friday already lemon orzo, starting by 2 pm.
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }))
|
||||
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true, start_by: '2026-10-09T14:00:00-04:00' } })
|
||||
await act(() => result.current.plan.refetch())
|
||||
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
|
||||
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days[4]).toEqual({ date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: '2026-10-09T14:00:00-04:00' })
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
})
|
||||
|
||||
it('refuses to dismiss a pending swap', async () => {
|
||||
const friday = deferred()
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||
const { result } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(result.current.swap.pending).toBe(true))
|
||||
expect(() => result.current.swap.dismiss()).toThrow('The week of 2026-10-05 has a swap pending, which cannot be dismissed.')
|
||||
await act(async () => friday.send(jsonResponse(200, planBody())))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/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]
|
||||
|
||||
// PlanDinner is a planned dinner: photo_id is its stored photo's id, from
|
||||
// photo, minutes its minutes from prep to table and method how it is
|
||||
// cooked, each when known.
|
||||
export type PlanDinner = { id: string; name: string; photo_id: string | undefined; minutes: number | null; method: Method | null }
|
||||
|
||||
// PlanDay is one day of a plan: its household-local date, the dinner
|
||||
// planned, whether that dinner is a Quest-log pick, and, for a crock-pot
|
||||
// dinner of known minutes, the instant it must start by.
|
||||
export type PlanDay = {
|
||||
date: string
|
||||
dinner: PlanDinner
|
||||
is_new: boolean
|
||||
start_by: string | null
|
||||
}
|
||||
|
||||
// Plan is GET /api/v1/plans/{weekStart}: the week's seven days, Monday to
|
||||
// Sunday, the AI's note on the draft, if any, and when it was last pushed to
|
||||
// Google Calendar, if ever. cached says the service worker answered from its
|
||||
// cache: the server may have changed the plan since.
|
||||
export type Plan = {
|
||||
id: string
|
||||
week_start: string
|
||||
status: PlanStatus
|
||||
ai_note: string | null
|
||||
calendar_synced_at: Date | null
|
||||
days: PlanDay[]
|
||||
cached: boolean
|
||||
}
|
||||
|
||||
// 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' ||
|
||||
(body.dinner.minutes !== null && !Number.isInteger(body.dinner.minutes)) ||
|
||||
typeof body.is_new !== 'boolean' ||
|
||||
(body.start_by !== null && typeof body.start_by !== 'string')
|
||||
) {
|
||||
throw new Error(`${where}: response has a day that is not a plan day`)
|
||||
}
|
||||
const dinner: PlanDinner = {
|
||||
id: body.dinner.id,
|
||||
name: body.dinner.name,
|
||||
photo_id: photoId(where, body.dinner.photo),
|
||||
minutes: body.dinner.minutes as number | null,
|
||||
method: body.dinner.method === null ? null : oneOf(where, 'method', METHODS, body.dinner.method),
|
||||
}
|
||||
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||
}
|
||||
|
||||
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
||||
function syncedAt(where: string, value: unknown): Date | null {
|
||||
if (value === null) return null
|
||||
const at = typeof value === 'string' ? new Date(value) : null
|
||||
if (at === null || Number.isNaN(at.getTime())) {
|
||||
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
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,
|
||||
calendar_synced_at: syncedAt(where, body.calendar_synced_at),
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Swap is one day's swap: the day, and the dinner chosen for it with whether
|
||||
// it was chosen from the Quest log.
|
||||
export type Swap = { date: string; dinner: PlanDinner; fromQuestLog: boolean }
|
||||
|
||||
// sameDay says whether two plan days are the same in every field.
|
||||
function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||
return (
|
||||
a.date === b.date &&
|
||||
a.dinner.id === b.dinner.id &&
|
||||
a.dinner.name === b.dinner.name &&
|
||||
a.dinner.photo_id === b.dinner.photo_id &&
|
||||
a.dinner.minutes === b.dinner.minutes &&
|
||||
a.dinner.method === b.dinner.method &&
|
||||
a.is_new === b.is_new &&
|
||||
a.start_by === b.start_by
|
||||
)
|
||||
}
|
||||
|
||||
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||
// cache, where they outlive the Draft board that sent them.
|
||||
function swapKey(weekStart: string) {
|
||||
return ['plans', weekStart, 'swap'] as const
|
||||
}
|
||||
|
||||
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
||||
// whether one is pending, and the failure of the latest, if it failed.
|
||||
export type SwapDay = {
|
||||
pending: boolean
|
||||
failed: { swap: Swap; error: Error } | null
|
||||
mutate: (chosen: Swap) => void
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
// useSwapDay swaps the dinner of one day of the week starting on weekStart
|
||||
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||
// server answers with the plan as swapped, which then replaces it. A refused
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile.
|
||||
// However the swap ends, the Quest log is read again: the server leaves a
|
||||
// dinner on the plan out of it.
|
||||
//
|
||||
// The week's swaps live in the mutation cache, not in the component, so
|
||||
// leaving the week and coming back keeps them: pending and failed are read
|
||||
// from there. Only the latest swap of the week speaks for it: the plan an
|
||||
// earlier one is answered with is not written over a later one, and its
|
||||
// failure is not the week's. dismiss leaves settled swaps behind; it is
|
||||
// refused while one is pending, so a later swap is always in the cache for
|
||||
// an earlier, pending one to see.
|
||||
export function useSwapDay(weekStart: string): SwapDay {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const key = planKey(weekStart)
|
||||
const mutationKey = swapKey(weekStart)
|
||||
const mutations = queryClient.getMutationCache()
|
||||
// The week's swaps, in the order sent.
|
||||
const swaps = useMutationState({
|
||||
filters: { mutationKey },
|
||||
select: (mutation) => ({ status: mutation.state.status, swap: mutation.state.variables as Swap, error: mutation.state.error }),
|
||||
})
|
||||
const latest = swaps.at(-1)
|
||||
|
||||
function mutate(chosen: Swap) {
|
||||
// isLatest says whether no swap of the week was sent after this one.
|
||||
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until dismissed: a later swap must stay in the cache while an
|
||||
// earlier one is pending.
|
||||
gcTime: Infinity,
|
||||
mutationFn: async ({ date, dinner }) => {
|
||||
const path = `${planPath(weekStart)}/days/${encodeURIComponent(date)}`
|
||||
const body = await apiFetch<unknown>(path, { method: 'PUT', body: { dinner_id: dinner.id } })
|
||||
return parsePlan(`PUT ${path}`, weekStart, body, false)
|
||||
},
|
||||
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
||||
await queryClient.cancelQueries({ queryKey: key })
|
||||
const plan = queryClient.getQueryData<Plan | null>(key)
|
||||
const previous = plan?.days.find((day) => day.date === date)
|
||||
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
||||
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
||||
return { previous, swapped }
|
||||
},
|
||||
onSuccess: (plan) => {
|
||||
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||
},
|
||||
// The day goes back only while it is still, field for field, the day
|
||||
// this swap wrote: a later swap or plan read that replaced it is left
|
||||
// alone.
|
||||
onError: (_error, _swap, context) => {
|
||||
if (context === undefined) return
|
||||
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
// A failure is the mutation's state, which failed reports.
|
||||
mutation.execute(chosen).catch(() => undefined)
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
const week = mutations.findAll({ mutationKey })
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) {
|
||||
throw new Error(`The week of ${weekStart} has a swap pending, which cannot be dismissed.`)
|
||||
}
|
||||
for (const mutation of week) mutations.remove(mutation)
|
||||
}
|
||||
|
||||
return {
|
||||
pending: swaps.some((swap) => swap.status === 'pending'),
|
||||
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||
mutate,
|
||||
dismiss,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* The drag's live region: read out, not shown. */
|
||||
.announcer {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type { PlanDay, PlanDinner, Swap } from './api.ts'
|
||||
import { weekdayName } from './week.ts'
|
||||
import styles from './drag.module.css'
|
||||
|
||||
// Pickup is a dinner picked up from the plan panel or the Quest-log strip,
|
||||
// and whether it is a Quest-log dinner.
|
||||
export type Pickup = { dinner: PlanDinner; fromQuestLog: boolean }
|
||||
|
||||
// Board is the week's grid as a drag drops onto it: its days, whether a
|
||||
// swap of the week is pending, and how a dinner dropped on a day is swapped
|
||||
// in.
|
||||
export type Board = { days: PlanDay[]; pending: boolean; drop: (swap: Swap) => void }
|
||||
|
||||
// Drag is the dinner being dragged, by pointer or keyboard, and the date of
|
||||
// the day it would replace, if any.
|
||||
export type Drag = { pickup: Pickup; keyboard: boolean; date: string | null }
|
||||
|
||||
type PlanDrag = {
|
||||
drag: Drag | null
|
||||
board: Board | null
|
||||
setBoard: (board: Board | null) => void
|
||||
message: string
|
||||
// start picks a dinner up, unless it cannot be dropped: there is no grid,
|
||||
// a swap of the week is pending, or the dinner is already this week. It
|
||||
// says whether it did.
|
||||
start: (pickup: Pickup, keyboard: boolean) => boolean
|
||||
// over moves the drag onto the day of date, or off every day (null).
|
||||
over: (date: string | null) => void
|
||||
// step moves a keyboard drag to the previous (-1) or next (1) day.
|
||||
step: (by: -1 | 1) => void
|
||||
// end drops the dinner on the day of date, or cancels the drag when date
|
||||
// is null. It says whether the dinner was dropped.
|
||||
end: (date: string | null) => boolean
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
const PlanDragContext = createContext<PlanDrag | null>(null)
|
||||
|
||||
function usePlanDrag(): PlanDrag {
|
||||
const context = useContext(PlanDragContext)
|
||||
if (context === null) throw new Error('usePlanDrag must be used inside PlanDragProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
// replaces says which dinner dropping on day replaces: "Friday: replaces
|
||||
// Pizza night."
|
||||
function replaces(day: PlanDay): string {
|
||||
return `${weekdayName(day.date)}: replaces ${day.dinner.name}.`
|
||||
}
|
||||
|
||||
// PlanDragProvider holds the drag of a dinner from the plan panel or the
|
||||
// Quest-log strip onto a day of the week's grid, which the grid registers as
|
||||
// the Board. Each step of a drag is announced to screen readers.
|
||||
export function PlanDragProvider({ children }: { children: ReactNode }) {
|
||||
const [board, setBoard] = useState<Board | null>(null)
|
||||
const [drag, setDrag] = useState<Drag | null>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
// The drag as last set, read by handlers that run before a render: a blur
|
||||
// the drop's focus move causes must not cancel the drag it just ended.
|
||||
const current = useRef<Drag | null>(null)
|
||||
|
||||
const value = useMemo((): PlanDrag => {
|
||||
function set(next: Drag | null) {
|
||||
current.current = next
|
||||
setDrag(next)
|
||||
}
|
||||
function dayOf(date: string | null): PlanDay | undefined {
|
||||
return board?.days.find((day) => day.date === date)
|
||||
}
|
||||
function cancel() {
|
||||
const now = current.current
|
||||
if (now === null) return
|
||||
set(null)
|
||||
setMessage(`Cancelled. ${now.pickup.dinner.name} was not swapped in.`)
|
||||
}
|
||||
return {
|
||||
drag,
|
||||
board,
|
||||
setBoard,
|
||||
message,
|
||||
start(pickup, keyboard) {
|
||||
const { name } = pickup.dinner
|
||||
if (board === null) {
|
||||
setMessage(`There is no plan this week to swap ${name} into.`)
|
||||
return false
|
||||
}
|
||||
if (board.pending) {
|
||||
setMessage(`A swap is still being saved. Pick ${name} up once it is done.`)
|
||||
return false
|
||||
}
|
||||
if (board.days.some((day) => day.dinner.id === pickup.dinner.id)) {
|
||||
setMessage(`${name} is already this week.`)
|
||||
return false
|
||||
}
|
||||
// A keyboard drag starts on Monday; a pointer drag on no day.
|
||||
const first = board.days[0]!
|
||||
set({ pickup, keyboard, date: keyboard ? first.date : null })
|
||||
setMessage(
|
||||
keyboard
|
||||
? `Picked up ${name}. ${replaces(first)} Use the arrow keys to choose a day, Enter to drop, Escape to cancel.`
|
||||
: `Picked up ${name}.`,
|
||||
)
|
||||
return true
|
||||
},
|
||||
over(date) {
|
||||
const now = current.current
|
||||
if (now === null || now.date === date) return
|
||||
set({ ...now, date })
|
||||
},
|
||||
step(by) {
|
||||
const now = current.current
|
||||
if (now === null || board === null) return
|
||||
const at = board.days.findIndex((day) => day.date === now.date)
|
||||
const next = board.days[Math.min(Math.max(at + by, 0), board.days.length - 1)]!
|
||||
set({ ...now, date: next.date })
|
||||
setMessage(replaces(next))
|
||||
},
|
||||
end(date) {
|
||||
const now = current.current
|
||||
if (now === null) return false
|
||||
const day = dayOf(date)
|
||||
if (day === undefined || board === null) {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
set(null)
|
||||
if (board.pending) {
|
||||
setMessage(`A swap is still being saved. ${now.pickup.dinner.name} was not swapped in.`)
|
||||
return false
|
||||
}
|
||||
board.drop({ date: day.date, dinner: now.pickup.dinner, fromQuestLog: now.pickup.fromQuestLog })
|
||||
setMessage(`Dropped ${now.pickup.dinner.name} on ${weekdayName(day.date)}, replacing ${day.dinner.name}.`)
|
||||
return true
|
||||
},
|
||||
cancel,
|
||||
}
|
||||
}, [board, drag, message])
|
||||
|
||||
return <PlanDragContext.Provider value={value}>{children}</PlanDragContext.Provider>
|
||||
}
|
||||
|
||||
// DragAnnouncer is the polite live region a drag's steps are announced in.
|
||||
export function DragAnnouncer() {
|
||||
const { message } = usePlanDrag()
|
||||
return (
|
||||
<p aria-live="polite" aria-atomic="true" data-testid="drag-announcer" className={styles.announcer}>
|
||||
{message}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// DropBoard registers the week's grid as the Board while it is shown.
|
||||
export function DropBoard({ days, pending, drop }: Board) {
|
||||
const { setBoard } = usePlanDrag()
|
||||
// drop is a new function on every render: the Board calls the latest.
|
||||
const latestDrop = useRef(drop)
|
||||
useLayoutEffect(() => {
|
||||
latestDrop.current = drop
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
setBoard({ days, pending, drop: (swap) => latestDrop.current(swap) })
|
||||
return () => setBoard(null)
|
||||
}, [setBoard, days, pending])
|
||||
return null
|
||||
}
|
||||
|
||||
// useDropTarget is the drag over the day of date, if it is over that day.
|
||||
export function useDropTarget(date: string): Drag | null {
|
||||
const { drag } = usePlanDrag()
|
||||
return drag !== null && drag.date === date ? drag : null
|
||||
}
|
||||
|
||||
// DROP_DATE is the attribute naming the date of a day a dinner drops on.
|
||||
export const DROP_DATE = 'data-drop-date'
|
||||
|
||||
// dateAt is the date of the day under the point (x, y), or null when it is
|
||||
// over no day.
|
||||
function dateAt(x: number, y: number): string | null {
|
||||
return document.elementFromPoint(x, y)?.closest(`[${DROP_DATE}]`)?.getAttribute(DROP_DATE) ?? null
|
||||
}
|
||||
|
||||
// A press becomes a drag once the pointer has moved this far, so a tap is
|
||||
// not one.
|
||||
const DRAG_THRESHOLD = 4
|
||||
|
||||
// The dragged copy floats this far below and to the right of the pointer,
|
||||
// as a drag image does, so it stays clear of the "Drop to swap" text
|
||||
// centred in the day the pointer is over. Where it would leave the
|
||||
// viewport it flips to the left of the pointer, or above it.
|
||||
const FLOAT_OFFSET = 48
|
||||
|
||||
// Press is a pointer held on a draggable: its id, where it went down, the
|
||||
// draggable's size, and whether it has become a drag.
|
||||
type Press = { pointerId: number; x: number; y: number; width: number; height: number; dragging: boolean }
|
||||
|
||||
// Float is where the dragged copy of a draggable is drawn, and how wide.
|
||||
export type Float = { left: number; top: number; width: number }
|
||||
|
||||
// floatAt is where the dragged copy of the held draggable is drawn for the
|
||||
// pointer at (x, y): offset below right of it, flipped where that would
|
||||
// leave the viewport.
|
||||
function floatAt(held: Press, x: number, y: number): Float {
|
||||
const right = x + FLOAT_OFFSET
|
||||
const below = y + FLOAT_OFFSET
|
||||
return {
|
||||
left: right + held.width > window.innerWidth ? x - FLOAT_OFFSET - held.width : right,
|
||||
top: below + held.height > window.innerHeight ? y - FLOAT_OFFSET - held.height : below,
|
||||
width: held.width,
|
||||
}
|
||||
}
|
||||
|
||||
// useDraggable makes a panel row or a strip chip a draggable dinner. With a
|
||||
// pointer it is pressed, captured and moved past DRAG_THRESHOLD to pick it
|
||||
// up, its copy floats with the pointer, and letting go over a day drops it
|
||||
// there, anywhere else cancels. With the keyboard Enter or Space picks it
|
||||
// up onto Monday, the arrow keys choose the day, Enter drops it and
|
||||
// Escape, or leaving it, cancels; a drop moves focus to the day's swap
|
||||
// button, as a Quest-log dinner leaves the Quest log once planned.
|
||||
export function useDraggable(pickup: Pickup) {
|
||||
const plan = usePlanDrag()
|
||||
const [float, setFloat] = useState<Float | null>(null)
|
||||
const press = useRef<Press | null>(null)
|
||||
const drag = plan.drag !== null && plan.drag.pickup.dinner.id === pickup.dinner.id ? plan.drag : null
|
||||
const disabled = plan.drag === null && (plan.board === null || plan.board.pending || plan.board.days.some((day) => day.dinner.id === pickup.dinner.id))
|
||||
|
||||
// A draggable that goes mid-drag, as on crossing to mobile, cancels it;
|
||||
// once the drag has ended, cancel does nothing.
|
||||
const { cancel } = plan
|
||||
const dragging = drag !== null
|
||||
const latestCancel = useRef(cancel)
|
||||
useLayoutEffect(() => {
|
||||
latestCancel.current = cancel
|
||||
})
|
||||
useEffect(() => {
|
||||
if (!dragging) return
|
||||
return () => latestCancel.current()
|
||||
}, [dragging])
|
||||
|
||||
function release() {
|
||||
press.current = null
|
||||
setFloat(null)
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent<HTMLElement>) {
|
||||
if (press.current !== null || plan.drag !== null || !event.isPrimary || event.button !== 0) return
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
press.current = {
|
||||
pointerId: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
dragging: false,
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent<HTMLElement>) {
|
||||
const held = press.current
|
||||
if (held === null || held.pointerId !== event.pointerId) return
|
||||
if (!held.dragging) {
|
||||
if (Math.hypot(event.clientX - held.x, event.clientY - held.y) < DRAG_THRESHOLD) return
|
||||
if (!plan.start(pickup, false)) {
|
||||
press.current = null
|
||||
return
|
||||
}
|
||||
held.dragging = true
|
||||
}
|
||||
setFloat(floatAt(held, event.clientX, event.clientY))
|
||||
plan.over(dateAt(event.clientX, event.clientY))
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent<HTMLElement>) {
|
||||
const held = press.current
|
||||
if (held === null || held.pointerId !== event.pointerId) return
|
||||
release()
|
||||
if (held.dragging) plan.end(dateAt(event.clientX, event.clientY))
|
||||
}
|
||||
|
||||
// A cancel, or losing the capture before the release, cancels the drag.
|
||||
// After a release the press is already over, so this ends only a drag
|
||||
// whose capture was lost before it.
|
||||
function onPointerCancel(event: PointerEvent<HTMLElement>) {
|
||||
const held = press.current
|
||||
if (held === null || held.pointerId !== event.pointerId) return
|
||||
release()
|
||||
if (held.dragging) plan.cancel()
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLElement>) {
|
||||
const button = event.currentTarget
|
||||
if (drag === null) {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
if (plan.drag === null) plan.start(pickup, true)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
release()
|
||||
plan.cancel()
|
||||
return
|
||||
}
|
||||
if (!drag.keyboard) return
|
||||
// Space picked it up and does not drop it; it does not scroll the page.
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') plan.step(-1)
|
||||
else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') plan.step(1)
|
||||
else if (event.key === 'Enter') drop(button.ownerDocument, drag.date)
|
||||
else if (event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// drop ends a keyboard drag on the day of date and, once dropped, moves
|
||||
// focus to that day's swap button.
|
||||
function drop(document: Document, date: string | null) {
|
||||
if (!plan.end(date)) return
|
||||
const swapButton = document.querySelector<HTMLElement>(`[${DROP_DATE}="${date}"] button`)
|
||||
if (swapButton === null) throw new Error(`The day ${date} dropped on has no swap button to focus.`)
|
||||
swapButton.focus()
|
||||
}
|
||||
|
||||
// Leaving a keyboard drag cancels it.
|
||||
function onBlur() {
|
||||
if (drag !== null && drag.keyboard) plan.cancel()
|
||||
}
|
||||
|
||||
return {
|
||||
drag,
|
||||
float,
|
||||
props: {
|
||||
'aria-disabled': disabled || undefined,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onLostPointerCapture: onPointerCancel,
|
||||
onKeyDown,
|
||||
onBlur,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
|
||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
||||
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||
|
||||
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('names the day of a date in full', () => {
|
||||
expect(weekdayName('2026-10-09')).toBe('Friday')
|
||||
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
||||
})
|
||||
|
||||
it('reads the day of the month of a date', () => {
|
||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||
expect(() => dayOfMonth('2026-10-32')).toThrow('not a real date')
|
||||
})
|
||||
|
||||
it('refuses a week start that is not a Monday, and a date that is not real', () => {
|
||||
expect(() => weekLabel('2026-10-06')).toThrow('week start 2026-10-06 is not a Monday')
|
||||
expect(() => addDays('2026-02-30', 1)).toThrow('not a real date')
|
||||
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,110 @@
|
||||
// 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()]!
|
||||
}
|
||||
|
||||
// dayOfMonth is date's day of the month: 1 to 31.
|
||||
export function dayOfMonth(date: string): number {
|
||||
return midnightUTC(date).getUTCDate()
|
||||
}
|
||||
|
||||
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' })
|
||||
|
||||
// weekdayName is date's day in full: "Monday" to "Sunday".
|
||||
export function weekdayName(date: string): string {
|
||||
return weekday.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -19,3 +19,8 @@
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@@ -15,4 +15,9 @@ describe('Tag', () => {
|
||||
render(<Tag tone="teal">Synced</Tag>)
|
||||
expect(screen.getByText('Synced')).toHaveClass(styles.teal!)
|
||||
})
|
||||
|
||||
it('supports the neutral tone', () => {
|
||||
render(<Tag tone="neutral">Regular</Tag>)
|
||||
expect(screen.getByText('Regular')).toHaveClass(styles.neutral!)
|
||||
})
|
||||
})
|
||||
|
||||
+3
-2
@@ -1,13 +1,14 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Tag.module.css'
|
||||
|
||||
export type TagTone = 'grape' | 'teal'
|
||||
export type TagTone = 'grape' | 'teal' | 'neutral'
|
||||
|
||||
export type TagProps = ComponentPropsWithRef<'span'> & {
|
||||
tone?: TagTone
|
||||
}
|
||||
|
||||
// Static label such as "Draft", "Side quest" or "Synced". Not interactive.
|
||||
// Static label such as "Draft", "Side quest", "Synced" or "Regular". Not
|
||||
// interactive.
|
||||
export function Tag({ tone = 'grape', className, ...rest }: TagProps) {
|
||||
const classes = [styles.tag, styles[tone], className].filter(Boolean).join(' ')
|
||||
return <span className={classes} {...rest} />
|
||||
|
||||
Reference in New Issue
Block a user