From 04f12f52b9d827917adfbba0f0a40e93014188e5 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 09:35:51 -0400 Subject: [PATCH] Show every settings home status line (mock 1p review fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · " from GET /api/v1/settings/calendar (Feature 16). Until then a 404 shows the truthful "Not connected". - Notifications & Google Chat: "Push on · " 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 --- web/src/App.test.tsx | 6 +- web/src/settings/SettingsPage.test.tsx | 129 ++++++++++++++++++++----- web/src/settings/SettingsPage.tsx | 50 +++++++--- web/src/settings/api.ts | 125 +++++++++++++++++++++++- web/src/test/ApiProviders.tsx | 9 +- 5 files changed, 279 insertions(+), 40 deletions(-) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index b6a93d0..59636b8 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -6,6 +6,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' +import { SETTINGS_API } from './test/ApiProviders.tsx' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') @@ -62,13 +63,12 @@ describe('App routes', () => { mockViewport(390) stubFetchByPath({ '/api/v1/me': () => jsonResponse(200, ME), - '/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' }), + ...SETTINGS_API, }) renderAt('/settings') const heading = await screen.findByRole('heading', { level: 1, name: 'Settings' }) expect(screen.getByRole('main')).toContainElement(heading) - expect(await screen.findByText('Anthropic · claude-sonnet-4-5')).toBeInTheDocument() + expect(await screen.findByText('Anthropic · Claude Sonnet')).toBeInTheDocument() }) }) diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index 1c95140..30c0639 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -4,10 +4,11 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { SETTINGS_API } from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' -import { formatClockTime } from './api.ts' +import { formatClockTime, modelDisplayName } from './api.ts' import { SettingsPage } from './SettingsPage.tsx' const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' } @@ -17,8 +18,19 @@ afterEach(() => { vi.unstubAllGlobals() }) -function stubSettings(household = () => jsonResponse(200, HOUSEHOLD), ai = () => jsonResponse(200, AI)) { - return stubFetchByPath({ '/api/v1/settings/household': household, '/api/v1/settings/ai': ai }) +const REGULARS_PATH = '/api/v1/dinners?status=regular' +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 Response> = {}) { + return stubFetchByPath({ + ...SETTINGS_API, + '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD), + '/api/v1/settings/ai': () => jsonResponse(200, AI), + ...overrides, + }) } function CurrentPath() { @@ -52,7 +64,7 @@ describe('SettingsPage', () => { expect(screen.getByRole('heading', { level: 1, name: 'Settings' })).toBeInTheDocument() expect(screen.getByText('Our kitchen')).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 () => { @@ -66,34 +78,88 @@ describe('SettingsPage', () => { ]) expect(group('Alerts').getByRole('link')).toHaveAttribute('href', '/settings/notifications') 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() renderSettings() 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(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([ + REGULARS_PATH, '/api/v1/settings/ai', + CALENDAR_PATH, '/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 () => { - 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() 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 () => { - stubSettings() + it('says no model is chosen for the seeded AI settings', async () => { + stubSettings({ '/api/v1/settings/ai': () => jsonResponse(200, { provider: 'anthropic', model: '' }) }) renderSettings() - await screen.findByText('Anthropic · claude-sonnet-4-5') - expect(row('Regular dinners')).toHaveAccessibleName('Regular dinners') - expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar') - expect(row('Notifications & Google Chat')).toHaveAccessibleName('Notifications & Google Chat') + expect(await within(row('AI assistant')).findByText('Anthropic · No model chosen')).toBeInTheDocument() }) 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)') const householdCard = screen.getByRole('region', { name: 'Household' }).querySelector('ul')!.parentElement! expect(declaredStyle(householdCard).boxShadow).toBe('') - await screen.findByText('Anthropic · claude-sonnet-4-5') + await screen.findByText('Anthropic · Claude Sonnet') }) it.each([ @@ -116,7 +182,7 @@ describe('SettingsPage', () => { ])('the %s row navigates to %s', async (name, path) => { stubSettings() renderSettings() - await screen.findByText('Anthropic · claude-sonnet-4-5') + await screen.findByText('Anthropic · Claude Sonnet') fireEvent.click(row(name)) 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 () => { stubSettings() renderSettings() - await screen.findByText('Anthropic · claude-sonnet-4-5') + await screen.findByText('Anthropic · Claude Sonnet') for (const link of screen.getAllByRole('link')) expectMinHitArea(link) }) it('reports a failed status with a retry that loads it', async () => { let householdCalls = 0 - stubSettings(() => { - householdCalls += 1 - return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD) + stubSettings({ + '/api/v1/settings/household': () => { + householdCalls += 1 + return householdCalls === 1 ? errorResponse(500, 'internal') : jsonResponse(200, HOUSEHOLD) + }, }) renderSettings() 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') fireEvent.click(screen.getByRole('button', { name: 'Retry' })) @@ -145,19 +213,34 @@ describe('SettingsPage', () => { }) 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() expect(await screen.findByRole('alert')).toHaveTextContent('unknown AI provider "mystery"') expect(row('AI assistant')).toHaveAccessibleName('AI assistant Admin') }) 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() 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', () => { it.each([ ['00:05', '12:05 am'], diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx index 81a41b2..91e0a28 100644 --- a/web/src/settings/SettingsPage.tsx +++ b/web/src/settings/SettingsPage.tsx @@ -8,18 +8,31 @@ import { ErrorBanner } from '../ui/ErrorBanner.tsx' import { Icon } from '../ui/Icon.tsx' import { Skeleton } from '../ui/Skeleton.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' -// Settings home (handoff mock 1p). Each row opens its settings page. -// -// Regular dinners, Google Calendar and Notifications & Google Chat show no -// status line yet: the server has no dinners, Google Calendar or -// notification endpoint to read one from. +// Settings home (handoff mock 1p). Each row opens its settings page and +// shows a live status line. Regular dinners, Google Calendar and +// Notifications & Google Chat read endpoints their features have not served +// yet; until then each shows its not-yet-available state (see api.ts). export function SettingsPage() { const household = useHouseholdSettings() 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 (
@@ -41,8 +54,18 @@ export function SettingsPage() {
)} - - + + - + `${aiProviderLabels[s.provider]} · ${s.model}`)} + status={status(ai, aiStatus)} tag={Admin} /> diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 6e93f4b..638ea0a 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -1,8 +1,20 @@ 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 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 // 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 = { 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( + apiFetch: ApiClient['apiFetch'], + path: string, + parse: (body: unknown) => T, +): Promise { + let body: unknown + try { + body = await apiFetch(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". export function formatClockTime(time: string): string { const match = clockTime.exec(time) diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index cc7e63a..359d115 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -2,10 +2,15 @@ import { QueryClientProvider } from '@tanstack/react-query' import { useState, type ReactNode } from 'react' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.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 = { + '/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/ai': () => jsonResponse(200, { provider: 'anthropic', model: 'claude-sonnet-4-5' }), }