Show every settings home status line (mock 1p review fixes)
The Regular dinners, Google Calendar and Notifications & Google Chat rows
now show status lines and read them from the server:
- Regular dinners: "N on rotation" from GET /api/v1/dinners?status=regular
(Feature 12). Until that endpoint exists the server answers 404 and the
row says "Rotation count unavailable"; no count is invented.
- Google Calendar: "Connected · <calendar name>" from
GET /api/v1/settings/calendar (Feature 16). Until then a 404 shows the
truthful "Not connected".
- Notifications & Google Chat: "Push on · <Google Chat space>" from
GET /api/v1/settings/notifications. Until then a 404 shows
"Push off · Google Chat not connected".
Only a 404 not_found counts as a missing endpoint; any other failure or a
malformed response shows in the error banner.
The AI assistant row names the model as the handoff does ("Anthropic ·
Claude Sonnet"), shows an unknown model by its id, and says "No model
chosen" for the seeded empty model instead of a trailing separator.
Maestro task: 97b50e29-b8a7-4e99-918d-99fa1b239313
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,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 { SETTINGS_API } from './test/ApiProviders.tsx'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, 'matchMedia')
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
@@ -62,13 +63,12 @@ describe('App routes', () => {
|
|||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
stubFetchByPath({
|
stubFetchByPath({
|
||||||
'/api/v1/me': () => jsonResponse(200, ME),
|
'/api/v1/me': () => jsonResponse(200, ME),
|
||||||
'/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
...SETTINGS_API,
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
|
|
||||||
})
|
})
|
||||||
renderAt('/settings')
|
renderAt('/settings')
|
||||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
|
const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' })
|
||||||
expect(screen.getByRole('main')).toContainElement(heading)
|
expect(screen.getByRole('main')).toContainElement(heading)
|
||||||
expect(await screen.findByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ 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 { 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 } 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 = { timezone: 'America/New_York', dinner_time: '17:30' }
|
||||||
@@ -17,8 +18,19 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
})
|
})
|
||||||
|
|
||||||
function stubSettings(household = () => jsonResponse(200, HOUSEHOLD), ai = () => jsonResponse(200, AI)) {
|
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
||||||
return stubFetchByPath({ '/api/v1/settings/household': household, '/api/v1/settings/ai': ai })
|
const CALENDAR_PATH = '/api/v1/settings/calendar'
|
||||||
|
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
||||||
|
|
||||||
|
// stubSettings answers as today's server does (see SETTINGS_API), with
|
||||||
|
// overrides for the paths a test changes.
|
||||||
|
function stubSettings(overrides: Record<string, () => Response> = {}) {
|
||||||
|
return stubFetchByPath({
|
||||||
|
...SETTINGS_API,
|
||||||
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD),
|
||||||
|
'/api/v1/settings/ai': () => jsonResponse(200, AI),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function CurrentPath() {
|
function CurrentPath() {
|
||||||
@@ -52,7 +64,7 @@ describe('SettingsPage', () => {
|
|||||||
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument()
|
||||||
expect(screen.getByText('Our kitchen')).toBeInTheDocument()
|
expect(screen.getByText('Our kitchen')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Shared household · changes apply to everyone')).toBeInTheDocument()
|
expect(screen.getByText('Shared household · changes apply to everyone')).toBeInTheDocument()
|
||||||
expect(await screen.findByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('groups the rows under Household, Alerts and Admin', async () => {
|
it('groups the rows under Household, Alerts and Admin', async () => {
|
||||||
@@ -66,34 +78,88 @@ describe('SettingsPage', () => {
|
|||||||
])
|
])
|
||||||
expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications')
|
expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications')
|
||||||
expect(group('Admin').getByRole('link')).toHaveAttribute('href', '/settings/ai')
|
expect(group('Admin').getByRole('link')).toHaveAttribute('href', '/settings/ai')
|
||||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
await screen.findByText('Anthropic · Claude Sonnet')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the live household and AI assistant status lines', async () => {
|
it('reads every status line from the server', async () => {
|
||||||
const fetchMock = stubSettings()
|
const fetchMock = stubSettings()
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||||
expect(within(row('AI assistant')).getByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||||
|
REGULARS_PATH,
|
||||||
'/api/v1/settings/ai',
|
'/api/v1/settings/ai',
|
||||||
|
CALENDAR_PATH,
|
||||||
'/api/v1/settings/household',
|
'/api/v1/settings/household',
|
||||||
|
NOTIFICATIONS_PATH,
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
|
||||||
|
stubSettings()
|
||||||
|
renderSettings()
|
||||||
|
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||||
|
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
|
||||||
|
stubSettings({
|
||||||
|
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
||||||
|
[CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }),
|
||||||
|
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
|
||||||
|
})
|
||||||
|
renderSettings()
|
||||||
|
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
|
||||||
|
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
await within(row('Notifications & Google Chat')).findByText('Push on · Dinner Crew space'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
|
||||||
|
stubSettings({
|
||||||
|
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
||||||
|
[CALENDAR_PATH]: () => jsonResponse(200, { connected: false }),
|
||||||
|
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
|
||||||
|
})
|
||||||
|
renderSettings()
|
||||||
|
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
|
||||||
|
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a status endpoint failure other than a missing endpoint', async () => {
|
||||||
|
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
|
||||||
|
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a connected calendar without a name', async () => {
|
||||||
|
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
|
||||||
|
renderSettings()
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status')
|
||||||
|
})
|
||||||
|
|
||||||
it('shows another provider and model as the settings name them', async () => {
|
it('shows another provider and model as the settings name them', async () => {
|
||||||
stubSettings(undefined, () => jsonResponse(200, { provider: 'openai_compatible', model: 'llama3.1:8b' }))
|
stubSettings({
|
||||||
|
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'openai_compatible', model: 'llama3.1:8b' }),
|
||||||
|
})
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
|
expect(await screen.findByText('OpenAI-compatible · llama3.1:8b')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows no status line for rows the server has no endpoint for yet', async () => {
|
it('says no model is chosen for the seeded AI settings', async () => {
|
||||||
stubSettings()
|
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: '' }) })
|
||||||
renderSettings()
|
renderSettings()
|
||||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument()
|
||||||
expect(row('Regular dinners')).toHaveAccessibleName('Regular dinners')
|
|
||||||
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
|
|
||||||
expect(row('Notifications & Google Chat')).toHaveAccessibleName('Notifications & Google Chat')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the AI assistant row Admin and rings its card in grape-300', async () => {
|
it('marks the AI assistant row Admin and rings its card in grape-300', async () => {
|
||||||
@@ -104,7 +170,7 @@ describe('SettingsPage', () => {
|
|||||||
expect(declaredStyle(adminCard).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
|
expect(declaredStyle(adminCard).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
|
||||||
const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement!
|
const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement!
|
||||||
expect(declaredStyle(householdCard).boxShadow).toBe('')
|
expect(declaredStyle(householdCard).boxShadow).toBe('')
|
||||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
await screen.findByText('Anthropic · Claude Sonnet')
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -116,7 +182,7 @@ describe('SettingsPage', () => {
|
|||||||
])('the %s row navigates to %s', async (name, path) => {
|
])('the %s row navigates to %s', async (name, path) => {
|
||||||
stubSettings()
|
stubSettings()
|
||||||
renderSettings()
|
renderSettings()
|
||||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
await screen.findByText('Anthropic · Claude Sonnet')
|
||||||
fireEvent.click(row(name))
|
fireEvent.click(row(name))
|
||||||
expect(screen.getByTestId('path')).toHaveTextContent(path)
|
expect(screen.getByTestId('path')).toHaveTextContent(path)
|
||||||
})
|
})
|
||||||
@@ -124,19 +190,21 @@ describe('SettingsPage', () => {
|
|||||||
it('gives every row a tap target of at least 44 px', async () => {
|
it('gives every row a tap target of at least 44 px', async () => {
|
||||||
stubSettings()
|
stubSettings()
|
||||||
renderSettings()
|
renderSettings()
|
||||||
await screen.findByText('Anthropic · claude-sonnet-4-5')
|
await screen.findByText('Anthropic · Claude Sonnet')
|
||||||
for (const link of screen.getAllByRole('link')) expectMinHitArea(link)
|
for (const link of screen.getAllByRole('link')) expectMinHitArea(link)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports a failed status with a retry that loads it', async () => {
|
it('reports a failed status with a retry that loads it', async () => {
|
||||||
let householdCalls = 0
|
let householdCalls = 0
|
||||||
stubSettings(() => {
|
stubSettings({
|
||||||
householdCalls += 1
|
'/api/v1/settings/household': () => {
|
||||||
return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD)
|
householdCalls += 1
|
||||||
|
return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
|
expect(await screen.findByRole('alert')).toHaveTextContent('Settings could not load: internal message')
|
||||||
expect(screen.getByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument()
|
expect(screen.getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||||
expect(row('Household')).toHaveAccessibleName('Household')
|
expect(row('Household')).toHaveAccessibleName('Household')
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||||
@@ -145,19 +213,34 @@ describe('SettingsPage', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('refuses an AI provider it has no name for', async () => {
|
it('refuses an AI provider it has no name for', async () => {
|
||||||
stubSettings(undefined, () => jsonResponse(200, { provider: 'mystery', model: 'm1' }))
|
stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'mystery', model: 'm1' }) })
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
|
expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"')
|
||||||
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
|
expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refuses a dinner time that is not HH:MM', async () => {
|
it('refuses a dinner time that is not HH:MM', async () => {
|
||||||
stubSettings(() => jsonResponse(200, { timezone: 'UTC', dinner_time: '5:30pm' }))
|
stubSettings({
|
||||||
|
'/api/v1/settings/household': () => jsonResponse(200, { 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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('modelDisplayName', () => {
|
||||||
|
it.each([
|
||||||
|
['claude-sonnet-4-5', 'Claude Sonnet'],
|
||||||
|
['claude-opus-4-1-20250805', 'Claude Opus'],
|
||||||
|
['claude-3-5-haiku-20241022', 'Claude Haiku'],
|
||||||
|
['claude-fable-5-1', 'Claude Fable'],
|
||||||
|
['gpt-4o', 'gpt-4o'],
|
||||||
|
['anthropic/claude-sonnet-4.5', 'anthropic/claude-sonnet-4.5'],
|
||||||
|
])('names %s %s', (model, name) => {
|
||||||
|
expect(modelDisplayName(model)).toBe(name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('formatClockTime', () => {
|
describe('formatClockTime', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['00:05', '12:05 am'],
|
['00:05', '12:05 am'],
|
||||||
|
|||||||
@@ -8,18 +8,31 @@ import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
|||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
import { Tag } from '../ui/Tag.tsx'
|
import { Tag } from '../ui/Tag.tsx'
|
||||||
import { aiProviderLabels, formatClockTime, useAISettings, useHouseholdSettings } from './api.ts'
|
import {
|
||||||
|
aiStatus,
|
||||||
|
calendarStatus,
|
||||||
|
formatClockTime,
|
||||||
|
notificationsStatus,
|
||||||
|
regularDinnersStatus,
|
||||||
|
useAISettings,
|
||||||
|
useCalendarStatus,
|
||||||
|
useHouseholdSettings,
|
||||||
|
useNotificationsStatus,
|
||||||
|
useRegularDinnersCount,
|
||||||
|
} from './api.ts'
|
||||||
import styles from './SettingsPage.module.css'
|
import styles from './SettingsPage.module.css'
|
||||||
|
|
||||||
// Settings home (handoff mock 1p). Each row opens its settings page.
|
// Settings home (handoff mock 1p). Each row opens its settings page and
|
||||||
//
|
// shows a live status line. Regular dinners, Google Calendar and
|
||||||
// Regular dinners, Google Calendar and Notifications & Google Chat show no
|
// Notifications & Google Chat read endpoints their features have not served
|
||||||
// status line yet: the server has no dinners, Google Calendar or
|
// yet; until then each shows its not-yet-available state (see api.ts).
|
||||||
// notification endpoint to read one from.
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const household = useHouseholdSettings()
|
const household = useHouseholdSettings()
|
||||||
const ai = useAISettings()
|
const ai = useAISettings()
|
||||||
const failed = [household, ai].filter((query) => query.isError)
|
const regulars = useRegularDinnersCount()
|
||||||
|
const calendar = useCalendarStatus()
|
||||||
|
const notifications = useNotificationsStatus()
|
||||||
|
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
@@ -41,8 +54,18 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Group label="Household">
|
<Group label="Household">
|
||||||
<SettingsRow to="/settings/regulars" icon={Heart} title="Regular dinners" />
|
<SettingsRow
|
||||||
<SettingsRow to="/settings/calendar" icon={Calendar} title="Google Calendar" />
|
to="/settings/regulars"
|
||||||
|
icon={Heart}
|
||||||
|
title="Regular dinners"
|
||||||
|
status={status(regulars, regularDinnersStatus)}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
to="/settings/calendar"
|
||||||
|
icon={Calendar}
|
||||||
|
title="Google Calendar"
|
||||||
|
status={status(calendar, calendarStatus)}
|
||||||
|
/>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
to="/settings/household"
|
to="/settings/household"
|
||||||
icon={House}
|
icon={House}
|
||||||
@@ -51,14 +74,19 @@ export function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group label="Alerts">
|
<Group label="Alerts">
|
||||||
<SettingsRow to="/settings/notifications" icon={Bell} title="Notifications & Google Chat" />
|
<SettingsRow
|
||||||
|
to="/settings/notifications"
|
||||||
|
icon={Bell}
|
||||||
|
title="Notifications & Google Chat"
|
||||||
|
status={status(notifications, notificationsStatus)}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group label="Admin" admin>
|
<Group label="Admin" admin>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
to="/settings/ai"
|
to="/settings/ai"
|
||||||
icon={Crosshair}
|
icon={Crosshair}
|
||||||
title="AI assistant"
|
title="AI assistant"
|
||||||
status={status(ai, (s) => `${aiProviderLabels[s.provider]} · ${s.model}`)}
|
status={status(ai, aiStatus)}
|
||||||
tag={<Tag className={styles.tag}>Admin</Tag>}
|
tag={<Tag className={styles.tag}>Admin</Tag>}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
+124
-1
@@ -1,8 +1,20 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useApi } from '../api/index.ts'
|
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
||||||
|
import { isRecord } from '../api/errors.ts'
|
||||||
|
|
||||||
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
|
export const HOUSEHOLD_SETTINGS_PATH = '/api/v1/settings/household'
|
||||||
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
|
export const AI_SETTINGS_PATH = '/api/v1/settings/ai'
|
||||||
|
// TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's
|
||||||
|
// dinners read API. Until it lands the server answers 404 and the Regular
|
||||||
|
// dinners row says the count is unavailable.
|
||||||
|
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
|
||||||
|
// TODO(Feature 16): the Google Calendar integration status. Until Feature 16
|
||||||
|
// serves it the server answers 404, which is the truth: not connected.
|
||||||
|
export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
|
||||||
|
// TODO(notifications feature): the Web Push and Google Chat status. Until it
|
||||||
|
// is served the server answers 404, which is the truth: push is off and no
|
||||||
|
// Google Chat space is connected.
|
||||||
|
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.
|
||||||
@@ -69,6 +81,117 @@ export function useAISettings() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
export function aiStatus(settings: AISettings): string {
|
||||||
|
const provider = aiProviderLabels[settings.provider]
|
||||||
|
if (settings.model === '') return `${provider} · No model chosen`
|
||||||
|
return `${provider} · ${modelDisplayName(settings.model)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude model ids, current ("claude-sonnet-4-5") and older
|
||||||
|
// ("claude-3-5-sonnet-20241022"), name their family.
|
||||||
|
const claudeModel = /^claude-(?:[0-9-]+-)?(opus|sonnet|haiku|fable)(?:-|$)/
|
||||||
|
const claudeFamilies: Record<string, string> = { opus: 'Opus', sonnet: 'Sonnet', haiku: 'Haiku', fable: 'Fable' }
|
||||||
|
|
||||||
|
// modelDisplayName names a model as the handoff does ("Claude Sonnet"); a
|
||||||
|
// model with no known family is shown by its id.
|
||||||
|
export function modelDisplayName(model: string): string {
|
||||||
|
const family = claudeModel.exec(model)?.[1]
|
||||||
|
return family === undefined ? model : `Claude ${claudeFamilies[family]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchUnlessAbsent GETs path and parses it, or returns null when the server
|
||||||
|
// has no such endpoint yet (404 not_found). Every other failure is an error.
|
||||||
|
async function fetchUnlessAbsent<T>(
|
||||||
|
apiFetch: ApiClient['apiFetch'],
|
||||||
|
path: string,
|
||||||
|
parse: (body: unknown) => T,
|
||||||
|
): Promise<T | null> {
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await apiFetch<unknown>(path)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 404 && error.code === 'not_found') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return parse(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// useRegularDinnersCount is how many Regulars the household has, or null
|
||||||
|
// while the dinners read API does not exist.
|
||||||
|
export function useRegularDinnersCount() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['dinners', 'regular', 'count'],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchUnlessAbsent(apiFetch, REGULAR_DINNERS_PATH, (body) => {
|
||||||
|
if (!isRecord(body) || !Array.isArray(body.dinners)) {
|
||||||
|
throw new Error(`GET ${REGULAR_DINNERS_PATH}: response has no dinners list`)
|
||||||
|
}
|
||||||
|
return body.dinners.length
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regularDinnersStatus(count: number | null): string {
|
||||||
|
if (count === null) return 'Rotation count unavailable'
|
||||||
|
if (count === 0) return 'None on rotation yet'
|
||||||
|
return `${count} on rotation`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalendarStatus is the Google Calendar integration status; calendar_name
|
||||||
|
// is the connected calendar's name.
|
||||||
|
export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string }
|
||||||
|
|
||||||
|
export function useCalendarStatus() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['settings', 'calendar'],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => {
|
||||||
|
if (isRecord(body) && body.connected === false) return { connected: false }
|
||||||
|
if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') {
|
||||||
|
return { connected: true, calendar_name: body.calendar_name }
|
||||||
|
}
|
||||||
|
throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calendarStatus(status: CalendarStatus | null): string {
|
||||||
|
if (status === null || !status.connected) return 'Not connected'
|
||||||
|
return `Connected · ${status.calendar_name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsStatus is whether Web Push is on and the connected Google
|
||||||
|
// Chat space's name, null when none is.
|
||||||
|
export type NotificationsStatus = { push: boolean; chat_space: string | null }
|
||||||
|
|
||||||
|
export function useNotificationsStatus() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['settings', 'notifications'],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
typeof body.push !== 'boolean' ||
|
||||||
|
(body.chat_space !== null && typeof body.chat_space !== 'string')
|
||||||
|
) {
|
||||||
|
throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`)
|
||||||
|
}
|
||||||
|
return { push: body.push, chat_space: body.chat_space }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// notificationsStatus is the handoff's "Push on · Dinner Crew space".
|
||||||
|
export function notificationsStatus(status: NotificationsStatus | null): string {
|
||||||
|
const push = status?.push === true ? 'Push on' : 'Push off'
|
||||||
|
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
|
||||||
|
}
|
||||||
|
|
||||||
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
|
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
|
||||||
export function formatClockTime(time: string): string {
|
export function formatClockTime(time: string): string {
|
||||||
const match = clockTime.exec(time)
|
const match = clockTime.exec(time)
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
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 { fakeLocation, jsonResponse } from './fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||||
|
|
||||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath.
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
|
// today's server does: the dinners, Google Calendar and notifications status
|
||||||
|
// endpoints do not exist yet.
|
||||||
export const SETTINGS_API = {
|
export const SETTINGS_API = {
|
||||||
|
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
|
'/api/v1/settings/calendar': () => 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, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
|
'/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user