Add the household settings screen
/settings/household edits the time zone (a searchable IANA list with each zone's current UTC offset), dinner time, day-of reminder, rotation check, and the plan draft, new suggestions and Google Chat Supply run day and time, with plain-word copy for each and "Changes apply to everyone in the household." Save PATCHes only the changed fields; the API's invalid_settings problems show under their fields. The leave guard and confirm sheet move out of the AI settings page so both screens share them. Maestro task 48f8b8c3-7d5c-415d-9a61-980dc065bb90. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+14
-2
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
|||||||
import { mockViewport } from './test/matchMedia.ts'
|
import { mockViewport } from './test/matchMedia.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||||
import App, { routes } from './App.tsx'
|
import App, { routes } from './App.tsx'
|
||||||
import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||||
import { InDataRouter } from './test/dataRouter.tsx'
|
import { InDataRouter } from './test/dataRouter.tsx'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -27,7 +27,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The routes that render a built screen rather than a placeholder page.
|
// The routes that render a built screen rather than a placeholder page.
|
||||||
const SCREENS = { '/settings': true, '/settings/ai': true }
|
const SCREENS = { '/settings': true, '/settings/ai': true, '/settings/household': true }
|
||||||
|
|
||||||
describe('App routes', () => {
|
describe('App routes', () => {
|
||||||
it('registers every handoff route plus the extra settings pages', () => {
|
it('registers every handoff route plus the extra settings pages', () => {
|
||||||
@@ -87,6 +87,18 @@ describe('App routes', () => {
|
|||||||
expect(screen.getByRole('main')).toContainElement(heading)
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('/settings/household renders the household settings screen inside the shell', async () => {
|
||||||
|
mockViewport(390)
|
||||||
|
stubFetchByPath({
|
||||||
|
'/api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
|
})
|
||||||
|
renderAt('/settings/household')
|
||||||
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Household' })
|
||||||
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
|
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('App bootstrap', () => {
|
describe('App bootstrap', () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
|||||||
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
import { OfflinePill } from './pwa/OfflinePill.tsx'
|
||||||
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||||
|
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||||
import { AppShell } from './shell/AppShell.tsx'
|
import { AppShell } from './shell/AppShell.tsx'
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export const routes = [
|
|||||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
|
'/settings/household': <HouseholdSettingsPage />,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChevronLeft } from 'lucide-react'
|
import { ChevronLeft } from 'lucide-react'
|
||||||
import { useEffect, useId, useState, type ReactNode } from 'react'
|
import { useEffect, useId, useState, type ReactNode } from 'react'
|
||||||
import { useBlocker, useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { ApiError } from '../api/index.ts'
|
import { ApiError } from '../api/index.ts'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
type AISettingsPatch,
|
type AISettingsPatch,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
import styles from './AISettingsPage.module.css'
|
import styles from './AISettingsPage.module.css'
|
||||||
|
import { Confirm, LeaveGuard } from './LeaveGuard.tsx'
|
||||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||||
|
|
||||||
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
|
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
|
||||||
@@ -57,31 +58,6 @@ export function AISettingsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LeaveGuard asks before leaving the screen with unsaved changes.
|
|
||||||
function LeaveGuard({ dirty }: { dirty: boolean }) {
|
|
||||||
const blocker = useBlocker(
|
|
||||||
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dirty) return
|
|
||||||
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
|
||||||
window.addEventListener('beforeunload', onBeforeUnload)
|
|
||||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
|
||||||
}, [dirty])
|
|
||||||
return (
|
|
||||||
<Confirm
|
|
||||||
open={blocker.state === 'blocked'}
|
|
||||||
title="Leave without saving?"
|
|
||||||
confirmLabel="Leave"
|
|
||||||
cancelLabel="Keep editing"
|
|
||||||
onCancel={() => blocker.reset?.()}
|
|
||||||
onConfirm={() => blocker.proceed?.()}
|
|
||||||
>
|
|
||||||
Your changes on this screen have not been saved.
|
|
||||||
</Confirm>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Header() {
|
function Header() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
@@ -537,52 +513,3 @@ function PromptField({ value, onChange }: { value: string; onChange: (value: str
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfirmProps = {
|
|
||||||
open: boolean
|
|
||||||
title: string
|
|
||||||
children: ReactNode
|
|
||||||
confirmLabel: string
|
|
||||||
cancelLabel?: string
|
|
||||||
busy?: boolean
|
|
||||||
error?: string | null
|
|
||||||
onCancel: () => void
|
|
||||||
onConfirm: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function Confirm({
|
|
||||||
open,
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
confirmLabel,
|
|
||||||
cancelLabel = 'Cancel',
|
|
||||||
busy = false,
|
|
||||||
error = null,
|
|
||||||
onCancel,
|
|
||||||
onConfirm,
|
|
||||||
}: ConfirmProps) {
|
|
||||||
const titleId = useId()
|
|
||||||
return (
|
|
||||||
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
|
||||||
<div className={styles.sheet}>
|
|
||||||
<h2 id={titleId} className={styles.sheetTitle}>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<p className={styles.sheetText}>{children}</p>
|
|
||||||
{error !== null && (
|
|
||||||
<p className={styles.error} role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className={styles.sheetActions}>
|
|
||||||
<Button variant="outline" onClick={onCancel}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button disabled={busy} onClick={onConfirm}>
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/* Settings · Household. */
|
||||||
|
.page {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 26px;
|
||||||
|
background: var(--color-accent-2-100);
|
||||||
|
color: var(--color-accent-2-900);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
height: 420px;
|
||||||
|
border-radius: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beside a day dropdown, a time matches its height. */
|
||||||
|
.pairedTime {
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint,
|
||||||
|
.error,
|
||||||
|
.status {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.errors {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: var(--color-accent-2-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0 6px 0 20px;
|
||||||
|
border: 1px solid var(--color-neutral-300);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneValue {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offset {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.change {
|
||||||
|
flex: none;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: auto;
|
||||||
|
display: grid;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneList {
|
||||||
|
max-height: 50vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneOption {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneOption .zoneName {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoneOption[aria-pressed='true'] {
|
||||||
|
background: var(--color-accent-2-100);
|
||||||
|
color: var(--color-accent-2-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { Route, Routes, useLocation } from 'react-router'
|
||||||
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
|
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||||
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { householdSettingsProblems } from './api.ts'
|
||||||
|
import { HouseholdSettingsPage } from './HouseholdSettingsPage.tsx'
|
||||||
|
import { matchesZone, utcOffset, zoneLabel } from './timezones.ts'
|
||||||
|
|
||||||
|
type Responder = (body: unknown) => Response | Promise<Response>
|
||||||
|
|
||||||
|
// stubAPI answers "METHOD path" from responders, each called with the
|
||||||
|
// request's decoded JSON body; GET /settings/household answers
|
||||||
|
// HOUSEHOLD_SETTINGS unless overridden. Any other request fails the test.
|
||||||
|
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
|
||||||
|
const all: Record<string, Responder> = {
|
||||||
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
|
...responders,
|
||||||
|
}
|
||||||
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
|
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
|
||||||
|
return Promise.resolve(respond(body))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
return fetchMock
|
||||||
|
}
|
||||||
|
|
||||||
|
// sent lists the requests other than GETs, as "METHOD path" and body.
|
||||||
|
function sent(fetchMock: Mock<typeof fetch>) {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||||
|
.map(([input, init]) => ({
|
||||||
|
request: `${init?.method} ${String(input)}`,
|
||||||
|
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
function CurrentPath() {
|
||||||
|
return <p data-testid="path">{useLocation().pathname}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||||
|
// the page at /settings/household in a data router, so its leave guard works.
|
||||||
|
async function renderPage() {
|
||||||
|
const api = createApiClient(
|
||||||
|
fakeLocation('/settings/household'),
|
||||||
|
createNetworkStatus(window),
|
||||||
|
createVerifiedSession(localStorage),
|
||||||
|
)
|
||||||
|
await api.loadMe()
|
||||||
|
render(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<InDataRouter path="/settings/household">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/settings/household" element={<HouseholdSettingsPage />} />
|
||||||
|
<Route path="*" element={<CurrentPath />} />
|
||||||
|
</Routes>
|
||||||
|
</InDataRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
await screen.findByLabelText('Dinner time')
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => screen.getByRole('button', { name: 'Save changes' })
|
||||||
|
const input = (name: string) => screen.getByLabelText(name)
|
||||||
|
const zoneGroup = () => screen.getByRole('group', { name: 'Time zone' })
|
||||||
|
|
||||||
|
describe('HouseholdSettingsPage', () => {
|
||||||
|
it('loads every household setting with plain-word copy', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Household' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Changes apply to everyone in the household.')).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(zoneGroup()).toHaveTextContent('America / New York')
|
||||||
|
expect(zoneGroup()).toHaveTextContent(utcOffset('America/New_York', new Date()))
|
||||||
|
expect(input('Dinner time')).toHaveValue('17:30')
|
||||||
|
expect(input('Dinner time')).toHaveAccessibleDescription(/crock-pot dinners count back from it/)
|
||||||
|
expect(input('Day-of reminder')).toHaveValue('16:30')
|
||||||
|
expect(input('Rotation check')).toHaveValue('09:00')
|
||||||
|
expect(input('Rotation check')).toHaveAccessibleDescription(/When we ask whether to keep a new dinner/)
|
||||||
|
expect(input('Plan draft day')).toHaveValue('5')
|
||||||
|
expect(within(input('Plan draft day')).getByRole('option', { selected: true })).toHaveTextContent('Friday')
|
||||||
|
expect(input('Plan draft time')).toHaveValue('09:00')
|
||||||
|
expect(input('New suggestions day')).toHaveValue('0')
|
||||||
|
expect(input('New suggestions time')).toHaveValue('08:00')
|
||||||
|
expect(input('Google Chat Supply run day')).toHaveValue('6')
|
||||||
|
expect(input('Google Chat Supply run time')).toHaveValue('08:00')
|
||||||
|
expect(input('Google Chat Supply run time')).toHaveAccessibleDescription(/Supply run is sent to your Google Chat/)
|
||||||
|
expect(save()).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a load failure with a retry', async () => {
|
||||||
|
stubAPI({ 'GET /api/v1/settings/household': () => errorResponse(500, 'internal') })
|
||||||
|
const api = createApiClient(
|
||||||
|
fakeLocation('/settings/household'),
|
||||||
|
createNetworkStatus(window),
|
||||||
|
createVerifiedSession(localStorage),
|
||||||
|
)
|
||||||
|
render(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<InDataRouter path="/settings/household">
|
||||||
|
<HouseholdSettingsPage />
|
||||||
|
</InDataRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Household settings could not load: internal message')
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves only the edited fields', async () => {
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
|
||||||
|
fireEvent.change(input('Rotation check'), { target: { value: '10:00' } })
|
||||||
|
fireEvent.change(input('Plan draft day'), { target: { value: '4' } })
|
||||||
|
fireEvent.change(input('Google Chat Supply run time'), { target: { value: '07:30' } })
|
||||||
|
// Changed and changed back: not sent.
|
||||||
|
fireEvent.change(input('Day-of reminder'), { target: { value: '16:00' } })
|
||||||
|
fireEvent.change(input('Day-of reminder'), { target: { value: '16:30' } })
|
||||||
|
fireEvent.click(save())
|
||||||
|
|
||||||
|
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
|
||||||
|
expect(sent(fetchMock)).toEqual([
|
||||||
|
{
|
||||||
|
request: 'PATCH /api/v1/settings/household',
|
||||||
|
body: { dinner_time: '18:15', review_time: '10:00', plan_draft_dow: 4, chat_autosend_time: '07:30' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(input('Dinner time')).toHaveValue('18:15')
|
||||||
|
expect(save()).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('chooses a time zone from the searchable list', async () => {
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||||
|
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
|
||||||
|
const list = within(sheet).getByRole('list', { name: 'Time zones' })
|
||||||
|
expect(within(list).getByRole('button', { pressed: true })).toHaveAccessibleName(
|
||||||
|
`America / New York, ${utcOffset('America/New_York', new Date())}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
|
||||||
|
target: { value: 'los angeles' },
|
||||||
|
})
|
||||||
|
const options = within(list).getAllByRole('button')
|
||||||
|
expect(options).toHaveLength(1)
|
||||||
|
expect(within(sheet).getByText('1 time zone')).toBeInTheDocument()
|
||||||
|
expect(options[0]).toHaveAccessibleName(`America / Los Angeles, ${utcOffset('America/Los_Angeles', new Date())}`)
|
||||||
|
fireEvent.click(options[0]!)
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||||
|
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
|
||||||
|
fireEvent.click(save())
|
||||||
|
await screen.findByText('Changes saved.')
|
||||||
|
expect(sent(fetchMock)).toEqual([
|
||||||
|
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'America/Los_Angeles' } },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says when no time zone matches the search', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||||
|
const sheet = screen.getByRole('dialog', { name: 'Choose a time zone' })
|
||||||
|
fireEvent.change(within(sheet).getByRole('searchbox'), { target: { value: 'Atlantis' } })
|
||||||
|
expect(within(sheet).getByText('No time zone matches.')).toBeInTheDocument()
|
||||||
|
expect(within(sheet).queryAllByRole('listitem')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the API validation errors beside their fields', async () => {
|
||||||
|
stubAPI({
|
||||||
|
'PATCH /api/v1/settings/household': () =>
|
||||||
|
jsonResponse(400, {
|
||||||
|
error: {
|
||||||
|
code: 'invalid_settings',
|
||||||
|
message:
|
||||||
|
'invalid household settings: timezone: unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York); dinner_time: invalid time "" (want 24-hour HH:MM, 00:00-23:59)',
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.change(input('Dinner time'), { target: { value: '' } })
|
||||||
|
fireEvent.click(save())
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: fix the settings marked below their fields.')
|
||||||
|
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'true')
|
||||||
|
expect(input('Dinner time')).toHaveAccessibleDescription(/^invalid time "" \(want 24-hour HH:MM, 00:00-23:59\)/)
|
||||||
|
expect(zoneGroup()).toHaveAccessibleDescription(
|
||||||
|
'unknown time zone "Mars/Olympus" (want an IANA zone such as America/New_York)',
|
||||||
|
)
|
||||||
|
expect(input('Day-of reminder')).toHaveAttribute('aria-invalid', 'false')
|
||||||
|
|
||||||
|
// Editing again clears the refused save's errors.
|
||||||
|
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
expect(input('Dinner time')).toHaveAttribute('aria-invalid', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error that names no field as a whole-form error', async () => {
|
||||||
|
stubAPI({ 'PATCH /api/v1/settings/household': () => errorResponse(500, 'internal') })
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.change(input('Dinner time'), { target: { value: '18:00' } })
|
||||||
|
fireEvent.click(save())
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: internal message')
|
||||||
|
expect(input('Dinner time')).toHaveValue('18:00')
|
||||||
|
expect(save()).toBeEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks before leaving with unsaved changes', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.change(input('New suggestions time'), { target: { value: '07:00' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||||
|
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
|
||||||
|
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(input('New suggestions time')).toHaveValue('07:00')
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||||
|
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Leave' }))
|
||||||
|
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves without asking when nothing changed', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||||
|
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps 44 px tap targets', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
for (const control of [
|
||||||
|
screen.getByRole('button', { name: 'Back to Settings' }),
|
||||||
|
within(zoneGroup()).getByRole('button', { name: 'Change' }),
|
||||||
|
input('Dinner time'),
|
||||||
|
input('Plan draft day'),
|
||||||
|
input('Plan draft time'),
|
||||||
|
save(),
|
||||||
|
]) {
|
||||||
|
expectMinHitArea(control)
|
||||||
|
}
|
||||||
|
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
|
||||||
|
expectMinHitArea(within(screen.getByRole('dialog')).getAllByRole('button', { pressed: true })[0]!)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('householdSettingsProblems', () => {
|
||||||
|
it('reads each field the server names', () => {
|
||||||
|
const error = new ApiError(
|
||||||
|
400,
|
||||||
|
'invalid_settings',
|
||||||
|
'invalid household settings: plan_draft_dow: invalid day of the week 9 (want 0-6, Sunday = 0); review_time: invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(householdSettingsProblems(error)).toEqual({
|
||||||
|
fields: {
|
||||||
|
plan_draft_dow: 'invalid day of the week 9 (want 0-6, Sunday = 0)',
|
||||||
|
review_time: 'invalid time "9" (want 24-hour HH:MM, 00:00-23:59)',
|
||||||
|
},
|
||||||
|
other: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a problem naming no field, and any other error, whole', () => {
|
||||||
|
expect(
|
||||||
|
householdSettingsProblems(
|
||||||
|
new ApiError(400, 'invalid_settings', 'invalid household settings: settings: no settings to change', false),
|
||||||
|
),
|
||||||
|
).toEqual({ fields: {}, other: ['settings: no settings to change'] })
|
||||||
|
expect(
|
||||||
|
householdSettingsProblems(new ApiError(400, 'invalid_request', 'plan_draft_dow: want a JSON int', false)),
|
||||||
|
).toEqual({ fields: {}, other: ['plan_draft_dow: want a JSON int'] })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('time zones', () => {
|
||||||
|
it('formats the UTC offset', () => {
|
||||||
|
const winter = new Date('2026-01-15T12:00:00Z')
|
||||||
|
expect(utcOffset('America/New_York', winter)).toBe('UTC-05:00')
|
||||||
|
expect(utcOffset('Asia/Kolkata', winter)).toBe('UTC+05:30')
|
||||||
|
expect(utcOffset('UTC', winter)).toBe('UTC+00:00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches every search word against the name and offset', () => {
|
||||||
|
const option = { zone: 'America/Argentina/Buenos_Aires', offset: 'UTC-03:00' }
|
||||||
|
expect(zoneLabel(option.zone)).toBe('America / Argentina / Buenos Aires')
|
||||||
|
expect(matchesZone(option, 'buenos aires')).toBe(true)
|
||||||
|
expect(matchesZone(option, 'Buenos_Aires')).toBe(true)
|
||||||
|
expect(matchesZone(option, 'utc-03')).toBe(true)
|
||||||
|
expect(matchesZone(option, 'buenos paris')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { Check, ChevronLeft } from 'lucide-react'
|
||||||
|
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { Button } from '../ui/Button.tsx'
|
||||||
|
import { Card } from '../ui/Card.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
|
import { Select } from '../ui/Select.tsx'
|
||||||
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { TextInput } from '../ui/TextInput.tsx'
|
||||||
|
import {
|
||||||
|
householdSettingsProblems,
|
||||||
|
useHouseholdSettings,
|
||||||
|
useSaveHouseholdSettings,
|
||||||
|
type HouseholdSettings,
|
||||||
|
type HouseholdSettingsField,
|
||||||
|
type HouseholdSettingsPatch,
|
||||||
|
} from './api.ts'
|
||||||
|
import styles from './HouseholdSettingsPage.module.css'
|
||||||
|
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||||
|
import { matchesZone, timeZoneOptions, utcOffset, zoneLabel } from './timezones.ts'
|
||||||
|
|
||||||
|
// Days of the week as the server counts them, from Sunday = 0.
|
||||||
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] as const
|
||||||
|
|
||||||
|
// Settings · Household: the time zone and the times the household's
|
||||||
|
// reminders and weekly AI runs happen at. Every field is edited as a draft
|
||||||
|
// and saved together; only the changed fields are sent.
|
||||||
|
export function HouseholdSettingsPage() {
|
||||||
|
const settings = useHouseholdSettings()
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
<p className={styles.shared}>Changes apply to everyone in the household.</p>
|
||||||
|
{settings.isPending && <Skeleton className={styles.loading} />}
|
||||||
|
{settings.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||||
|
Household settings could not load: {settings.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{settings.isSuccess && <HouseholdSettingsForm saved={settings.data} onDirtyChange={setDirty} />}
|
||||||
|
<LeaveGuard dirty={dirty} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||||
|
<Icon icon={ChevronLeft} size="nav" />
|
||||||
|
</IconButton>
|
||||||
|
<div>
|
||||||
|
<p className={styles.kicker}>Settings</p>
|
||||||
|
<h1 className={styles.title}>Household</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchOf is the PATCH body for the fields draft changes.
|
||||||
|
function patchOf(saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettingsPatch {
|
||||||
|
const patch: Record<string, string | number> = {}
|
||||||
|
for (const field of Object.keys(saved) as HouseholdSettingsField[]) {
|
||||||
|
if (draft[field] !== saved[field]) patch[field] = draft[field]
|
||||||
|
}
|
||||||
|
return patch as HouseholdSettingsPatch
|
||||||
|
}
|
||||||
|
|
||||||
|
type HouseholdSettingsFormProps = {
|
||||||
|
saved: HouseholdSettings
|
||||||
|
// Called with whether the form holds unsaved changes.
|
||||||
|
onDirtyChange: (dirty: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
|
||||||
|
const [draft, setDraft] = useState(saved)
|
||||||
|
const save = useSaveHouseholdSettings()
|
||||||
|
const patch = patchOf(saved, draft)
|
||||||
|
const dirty = Object.keys(patch).length > 0
|
||||||
|
const problems = save.isError ? householdSettingsProblems(save.error) : { fields: {}, other: [] }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange(dirty)
|
||||||
|
}, [dirty, onDirtyChange])
|
||||||
|
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||||
|
|
||||||
|
function edit(change: Partial<HouseholdSettings>) {
|
||||||
|
save.reset()
|
||||||
|
setDraft((current) => ({ ...current, ...change }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
save.mutate(patch, { onSuccess: setDraft })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section title="Time zone">
|
||||||
|
<TimeZoneField value={draft.timezone} onChange={(timezone) => edit({ timezone })} error={problems.fields.timezone} />
|
||||||
|
<p className={styles.hint}>Every time on this screen is in this zone.</p>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Dinner">
|
||||||
|
<TimeField
|
||||||
|
draft={draft}
|
||||||
|
edit={edit}
|
||||||
|
problems={problems.fields}
|
||||||
|
name="dinner_time"
|
||||||
|
label="Dinner time"
|
||||||
|
hint="When dinner is on the table. Calendar events start then, and crock-pot dinners count back from it to show when to start."
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
draft={draft}
|
||||||
|
edit={edit}
|
||||||
|
problems={problems.fields}
|
||||||
|
name="reminder_time"
|
||||||
|
label="Day-of reminder"
|
||||||
|
hint="When we remind you what's for dinner tonight."
|
||||||
|
/>
|
||||||
|
<TimeField
|
||||||
|
draft={draft}
|
||||||
|
edit={edit}
|
||||||
|
problems={problems.fields}
|
||||||
|
name="review_time"
|
||||||
|
label="Rotation check"
|
||||||
|
hint="When we ask whether to keep a new dinner: the day after you try a Quest-log dinner, we ask “Keep it in rotation?”"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Every week">
|
||||||
|
<DayTimeField
|
||||||
|
draft={draft}
|
||||||
|
edit={edit}
|
||||||
|
problems={problems.fields}
|
||||||
|
day="plan_draft_dow"
|
||||||
|
time="plan_draft_time"
|
||||||
|
label="Plan draft"
|
||||||
|
hint="When the AI drafts next week's plan on the Draft board for you to adjust."
|
||||||
|
/>
|
||||||
|
<DayTimeField
|
||||||
|
draft={draft}
|
||||||
|
edit={edit}
|
||||||
|
problems={problems.fields}
|
||||||
|
day="suggestions_dow"
|
||||||
|
time="suggestions_time"
|
||||||
|
label="New suggestions"
|
||||||
|
hint="When a fresh batch of Side quests arrives to swipe through."
|
||||||
|
/>
|
||||||
|
<DayTimeField
|
||||||
|
draft={draft}
|
||||||
|
edit={edit}
|
||||||
|
problems={problems.fields}
|
||||||
|
day="chat_autosend_dow"
|
||||||
|
time="chat_autosend_time"
|
||||||
|
label="Google Chat Supply run"
|
||||||
|
hint="When the week's Supply run is sent to your Google Chat space."
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className={styles.status} role="status">
|
||||||
|
{save.isSuccess && !dirty && 'Changes saved.'}
|
||||||
|
</div>
|
||||||
|
{save.isError && (
|
||||||
|
<div className={styles.errors} role="alert">
|
||||||
|
{Object.keys(problems.fields).length > 0 && (
|
||||||
|
<p className={styles.error}>Not saved: fix the settings marked below their fields.</p>
|
||||||
|
)}
|
||||||
|
{problems.other.map((reason) => (
|
||||||
|
<p key={reason} className={styles.error}>
|
||||||
|
Not saved: {reason}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button className={styles.save} disabled={!dirty || save.isPending} onClick={onSave}>
|
||||||
|
{save.isPending ? 'Saving…' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<Card role="region" aria-labelledby={titleId} className={styles.card}>
|
||||||
|
<h2 id={titleId} className={styles.cardTitle}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({ id, error }: { id: string; error: string | undefined }) {
|
||||||
|
if (error === undefined) return null
|
||||||
|
return (
|
||||||
|
<p id={id} className={styles.error}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
draft: HouseholdSettings
|
||||||
|
edit: (change: Partial<HouseholdSettings>) => void
|
||||||
|
problems: Partial<Record<HouseholdSettingsField, string>>
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeFieldProps = FieldProps & { name: 'dinner_time' | 'reminder_time' | 'review_time' }
|
||||||
|
|
||||||
|
function TimeField({ draft, edit, problems, name, label, hint }: TimeFieldProps) {
|
||||||
|
const hintId = useId()
|
||||||
|
const errorId = useId()
|
||||||
|
const error = problems[name]
|
||||||
|
return (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<TextInput
|
||||||
|
label={label}
|
||||||
|
type="time"
|
||||||
|
className={styles.time}
|
||||||
|
value={draft[name]}
|
||||||
|
aria-invalid={error !== undefined}
|
||||||
|
aria-describedby={error === undefined ? hintId : `${errorId} ${hintId}`}
|
||||||
|
onChange={(event) => edit({ [name]: event.target.value })}
|
||||||
|
/>
|
||||||
|
<FieldError id={errorId} error={error} />
|
||||||
|
<p id={hintId} className={styles.hint}>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DayTimeFieldProps = FieldProps & {
|
||||||
|
day: 'plan_draft_dow' | 'suggestions_dow' | 'chat_autosend_dow'
|
||||||
|
time: 'plan_draft_time' | 'suggestions_time' | 'chat_autosend_time'
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayTimeField({ draft, edit, problems, day, time, label, hint }: DayTimeFieldProps) {
|
||||||
|
const hintId = useId()
|
||||||
|
const dayErrorId = useId()
|
||||||
|
const timeErrorId = useId()
|
||||||
|
const dayError = problems[day]
|
||||||
|
const timeError = problems[time]
|
||||||
|
return (
|
||||||
|
<div className={styles.field} role="group" aria-label={label}>
|
||||||
|
<div className={styles.columns}>
|
||||||
|
<Select
|
||||||
|
label={`${label} day`}
|
||||||
|
value={draft[day]}
|
||||||
|
aria-invalid={dayError !== undefined}
|
||||||
|
aria-describedby={dayError === undefined ? hintId : `${dayErrorId} ${hintId}`}
|
||||||
|
onChange={(event) => edit({ [day]: Number(event.target.value) })}
|
||||||
|
>
|
||||||
|
{days.map((name, index) => (
|
||||||
|
<option key={name} value={index}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<TextInput
|
||||||
|
label={`${label} time`}
|
||||||
|
type="time"
|
||||||
|
className={`${styles.time} ${styles.pairedTime}`}
|
||||||
|
value={draft[time]}
|
||||||
|
aria-invalid={timeError !== undefined}
|
||||||
|
aria-describedby={timeError === undefined ? hintId : `${timeErrorId} ${hintId}`}
|
||||||
|
onChange={(event) => edit({ [time]: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FieldError id={dayErrorId} error={dayError} />
|
||||||
|
<FieldError id={timeErrorId} error={timeError} />
|
||||||
|
<p id={hintId} className={styles.hint}>
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeZoneFieldProps = {
|
||||||
|
value: string
|
||||||
|
onChange: (zone: string) => void
|
||||||
|
error: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeZoneField shows the chosen zone and its current UTC offset; Change
|
||||||
|
// opens a searchable list of every IANA zone.
|
||||||
|
function TimeZoneField({ value, onChange, error }: TimeZoneFieldProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const labelId = useId()
|
||||||
|
const errorId = useId()
|
||||||
|
const offset = useMemo(() => utcOffset(value, new Date()), [value])
|
||||||
|
return (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<span id={labelId} className={styles.groupLabel}>
|
||||||
|
Time zone
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={styles.zoneRow}
|
||||||
|
role="group"
|
||||||
|
aria-labelledby={labelId}
|
||||||
|
aria-describedby={error === undefined ? undefined : errorId}
|
||||||
|
>
|
||||||
|
<span className={styles.zoneValue}>
|
||||||
|
<span className={styles.zoneName}>{zoneLabel(value)}</span>
|
||||||
|
<span className={styles.offset}>{offset}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" className={styles.change} onClick={() => setOpen(true)}>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldError id={errorId} error={error} />
|
||||||
|
{open && (
|
||||||
|
<TimeZoneSheet
|
||||||
|
value={value}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onChoose={(zone) => {
|
||||||
|
onChange(zone)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeZoneSheetProps = {
|
||||||
|
value: string
|
||||||
|
onClose: () => void
|
||||||
|
onChoose: (zone: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeZoneSheet({ value, onClose, onChoose }: TimeZoneSheetProps) {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const titleId = useId()
|
||||||
|
const options = useMemo(() => timeZoneOptions(value, new Date()), [value])
|
||||||
|
const shown = options.filter((option) => matchesZone(option, search))
|
||||||
|
return (
|
||||||
|
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<div className={styles.sheet}>
|
||||||
|
<h2 id={titleId} className={styles.cardTitle}>
|
||||||
|
Choose a time zone
|
||||||
|
</h2>
|
||||||
|
<TextInput
|
||||||
|
label="Search time zones"
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
placeholder="City, region or UTC offset"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className={styles.hint} role="status">
|
||||||
|
{shown.length === 0 ? 'No time zone matches.' : `${shown.length} ${shown.length === 1 ? 'time zone' : 'time zones'}`}
|
||||||
|
</p>
|
||||||
|
<ul className={styles.zoneList} aria-label="Time zones">
|
||||||
|
{shown.map((option) => {
|
||||||
|
const current = option.zone === value
|
||||||
|
return (
|
||||||
|
<li key={option.zone}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.zoneOption}
|
||||||
|
aria-pressed={current}
|
||||||
|
aria-label={`${zoneLabel(option.zone)}, ${option.offset}`}
|
||||||
|
onClick={() => onChoose(option.zone)}
|
||||||
|
>
|
||||||
|
<span className={styles.zoneName}>{zoneLabel(option.zone)}</span>
|
||||||
|
<span className={styles.offset}>{option.offset}</span>
|
||||||
|
{current && <Icon icon={Check} className={styles.check} />}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/* The confirm sheet the settings screens share. */
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useId, type ReactNode } from 'react'
|
||||||
|
import { useBlocker } from 'react-router'
|
||||||
|
import { Button } from '../ui/Button.tsx'
|
||||||
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
|
import styles from './LeaveGuard.module.css'
|
||||||
|
|
||||||
|
// LeaveGuard asks before leaving the screen with unsaved changes.
|
||||||
|
export function LeaveGuard({ dirty }: { dirty: boolean }) {
|
||||||
|
const blocker = useBlocker(
|
||||||
|
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
||||||
|
)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return
|
||||||
|
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
|
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
||||||
|
}, [dirty])
|
||||||
|
return (
|
||||||
|
<Confirm
|
||||||
|
open={blocker.state === 'blocked'}
|
||||||
|
title="Leave without saving?"
|
||||||
|
confirmLabel="Leave"
|
||||||
|
cancelLabel="Keep editing"
|
||||||
|
onCancel={() => blocker.reset?.()}
|
||||||
|
onConfirm={() => blocker.proceed?.()}
|
||||||
|
>
|
||||||
|
Your changes on this screen have not been saved.
|
||||||
|
</Confirm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmProps = {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
confirmLabel: string
|
||||||
|
cancelLabel?: string
|
||||||
|
busy?: boolean
|
||||||
|
error?: string | null
|
||||||
|
onCancel: () => void
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm is a sheet asking the household to confirm one action.
|
||||||
|
export function Confirm({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
busy = false,
|
||||||
|
error = null,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: ConfirmProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onCancel} aria-labelledby={titleId}>
|
||||||
|
<div className={styles.sheet}>
|
||||||
|
<h2 id={titleId} className={styles.title}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className={styles.text}>{children}</p>
|
||||||
|
{error !== null && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy} onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,14 +4,14 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { AI_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { formatClockTime, modelDisplayName } from './api.ts'
|
import { formatClockTime, modelDisplayName } from './api.ts'
|
||||||
import { SettingsPage } from './SettingsPage.tsx'
|
import { SettingsPage } from './SettingsPage.tsx'
|
||||||
|
|
||||||
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
|
const HOUSEHOLD = HOUSEHOLD_SETTINGS
|
||||||
const AI = AI_SETTINGS
|
const AI = AI_SETTINGS
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -221,7 +221,7 @@ describe('SettingsPage', () => {
|
|||||||
|
|
||||||
it('refuses a dinner time that is not HH:MM', async () => {
|
it('refuses a dinner time that is not HH:MM', async () => {
|
||||||
stubSettings({
|
stubSettings({
|
||||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'UTC', dinner_time: '5:30pm' }),
|
'/api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD, timezone: 'UTC', dinner_time: '5:30pm' }),
|
||||||
})
|
})
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
|
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
|
||||||
|
|||||||
+114
-12
@@ -23,13 +23,45 @@ export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
|
|||||||
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
|
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
|
||||||
|
|
||||||
// HouseholdSettings is the part of GET /api/v1/settings/household the
|
// HouseholdSettings is the part of GET /api/v1/settings/household the
|
||||||
// settings pages read.
|
// settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days
|
||||||
|
// of the week count from Sunday = 0.
|
||||||
export type HouseholdSettings = {
|
export type HouseholdSettings = {
|
||||||
timezone: string
|
timezone: string
|
||||||
// Zero-padded 24-hour HH:MM.
|
|
||||||
dinner_time: string
|
dinner_time: string
|
||||||
|
reminder_time: string
|
||||||
|
// The rotation check: when the household is asked "Keep it in rotation?".
|
||||||
|
review_time: string
|
||||||
|
plan_draft_dow: number
|
||||||
|
plan_draft_time: string
|
||||||
|
suggestions_dow: number
|
||||||
|
suggestions_time: string
|
||||||
|
chat_autosend_dow: number
|
||||||
|
chat_autosend_time: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HouseholdSettingsPatch is the PATCH /api/v1/settings/household body: only
|
||||||
|
// the fields that change.
|
||||||
|
export type HouseholdSettingsPatch = Partial<HouseholdSettings>
|
||||||
|
|
||||||
|
export type HouseholdSettingsField = keyof HouseholdSettings
|
||||||
|
|
||||||
|
const householdTimeFields = [
|
||||||
|
'dinner_time',
|
||||||
|
'reminder_time',
|
||||||
|
'review_time',
|
||||||
|
'plan_draft_time',
|
||||||
|
'suggestions_time',
|
||||||
|
'chat_autosend_time',
|
||||||
|
] as const satisfies readonly HouseholdSettingsField[]
|
||||||
|
|
||||||
|
const householdDayFields = [
|
||||||
|
'plan_draft_dow',
|
||||||
|
'suggestions_dow',
|
||||||
|
'chat_autosend_dow',
|
||||||
|
] as const satisfies readonly HouseholdSettingsField[]
|
||||||
|
|
||||||
|
const householdFields: readonly HouseholdSettingsField[] = ['timezone', ...householdTimeFields, ...householdDayFields]
|
||||||
|
|
||||||
// aiProviderLabels names every AI provider preset the server accepts
|
// aiProviderLabels names every AI provider preset the server accepts
|
||||||
// (internal/ai/llm/presets) as the household reads it.
|
// (internal/ai/llm/presets) as the household reads it.
|
||||||
export const aiProviderLabels = {
|
export const aiProviderLabels = {
|
||||||
@@ -301,22 +333,92 @@ export function useTestRecipeSearch() {
|
|||||||
|
|
||||||
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||||
|
|
||||||
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
|
// parseHouseholdSettings refuses a body that is not the household settings,
|
||||||
// formatClockTime never sees one.
|
// or has a time that is not HH:MM, so formatClockTime never sees one.
|
||||||
|
function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings {
|
||||||
|
if (!isRecord(body) || typeof body.timezone !== 'string') {
|
||||||
|
throw new Error(`${where}: response is not the household settings`)
|
||||||
|
}
|
||||||
|
const times = {} as Record<(typeof householdTimeFields)[number], string>
|
||||||
|
for (const field of householdTimeFields) {
|
||||||
|
const value = body[field]
|
||||||
|
if (typeof value !== 'string' || !clockTime.test(value)) {
|
||||||
|
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not HH:MM`)
|
||||||
|
}
|
||||||
|
times[field] = value
|
||||||
|
}
|
||||||
|
const days = {} as Record<(typeof householdDayFields)[number], number>
|
||||||
|
for (const field of householdDayFields) {
|
||||||
|
const value = body[field]
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 6) {
|
||||||
|
throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a day of the week (0-6)`)
|
||||||
|
}
|
||||||
|
days[field] = value
|
||||||
|
}
|
||||||
|
return { timezone: body.timezone, ...times, ...days }
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
|
||||||
|
|
||||||
export function useHouseholdSettings() {
|
export function useHouseholdSettings() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['settings', 'household'],
|
queryKey: HOUSEHOLD_SETTINGS_KEY,
|
||||||
queryFn: async () => {
|
queryFn: async () =>
|
||||||
const settings = await apiFetch<HouseholdSettings>(HOUSEHOLD_SETTINGS_PATH)
|
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
|
||||||
if (!clockTime.test(settings.dinner_time)) {
|
|
||||||
throw new Error(`GET ${HOUSEHOLD_SETTINGS_PATH}: dinner_time "${settings.dinner_time}" is not HH:MM`)
|
|
||||||
}
|
|
||||||
return { timezone: settings.timezone, dinner_time: settings.dinner_time } satisfies HouseholdSettings
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSaveHouseholdSettings() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (patch: HouseholdSettingsPatch) =>
|
||||||
|
parseHouseholdSettings(
|
||||||
|
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
||||||
|
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
|
||||||
|
),
|
||||||
|
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HouseholdSettingsProblems is a refused household settings save: the reason
|
||||||
|
// for each field the server named, and any reasons that name no field.
|
||||||
|
export type HouseholdSettingsProblems = {
|
||||||
|
fields: Partial<Record<HouseholdSettingsField, string>>
|
||||||
|
other: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalidHouseholdPrefix = 'invalid household settings: '
|
||||||
|
|
||||||
|
// A problem starts with a field name and ": " (internal/household
|
||||||
|
// ValidationError.Error joins them with "; ").
|
||||||
|
const problemStart = /; (?=[a-z_]+: )/
|
||||||
|
|
||||||
|
// householdSettingsProblems reads the server's invalid_settings error into
|
||||||
|
// the problems it lists. Any other error is one problem naming no field.
|
||||||
|
export function householdSettingsProblems(error: Error): HouseholdSettingsProblems {
|
||||||
|
const problems: HouseholdSettingsProblems = { fields: {}, other: [] }
|
||||||
|
if (
|
||||||
|
!(error instanceof ApiError) ||
|
||||||
|
error.code !== 'invalid_settings' ||
|
||||||
|
!error.message.startsWith(invalidHouseholdPrefix)
|
||||||
|
) {
|
||||||
|
problems.other.push(error.message)
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
for (const problem of error.message.slice(invalidHouseholdPrefix.length).split(problemStart)) {
|
||||||
|
const colon = problem.indexOf(': ')
|
||||||
|
const field = problem.slice(0, colon)
|
||||||
|
if (colon !== -1 && (householdFields as readonly string[]).includes(field)) {
|
||||||
|
problems.fields[field as HouseholdSettingsField] = problem.slice(colon + 2)
|
||||||
|
} else {
|
||||||
|
problems.other.push(problem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|
||||||
// aiStatus is the AI assistant row's status line: the provider and the
|
// aiStatus is the AI assistant row's status line: the provider and the
|
||||||
// model's display name, or that no model is chosen yet (the seed).
|
// model's display name, or that no model is chosen yet (the seed).
|
||||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// The IANA time zones the browser knows, with their current UTC offsets, for
|
||||||
|
// the household time zone picker.
|
||||||
|
|
||||||
|
export type TimeZoneOption = {
|
||||||
|
zone: string
|
||||||
|
// "UTC-04:00"; "UTC+00:00" for UTC itself.
|
||||||
|
offset: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// utcOffset is zone's offset from UTC at instant, as "UTC+05:30".
|
||||||
|
export function utcOffset(zone: string, instant: Date): string {
|
||||||
|
const name = new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' })
|
||||||
|
.formatToParts(instant)
|
||||||
|
.find((part) => part.type === 'timeZoneName')?.value
|
||||||
|
if (name === undefined) throw new Error(`no UTC offset for time zone "${zone}"`)
|
||||||
|
if (name === 'GMT') return 'UTC+00:00'
|
||||||
|
if (!/^GMT[+-]\d\d:\d\d$/.test(name)) throw new Error(`time zone "${zone}" has an unreadable offset "${name}"`)
|
||||||
|
return `UTC${name.slice(3)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeZoneOptions lists every zone the browser supports, plus current (the
|
||||||
|
// saved zone, which may be an alias the list leaves out), sorted by name.
|
||||||
|
export function timeZoneOptions(current: string, instant: Date): TimeZoneOption[] {
|
||||||
|
const zones = new Set(Intl.supportedValuesOf('timeZone'))
|
||||||
|
zones.add(current)
|
||||||
|
return [...zones].sort().map((zone) => ({ zone, offset: utcOffset(zone, instant) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneLabel reads a zone as words: "America/Argentina/Buenos_Aires" is
|
||||||
|
// "America / Argentina / Buenos Aires".
|
||||||
|
export function zoneLabel(zone: string): string {
|
||||||
|
return zone.replaceAll('_', ' ').replaceAll('/', ' / ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesZone is whether every word of the search text is in option's name
|
||||||
|
// or offset, in any case.
|
||||||
|
export function matchesZone(option: TimeZoneOption, search: string): boolean {
|
||||||
|
const needle = search.trim().toLowerCase()
|
||||||
|
if (needle === '') return true
|
||||||
|
const haystack = `${option.zone} ${zoneLabel(option.zone)} ${option.offset}`.toLowerCase()
|
||||||
|
return needle.split(/\s+/).every((word) => haystack.includes(word))
|
||||||
|
}
|
||||||
@@ -16,6 +16,26 @@ export const AI_SETTINGS = {
|
|||||||
updated_at: '2026-10-01T15:00:00.000Z',
|
updated_at: '2026-10-01T15:00:00.000Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
||||||
|
// times in New York.
|
||||||
|
export const HOUSEHOLD_SETTINGS = {
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
dinner_time: '17:30',
|
||||||
|
reminder_time: '16:30',
|
||||||
|
review_time: '09:00',
|
||||||
|
plan_draft_dow: 5,
|
||||||
|
plan_draft_time: '09:00',
|
||||||
|
suggestions_dow: 0,
|
||||||
|
suggestions_time: '08:00',
|
||||||
|
chat_autosend_dow: 6,
|
||||||
|
chat_autosend_time: '08:00',
|
||||||
|
onboarding_completed_at: null,
|
||||||
|
updates_seen_at: null,
|
||||||
|
updated_by: null,
|
||||||
|
created_at: '2026-10-01T15:00:00.000Z',
|
||||||
|
updated_at: '2026-10-01T15:00:00.000Z',
|
||||||
|
}
|
||||||
|
|
||||||
// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the
|
// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the
|
||||||
// SearXNG sidecar, no Brave key stored.
|
// SearXNG sidecar, no Brave key stored.
|
||||||
export const RECIPE_SEARCH_SETTINGS = {
|
export const RECIPE_SEARCH_SETTINGS = {
|
||||||
@@ -34,7 +54,7 @@ export const SETTINGS_API = {
|
|||||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user