From e824d51823945dbb9ddb409f265245685ec6e34c Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 30 Sep 2026 07:28:17 -0400 Subject: [PATCH] Make the admin timezone an explicit manual picker (task ade35bd5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the free-text Timezone box with a select of curated IANA zones, an "API host local time" choice that clears the zone, and an "Other IANA zone…" choice that opens a text entry for any other zone. A saved zone outside the list opens in that entry on load. Helper text states this is the application timezone and the source of local time for the panel, scheduler and admin app. Saving still patches only changed fields, so a timezone change sends exactly {"timezone": ...}; nothing is written on load and the browser zone is never read. API tests add invalid-zone 422 cases, a non-admin timezone PATCH refusal, and a lone timezone PATCH sticking on a fresh database. Co-Authored-By: Claude Opus 5.5 --- admin/src/settings/SettingsScreen.css | 6 + admin/src/settings/SettingsScreen.test.tsx | 172 +++++++++++++++++++-- admin/src/settings/SettingsScreen.tsx | 70 ++++++--- tests/test_api_admin_settings.py | 28 ++++ 4 files changed, 245 insertions(+), 31 deletions(-) diff --git a/admin/src/settings/SettingsScreen.css b/admin/src/settings/SettingsScreen.css index 68829b4..a9958b1 100644 --- a/admin/src/settings/SettingsScreen.css +++ b/admin/src/settings/SettingsScreen.css @@ -258,6 +258,12 @@ color: var(--nq-a-ink-secondary); } +.settings-hint { + margin: 0; + font-size: 12.5px; + color: var(--nq-a-ink-secondary); +} + .settings-input { width: 100%; min-height: var(--nq-a-tap-min); diff --git a/admin/src/settings/SettingsScreen.test.tsx b/admin/src/settings/SettingsScreen.test.tsx index 7b371e1..3c4cfc2 100644 --- a/admin/src/settings/SettingsScreen.test.tsx +++ b/admin/src/settings/SettingsScreen.test.tsx @@ -499,37 +499,37 @@ describe("SettingsScreen — preferences", () => { expect(writes()).toHaveLength(1); }); - it("a changed timezone patches exactly the timezone", async () => { + it("a timezone picked from the list 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 " } }); + fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } }); save(form); await screen.findByText("Preferences saved."); expect(writes()).toEqual([ - { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } }, + { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } }, ]); const reloaded = screen.getByRole("form", { name: "Preferences" }); - expect(field(reloaded, "Timezone").value).toBe("Europe/London"); + expect(field(reloaded, "Timezone").value).toBe("America/New_York"); }); it.each(["New York", "America/New York", "EST5EDT?", "/Chicago"])( - "an invalid timezone %j is rejected before any request", + "an invalid other timezone %j is rejected before any request", async (value) => { const { form } = await renderPreferences(); - fireEvent.change(field(form, "Timezone"), { target: { value } }); + fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } }); + fireEvent.change(field(form, "Other 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(field(form, "Other 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" } }); + fireEvent.change(field(form, "Timezone"), { target: { value: "__other__" } }); + fireEvent.change(field(form, "Other 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); @@ -537,8 +537,9 @@ describe("SettingsScreen — preferences", () => { 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); + expect(field(form, "Other timezone").value).toBe("Mars/Olympus_Mons"); + expect(field(form, "Other timezone").disabled).toBe(false); + expect(settings.timezone).toBe("America/Chicago"); }); it("a failed load shows the reason and Try again refetches", async () => { @@ -643,3 +644,150 @@ describe("SettingsScreen — no timezone auto-set", () => { expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe(""); }); }); + +describe("SettingsScreen — manual timezone control", () => { + beforeEach(() => { + sessionStorage.clear(); + storeTokens({ access_token: "at-123", expires_in: 600 }); + children = [child(1, "Declan", 1)]; + settings = { ...DEFAULT_SETTINGS, timezone: "" }; + writeResponse = null; + fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + async function renderForm() { + render(); + await screen.findByTestId("settings-child-1"); + return screen.findByRole("form", { name: "Preferences" }); + } + + function save(form: HTMLElement) { + fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); + } + + /** The Timezone control, which must be a real select, not a free-text box. */ + function picker(form: HTMLElement): HTMLSelectElement { + const control = within(form).getByRole("combobox", { name: "Timezone" }); + expect(control).toBeInstanceOf(HTMLSelectElement); + return control as HTMLSelectElement; + } + + it("is a picker of curated zones with host-local and other-zone choices", async () => { + const form = await renderForm(); + const select = picker(form); + const values = Array.from(select.options).map((o) => o.value); + expect(values[0]).toBe(""); + expect(select.options[0].textContent).toBe("API host local time (no zone set)"); + for (const zone of [ + "UTC", + "America/New_York", + "America/Chicago", + "America/Denver", + "America/Phoenix", + "America/Los_Angeles", + "America/Anchorage", + "Pacific/Honolulu", + ]) { + expect(values).toContain(zone); + } + expect(select.options[select.options.length - 1].textContent).toBe("Other IANA zone…"); + expect(select.value).toBe(""); + expect(within(form).queryByLabelText("Other timezone")).toBeNull(); + }); + + it("explains that it is the application timezone for every surface", async () => { + const form = await renderForm(); + const help = document.getElementById(picker(form).getAttribute("aria-describedby") ?? ""); + expect(help?.textContent).toMatch(/application timezone/); + expect(help?.textContent).toMatch(/panel, the scheduler and this admin app/); + expect(help?.textContent).toMatch(/API host's local time/); + }); + + it("a picked zone saves via PATCH and is shown after a reload", async () => { + const form = await renderForm(); + expect(writes()).toEqual([]); + fireEvent.change(picker(form), { target: { value: "America/New_York" } }); + save(form); + await screen.findByText("Preferences saved."); + expect(writes()).toEqual([ + { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } }, + ]); + cleanup(); + const reloaded = await renderForm(); + expect(picker(reloaded).value).toBe("America/New_York"); + expect(writes()).toHaveLength(1); + }); + + it("an other zone typed by hand saves via PATCH and is shown after a reload", async () => { + const form = await renderForm(); + fireEvent.change(picker(form), { target: { value: "__other__" } }); + const entry = within(form).getByLabelText("Other timezone") as HTMLInputElement; + expect(entry.value).toBe(""); + fireEvent.change(entry, { target: { value: " America/Boise " } }); + save(form); + await screen.findByText("Preferences saved."); + expect(writes()).toEqual([ + { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/Boise" } }, + ]); + cleanup(); + const reloaded = await renderForm(); + expect(picker(reloaded).value).toBe("__other__"); + expect((within(reloaded).getByLabelText("Other timezone") as HTMLInputElement).value).toBe( + "America/Boise", + ); + }); + + it("a saved zone outside the list is shown in the other-zone entry on load", async () => { + settings = { ...DEFAULT_SETTINGS, timezone: "Asia/Kathmandu" }; + const form = await renderForm(); + expect(picker(form).value).toBe("__other__"); + expect((within(form).getByLabelText("Other timezone") as HTMLInputElement).value).toBe( + "Asia/Kathmandu", + ); + expect(writes()).toEqual([]); + }); + + it("choosing API host local time clears the zone and persists after a reload", async () => { + settings = { ...DEFAULT_SETTINGS, timezone: "America/Denver" }; + const form = await renderForm(); + expect(picker(form).value).toBe("America/Denver"); + fireEvent.change(picker(form), { target: { value: "" } }); + save(form); + await screen.findByText("Preferences saved."); + expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]); + cleanup(); + const reloaded = await renderForm(); + expect(picker(reloaded).value).toBe(""); + }); + + it("picking a zone and then the saved one again sends nothing", async () => { + settings = { ...DEFAULT_SETTINGS, timezone: "UTC" }; + const form = await renderForm(); + fireEvent.change(picker(form), { target: { value: "Europe/Paris" } }); + fireEvent.change(picker(form), { target: { value: "UTC" } }); + save(form); + expect(screen.getByTestId("preferences-message").textContent).toBe("No changes to save."); + expect(writes()).toEqual([]); + }); + + it("never sends a timezone_configured field", async () => { + const form = await renderForm(); + fireEvent.change(picker(form), { target: { value: "America/Los_Angeles" } }); + save(form); + await screen.findByText("Preferences saved."); + const again = screen.getByRole("form", { name: "Preferences" }); + fireEvent.change(picker(again), { target: { value: "" } }); + save(again); + await waitFor(() => expect(writes()).toHaveLength(2)); + for (const write of writes()) { + expect(Object.keys(write.body as object)).toEqual(["timezone"]); + expect(write.body).not.toHaveProperty("timezone_configured"); + } + }); +}); diff --git a/admin/src/settings/SettingsScreen.tsx b/admin/src/settings/SettingsScreen.tsx index ddfb846..1f31baa 100644 --- a/admin/src/settings/SettingsScreen.tsx +++ b/admin/src/settings/SettingsScreen.tsx @@ -6,7 +6,8 @@ * * Preferences: the household settings (planning horizon, day rollover, * timezone, notifications). Save patches only the changed fields. The - * timezone is only ever set by the admin saving it here. + * timezone — the application's source of local time — is only ever set by + * the admin picking or typing a zone here and saving it. * * One write at a time across both sections. */ @@ -256,7 +257,7 @@ 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. */ +/** The Timezone picker's zones; any other IANA zone can be typed via "Other". */ export const COMMON_TIMEZONES = [ "UTC", "America/New_York", @@ -290,6 +291,9 @@ export const COMMON_TIMEZONES = [ "Pacific/Auckland", ]; +/** The picker's "Other IANA zone…" option; never sent to the API. */ +const OTHER_ZONE = "__other__"; + /** The form's working copy: the number is text so it can be mid-edit. */ type PreferencesDraft = Omit & { horizon_days: string }; @@ -348,6 +352,10 @@ function PreferencesForm({ }) { const [draft, setDraft] = useState(() => preferencesDraft(settings)); const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); + // A saved zone outside the curated list opens in the free-text entry. + const [otherZone, setOtherZone] = useState( + () => settings.timezone !== "" && !COMMON_TIMEZONES.includes(settings.timezone), + ); const disabled = saving || locked; function submit(event: FormEvent) { @@ -400,25 +408,49 @@ function PreferencesForm({ /> {timeInput("day_rollover_time", "Day rollover")} -