Admin app: explicit manual timezone picker (task ade35bd5) (#283)

Co-authored-by: Joshua <joshua@cnjmail.com>
Co-committed-by: Joshua <joshua@cnjmail.com>
This commit was merged in pull request #283.
This commit is contained in:
2026-09-30 07:45:08 -04:00
committed by approver_agent
parent 2db780e3ee
commit 69db92b7b8
4 changed files with 245 additions and 31 deletions
+6
View File
@@ -258,6 +258,12 @@
color: var(--nq-a-ink-secondary); color: var(--nq-a-ink-secondary);
} }
.settings-hint {
margin: 0;
font-size: 12.5px;
color: var(--nq-a-ink-secondary);
}
.settings-input { .settings-input {
width: 100%; width: 100%;
min-height: var(--nq-a-tap-min); min-height: var(--nq-a-tap-min);
+160 -12
View File
@@ -499,37 +499,37 @@ describe("SettingsScreen — preferences", () => {
expect(writes()).toHaveLength(1); 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 { form } = await renderPreferences();
const input = field(form, "Timezone"); fireEvent.change(field(form, "Timezone"), { target: { value: "America/New_York" } });
expect(input.getAttribute("list")).toBe("settings-timezones");
fireEvent.change(input, { target: { value: " Europe/London " } });
save(form); save(form);
await screen.findByText("Preferences saved."); await screen.findByText("Preferences saved.");
expect(writes()).toEqual([ 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" }); 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"])( 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) => { async (value) => {
const { form } = await renderPreferences(); 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); save(form);
expect(screen.getByTestId("preferences-message").textContent).toBe( expect(screen.getByTestId("preferences-message").textContent).toBe(
"Timezone must be an IANA zone like America/New_York, or UTC.", "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([]); expect(writes()).toEqual([]);
}, },
); );
it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => { it("a 422 on an unknown timezone shows the API detail and keeps the edit", async () => {
const { form } = await renderPreferences(); 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 = () => writeResponse = () =>
jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422); jsonResponse({ detail: "timezone must be an IANA time zone name such as 'America/New_York'" }, 422);
save(form); save(form);
@@ -537,8 +537,9 @@ describe("SettingsScreen — preferences", () => {
expect(message.textContent).toBe( expect(message.textContent).toBe(
"The preferences could not be saved: timezone must be an IANA time zone name such as 'America/New_York'", "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, "Other timezone").value).toBe("Mars/Olympus_Mons");
expect(field(form, "Timezone").disabled).toBe(false); 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 () => { 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(""); 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(<SettingsScreen onClose={vi.fn()} />);
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");
}
});
});
+51 -19
View File
@@ -6,7 +6,8 @@
* *
* Preferences: the household settings (planning horizon, day rollover, * Preferences: the household settings (planning horizon, day rollover,
* timezone, notifications). Save patches only the changed fields. The * 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. * 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). */ /** "UTC" or an IANA-looking "Area/Location" (the API checks it resolves). */
const IANA_ZONE = /^(UTC|[A-Za-z]+(\/[A-Za-z0-9_+-]+)+)$/; 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 = [ export const COMMON_TIMEZONES = [
"UTC", "UTC",
"America/New_York", "America/New_York",
@@ -290,6 +291,9 @@ export const COMMON_TIMEZONES = [
"Pacific/Auckland", "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. */ /** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string }; type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -348,6 +352,10 @@ function PreferencesForm({
}) { }) {
const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings)); const [draft, setDraft] = useState<PreferencesDraft>(() => preferencesDraft(settings));
const update = (patch: Partial<PreferencesDraft>) => setDraft((d) => ({ ...d, ...patch })); const update = (patch: Partial<PreferencesDraft>) => 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; const disabled = saving || locked;
function submit(event: FormEvent) { function submit(event: FormEvent) {
@@ -400,25 +408,49 @@ function PreferencesForm({
/> />
</label> </label>
{timeInput("day_rollover_time", "Day rollover")} {timeInput("day_rollover_time", "Day rollover")}
<label className="settings-field"> <div className="settings-field">
<span className="settings-label">Timezone</span> <label className="settings-label" htmlFor="settings-timezone">
<input Timezone
</label>
<select
id="settings-timezone"
className="settings-input" className="settings-input"
type="text" aria-describedby="settings-timezone-help"
list="settings-timezones" value={otherZone ? OTHER_ZONE : draft.timezone}
placeholder="Host local time"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
disabled={disabled} disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })} onChange={(event) => {
/> const choice = event.target.value;
</label> setOtherZone(choice === OTHER_ZONE);
<datalist id="settings-timezones"> if (choice !== OTHER_ZONE) update({ timezone: choice });
{COMMON_TIMEZONES.map((zone) => ( }}
<option key={zone} value={zone} /> >
))} <option value="">API host local time (no zone set)</option>
</datalist> {COMMON_TIMEZONES.map((zone) => (
<option key={zone} value={zone}>
{zone}
</option>
))}
<option value={OTHER_ZONE}>Other IANA zone…</option>
</select>
{otherZone ? (
<input
className="settings-input"
type="text"
aria-label="Other timezone"
placeholder="Area/Location, e.g. America/Boise"
autoComplete="off"
spellCheck={false}
value={draft.timezone}
disabled={disabled}
onChange={(event) => update({ timezone: event.target.value })}
/>
) : null}
<p className="settings-hint" id="settings-timezone-help">
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&apos;s
local time is used.
</p>
</div>
<label className="settings-field"> <label className="settings-field">
<span className="settings-label">Notify target</span> <span className="settings-label">Notify target</span>
<input <input
+28
View File
@@ -270,6 +270,24 @@ async def test_manual_timezone_patch_persists_and_is_returned(
assert "timezone_configured" not in stored assert "timezone_configured" not in stored
@pytest.mark.parametrize("zone", ["America/Los_Angeles", ""])
async def test_timezone_alone_sticks_on_a_fresh_database(
settings_client: SimpleNamespace, zone: str
) -> None:
"""A lone ``timezone`` PATCH needs no other field to persist."""
client = settings_client.client
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": zone},
headers=_admin_headers(),
)
assert patched.status_code == 200
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == {**DEFAULT_SETTINGS, "timezone": zone}
async def test_timezone_configured_is_an_unknown_field( async def test_timezone_configured_is_an_unknown_field(
settings_client: SimpleNamespace, settings_client: SimpleNamespace,
) -> None: ) -> None:
@@ -320,6 +338,9 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"), ({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
({"celebration_enabled": 1}, "celebration_enabled"), ({"celebration_enabled": 1}, "celebration_enabled"),
({"notify_target": 5}, "notify_target"), ({"notify_target": 5}, "notify_target"),
({"timezone": "Mars/Olympus_Mons"}, "timezone"),
({"timezone": "America/New York"}, "timezone"),
({"timezone": 5}, "timezone"),
], ],
) )
async def test_invalid_value_is_rejected_and_stored_settings_unchanged( async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
@@ -474,6 +495,13 @@ async def test_settings_routes_refuse_non_admin_and_absent_credentials(
) )
assert absent_patch.status_code == 401 assert absent_patch.status_code == 401
forbidden_zone = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "America/New_York"},
headers={"Authorization": f"Bearer {non_admin}"},
)
assert forbidden_zone.status_code == 403
# No refused request changed anything: the settings are untouched. # No refused request changed anything: the settings are untouched.
after = await client.get( after = await client.get(
"/api/v1/admin/settings", headers=_admin_headers() "/api/v1/admin/settings", headers=_admin_headers()