Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 6e01f4e069 Offer the UTC zones and the saved zone in the time zone picker
CI / ci (push) Successful in 1m57s
CI / ci (pull_request) Successful in 2m0s
Intl.supportedValuesOf('timeZone') lists only canonical zones, so UTC,
Etc/UTC, GMT and Etc/GMT, which the server accepts, could not be chosen.
Merge them into the list, and keep the saved zone listed after another
is chosen so an alias such as US/Eastern can be chosen back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:14:49 -04:00
dev_agentandClaude Opus 5.5 85d3c1684a Add the household settings screen
CI / ci (push) Successful in 1m55s
CI / ci (pull_request) Successful in 2m0s
/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>
2026-10-06 11:06:09 -04:00
dev_agent d3839fd4a1 Recipe search settings section (#83)
CI / ci (push) Successful in 1m56s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 10:59:31 -04:00
27 changed files with 2208 additions and 136 deletions
+6 -2
View File
@@ -44,6 +44,9 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
# trusts no peer, which is right for local development.
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
# Optional: a SearXNG instance the Settings screen suggests as the recipe
# search URL until one is saved.
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
@@ -53,8 +56,9 @@ make dev
http://localhost:5173. Configuration is environment-only and fails fast:
every variable above is required except the commented-out session
lifetimes (when set they must be positive, with MAX at least IDLE),
trusted proxy list (every entry must be an IP or CIDR) and
`MEALQUEST_DEFAULT_TZ`, and
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
(an absolute `http://` or `https://` URL without credentials, a query or a
fragment) and `MEALQUEST_DEFAULT_TZ`, and
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
endpoints the issuer advertises must be `https://`; plain `http://` is
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
+2 -2
View File
@@ -79,9 +79,9 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
search, err := recipes.NewSearchSettingsService(db, box)
search, err := recipes.NewSearchSettingsService(db, box, cfg.SearXNGURL)
if err != nil {
return err
return fmt.Errorf("%s: %w", config.EnvSearXNGURL, err)
}
ai, err := aisettings.New(db, box, cfg.PublicURL.String())
+4 -4
View File
@@ -68,10 +68,10 @@ MEALQUEST_SESSION_MAX=4320h
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret.
# MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. Planned; not
# read by the service yet. The app takes its recipe-search provider and
# base URL from PATCH /api/v1/settings/recipe-search; see
# deploy/RUNBOOK.md "Recipe search (SearXNG)".
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
+16 -8
View File
@@ -417,15 +417,23 @@ The `searxng` service in `deploy/compose.yaml` is a private SearXNG
instance for recipe search. On the compose network it is reachable at
`http://searxng:8080`; that is the address to configure in the app.
Starting the sidecar does not connect the app to it. The service does not
read `MEALQUEST_SEARXNG_URL`: `deploy/compose.yaml` sets it for a future
wiring, and today it does nothing. The recipe-search provider and base URL
live in the `recipe_search_settings` row and are set through
`GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the Settings
screen's recipe search; the screen itself is not built yet). Until that row
exists, recipe search answers 409 `not_configured`.
Starting the sidecar does not connect the app to it. The recipe-search
provider and base URL live in the `recipe_search_settings` row and are set
through `GET`/`PATCH /api/v1/settings/recipe-search` (the API behind the
recipe search card on Settings → AI assistant). Until that row exists,
recipe search answers 409 `not_configured`.
After the first deploy, from a signed-in session (every non-`GET` request
`deploy/compose.yaml` sets `MEALQUEST_SEARXNG_URL=http://searxng:8080`.
The service only suggests it: `GET` returns it as `default_base_url`, and
the recipe search card pre-fills the SearXNG URL with it while no URL is
saved. Nothing is stored until the card is saved, and a saved `base_url`
always wins. A set value must be an absolute `http://` or `https://` URL
without credentials, a query or a fragment, or the service refuses to
start; unset, `default_base_url` is `null`.
To set it up, open Settings → AI assistant, check that the recipe search
card shows SearXNG with `http://searxng:8080`, select **Save recipe search**
and then **Test**. Or, from a signed-in session (every non-`GET` request
needs the `X-MealQuest-CSRF` header set to `csrfToken` from
`GET /api/v1/me`):
+3
View File
@@ -149,6 +149,9 @@ automatic** (D-021).
the Brave key (`api_key`, write-only: responses carry only
`api_key_set` and `api_key_last4`; an empty string removes it). The
result must be usable: SearXNG needs a base_url, Brave a key.
Responses also carry the read-only `default_base_url`, the deployment's
`MEALQUEST_SEARXNG_URL` (or null), which the client pre-fills while no
base_url is saved; only a PATCH of base_url stores a URL.
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
masala" and returns `{query, count}`, 409 `not_configured`, or 502
`search_failed` with the provider's error.
+11
View File
@@ -35,6 +35,8 @@ const (
EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
EnvSearXNGURL = "MEALQUEST_SEARXNG_URL"
)
// Session lifetime defaults (D-004): long enough that the kitchen tablet
@@ -73,6 +75,13 @@ type Config struct {
// validates it only while the settings do not exist yet, and ignores it
// once they do. Empty when unset.
DefaultTZ string
// SearXNGURL is the deployment's SearXNG instance, which the Settings
// screen suggests while no recipe-search URL is saved. It is optional
// and validated by recipes.NewSearchSettingsService: the compose
// network's http://searxng:8080 is neither https nor loopback, so the
// secure-URL rule does not apply. Empty when unset.
SearXNGURL string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -107,6 +116,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
TrustedProxy: r.trustedProxy(EnvTrustedProxy),
DefaultTZ: r.optional(EnvDefaultTZ),
SearXNGURL: r.optional(EnvSearXNGURL),
}
if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle {
r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle)
+5
View File
@@ -422,6 +422,8 @@ func TestLoadValues(t *testing.T) {
EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -480,6 +482,9 @@ func TestLoadValues(t *testing.T) {
if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
}
if cfg.SearXNGURL != "http://searxng:8080" {
t.Errorf("SearXNGURL = %q, want http://searxng:8080", cfg.SearXNGURL)
}
}
func TestSecretRedacted(t *testing.T) {
+40 -17
View File
@@ -30,10 +30,15 @@ var ErrSearchNotConfigured = errors.New("recipes: recipe search is not configure
type SearchSettings struct {
// Provider is "searxng" or "brave", nil until first chosen.
Provider *string `json:"provider"`
// BaseURL is the SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// BaseURL is the saved SearXNG instance's URL, nil when not set.
BaseURL *string `json:"base_url"`
// DefaultBaseURL is the deployment's SearXNG URL
// (MEALQUEST_SEARXNG_URL), for the client to suggest while BaseURL is
// not set. It is never saved by itself: only a PATCH of base_url saves
// a URL. Nil when the deployment names none.
DefaultBaseURL *string `json:"default_base_url"`
APIKeySet bool `json:"api_key_set"`
APIKeyLast4 *string `json:"api_key_last4"`
// UpdatedAt is nil until the settings are first saved.
UpdatedAt *string `json:"updated_at"`
}
@@ -75,32 +80,49 @@ func (e *SearchSettingsError) Error() string {
type SearchSettingsService struct {
st *store.Store
box *secretbox.Box
// defaultBaseURL is SearchSettings.DefaultBaseURL, "" for none.
defaultBaseURL string
// newProvider is NewSearchProvider; tests point Brave elsewhere.
newProvider func(db.RecipeSearchSetting, *secretbox.Box) (SearchProvider, error)
}
// NewSearchSettingsService returns the service over st, sealing keys with
// box.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box) (*SearchSettingsService, error) {
// box. defaultBaseURL is the deployment's SearXNG URL, "" for none; a set
// one must be a valid SearXNG URL.
func NewSearchSettingsService(st *store.Store, box *secretbox.Box, defaultBaseURL string) (*SearchSettingsService, error) {
if st == nil {
return nil, errors.New("recipes: nil store")
}
if box == nil {
return nil, errors.New("recipes: nil secret box")
}
return &SearchSettingsService{st: st, box: box, newProvider: NewSearchProvider}, nil
if defaultBaseURL != "" {
if _, err := NewSearXNG(defaultBaseURL); err != nil {
return nil, fmt.Errorf("recipes: default SearXNG URL: %s", strings.TrimPrefix(err.Error(), "recipes: "))
}
}
return &SearchSettingsService{st: st, box: box, defaultBaseURL: defaultBaseURL, newProvider: NewSearchProvider}, nil
}
// Get returns the settings; before any are saved every field is unset.
// Get returns the settings; before any are saved every field but
// DefaultBaseURL is unset.
func (s *SearchSettingsService) Get(ctx context.Context) (SearchSettings, error) {
row, err := db.New(s.st.Reader).GetRecipeSearchSettings(ctx)
if errors.Is(err, sql.ErrNoRows) {
return SearchSettings{}, nil
return SearchSettings{DefaultBaseURL: s.defaultBase()}, nil
}
if err != nil {
return SearchSettings{}, fmt.Errorf("recipes: read recipe search settings: %w", err)
}
return searchSettingsFrom(row), nil
return s.settingsFrom(row), nil
}
func (s *SearchSettingsService) defaultBase() *string {
if s.defaultBaseURL == "" {
return nil
}
u := s.defaultBaseURL
return &u
}
// Update applies patch and returns the settings it leaves. The first update
@@ -141,7 +163,7 @@ func (s *SearchSettingsService) Update(ctx context.Context, patch SearchSettings
if err != nil {
return SearchSettings{}, err
}
return searchSettingsFrom(saved), nil
return s.settingsFrom(saved), nil
}
// apply returns row with patch applied, validated.
@@ -226,14 +248,15 @@ func (s *SearchSettingsService) TestSearch(ctx context.Context) (int, error) {
return len(cands), nil
}
func searchSettingsFrom(row db.RecipeSearchSetting) SearchSettings {
func (s *SearchSettingsService) settingsFrom(row db.RecipeSearchSetting) SearchSettings {
provider, updated := row.Provider, row.UpdatedAt
return SearchSettings{
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
Provider: &provider,
BaseURL: nullString(row.BaseUrl),
DefaultBaseURL: s.defaultBase(),
APIKeySet: len(row.ApiKeyCt) > 0,
APIKeyLast4: nullString(row.ApiKeyLast4),
UpdatedAt: &updated,
}
}
+76 -1
View File
@@ -19,6 +19,13 @@ import (
const testBraveKey = "BSA-secret-brave-key-9f3c"
func newTestSearchSettings(t *testing.T) *SearchSettingsService {
t.Helper()
return newTestSearchSettingsWithDefault(t, "")
}
// newTestSearchSettingsWithDefault is newTestSearchSettings with
// defaultBaseURL as the deployment's SearXNG URL.
func newTestSearchSettingsWithDefault(t *testing.T, defaultBaseURL string) *SearchSettingsService {
t.Helper()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
@@ -38,7 +45,7 @@ func newTestSearchSettings(t *testing.T) *SearchSettingsService {
if err != nil {
t.Fatal(err)
}
svc, err := NewSearchSettingsService(st, box)
svc, err := NewSearchSettingsService(st, box, defaultBaseURL)
if err != nil {
t.Fatal(err)
}
@@ -124,6 +131,74 @@ func TestSearchSettingsUpdate(t *testing.T) {
}
}
func TestSearchSettingsDefaultBaseURL(t *testing.T) {
t.Parallel()
svc := newTestSearchSettingsWithDefault(t, "http://searxng:8080")
got, err := svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" || got.BaseURL != nil || got.Provider != nil {
t.Errorf("Get before any update = %+v, want only default_base_url set", got)
}
if _, err := svc.Provider(t.Context()); !errors.Is(err, ErrSearchNotConfigured) {
t.Errorf("Provider with only a default error = %v, want ErrSearchNotConfigured", err)
}
// The default is a suggestion: SearXNG still needs a saved URL.
if _, err := svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng")}); err == nil {
t.Error("choosing SearXNG without a URL succeeded with a default set")
}
got, err = svc.Update(t.Context(), SearchSettingsPatch{Provider: ptr("searxng"), BaseURL: ptr("https://search.example")})
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" || got.DefaultBaseURL == nil || *got.DefaultBaseURL != "http://searxng:8080" {
t.Errorf("Update result = %+v, want the saved URL beside the default", got)
}
got, err = svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
if *got.BaseURL != "https://search.example" {
t.Errorf("Get base_url = %q, want the saved URL, not the default", *got.BaseURL)
}
row, err := db.New(svc.st.Reader).GetRecipeSearchSettings(t.Context())
if err != nil {
t.Fatal(err)
}
if row.BaseUrl.String != "https://search.example" {
t.Errorf("stored base_url = %q", row.BaseUrl.String)
}
}
func TestNewSearchSettingsServiceRejectsBadDefault(t *testing.T) {
t.Parallel()
st, err := store.Open(t.Context(), t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
for _, u := range []string{"searxng:8080", "ftp://searxng", "http://user:pw@searxng:8080"} {
if _, err := NewSearchSettingsService(st, box, u); err == nil {
t.Errorf("NewSearchSettingsService(%q) succeeded", u)
} else if strings.Contains(err.Error(), "pw") {
t.Errorf("error for %q echoes the URL: %v", u, err)
}
}
}
func TestSearchSettingsTestSearch(t *testing.T) {
t.Parallel()
searx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -46,7 +46,7 @@ func TestRecipeSearchSettingsAPI(t *testing.T) {
}
rec, body := call(http.MethodGet, recipeSearchPath, "")
if rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false {
if def, ok := body["default_base_url"]; rec.Code != http.StatusOK || body["provider"] != nil || body["api_key_set"] != false || !ok || def != nil {
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
}
rec, body = call(http.MethodPost, recipeSearchTestPath, "")
+1 -1
View File
@@ -132,7 +132,7 @@ func newTestPhotos(t *testing.T) *recipes.PhotoCache {
// configured, over a migrated temp database and a random secret box.
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
t.Helper()
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t))
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
if err != nil {
t.Fatal(err)
}
+15 -2
View File
@@ -5,7 +5,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 { AI_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'
afterEach(() => {
@@ -27,7 +27,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
}
// 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', () => {
it('registers every handoff route plus the extra settings pages', () => {
@@ -80,12 +80,25 @@ describe('App routes', () => {
'/api/v1/me': () => jsonResponse(200, ME),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
})
renderAt('/settings/ai')
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
expect(screen.getByRole('main')).toContainElement(heading)
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', () => {
+2
View File
@@ -7,6 +7,7 @@ import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { AppShell } from './shell/AppShell.tsx'
@@ -33,6 +34,7 @@ export const routes = [
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
@@ -251,3 +251,33 @@
justify-content: flex-end;
gap: 10px;
}
/* Recipe search card, below the AI assistant's actions. */
.card {
display: flex;
flex-direction: column;
gap: 14px;
border: 1px solid var(--color-neutral-300);
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.cardLoading {
height: 160px;
border-radius: var(--radius-lg);
}
.cardActions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 10px;
}
.cardActions > button {
min-height: 52px;
}
+275 -2
View File
@@ -4,11 +4,12 @@ import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS } from '../test/ApiProviders.tsx'
import { AI_SETTINGS, RECIPE_SEARCH_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 { AISettingsPage, formatVerifiedDate, maskedKey } from './AISettingsPage.tsx'
import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx'
import { maskedKey } from './api.ts'
const MODELS = {
models: [
@@ -30,6 +31,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
'GET /api/v1/me': () => jsonResponse(200, ME),
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
...responders,
}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
@@ -422,6 +424,277 @@ describe('AISettingsPage', () => {
})
})
// apiError is an API error response carrying message.
function apiError(status: number, code: string, message: string): Response {
return jsonResponse(status, { error: { code, message, retryable: false } })
}
const BRAVE_SETTINGS = { ...RECIPE_SEARCH_SETTINGS, provider: 'brave', api_key_set: true, api_key_last4: 'b7c2' }
describe('Recipe search card', () => {
const card = () => screen.getByRole('region', { name: 'Recipe search' })
const searxng = () => within(card()).getByRole('radio', { name: 'SearXNG' })
const brave = () => within(card()).getByRole('radio', { name: 'Brave' })
const url = () => within(card()).getByRole('textbox', { name: 'SearXNG URL' })
const saveSearch = () => within(card()).getByRole('button', { name: 'Save recipe search' })
const testSearch = () => within(card()).getByRole('button', { name: 'Test' })
async function renderCard() {
await renderPage()
await within(card()).findByRole('radiogroup', { name: 'Recipe search provider' })
}
// addKey enters key in the Brave key sheet.
function addKey(button: string, key: string) {
fireEvent.click(within(card()).getByRole('button', { name: button }))
const sheet = screen.getByRole('dialog')
fireEvent.change(within(sheet).getByLabelText('Brave API key'), { target: { value: key } })
fireEvent.click(within(sheet).getByRole('button', { name: 'Use this key' }))
}
it('shows the saved SearXNG URL', async () => {
stubAPI()
await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('http://searxng:8080')
expect(within(card()).queryByText('Brave API key')).not.toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
expect(testSearch()).toBeEnabled()
})
const UNCONFIGURED = {
provider: null,
base_url: null,
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: null,
}
it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED),
'PATCH /api/v1/settings/recipe-search': (body) =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
})
await renderCard()
expect(searxng()).toHaveAttribute('aria-checked', 'true')
expect(url()).toHaveValue('http://searxng:8080')
expect(testSearch()).toBeDisabled()
expect(saveSearch()).toBeEnabled()
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{
request: 'PATCH /api/v1/settings/recipe-search',
body: { provider: 'searxng', base_url: 'http://searxng:8080' },
},
])
})
it('starts with an empty URL when the deployment names no SearXNG', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }),
})
await renderCard()
expect(url()).toHaveValue('')
})
it('shows the saved URL, not the deployment\'s', async () => {
stubAPI({
'GET /api/v1/settings/recipe-search': () =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }),
})
await renderCard()
expect(url()).toHaveValue('https://search.example')
expect(saveSearch()).toBeDisabled()
})
it('switches providers, keeping each provider\'s field', async () => {
stubAPI()
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(brave())
expect(brave()).toHaveAttribute('aria-checked', 'true')
expect(within(card()).queryByRole('textbox', { name: 'SearXNG URL' })).not.toBeInTheDocument()
expect(within(card()).getByText('No key stored')).toBeInTheDocument()
expect(within(card()).getByRole('button', { name: 'Add key' })).toBeInTheDocument()
fireEvent.click(searxng())
expect(url()).toHaveValue('https://search.example')
})
it('saves a new SearXNG URL', async () => {
const fetchMock = stubAPI({
'PATCH /api/v1/settings/recipe-search': (body) =>
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
})
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(testSearch()).toBeDisabled()
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { base_url: 'https://search.example' } },
])
expect(saveSearch()).toBeDisabled()
expect(testSearch()).toBeEnabled()
})
it('saves a switch to Brave with its key, then shows only the key\'s last four characters', async () => {
const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
fireEvent.click(brave())
addKey('Add key', 'BSA-secret-key-b7c2')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(within(card()).getByText('New key not saved yet')).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
fireEvent.click(saveSearch())
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { provider: 'brave', api_key: 'BSA-secret-key-b7c2' } },
])
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(within(card()).queryByText('New key not saved yet')).not.toBeInTheDocument()
expect(within(card()).getByRole('button', { name: 'Replace' })).toBeInTheDocument()
expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2')
})
it('masks a stored Brave key and replaces it', async () => {
const fetchMock = stubAPI({
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS),
'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, { ...BRAVE_SETTINGS, api_key_last4: '9d0e' }),
})
await renderCard()
expect(brave()).toHaveAttribute('aria-checked', 'true')
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
addKey('Replace', 'BSA-other-key-9d0e')
fireEvent.click(within(card()).getByRole('button', { name: 'Discard' }))
expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument()
expect(saveSearch()).toBeDisabled()
addKey('Replace', 'BSA-other-key-9d0e')
fireEvent.click(saveSearch())
expect(await within(card()).findByText('••••••••9d0e')).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/recipe-search', body: { api_key: 'BSA-other-key-9d0e' } },
])
})
it('reports a save the server refuses', async () => {
stubAPI({
'PATCH /api/v1/settings/recipe-search': () =>
apiError(400, 'invalid_settings', 'invalid recipe search settings: api_key: must be at least 8 characters'),
})
await renderCard()
fireEvent.click(brave())
addKey('Add key', 'short')
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent(
'Not saved: invalid recipe search settings: api_key: must be at least 8 characters.',
)
})
it('shows how many recipes the test found', async () => {
const fetchMock = stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/recipe-search/test', body: undefined }])
})
it('shows the error when the test fails', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent(
'Test failed: recipes: SearXNG answered 403 Forbidden',
)
})
it('clears a test result when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument()
})
it('clears a test error when the settings are edited', async () => {
stubAPI({
'POST /api/v1/settings/recipe-search/test': () =>
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
})
await renderCard()
fireEvent.click(testSearch())
expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed')
fireEvent.click(brave())
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
expect(within(card()).queryByRole('alert')).not.toBeInTheDocument()
})
it('asks before leaving with an unsaved recipe search change', async () => {
stubAPI()
await renderCard()
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument()
})
it('does not count the suggested SearXNG URL as an unsaved change until it is edited', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
expect(saveSearch()).toBeEnabled()
expect(unload()).toBe(false)
fireEvent.change(url(), { target: { value: 'https://search.example' } })
expect(unload()).toBe(true)
fireEvent.change(url(), { target: { value: 'http://searxng:8080' } })
expect(unload()).toBe(false)
})
it('leaves the untouched first-use card without asking', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED) })
await renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('keeps 44 px tap targets', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
for (const control of [
searxng(),
brave(),
within(card()).getByRole('button', { name: 'Replace' }),
testSearch(),
saveSearch(),
]) {
expectMinHitArea(control)
}
})
})
describe('maskedKey', () => {
it('shows only the last four characters', () => {
expect(maskedKey('3f9a')).toBe('••••••••3f9a')
+21 -77
View File
@@ -1,6 +1,6 @@
import { ChevronLeft } from 'lucide-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 { Button } from '../ui/Button.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
@@ -14,6 +14,7 @@ import { TextInput } from '../ui/TextInput.tsx'
import {
aiKeyPolicy,
aiProviderLabels,
maskedKey,
providersWithBaseURL,
useAIModels,
useAISettings,
@@ -26,6 +27,8 @@ import {
type AISettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
import { Confirm, LeaveGuard } from './LeaveGuard.tsx'
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
const TYPE_MODEL = '\u0000type'
@@ -33,9 +36,12 @@ const TYPE_MODEL = '\u0000type'
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
// system prompt are edited as a draft and saved together; the API key is
// stored (and verified) on its own, straight away. The client only ever
// sees the key's last four characters.
// sees the key's last four characters. Recipe search is its own card, saved
// on its own.
export function AISettingsPage() {
const settings = useAISettings()
const [aiDirty, setAIDirty] = useState(false)
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
return (
<div className={styles.page}>
<Header />
@@ -45,7 +51,9 @@ export function AISettingsPage() {
AI settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <AISettingsForm saved={settings.data} />}
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={setAIDirty} />}
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
</div>
)
}
@@ -95,7 +103,13 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
return patch
}
function AISettingsForm({ saved }: { saved: AISettings }) {
type AISettingsFormProps = {
saved: AISettings
// Called with whether the form holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
const [draft, setDraft] = useState(() => draftOf(saved))
const [typingModel, setTypingModel] = useState(false)
const [confirmingReset, setConfirmingReset] = useState(false)
@@ -116,15 +130,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
const listed = canList && models.isSuccess ? models.data : []
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
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])
onDirtyChange(dirty)
}, [dirty, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
function edit(change: Partial<Draft>) {
save.reset()
@@ -285,26 +294,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
>
This restores MealQuest's default system prompt for everyone in the household.
</Confirm>
<Confirm
open={blocker.state === 'blocked'}
title="Leave without saving?"
confirmLabel="Leave"
cancelLabel="Keep editing"
onCancel={() => blocker.reset?.()}
onConfirm={() => blocker.proceed?.()}
>
Your changes to the AI assistant have not been saved.
</Confirm>
</>
)
}
// maskedKey shows the stored key as dots and its last four characters, all
// the client ever knows of it.
export function maskedKey(last4: string): string {
return `••••••••${last4}`
}
// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1".
export function formatVerifiedDate(timestamp: string): string {
const date = new Date(timestamp)
@@ -520,52 +513,3 @@ function PromptField({ value, onChange }: { value: string; onChange: (value: str
</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,366 @@
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('offers the UTC zones the browser list leaves out', async () => {
expect(Intl.supportedValuesOf('timeZone')).not.toContain('Etc/UTC')
const fetchMock = stubAPI({
'PATCH /api/v1/settings/household': (body) => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, ...(body as object) }),
})
await renderPage()
expect(zoneGroup()).toHaveTextContent('America / New York')
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' })
fireEvent.change(within(sheet).getByRole('searchbox', { name: 'Search time zones' }), {
target: { value: 'utc' },
})
expect(within(list).getByRole('button', { name: 'UTC, UTC+00:00' })).toBeInTheDocument()
fireEvent.click(within(list).getByRole('button', { name: 'Etc / UTC, UTC+00:00' }))
expect(zoneGroup()).toHaveTextContent('Etc / UTC')
fireEvent.click(save())
await screen.findByText('Changes saved.')
expect(sent(fetchMock)).toEqual([
{ request: 'PATCH /api/v1/settings/household', body: { timezone: 'Etc/UTC' } },
])
})
it('keeps offering a saved zone the browser list leaves out after another is chosen', async () => {
expect(Intl.supportedValuesOf('timeZone')).not.toContain('US/Eastern')
stubAPI({
'GET /api/v1/settings/household': () => jsonResponse(200, { ...HOUSEHOLD_SETTINGS, timezone: 'US/Eastern' }),
})
await renderPage()
const search = (value: string) =>
fireEvent.change(screen.getByRole('searchbox', { name: 'Search time zones' }), { target: { value } })
const option = (name: RegExp) =>
within(screen.getByRole('list', { name: 'Time zones' })).getByRole('button', { name })
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
search('los angeles')
fireEvent.click(option(/^America \/ Los Angeles,/))
expect(zoneGroup()).toHaveTextContent('America / Los Angeles')
fireEvent.click(within(zoneGroup()).getByRole('button', { name: 'Change' }))
search('us eastern')
fireEvent.click(option(/^US \/ Eastern,/))
expect(zoneGroup()).toHaveTextContent('US / Eastern')
expect(save()).toBeDisabled()
})
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)
})
})
+398
View File
@@ -0,0 +1,398 @@
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}
saved={saved.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
saved: 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, saved, 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}
saved={saved}
onClose={() => setOpen(false)}
onChoose={(zone) => {
onChange(zone)
setOpen(false)
}}
/>
)}
</div>
)
}
type TimeZoneSheetProps = {
value: string
// The saved zone, offered even after another is chosen.
saved: string
onClose: () => void
onChoose: (zone: string) => void
}
function TimeZoneSheet({ value, saved, onClose, onChoose }: TimeZoneSheetProps) {
const [search, setSearch] = useState('')
const titleId = useId()
const options = useMemo(() => timeZoneOptions([saved, value], new Date()), [saved, 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>
)
}
+38
View File
@@ -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;
}
+80
View File
@@ -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>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useEffect, useId, useState } from 'react'
import { ApiError } from '../api/index.ts'
import { Button } from '../ui/Button.tsx'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Segmented } from '../ui/Segmented.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import {
maskedKey,
recipeSearchProviderLabels,
useRecipeSearchSettings,
useSaveRecipeSearchSettings,
useTestRecipeSearch,
type RecipeSearchProvider,
type RecipeSearchSettings,
type RecipeSearchSettingsPatch,
} from './api.ts'
import styles from './AISettingsPage.module.css'
const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({
value: value as RecipeSearchProvider,
label,
}))
type RecipeSearchCardProps = {
// Called with whether the card holds unsaved changes.
onDirtyChange: (dirty: boolean) => void
}
// RecipeSearchCard is where recipes are searched for: a SearXNG instance or
// Brave. The provider, SearXNG URL and a new Brave key are edited as a draft
// and saved together; the client only ever sees the stored key's last four
// characters. Test searches with the saved settings.
export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) {
const settings = useRecipeSearchSettings()
const titleId = useId()
return (
<Card className={styles.card} aria-labelledby={titleId} role="region">
<h2 id={titleId} className={styles.cardTitle}>
Recipe search
</h2>
<p className={styles.hint}>Where MealQuest looks for real recipes behind each Side quest.</p>
{settings.isPending && <Skeleton className={styles.cardLoading} />}
{settings.isError && (
<ErrorBanner onRetry={() => void settings.refetch()}>
Recipe search settings could not load: {settings.error.message}
</ErrorBanner>
)}
{settings.isSuccess && <RecipeSearchForm saved={settings.data} onDirtyChange={onDirtyChange} />}
</Card>
)
}
type Draft = {
provider: RecipeSearchProvider
base_url: string
// A new Brave key to store, null when the stored one stays.
api_key: string | null
}
// draftOf starts a household that has not set up recipe search on SearXNG,
// the sidecar every deployment runs. With no URL saved the field holds the
// deployment's SearXNG URL, which saving stores.
function draftOf(saved: RecipeSearchSettings): Draft {
return {
provider: saved.provider ?? 'searxng',
base_url: saved.base_url ?? saved.default_base_url ?? '',
api_key: null,
}
}
// patchOf is the PATCH body for what draft changes. Only the chosen
// provider's field is sent: the other provider's is left as it is.
function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch {
const patch: RecipeSearchSettingsPatch = {}
if (draft.provider !== saved.provider) patch.provider = draft.provider
if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url
if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key
return patch
}
function sameDraft(a: Draft, b: Draft): boolean {
return a.provider === b.provider && a.base_url === b.base_url && a.api_key === b.api_key
}
function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) {
const [draft, setDraft] = useState(() => draftOf(saved))
const save = useSaveRecipeSearchSettings()
const test = useTestRecipeSearch()
const patch = patchOf(saved, draft)
const dirty = Object.keys(patch).length > 0
// Only the household's own edits are unsaved changes. Before recipe search
// is set up the untouched draft already differs from what is saved — it
// suggests SearXNG and the deployment's URL — but leaving it loses nothing.
const edited = dirty && !sameDraft(draft, draftOf(saved))
useEffect(() => {
onDirtyChange(edited)
}, [edited, onDirtyChange])
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
// An edit clears the last save and test results: they describe the saved
// settings, not the draft.
function edit(change: Partial<Draft>) {
save.reset()
test.reset()
setDraft((current) => ({ ...current, ...change }))
}
function onSave() {
save.mutate(patch, {
onSuccess: (settings) => {
setDraft(draftOf(settings))
test.reset()
},
})
}
return (
<>
<Segmented
label="Recipe search provider"
options={providerOptions}
value={draft.provider}
onValueChange={(provider) => edit({ provider })}
/>
{draft.provider === 'searxng' ? (
<TextInput
label="SearXNG URL"
type="url"
className={styles.mono}
value={draft.base_url}
placeholder="https://"
autoComplete="off"
spellCheck={false}
onChange={(event) => edit({ base_url: event.target.value })}
/>
) : (
<BraveKey
last4={saved.api_key_last4}
newKey={draft.api_key}
onKey={(api_key) => edit({ api_key })}
/>
)}
<div className={styles.status} role="status">
{save.isSuccess && !dirty && 'Recipe search saved.'}
{test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
</div>
{save.isError && (
<p className={styles.error} role="alert">
{saveErrorMessage(save.error)}
</p>
)}
{test.isError && (
<p className={styles.error} role="alert">
{testErrorMessage(test.error)}
</p>
)}
{dirty && saved.provider !== null && <p className={styles.note}>Save your changes to test them.</p>}
<div className={styles.cardActions}>
<Button
variant="outline"
disabled={dirty || saved.provider === null || test.isPending}
onClick={() => test.mutate()}
>
{test.isPending ? 'Testing…' : 'Test'}
</Button>
<Button disabled={!dirty || save.isPending} onClick={onSave}>
{save.isPending ? 'Saving…' : 'Save recipe search'}
</Button>
</div>
</>
)
}
type BraveKeyProps = {
last4: string | null
newKey: string | null
onKey: (key: string | null) => void
}
// BraveKey shows the stored key masked, or the new one waiting to be saved,
// and takes a new key in a sheet. The key is sent only when the card is
// saved.
function BraveKey({ last4, newKey, onKey }: BraveKeyProps) {
const [replacing, setReplacing] = useState(false)
const [key, setKey] = useState('')
const labelId = useId()
const sheetTitleId = useId()
function close() {
setReplacing(false)
setKey('')
}
let shown = 'No key stored'
if (newKey !== null) shown = maskedKey(newKey.slice(-4))
else if (last4 !== null) shown = maskedKey(last4)
return (
<div className={styles.key}>
<span id={labelId} className={styles.label}>
Brave API key
</span>
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
<span className={styles.keyValue}>{shown}</span>
<button type="button" className={styles.replace} onClick={() => setReplacing(true)}>
{last4 === null && newKey === null ? 'Add key' : 'Replace'}
</button>
</div>
{newKey !== null && (
<div className={styles.keyStatus}>
<p className={styles.note}>New key not saved yet</p>
<button type="button" className={styles.link} onClick={() => onKey(null)}>
Discard
</button>
</div>
)}
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
<form
className={styles.sheet}
onSubmit={(event) => {
event.preventDefault()
onKey(key)
close()
}}
>
<h2 id={sheetTitleId} className={styles.sheetTitle}>
{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}
</h2>
<p className={styles.sheetText}>
The key is stored on the server when you save recipe search. This device only ever sees its last four
characters.
</p>
<TextInput
label="Brave API key"
type="password"
className={styles.mono}
value={key}
autoComplete="off"
spellCheck={false}
onChange={(event) => setKey(event.target.value)}
/>
<div className={styles.sheetActions}>
<Button variant="outline" onClick={close}>
Cancel
</Button>
<Button type="submit" disabled={key === ''}>
Use this key
</Button>
</div>
</form>
</Sheet>
</div>
)
}
function saveErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.`
return `Not saved: ${error.message}`
}
// testErrorMessage says why POST /settings/recipe-search/test failed, by
// the error code the server names (internal/server/recipesearch.go).
function testErrorMessage(error: Error): string {
if (error instanceof ApiError && error.code === 'not_configured') {
return 'Test failed: recipe search is not set up yet.'
}
return `Test failed: ${error.message}`
}
+3 -3
View File
@@ -4,14 +4,14 @@ 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 { 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 { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { formatClockTime, modelDisplayName } from './api.ts'
import { SettingsPage } from './SettingsPage.tsx'
const HOUSEHOLD = { timezone: 'America/New_York', dinner_time: '17:30' }
const HOUSEHOLD = HOUSEHOLD_SETTINGS
const AI = AI_SETTINGS
afterEach(() => {
@@ -221,7 +221,7 @@ describe('SettingsPage', () => {
it('refuses a dinner time that is not HH:MM', async () => {
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()
expect(await screen.findByRole('alert')).toHaveTextContent('dinner_time "5:30pm" is not HH:MM')
+227 -12
View File
@@ -8,6 +8,8 @@ export const AI_MODELS_PATH = '/api/v1/settings/ai/models'
export const AI_KEY_PATH = '/api/v1/settings/ai/api-key'
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt'
export const RECIPE_SEARCH_PATH = '/api/v1/settings/recipe-search'
export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
// 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.
@@ -21,13 +23,45 @@ export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
// 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 = {
timezone: string
// Zero-padded 24-hour HH:MM.
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
// (internal/ai/llm/presets) as the household reads it.
export const aiProviderLabels = {
@@ -192,24 +226,199 @@ export function useResetAIPrompt() {
return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined)
}
// recipeSearchProviderLabels names every recipe search provider the server
// accepts (internal/recipes/searchsettings.go).
export const recipeSearchProviderLabels = {
searxng: 'SearXNG',
brave: 'Brave',
} as const
export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels
// RecipeSearchSettings is GET /api/v1/settings/recipe-search. The Brave API
// key itself is never sent: only whether one is stored and its last four
// characters.
export type RecipeSearchSettings = {
// Null until recipe search is first set up.
provider: RecipeSearchProvider | null
// The saved SearXNG instance's URL, null when none is set.
base_url: string | null
// The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while
// base_url is null; null when the deployment names none. Only saving
// base_url stores a URL.
default_base_url: string | null
api_key_set: boolean
api_key_last4: string | null
updated_at: string | null
}
// RecipeSearchSettingsPatch is the PATCH /api/v1/settings/recipe-search
// body: only the fields that change. api_key is the Brave key in plain text.
export type RecipeSearchSettingsPatch = {
provider?: RecipeSearchProvider
base_url?: string
api_key?: string
}
// RecipeSearchTest is POST /api/v1/settings/recipe-search/test: how many
// recipes the saved provider found for query.
export type RecipeSearchTest = { query: string; count: number }
function isRecipeSearchProvider(value: string): value is RecipeSearchProvider {
return Object.hasOwn(recipeSearchProviderLabels, value)
}
function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSettings {
if (
!isRecord(body) ||
!isStringOrNull(body.provider) ||
!isStringOrNull(body.base_url) ||
!isStringOrNull(body.default_base_url) ||
typeof body.api_key_set !== 'boolean' ||
!isStringOrNull(body.api_key_last4) ||
!isStringOrNull(body.updated_at)
) {
throw new Error(`${where}: response is not the recipe search settings`)
}
if (body.provider !== null && !isRecipeSearchProvider(body.provider)) {
throw new Error(`${where}: unknown recipe search provider "${body.provider}"`)
}
return {
provider: body.provider,
base_url: body.base_url,
default_base_url: body.default_base_url,
api_key_set: body.api_key_set,
api_key_last4: body.api_key_last4,
updated_at: body.updated_at,
}
}
const RECIPE_SEARCH_KEY = ['settings', 'recipe-search'] as const
export function useRecipeSearchSettings() {
const { apiFetch } = useApi()
return useQuery({
queryKey: RECIPE_SEARCH_KEY,
queryFn: async () =>
parseRecipeSearchSettings(`GET ${RECIPE_SEARCH_PATH}`, await apiFetch<unknown>(RECIPE_SEARCH_PATH)),
})
}
export function useSaveRecipeSearchSettings() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (patch: RecipeSearchSettingsPatch) =>
parseRecipeSearchSettings(
`PATCH ${RECIPE_SEARCH_PATH}`,
await apiFetch<unknown>(RECIPE_SEARCH_PATH, { method: 'PATCH', body: patch }),
),
onSuccess: (settings) => queryClient.setQueryData(RECIPE_SEARCH_KEY, settings),
})
}
// useTestRecipeSearch searches with the saved settings.
export function useTestRecipeSearch() {
const { apiFetch } = useApi()
return useMutation({
mutationFn: async () => {
const body = await apiFetch<unknown>(RECIPE_SEARCH_TEST_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.query !== 'string' || typeof body.count !== 'number') {
throw new Error(`POST ${RECIPE_SEARCH_TEST_PATH}: response is not a search test result`)
}
return { query: body.query, count: body.count } satisfies RecipeSearchTest
},
})
}
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
// formatClockTime never sees one.
// parseHouseholdSettings refuses a body that is not the household settings,
// 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() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'household'],
queryFn: async () => {
const settings = await apiFetch<HouseholdSettings>(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
},
queryKey: HOUSEHOLD_SETTINGS_KEY,
queryFn: async () =>
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
})
}
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
// model's display name, or that no model is chosen yet (the seed).
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
@@ -321,6 +530,12 @@ export function notificationsStatus(status: NotificationsStatus | null): string
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
}
// maskedKey shows a stored key as dots and its last four characters, all
// the client ever knows of it.
export function maskedKey(last4: string): string {
return `••••••••${last4}`
}
// 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)
+46
View File
@@ -0,0 +1,46 @@
// 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)}`
}
// Zones the server accepts that Intl.supportedValuesOf('timeZone') leaves
// out: it lists only canonical zones, and UTC is not one of them.
const unlistedZones = ['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']
// timeZoneOptions lists every zone the browser supports, the unlisted UTC
// zones, and kept (the saved and chosen zones, which may be aliases the
// list leaves out), sorted by name.
export function timeZoneOptions(kept: string[], instant: Date): TimeZoneOption[] {
const zones = new Set([...Intl.supportedValuesOf('timeZone'), ...unlistedZones, ...kept])
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))
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppRoutes } from '../App.tsx'
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle, outerSize } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { expectMinHitArea } from '../test/hitArea.ts'
import { stubFetchByPath } from '../test/fetchStub.ts'
import { mockViewport } from '../test/matchMedia.ts'
@@ -25,11 +25,11 @@ function renderAt(path: string, width: number) {
const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API)
render(
<MemoryRouter initialEntries={[path]}>
<InDataRouter path={path}>
<ApiProviders>
<AppRoutes />
</ApiProviders>
</MemoryRouter>,
</InDataRouter>,
)
return resize
}
+33 -1
View File
@@ -16,6 +16,37 @@ export const AI_SETTINGS = {
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
// SearXNG sidecar, no Brave key stored.
export const RECIPE_SEARCH_SETTINGS = {
provider: 'searxng',
base_url: 'http://searxng:8080',
default_base_url: 'http://searxng:8080',
api_key_set: false,
api_key_last4: null,
updated_at: '2026-10-01T15:00:00.000Z',
}
// 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.
@@ -23,8 +54,9 @@ 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, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
}
// ApiProviders gives the pages under test what App gives them: an API client