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")}
-
-
+ onChange={(event) => {
+ const choice = event.target.value;
+ setOtherZone(choice === OTHER_ZONE);
+ if (choice !== OTHER_ZONE) update({ timezone: choice });
+ }}
+ >
+
+ {COMMON_TIMEZONES.map((zone) => (
+
+ ))}
+
+
+ {otherZone ? (
+ update({ timezone: event.target.value })}
+ />
+ ) : null}
+
+ The application timezone: the source of local time for the panel, the scheduler and this
+ admin app. It changes only when you save it here; with no zone set, the API host's
+ local time is used.
+