Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
775c7704d9 | ||
|
|
09972ccbfe |
@@ -17,6 +17,14 @@ 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;
|
||||
/**
|
||||
* True once the admin explicitly chose `timezone` (even ""); false on a
|
||||
* fresh install, where the screen auto-sets the browser's zone. API-only:
|
||||
* never shown in the form.
|
||||
*/
|
||||
timezone_configured: boolean;
|
||||
}
|
||||
|
||||
/** Only the supplied fields are updated; the API answers with all of them. */
|
||||
|
||||
@@ -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,8 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
|
||||
afternoon_reminder_enabled: false,
|
||||
end_of_day_report_enabled: true,
|
||||
celebration_enabled: false,
|
||||
timezone: "America/Chicago",
|
||||
timezone_configured: true,
|
||||
};
|
||||
|
||||
function child(
|
||||
@@ -370,10 +373,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 +500,48 @@ 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", timezone_configured: true } },
|
||||
]);
|
||||
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 +558,133 @@ describe("SettingsScreen — preferences", () => {
|
||||
await screen.findByRole("form", { name: "Preferences" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsScreen — timezone auto-set", () => {
|
||||
let zoneSpy: ReturnType<typeof vi.spyOn> | 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)];
|
||||
// A fresh install: no zone, and the admin never chose one.
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false };
|
||||
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(<SettingsScreen onClose={vi.fn()} />);
|
||||
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", timezone_configured: true } },
|
||||
]);
|
||||
expect(settings.timezone).toBe("America/New_York");
|
||||
expect(settings.timezone_configured).toBe(true);
|
||||
|
||||
// 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(
|
||||
<StrictMode>
|
||||
<SettingsScreen onClose={vi.fn()} />
|
||||
</StrictMode>,
|
||||
);
|
||||
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", timezone_configured: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("an admin clearing the timezone is not re-set by the browser zone, even after a reopen", 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_configured: true },
|
||||
{ timezone: "", timezone_configured: true },
|
||||
]);
|
||||
const reloaded = screen.getByRole("form", { name: "Preferences" });
|
||||
expect(field(reloaded, "Timezone").value).toBe("");
|
||||
|
||||
// Close and reopen: the screen re-fetches "" but the choice persisted.
|
||||
cleanup();
|
||||
const reopened = await renderForm();
|
||||
expect(field(reopened, "Timezone").value).toBe("");
|
||||
expect(writes()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("an empty timezone the admin chose is never auto-set", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
settings = { ...settings, timezone_configured: true };
|
||||
const form = await renderForm();
|
||||
expect(field(form, "Timezone").value).toBe("");
|
||||
expect(writes()).toEqual([]);
|
||||
});
|
||||
|
||||
it("a set timezone is shown and never overwritten by the browser zone", async () => {
|
||||
mockBrowserZone("America/New_York");
|
||||
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false };
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* 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. A timezone
|
||||
* the admin never chose is set once, on load, to the browser's own zone;
|
||||
* saving the timezone (even clearing it) marks it chosen, so it sticks.
|
||||
*
|
||||
* One write at a time across both sections.
|
||||
*/
|
||||
@@ -252,6 +254,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<HouseholdSettings, "horizon_days"> & { horizon_days: string };
|
||||
|
||||
@@ -275,16 +327,24 @@ 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)[]) {
|
||||
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
|
||||
}
|
||||
// An explicit choice — including "" (the API host's local time) — must
|
||||
// persist, or the next load would auto-set the browser's zone over it.
|
||||
if ("timezone" in changes) changes.timezone_configured = true;
|
||||
if (Object.keys(changes).length === 0) return "No changes to save.";
|
||||
return changes;
|
||||
}
|
||||
@@ -357,6 +417,25 @@ function PreferencesForm({
|
||||
/>
|
||||
</label>
|
||||
{timeInput("day_rollover_time", "Day rollover")}
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Timezone</span>
|
||||
<input
|
||||
className="settings-input"
|
||||
type="text"
|
||||
list="settings-timezones"
|
||||
placeholder="Host local time"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={draft.timezone}
|
||||
disabled={disabled}
|
||||
onChange={(event) => update({ timezone: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<datalist id="settings-timezones">
|
||||
{COMMON_TIMEZONES.map((zone) => (
|
||||
<option key={zone} value={zone} />
|
||||
))}
|
||||
</datalist>
|
||||
<label className="settings-field">
|
||||
<span className="settings-label">Notify target</span>
|
||||
<input
|
||||
@@ -495,6 +574,8 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
} | null>(null);
|
||||
// A ref, not state: two taps in one frame must not both see `busy === null`.
|
||||
const inFlight = useRef(false);
|
||||
// The never-chosen-timezone auto-set fires at most once per screen.
|
||||
const timezoneAutoSet = useRef(false);
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const addRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -539,8 +620,29 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
let cancelled = false;
|
||||
setPreferences({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setPreferences({ kind: "ready", settings });
|
||||
.then(async (settings) => {
|
||||
if (cancelled) return;
|
||||
// Never chosen: adopt the browser's zone before the form shows, so
|
||||
// nothing the admin enters can be overwritten by it. An empty zone the
|
||||
// admin chose (timezone_configured) is left alone.
|
||||
const unchosen = settings.timezone === "" && !settings.timezone_configured;
|
||||
const detected = unchosen ? detectTimezone() : "";
|
||||
if (!detected || timezoneAutoSet.current) {
|
||||
setPreferences({ kind: "ready", settings });
|
||||
return;
|
||||
}
|
||||
timezoneAutoSet.current = true;
|
||||
try {
|
||||
const saved = await updateSettings({ timezone: detected, timezone_configured: true });
|
||||
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setPreferences({ kind: "ready", settings });
|
||||
setPreferencesMessage({
|
||||
kind: "error",
|
||||
text: errorMessage(error, `The timezone could not be set to ${detected}`),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
|
||||
+12
-7
@@ -165,12 +165,13 @@ integration's Home-Assistant config-entry options remain a temporary,
|
||||
SEPARATE source until Feature 18 wires the client to the API — the two
|
||||
are deliberately not unified here:
|
||||
|
||||
- ``GET /settings`` returns the current effective settings: all eleven
|
||||
- ``GET /settings`` returns the current effective settings: all twelve
|
||||
documented fields (the rolling horizon, the day rollover time, the
|
||||
notify target, the three notification times, the four enable
|
||||
toggles and the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local) — the
|
||||
defaults on a fresh database.
|
||||
toggles, the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local — and
|
||||
``timezone_configured``, whether the admin explicitly chose that
|
||||
zone) — the defaults on a fresh database.
|
||||
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a
|
||||
JSON object of settings field names to new values, passed STRAIGHT
|
||||
to :func:`nestquest_core.settings_store.update_settings` — the
|
||||
@@ -905,7 +906,7 @@ class AdminSettingsResponse(BaseModel):
|
||||
"""The effective settings as the admin plane serializes them.
|
||||
|
||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
||||
— all ELEVEN documented fields, resolved (every absent stored field
|
||||
— all TWELVE documented fields, resolved (every absent stored field
|
||||
already fell back to its default in the core). The same shape both
|
||||
the GET and the PATCH route answer with.
|
||||
"""
|
||||
@@ -923,6 +924,9 @@ class AdminSettingsResponse(BaseModel):
|
||||
#: The household IANA time zone the API reads its clock in; ``""``
|
||||
#: means the API host's local time.
|
||||
timezone: str
|
||||
#: Whether the admin explicitly chose ``timezone`` (even ``""``);
|
||||
#: ``False`` lets the admin UI auto-set the browser's zone once.
|
||||
timezone_configured: bool
|
||||
|
||||
|
||||
class AdminMissedSweepResponse(BaseModel):
|
||||
@@ -2179,7 +2183,7 @@ def _settings_response(
|
||||
"""Serialize one core NestQuestSettings into the documented payload.
|
||||
|
||||
Explicit field-by-field (not ``dataclasses.asdict``) so the payload
|
||||
stays pinned to the eleven documented fields even if the core
|
||||
stays pinned to the twelve documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
@@ -2194,6 +2198,7 @@ def _settings_response(
|
||||
end_of_day_report_enabled=settings.end_of_day_report_enabled,
|
||||
celebration_enabled=settings.celebration_enabled,
|
||||
timezone=settings.timezone,
|
||||
timezone_configured=settings.timezone_configured,
|
||||
)
|
||||
|
||||
|
||||
@@ -2291,7 +2296,7 @@ async def admin_history_csv(
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||
"""Return the current effective settings (all eleven documented fields).
|
||||
"""Return the current effective settings (all twelve documented fields).
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
|
||||
@@ -119,6 +119,12 @@ DEFAULT_AUTOMATION_ENABLED = True
|
||||
# Home Assistant's configured ``time_zone`` and ignores this field.
|
||||
CONF_TIMEZONE = "timezone"
|
||||
DEFAULT_TIMEZONE = ""
|
||||
# Whether the admin has made an explicit ``timezone`` choice. ``False``
|
||||
# (a fresh install) lets the admin UI auto-set the browser's zone once;
|
||||
# ``True`` pins the stored value — including an explicitly-chosen empty
|
||||
# ``timezone`` (the API host's local time) — so it is never auto-set again.
|
||||
CONF_TIMEZONE_CONFIGURED = "timezone_configured"
|
||||
DEFAULT_TIMEZONE_CONFIGURED = False
|
||||
|
||||
# Panel-plane API client (Feature 18): the integration reaches the
|
||||
# NestQuest API service (Feature 16) over HTTP instead of the database.
|
||||
|
||||
@@ -49,6 +49,7 @@ from .const import (
|
||||
CONF_MORNING_SUMMARY_TIME,
|
||||
CONF_NOTIFY_TARGET,
|
||||
CONF_TIMEZONE,
|
||||
CONF_TIMEZONE_CONFIGURED,
|
||||
DEFAULT_AFTERNOON_REMINDER_TIME,
|
||||
DEFAULT_AUTOMATION_ENABLED,
|
||||
DEFAULT_DAY_ROLLOVER_TIME,
|
||||
@@ -56,6 +57,7 @@ from .const import (
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_TIMEZONE,
|
||||
DEFAULT_TIMEZONE_CONFIGURED,
|
||||
LOGGER,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
@@ -241,6 +243,12 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
|
||||
_resolve_bool,
|
||||
),
|
||||
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
|
||||
(
|
||||
"timezone_configured",
|
||||
CONF_TIMEZONE_CONFIGURED,
|
||||
DEFAULT_TIMEZONE_CONFIGURED,
|
||||
_resolve_bool,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -271,6 +279,10 @@ class NestQuestSettings:
|
||||
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
|
||||
timezone: str = DEFAULT_TIMEZONE
|
||||
#: ``True`` once the admin explicitly chose :attr:`timezone` (even
|
||||
#: the empty host-local value); ``False`` on a fresh install, where
|
||||
#: the admin UI may auto-set the browser's zone.
|
||||
timezone_configured: bool = DEFAULT_TIMEZONE_CONFIGURED
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate STRICTLY at construction so a hand-built instance
|
||||
@@ -303,6 +315,7 @@ class NestQuestSettings:
|
||||
"afternoon_reminder_enabled",
|
||||
"end_of_day_report_enabled",
|
||||
"celebration_enabled",
|
||||
"timezone_configured",
|
||||
):
|
||||
value = getattr(self, name)
|
||||
if not isinstance(value, bool):
|
||||
|
||||
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
|
||||
side reads the other's values.
|
||||
|
||||
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
|
||||
object holding exactly the ELEVEN settings fields, keyed by field name
|
||||
object holding exactly the TWELVE settings fields, keyed by field name
|
||||
(which equals the ``CONF_*`` option key for every field), complete
|
||||
after the first update:
|
||||
|
||||
@@ -33,7 +33,11 @@ after the first update:
|
||||
booleans,
|
||||
- ``timezone`` — ``""`` (the API host's local time) or an IANA time
|
||||
zone name such as ``America/New_York``. A document stored before
|
||||
this field existed simply lacks it and resolves to ``""``.
|
||||
this field existed simply lacks it and resolves to ``""``,
|
||||
- ``timezone_configured`` — a real boolean: ``True`` once the admin
|
||||
explicitly chose ``timezone`` (including the empty host-local value),
|
||||
so the admin UI never auto-sets it again. A document stored before
|
||||
this field existed lacks it and resolves to ``False``.
|
||||
|
||||
Only values whose core validators ACCEPTED are ever written, so a
|
||||
stored document always round-trips through the core's option builders.
|
||||
@@ -175,7 +179,7 @@ async def update_settings(
|
||||
update can never leave a half-applied document behind.
|
||||
|
||||
The validated changes are merged onto the current effective
|
||||
settings and the merged (complete, eleven-field) document is persisted
|
||||
settings and the merged (complete, twelve-field) document is persisted
|
||||
in one atomic upsert; the updated settings are returned. The whole
|
||||
read → merge → write span runs inside the per-database settings
|
||||
lock, so concurrent updates queue instead of interleaving.
|
||||
|
||||
@@ -13,7 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy.
|
||||
Proven per the done-condition, everything read back through the API:
|
||||
|
||||
- ``GET /api/v1/admin/settings`` returns the current effective settings
|
||||
— the eleven documented fields at their defaults on a fresh database.
|
||||
— the twelve documented fields at their defaults on a fresh database.
|
||||
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
|
||||
NEXT read reflects them; a later partial update merges onto the
|
||||
current effective settings (omitted fields keep their stored value),
|
||||
@@ -60,7 +60,7 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
|
||||
SETTINGS_META_KEY = "settings"
|
||||
|
||||
#: The all-defaults effective settings a fresh database reports — the
|
||||
#: documented eleven fields, asserted literally so the payload contract is
|
||||
#: documented twelve fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
@@ -74,6 +74,7 @@ DEFAULT_SETTINGS = {
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
"timezone": "",
|
||||
"timezone_configured": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +105,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
|
||||
async def test_get_settings_returns_defaults_on_a_fresh_database(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""GET returns the eleven documented fields at their defaults."""
|
||||
"""GET returns the twelve documented fields at their defaults."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
@@ -140,7 +141,7 @@ async def test_update_persists_and_the_next_read_reflects_it(
|
||||
assert reread.status_code == 200
|
||||
assert reread.json() == expected
|
||||
|
||||
# ...and the DATABASE holds the merged document: the complete eleven
|
||||
# ...and the DATABASE holds the merged document: the complete twelve
|
||||
# fields, keyed by field name, under the well-known meta_state key.
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
@@ -222,6 +223,40 @@ async def test_null_resets_a_field_to_its_default(
|
||||
assert reread.json()["horizon_days"] == 14
|
||||
|
||||
|
||||
async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
|
||||
settings_client: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An explicit empty (host-local) zone persists as configured.
|
||||
|
||||
The admin clearing the zone PATCHes ``timezone: ""`` together with
|
||||
``timezone_configured: true``; the NEXT read — a reopened settings
|
||||
screen — sees the marker, so the empty value is a persisted choice
|
||||
rather than a fresh install to auto-set.
|
||||
"""
|
||||
client = settings_client.client
|
||||
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers())
|
||||
assert fresh.json()["timezone_configured"] is False
|
||||
|
||||
patched = await client.patch(
|
||||
"/api/v1/admin/settings",
|
||||
json={"timezone": "", "timezone_configured": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
expected = {**DEFAULT_SETTINGS, "timezone_configured": True}
|
||||
assert patched.json() == expected
|
||||
|
||||
reread = await client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
assert reread.json() == expected
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
)
|
||||
assert raw is not None
|
||||
assert json.loads(raw)["timezone_configured"] is True
|
||||
|
||||
|
||||
# --- validation: rejected values never change the stored settings -------
|
||||
|
||||
|
||||
@@ -238,6 +273,8 @@ async def test_null_resets_a_field_to_its_default(
|
||||
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
|
||||
({"celebration_enabled": 1}, "celebration_enabled"),
|
||||
({"notify_target": 5}, "notify_target"),
|
||||
({"timezone_configured": "true"}, "timezone_configured"),
|
||||
({"timezone_configured": 1}, "timezone_configured"),
|
||||
],
|
||||
)
|
||||
async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
|
||||
|
||||
@@ -236,6 +236,25 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None:
|
||||
assert _settings.NestQuestSettings().timezone_configured is False
|
||||
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False
|
||||
assert (
|
||||
_settings.NestQuestSettings.from_options(
|
||||
{"timezone_configured": True}
|
||||
).timezone_configured
|
||||
is True
|
||||
)
|
||||
for value in ("true", 1, None):
|
||||
with pytest.raises(ValueError, match="timezone_configured"):
|
||||
_settings.NestQuestSettings(timezone_configured=value)
|
||||
if value is not None:
|
||||
with pytest.raises(ValueError):
|
||||
_settings.NestQuestSettings.from_options(
|
||||
{"timezone_configured": value}
|
||||
)
|
||||
|
||||
|
||||
# --- the missed-sweep scheduler ----------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user