From 77cc8428d0e45477b1b8cfa583801c4e1d56a40c Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 26 Sep 2026 12:59:55 -0400 Subject: [PATCH] Admin Settings: edit the household timezone and auto-set it from the browser zone when unset Adds timezone to the HouseholdSettings client type and a Timezone field (text input with a datalist of common IANA zones) to the Preferences form. Save validates the value as UTC or an Area/Location name and patches it only when changed. When the loaded timezone is empty, the screen patches the browser's Intl zone once before showing the form, so nothing the admin enters can be overwritten; a missing or unusable zone leaves the field empty. Co-Authored-By: Claude Opus 5.5 --- admin/src/api/settings.ts | 2 + admin/src/settings/SettingsScreen.test.tsx | 156 ++++++++++++++++++++- admin/src/settings/SettingsScreen.tsx | 102 +++++++++++++- 3 files changed, 256 insertions(+), 4 deletions(-) diff --git a/admin/src/api/settings.ts b/admin/src/api/settings.ts index a27030b..b72d035 100644 --- a/admin/src/api/settings.ts +++ b/admin/src/api/settings.ts @@ -17,6 +17,8 @@ export interface HouseholdSettings { afternoon_reminder_enabled: boolean; end_of_day_report_enabled: boolean; celebration_enabled: boolean; + /** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */ + timezone: string; } /** Only the supplied fields are updated; the API answers with all of them. */ diff --git a/admin/src/settings/SettingsScreen.test.tsx b/admin/src/settings/SettingsScreen.test.tsx index d687e62..430326d 100644 --- a/admin/src/settings/SettingsScreen.test.tsx +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -1,3 +1,4 @@ +import { StrictMode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import App from "../App"; @@ -21,6 +22,7 @@ const DEFAULT_SETTINGS: HouseholdSettings = { afternoon_reminder_enabled: false, end_of_day_report_enabled: true, celebration_enabled: false, + timezone: "America/Chicago", }; function child( @@ -370,10 +372,11 @@ describe("SettingsScreen — preferences", () => { fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); } - it("renders the current value of the ten settings form fields (timezone is API-only)", async () => { + it("renders the current value of all eleven settings form fields", async () => { const { form } = await renderPreferences(); expect(field(form, "Planning horizon (days)").value).toBe("14"); expect(field(form, "Day rollover").value).toBe("03:00"); + expect(field(form, "Timezone").value).toBe("America/Chicago"); expect(field(form, "Notify target").value).toBe("notify.family"); expect(field(form, "Morning summary time").value).toBe("07:00"); expect(field(form, "Afternoon reminder time").value).toBe("15:30"); @@ -496,6 +499,46 @@ describe("SettingsScreen — preferences", () => { expect(writes()).toHaveLength(1); }); + it("a changed timezone patches exactly the timezone", async () => { + const { form } = await renderPreferences(); + const input = field(form, "Timezone"); + expect(input.getAttribute("list")).toBe("settings-timezones"); + fireEvent.change(input, { target: { value: " Europe/London " } }); + save(form); + await screen.findByText("Preferences saved."); + expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } }]); + const reloaded = screen.getByRole("form", { name: "Preferences" }); + expect(field(reloaded, "Timezone").value).toBe("Europe/London"); + }); + + it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])( + "an invalid timezone %j is rejected before any request", + async (value) => { + const { form } = await renderPreferences(); + fireEvent.change(field(form, "Timezone"), { target: { value } }); + save(form); + expect(screen.getByTestId("preferences-message").textContent).toBe( + "Timezone must be an IANA zone like America/New_York, or UTC.", + ); + expect(field(form, "Timezone").value).toBe(value); + expect(writes()).toEqual([]); + }, + ); + + it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => { + const { form } = await renderPreferences(); + fireEvent.change(field(form, "Timezone"), { target: { value: "Mars/Olympus_Mons" } }); + writeResponse = () => + jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422); + save(form); + const message = await screen.findByTestId("preferences-message"); + expect(message.textContent).toBe( + "The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'", + ); + expect(field(form, "Timezone").value).toBe("Mars/Olympus_Mons"); + expect(field(form, "Timezone").disabled).toBe(false); + }); + it("a failed load shows the reason and Try again refetches", async () => { let fail = true; fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { @@ -512,3 +555,114 @@ describe("SettingsScreen — preferences", () => { await screen.findByRole("form", { name: "Preferences" }); }); }); + +describe("SettingsScreen — timezone auto-set", () => { + let zoneSpy: ReturnType | null; + + function mockBrowserZone(timeZone: string | undefined) { + const real = Intl.DateTimeFormat.prototype.resolvedOptions; + zoneSpy = vi + .spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions") + .mockImplementation(function (this: Intl.DateTimeFormat) { + return { ...real.call(this), timeZone } as Intl.ResolvedDateTimeFormatOptions; + }); + } + + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + children = [child(1, "Declan", 1)]; + settings = { ...DEFAULT_SETTINGS, timezone: "" }; + writeResponse = null; + zoneSpy = null; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + zoneSpy?.mockRestore(); + vi.unstubAllGlobals(); + }); + + async function renderForm() { + render(); + await screen.findByTestId("settings-child-1"); + return screen.findByRole("form", { name: "Preferences" }); + } + + it("an unset timezone is set once, on load, to the browser's zone and shown", async () => { + mockBrowserZone("America/New_York"); + const form = await renderForm(); + expect(field(form, "Timezone").value).toBe("America/New_York"); + expect(writes()).toEqual([ + { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } }, + ]); + expect(settings.timezone).toBe("America/New_York"); + + // A later save of another field does not re-send the timezone. + fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } }); + fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); + await screen.findByText("Preferences saved."); + expect(writes()).toHaveLength(2); + expect(writes()[1].body).toEqual({ horizon_days: 21 }); + }); + + it("StrictMode's doubled effects still auto-set exactly once", async () => { + mockBrowserZone("America/New_York"); + render( + + + , + ); + const form = await screen.findByRole("form", { name: "Preferences" }); + expect(field(form, "Timezone").value).toBe("America/New_York"); + expect(writes()).toEqual([ + { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } }, + ]); + }); + + it("an admin clearing the timezone is not re-set by the browser zone", async () => { + mockBrowserZone("America/New_York"); + const form = await renderForm(); + fireEvent.change(field(form, "Timezone"), { target: { value: "" } }); + fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); + await screen.findByText("Preferences saved."); + expect(writes().map((w) => w.body)).toEqual([{ timezone: "America/New_York" }, { timezone: "" }]); + const reloaded = screen.getByRole("form", { name: "Preferences" }); + expect(field(reloaded, "Timezone").value).toBe(""); + }); + + it("a set timezone is shown and never overwritten by the browser zone", async () => { + mockBrowserZone("America/New_York"); + settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" }; + const form = await renderForm(); + expect(field(form, "Timezone").value).toBe("Europe/Dublin"); + expect(writes()).toEqual([]); + }); + + it.each([ + ["nothing", undefined], + ["an empty zone", ""], + ["an unknown zone", "Etc/Unknown"], + ["a non-IANA zone", "not a zone"], + ])("detection returning %s leaves the field empty and sends nothing", async (_case, zone) => { + mockBrowserZone(zone); + const form = await renderForm(); + expect(field(form, "Timezone").value).toBe(""); + expect(screen.queryByTestId("preferences-message")).toBeNull(); + expect(writes()).toEqual([]); + }); + + it("a rejected auto-set shows why and leaves the field empty for manual entry", async () => { + mockBrowserZone("America/New_York"); + writeResponse = () => jsonResponse({ detail: "timezone is not known" }, 422); + const form = await renderForm(); + expect(field(form, "Timezone").value).toBe(""); + expect(field(form, "Timezone").disabled).toBe(false); + expect(screen.getByTestId("preferences-message").textContent).toBe( + "The timezone could not be set to America/New_York: timezone is not known", + ); + expect(writes()).toHaveLength(1); + }); +}); diff --git a/admin/src/settings/SettingsScreen.tsx b/admin/src/settings/SettingsScreen.tsx index 57c46d4..3a275a0 100644 --- a/admin/src/settings/SettingsScreen.tsx +++ b/admin/src/settings/SettingsScreen.tsx @@ -5,7 +5,8 @@ * household's child profiles. Every write refetches the list. * * Preferences: the household settings (planning horizon, day rollover, - * notifications). Save patches only the changed fields. + * timezone, notifications). Save patches only the changed fields. An unset + * timezone is set once, on load, to the browser's own zone. * * One write at a time across both sections. */ @@ -252,6 +253,56 @@ const NOTIFICATIONS: { label: string; time: TimeField; toggle: ToggleField }[] = const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/; +/** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */ +const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/; + +/** The Timezone field's suggestions; any other IANA zone can be typed. */ +export const COMMON_TIMEZONES = [ + "UTC", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Phoenix", + "America/Los_Angeles", + "America/Anchorage", + "Pacific/Honolulu", + "America/Halifax", + "America/St_Johns", + "America/Toronto", + "America/Vancouver", + "America/Mexico_City", + "America/Sao_Paulo", + "Europe/London", + "Europe/Dublin", + "Europe/Paris", + "Europe/Berlin", + "Europe/Madrid", + "Europe/Rome", + "Europe/Athens", + "Africa/Johannesburg", + "Asia/Dubai", + "Asia/Kolkata", + "Asia/Singapore", + "Asia/Shanghai", + "Asia/Tokyo", + "Australia/Perth", + "Australia/Sydney", + "Pacific/Auckland", +]; + +/** + * The browser's own IANA zone — the location-derived default for an unset + * household timezone — or "" when it is missing or not a usable zone name. + */ +export function detectTimezone(): string { + try { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return typeof zone === "string" && zone !== "Etc/Unknown" && IANA_ZONE.test(zone) ? zone : ""; + } catch { + return ""; + } +} + /** The form's working copy: the number is text so it can be mid-edit. */ type PreferencesDraft = Omit & { horizon_days: string }; @@ -275,11 +326,16 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti for (const [key, label] of times) { if (!HH_MM.test(draft[key])) return `${label} must be a time like 07:30.`; } + const timezone = draft.timezone.trim(); + if (timezone && !IANA_ZONE.test(timezone)) { + return "Timezone must be an IANA zone like America/New_York, or UTC."; + } const next: HouseholdSettings = { ...draft, horizon_days: horizonDays, notify_target: draft.notify_target.trim(), + timezone, }; const changes: SettingsChanges = {}; for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) { @@ -357,6 +413,25 @@ function PreferencesForm({ /> {timeInput("day_rollover_time", "Day rollover")} + + + {COMMON_TIMEZONES.map((zone) => ( +