Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cb62b0260 | ||
|
|
ba0b2032cc | ||
|
|
ed230961e0 |
@@ -1,144 +1,5 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.9.4 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release fixing the admin timezone control that shipped in 0.9.3.
|
||||
|
||||
- **Clearing the timezone now persists:** in 0.9.3 the Settings screen
|
||||
re-detected the browser zone whenever the timezone field was empty, so an admin
|
||||
could not leave it empty ("use the API host's local time") — closing and
|
||||
reopening the screen set it again. A new persisted, API-only
|
||||
`timezone_configured` marker (default `false`) now distinguishes "never set"
|
||||
from "admin chose empty": the one-time auto-set runs only when the timezone is
|
||||
empty AND the marker is false, and any saved timezone change — including
|
||||
clearing it — sets the marker, so an explicit empty choice survives. (PRs #266,
|
||||
#268.)
|
||||
- Nothing else changes: the `timezone` setting, its auto-set from the browser zone
|
||||
on a fresh install, the Add/Save flow, and the rest of the Settings screen are
|
||||
as in 0.9.3.
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step is
|
||||
needed. Settings stored before this release have no `timezone_configured` value
|
||||
and read it as `false`, so an install whose timezone is empty will auto-set the
|
||||
browser zone once on the next open — after which the choice persists.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.3 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the admin timezone control onto `main`.
|
||||
|
||||
- **Admins set the system timezone from the PWA:** the Preferences screen gains a
|
||||
Timezone field (a text input with a local datalist of common IANA zones;
|
||||
`UTC` or `Area/Location`). On a fresh install the value is set AUTOMATICALLY
|
||||
from the browser's IANA zone when the setting is empty on first load — no
|
||||
geolocation prompt and no network lookup — and an admin can change or clear it
|
||||
at any time. Only a changed value is sent, and the API's 422 for an unknown zone
|
||||
is shown by the form. (PR #266.)
|
||||
- This makes the household-local clock (overdue, daily rollover) configurable
|
||||
from the UI, replacing the manual
|
||||
`PATCH /api/v1/admin/settings {"timezone": ...}` step noted in 0.9.1. The
|
||||
setting still defaults to the API host's local time when left empty.
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step is
|
||||
needed. No runtime dependency is added.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.2 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the admin "Delete task" capability onto `main`.
|
||||
|
||||
- **Delete a task in the admin PWA:** the definition edit sheet gains an
|
||||
admin-only Delete action with hybrid semantics (D-017, owner decision "delete
|
||||
if there's no history, retire/deactivate if there is"). If the definition has
|
||||
**no completion history**, it is hard-deleted together with its instances,
|
||||
assignees, windows and its schedule rule (when unreferenced), all in one
|
||||
transaction under the connection lock. If it has **any completion history** (a
|
||||
completion or an un-completion), nothing is deleted: it is retired by
|
||||
deactivation instead, preserving the definition, rule, assignees, windows and
|
||||
all history, with future uncompleted instances cleared as with any
|
||||
deactivation. (PR #262.)
|
||||
- **API:** `DELETE /api/v1/admin/quest-definitions/{id}` lives on the admin plane
|
||||
(`require_admin`; a non-admin is refused and an unknown id is 404). The response
|
||||
reports `deleted`/`retired` and carries the retired definition (`null` when
|
||||
deleted). The kids' panel gains no control, and `QuestDefinitionsDao` now has
|
||||
exactly one, history-guarded, delete method.
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**; no migration or backup step is
|
||||
needed beyond the 0.8.0 note. No runtime dependency is added.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.1 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the owner-reported UI-bug fixes for the kids' panel, plus a
|
||||
household-timezone correction, onto `main`.
|
||||
|
||||
- **Household-local time:** a new `timezone` setting (an IANA name such as
|
||||
`America/New_York`; empty keeps the API host's local time) is threaded into the
|
||||
API's clock reads, so overdue and the daily rollover use the household's day
|
||||
rather than a UTC host's. **Set it once after upgrading** (see the Upgrade
|
||||
note); the admin UI has no field for it yet. (PR #254.)
|
||||
- **Overdue and rollover correctness:** quests are no longer marked overdue
|
||||
before their due time, and the missed-sweep scheduler fires at the configured
|
||||
rollover across DST transitions — its delay is now a true elapsed-time
|
||||
difference, with documented handling of nonexistent/ambiguous local times.
|
||||
(PRs #254, #257.)
|
||||
- **Complete works on the panel:** a TypeScript ES2022 `useDefineForClassFields`
|
||||
default emitted native class fields that shadowed Lit's reactive accessors, so
|
||||
the confirm dialog never re-rendered and a tap appeared to do nothing; the
|
||||
panel now opens the confirm and completes. (PR #254.)
|
||||
- **Panel polish:** the party-board hint line no longer hides behind the dock
|
||||
(#244); the Quest Log header crest renders with its shield classes (#252); the
|
||||
party-board wordmark is the new compass-rose monogram plus Cinzel Decorative
|
||||
text, replacing the d20 dice (#253); and the quest meta line keeps its `meta`
|
||||
class when not late (#255).
|
||||
|
||||
### Upgrade note
|
||||
|
||||
The database schema is unchanged at version **9**. After upgrading, set the
|
||||
household timezone once so date, overdue and rollover behaviour match the house
|
||||
(otherwise it falls back to the API host's local time, which may be UTC):
|
||||
|
||||
```
|
||||
PATCH /api/v1/admin/settings {"timezone": "America/New_York"}
|
||||
```
|
||||
|
||||
No runtime dependency is added.
|
||||
|
||||
### Requirements
|
||||
|
||||
Home Assistant 2024.6.0 or newer. Install through HACS from the
|
||||
`Maniacs-Mansion/NestQuest` GitHub mirror (kept current by the Gitea push
|
||||
mirror).
|
||||
|
||||
## Version 0.9.0 — 2026-09-26
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -17,14 +17,6 @@ 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,4 +1,3 @@
|
||||
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";
|
||||
@@ -22,8 +21,6 @@ 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(
|
||||
@@ -373,11 +370,10 @@ describe("SettingsScreen — preferences", () => {
|
||||
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
|
||||
}
|
||||
|
||||
it("renders the current value of all eleven settings form fields", async () => {
|
||||
it("renders the current value of the ten settings form fields (timezone is API-only)", 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");
|
||||
@@ -500,48 +496,6 @@ 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) => {
|
||||
@@ -558,133 +512,3 @@ 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,9 +5,7 @@
|
||||
* household's child profiles. Every write refetches the list.
|
||||
*
|
||||
* Preferences: the household settings (planning horizon, day rollover,
|
||||
* 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.
|
||||
* notifications). Save patches only the changed fields.
|
||||
*
|
||||
* One write at a time across both sections.
|
||||
*/
|
||||
@@ -254,56 +252,6 @@ 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 };
|
||||
|
||||
@@ -327,24 +275,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)[]) {
|
||||
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;
|
||||
}
|
||||
@@ -417,25 +357,6 @@ 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
|
||||
@@ -574,8 +495,6 @@ 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);
|
||||
@@ -620,29 +539,8 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
|
||||
let cancelled = false;
|
||||
setPreferences({ kind: "loading" });
|
||||
fetchSettings()
|
||||
.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}`),
|
||||
});
|
||||
}
|
||||
.then((settings) => {
|
||||
if (!cancelled) setPreferences({ kind: "ready", settings });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
|
||||
+7
-12
@@ -165,13 +165,12 @@ 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 twelve
|
||||
- ``GET /settings`` returns the current effective settings: all eleven
|
||||
documented fields (the rolling horizon, the day rollover time, the
|
||||
notify target, the three notification times, the four enable
|
||||
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.
|
||||
toggles and the ``timezone`` — the IANA household zone the
|
||||
household-local dates derive from, empty meaning host local) — 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
|
||||
@@ -906,7 +905,7 @@ class AdminSettingsResponse(BaseModel):
|
||||
"""The effective settings as the admin plane serializes them.
|
||||
|
||||
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
|
||||
— all TWELVE documented fields, resolved (every absent stored field
|
||||
— all ELEVEN 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.
|
||||
"""
|
||||
@@ -924,9 +923,6 @@ 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):
|
||||
@@ -2183,7 +2179,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 twelve documented fields even if the core
|
||||
stays pinned to the eleven documented fields even if the core
|
||||
dataclass later grows another one.
|
||||
"""
|
||||
return AdminSettingsResponse(
|
||||
@@ -2198,7 +2194,6 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -2296,7 +2291,7 @@ async def admin_history_csv(
|
||||
response_model=AdminSettingsResponse,
|
||||
)
|
||||
async def admin_get_settings(request: Request) -> AdminSettingsResponse:
|
||||
"""Return the current effective settings (all twelve documented fields).
|
||||
"""Return the current effective settings (all eleven documented fields).
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
|
||||
@@ -119,12 +119,6 @@ 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,7 +49,6 @@ 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,
|
||||
@@ -57,7 +56,6 @@ from .const import (
|
||||
DEFAULT_HORIZON_DAYS,
|
||||
DEFAULT_MORNING_SUMMARY_TIME,
|
||||
DEFAULT_TIMEZONE,
|
||||
DEFAULT_TIMEZONE_CONFIGURED,
|
||||
LOGGER,
|
||||
TIME_PATTERN,
|
||||
)
|
||||
@@ -243,12 +241,6 @@ _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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -279,10 +271,6 @@ 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
|
||||
@@ -315,7 +303,6 @@ 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 TWELVE settings fields, keyed by field name
|
||||
object holding exactly the ELEVEN settings fields, keyed by field name
|
||||
(which equals the ``CONF_*`` option key for every field), complete
|
||||
after the first update:
|
||||
|
||||
@@ -33,11 +33,7 @@ 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 ``""``,
|
||||
- ``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``.
|
||||
this field existed simply lacks it and resolves to ``""``.
|
||||
|
||||
Only values whose core validators ACCEPTED are ever written, so a
|
||||
stored document always round-trips through the core's option builders.
|
||||
@@ -179,7 +175,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, twelve-field) document is persisted
|
||||
settings and the merged (complete, eleven-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.
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.9.4"
|
||||
"version": "0.9.0"
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ const { is: nt, defineProperty: it, getOwnPropertyDescriptor: st, getOwnProperty
|
||||
return t;
|
||||
} }, Ze = (i, e) => !nt(i, e), ye = { attribute: !0, type: String, converter: ae, reflect: !1, useDefault: !1, hasChanged: Ze };
|
||||
Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), K.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap();
|
||||
let T = class extends HTMLElement {
|
||||
let z = class extends HTMLElement {
|
||||
static addInitializer(e) {
|
||||
this._$Ei(), (this.l ??= []).push(e);
|
||||
}
|
||||
@@ -246,7 +246,7 @@ let T = class extends HTMLElement {
|
||||
firstUpdated(e) {
|
||||
}
|
||||
};
|
||||
T.elementStyles = [], T.shadowRootOptions = { mode: "open" }, T[I("elementProperties")] = /* @__PURE__ */ new Map(), T[I("finalized")] = /* @__PURE__ */ new Map(), ct?.({ ReactiveElement: T }), (K.reactiveElementVersions ??= []).push("2.1.2");
|
||||
z.elementStyles = [], z.shadowRootOptions = { mode: "open" }, z[I("elementProperties")] = /* @__PURE__ */ new Map(), z[I("finalized")] = /* @__PURE__ */ new Map(), ct?.({ ReactiveElement: z }), (K.reactiveElementVersions ??= []).push("2.1.2");
|
||||
const pe = globalThis, be = (i) => i, V = pe.trustedTypes, ve = V ? V.createPolicy("lit-html", { createHTML: (i) => i }) : void 0, Qe = "$lit$", k = `lit$${Math.random().toFixed(9).slice(2)}$`, We = "?" + k, dt = `<${We}>`, A = document, P = () => A.createComment(""), H = (i) => i === null || typeof i != "object" && typeof i != "function", he = Array.isArray, pt = (i) => he(i) || typeof i?.[Symbol.iterator] == "function", Y = `[
|
||||
\f\r]`, L = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, xe = /-->/g, we = />/g, $ = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^
|
||||
\f\r"'\`<>=]|("|')|))|$)`, "g"), ke = /'/g, $e = /"/g, Ge = /^(?:script|style|textarea|title)$/i, Ke = (i) => (e, ...t) => ({ _$litType$: i, strings: e, values: t }), d = Ke(1), qe = Ke(2), S = /* @__PURE__ */ Symbol.for("lit-noChange"), g = /* @__PURE__ */ Symbol.for("lit-nothing"), Me = /* @__PURE__ */ new WeakMap(), M = A.createTreeWalker(A, 129);
|
||||
@@ -266,13 +266,13 @@ const ht = (i, e) => {
|
||||
}
|
||||
return [Je(i, a + (i[t] || "<?>") + (e === 2 ? "</svg>" : e === 3 ? "</math>" : "")), n];
|
||||
};
|
||||
class R {
|
||||
class U {
|
||||
constructor({ strings: e, _$litType$: t }, n) {
|
||||
let s;
|
||||
this.parts = [];
|
||||
let a = 0, r = 0;
|
||||
const l = e.length - 1, o = this.parts, [p, u] = ht(e, t);
|
||||
if (this.el = R.createElement(p, n), M.currentNode = this.el.content, t === 2 || t === 3) {
|
||||
if (this.el = U.createElement(p, n), M.currentNode = this.el.content, t === 2 || t === 3) {
|
||||
const c = this.el.content.firstChild;
|
||||
c.replaceWith(...c.childNodes);
|
||||
}
|
||||
@@ -303,11 +303,11 @@ class R {
|
||||
return n.innerHTML = e, n;
|
||||
}
|
||||
}
|
||||
function z(i, e, t = i, n) {
|
||||
function T(i, e, t = i, n) {
|
||||
if (e === S) return e;
|
||||
let s = n !== void 0 ? t._$Co?.[n] : t._$Cl;
|
||||
const a = H(e) ? void 0 : e._$litDirective$;
|
||||
return s?.constructor !== a && (s?._$AO?.(!1), a === void 0 ? s = void 0 : (s = new a(i), s._$AT(i, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = s : t._$Cl = s), s !== void 0 && (e = z(i, s._$AS(i, e.values), s, n)), e;
|
||||
return s?.constructor !== a && (s?._$AO?.(!1), a === void 0 ? s = void 0 : (s = new a(i), s._$AT(i, t, n)), n !== void 0 ? (t._$Co ??= [])[n] = s : t._$Cl = s), s !== void 0 && (e = T(i, s._$AS(i, e.values), s, n)), e;
|
||||
}
|
||||
class ut {
|
||||
constructor(e, t) {
|
||||
@@ -356,7 +356,7 @@ class N {
|
||||
return this._$AB;
|
||||
}
|
||||
_$AI(e, t = this) {
|
||||
e = z(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : pt(e) ? this.k(e) : this._(e);
|
||||
e = T(this, e, t), H(e) ? e === g || e == null || e === "" ? (this._$AH !== g && this._$AR(), this._$AH = g) : e !== this._$AH && e !== S && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : pt(e) ? this.k(e) : this._(e);
|
||||
}
|
||||
O(e) {
|
||||
return this._$AA.parentNode.insertBefore(e, this._$AB);
|
||||
@@ -368,7 +368,7 @@ class N {
|
||||
this._$AH !== g && H(this._$AH) ? this._$AA.nextSibling.data = e : this.T(A.createTextNode(e)), this._$AH = e;
|
||||
}
|
||||
$(e) {
|
||||
const { values: t, _$litType$: n } = e, s = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = R.createElement(Je(n.h, n.h[0]), this.options)), n);
|
||||
const { values: t, _$litType$: n } = e, s = typeof n == "number" ? this._$AC(e) : (n.el === void 0 && (n.el = U.createElement(Je(n.h, n.h[0]), this.options)), n);
|
||||
if (this._$AH?._$AD === s) this._$AH.p(t);
|
||||
else {
|
||||
const a = new ut(s, this), r = a.u(this.options);
|
||||
@@ -377,7 +377,7 @@ class N {
|
||||
}
|
||||
_$AC(e) {
|
||||
let t = Me.get(e.strings);
|
||||
return t === void 0 && Me.set(e.strings, t = new R(e)), t;
|
||||
return t === void 0 && Me.set(e.strings, t = new U(e)), t;
|
||||
}
|
||||
k(e) {
|
||||
he(this._$AH) || (this._$AH = [], this._$AR());
|
||||
@@ -409,11 +409,11 @@ class J {
|
||||
_$AI(e, t = this, n, s) {
|
||||
const a = this.strings;
|
||||
let r = !1;
|
||||
if (a === void 0) e = z(this, e, t, 0), r = !H(e) || e !== this._$AH && e !== S, r && (this._$AH = e);
|
||||
if (a === void 0) e = T(this, e, t, 0), r = !H(e) || e !== this._$AH && e !== S, r && (this._$AH = e);
|
||||
else {
|
||||
const l = e;
|
||||
let o, p;
|
||||
for (e = a[0], o = 0; o < a.length - 1; o++) p = z(this, l[n + o], t, o), p === S && (p = this._$AH[o]), r ||= !H(p) || p !== this._$AH[o], p === g ? e = g : e !== g && (e += (p ?? "") + a[o + 1]), this._$AH[o] = p;
|
||||
for (e = a[0], o = 0; o < a.length - 1; o++) p = T(this, l[n + o], t, o), p === S && (p = this._$AH[o]), r ||= !H(p) || p !== this._$AH[o], p === g ? e = g : e !== g && (e += (p ?? "") + a[o + 1]), this._$AH[o] = p;
|
||||
}
|
||||
r && !s && this.j(e);
|
||||
}
|
||||
@@ -442,7 +442,7 @@ class gt extends J {
|
||||
super(e, t, n, s, a), this.type = 5;
|
||||
}
|
||||
_$AI(e, t = this) {
|
||||
if ((e = z(this, e, t, 0) ?? g) === S) return;
|
||||
if ((e = T(this, e, t, 0) ?? g) === S) return;
|
||||
const n = this._$AH, s = e === g && n !== g || e.capture !== n.capture || e.once !== n.once || e.passive !== n.passive, a = e !== g && (n === g || s);
|
||||
s && this.element.removeEventListener(this.name, this, n), a && this.element.addEventListener(this.name, this, e), this._$AH = e;
|
||||
}
|
||||
@@ -458,11 +458,11 @@ class _t {
|
||||
return this._$AM._$AU;
|
||||
}
|
||||
_$AI(e) {
|
||||
z(this, e);
|
||||
T(this, e);
|
||||
}
|
||||
}
|
||||
const yt = { I: N }, bt = pe.litHtmlPolyfillSupport;
|
||||
bt?.(R, N), (pe.litHtmlVersions ??= []).push("3.3.3");
|
||||
bt?.(U, N), (pe.litHtmlVersions ??= []).push("3.3.3");
|
||||
const vt = (i, e, t) => {
|
||||
const n = t?.renderBefore ?? e;
|
||||
let s = n._$litPart$;
|
||||
@@ -473,7 +473,7 @@ const vt = (i, e, t) => {
|
||||
return s._$AI(i), s;
|
||||
};
|
||||
const ue = globalThis;
|
||||
let C = class extends T {
|
||||
let C = class extends z {
|
||||
constructor() {
|
||||
super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
|
||||
}
|
||||
@@ -592,7 +592,7 @@ function Ct(i) {
|
||||
function Se(i) {
|
||||
return Number.isFinite(i) ? Math.min(100, Math.max(0, i)) : 0;
|
||||
}
|
||||
function Te(i) {
|
||||
function ze(i) {
|
||||
return Math.max(0, Math.round(i));
|
||||
}
|
||||
function At(i) {
|
||||
@@ -620,7 +620,7 @@ function St(i, e) {
|
||||
);
|
||||
return Number.isFinite(s) ? s - i : 0;
|
||||
}
|
||||
function Tt(i, e) {
|
||||
function zt(i, e) {
|
||||
if (typeof i != "string")
|
||||
return null;
|
||||
const t = i.split("-");
|
||||
@@ -636,14 +636,14 @@ function Tt(i, e) {
|
||||
const r = Date.UTC(n, s - 1, a), l = new Date(r - St(r, e));
|
||||
return Number.isNaN(l.getTime()) ? null : l;
|
||||
}
|
||||
function zt(i) {
|
||||
function Tt(i) {
|
||||
const e = qt[i];
|
||||
return e || i.charAt(0).toUpperCase() + i.slice(1);
|
||||
}
|
||||
function Et(i, e) {
|
||||
return Mt[i] ?? e;
|
||||
}
|
||||
const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z", Nt = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z", Lt = "M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8", Ot = d`<svg
|
||||
const Te = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 24.1 50 26 55.5 24.1Z M100 50 75.9 44.5 74 50 75.9 55.5Z M50 97 55.5 75.9 50 74 44.5 75.9Z M0 50 24.1 55.5 26 50 24.1 44.5Z M29.1 29.1 29.7 36.3 36.3 29.7Z M70.9 29.1 63.7 29.7 70.3 36.3Z M70.9 70.9 70.3 63.7 63.7 70.3Z M29.1 70.9 36.3 70.3 29.7 63.7Z", Nt = "M50 26.5 70.4 38.2 70.4 61.8 50 73.5 29.6 61.8 29.6 38.2Z", Lt = "M29.6 38.2H70.4M29.6 61.8H70.4M50 26.5 36.5 38.2M50 26.5 63.5 38.2M50 73.5 36.5 61.8M50 73.5 63.5 61.8M36.5 38.2V61.8M63.5 38.2V61.8M29.6 38.2 36.5 61.8M36.5 38.2 29.6 61.8M70.4 38.2 63.5 61.8M63.5 38.2 70.4 61.8", Ot = d`<svg
|
||||
class="monogram"
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
@@ -850,7 +850,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
width: 188px;
|
||||
height: 214px;
|
||||
padding: 4px;
|
||||
clip-path: ${x(ze)};
|
||||
clip-path: ${x(Te)};
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
@@ -861,7 +861,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 70px;
|
||||
clip-path: ${x(ze)};
|
||||
clip-path: ${x(Te)};
|
||||
background: var(--nq-p-crest);
|
||||
}
|
||||
|
||||
@@ -1113,7 +1113,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon>
|
||||
</svg>`, Rt = d`<svg
|
||||
</svg>`, Ut = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1125,7 +1125,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
|
||||
<path d="M12 9v4"></path>
|
||||
<path d="M12 17h.01"></path>
|
||||
</svg>`, Ut = d`<svg
|
||||
</svg>`, Rt = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1137,11 +1137,11 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"></path>
|
||||
<path d="m2 2 20 20"></path>
|
||||
</svg>`, Ne = {
|
||||
icon: Rt,
|
||||
icon: Ut,
|
||||
headline: "NestQuest is not set up yet",
|
||||
body: "A parent needs to finish setting up NestQuest before the party can gather."
|
||||
}, jt = {
|
||||
icon: Ut,
|
||||
icon: Rt,
|
||||
headline: "The records cannot be reached",
|
||||
body: "The party's records are quiet right now. NestQuest will return shortly."
|
||||
}, Dt = d`<svg
|
||||
@@ -1280,7 +1280,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
`sensor.nestquest_${e}_completion_pct_today`
|
||||
), r = this._state(
|
||||
`binary_sensor.nestquest_${e}_present_today`
|
||||
), l = n?.attributes ?? {}, o = Te(te(n?.state, 0)), p = Te(te(s?.state, 0)), u = a ? te(a.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? Se(u) : o > 0 ? Se(p / o * 100) : 0;
|
||||
), l = n?.attributes ?? {}, o = ze(te(n?.state, 0)), p = ze(te(s?.state, 0)), u = a ? te(a.state, Number.NaN) : Number.NaN, c = Number.isFinite(u) ? Se(u) : o > 0 ? Se(p / o * 100) : 0;
|
||||
let f = !0;
|
||||
const h = l.present;
|
||||
typeof h == "boolean" ? f = h : r && (f = String(r.state ?? "").trim().toLowerCase() === "on");
|
||||
@@ -1293,10 +1293,10 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
typeof X == "string" && X.trim() && (m = X.trim());
|
||||
}
|
||||
m || (m = At(e));
|
||||
const v = Tt(
|
||||
const v = zt(
|
||||
r?.attributes?.next_present,
|
||||
this._timeZone()
|
||||
), U = v ? `Returns ${ee(v, this._timeZone())}` : null;
|
||||
), R = v ? `Returns ${ee(v, this._timeZone())}` : null;
|
||||
return {
|
||||
slug: e,
|
||||
name: m,
|
||||
@@ -1305,7 +1305,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
completed: p,
|
||||
due: o,
|
||||
pct: c,
|
||||
returns: U,
|
||||
returns: R,
|
||||
unresolved: t
|
||||
};
|
||||
}
|
||||
@@ -1439,7 +1439,7 @@ const ze = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", Ee = "M50 3 44.5 2
|
||||
<span class="clock">${$t(this._now, this._timeZone())}</span>
|
||||
</div>
|
||||
`;
|
||||
const t = zt(e.condition), n = Et(e.condition, t), s = e.temperature === null ? g : d`<span class="temp">${Math.round(e.temperature)}°</span>`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`;
|
||||
const t = Tt(e.condition), n = Et(e.condition, t), s = e.temperature === null ? g : d`<span class="temp">${Math.round(e.temperature)}°</span>`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`;
|
||||
return d`
|
||||
<div class="dock">
|
||||
${Dt}
|
||||
@@ -1539,8 +1539,8 @@ const Be = (i, e, t) => {
|
||||
else if (p === void 0 && (p = Be(r, h, m), u = Be(l, c, f)), p.has(l[c])) if (p.has(l[f])) {
|
||||
const _ = u.get(r[h]), v = _ !== void 0 ? s[_] : null;
|
||||
if (v === null) {
|
||||
const U = O(i, s[c]);
|
||||
q(U, a[h]), o[h] = U;
|
||||
const R = O(i, s[c]);
|
||||
q(R, a[h]), o[h] = R;
|
||||
} else o[h] = q(v, a[h]), O(i, s[c], v), s[_] = null;
|
||||
h++;
|
||||
} else ne(s[f]), f--;
|
||||
@@ -2387,7 +2387,7 @@ function pn(i, e) {
|
||||
);
|
||||
return Number.isFinite(s) ? s - i : 0;
|
||||
}
|
||||
function Re(i, e) {
|
||||
function Ue(i, e) {
|
||||
if (typeof i != "string")
|
||||
return null;
|
||||
const t = i.split("-");
|
||||
@@ -2470,7 +2470,7 @@ function gn(i) {
|
||||
function _n(i, e) {
|
||||
return mn[i] ?? e;
|
||||
}
|
||||
const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", je = [-9, 6, -4], bn = Ve`
|
||||
const Re = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50% 0, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)", je = [-9, 6, -4], bn = Ve`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -2524,44 +2524,13 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
button.return {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: var(--nq-p-button-height);
|
||||
padding: 0 28px 0 20px;
|
||||
border: 2px solid rgba(92, 62, 26, 0.5);
|
||||
border-radius: 10px;
|
||||
background: rgba(92, 62, 26, 0.1);
|
||||
color: var(--nq-p-ink);
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
button.return:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button.return svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.crest {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 96px;
|
||||
height: 110px;
|
||||
padding: 4px;
|
||||
clip-path: ${x(Ue)};
|
||||
clip-path: ${x(Re)};
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
@@ -2572,7 +2541,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 32px;
|
||||
clip-path: ${x(Ue)};
|
||||
clip-path: ${x(Re)};
|
||||
background: var(--nq-p-crest);
|
||||
}
|
||||
|
||||
@@ -3374,16 +3343,6 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
>
|
||||
<path d="M18 6 6 18"></path>
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>`, xn = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6"></path>
|
||||
</svg>`, se = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -3396,7 +3355,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26"
|
||||
></polygon>
|
||||
</svg>`, wn = d`<svg
|
||||
</svg>`, xn = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -3431,7 +3390,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
<path d="M20 12h2"></path>
|
||||
<path d="m6.34 17.66-1.41 1.41"></path>
|
||||
<path d="m19.07 4.93-1.41 1.41"></path>
|
||||
</svg>`, kn = d`<svg
|
||||
</svg>`, wn = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -3441,7 +3400,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
|
||||
</svg>`, $n = d`<svg
|
||||
</svg>`, kn = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -3452,7 +3411,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
>
|
||||
<path d="M3.5 21 12 3.5 20.5 21"></path>
|
||||
<path d="M9 21l3-8 3 8"></path>
|
||||
</svg>`, qn = d`<svg
|
||||
</svg>`, $n = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -3463,7 +3422,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon>
|
||||
</svg>`, Mn = d`<svg
|
||||
</svg>`, qn = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -3475,7 +3434,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
|
||||
<path d="M12 9v4"></path>
|
||||
<path d="M12 17h.01"></path>
|
||||
</svg>`, Cn = d`<svg
|
||||
</svg>`, Mn = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -3486,7 +3445,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
>
|
||||
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"></path>
|
||||
<path d="m2 2 20 20"></path>
|
||||
</svg>`, An = d`<svg
|
||||
</svg>`, Cn = d`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -3497,22 +3456,22 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
>
|
||||
<path d="M13 16a3 3 0 1 1 0 6H7a5 5 0 1 1 4.9-6Z"></path>
|
||||
<path d="M10.1 9A6 6 0 0 1 16 4a4.5 4.5 0 0 0 8.9 4.2v.1a6 6 0 0 1-5.2 5.7"></path>
|
||||
</svg>`, Sn = {
|
||||
icon: qn,
|
||||
</svg>`, An = {
|
||||
icon: $n,
|
||||
headline: "No adventurer chosen",
|
||||
body: "Open The Party and tap your crest to open your quest log."
|
||||
}, Tn = {
|
||||
icon: Mn,
|
||||
}, Sn = {
|
||||
icon: qn,
|
||||
headline: "NestQuest is not set up yet",
|
||||
body: "A parent needs to finish setting up NestQuest."
|
||||
}, zn = {
|
||||
icon: Cn,
|
||||
icon: Mn,
|
||||
headline: "The records cannot be reached",
|
||||
body: "The party's records are quiet right now. NestQuest will return shortly."
|
||||
}, De = [
|
||||
{ key: "morning", name: "Morning", range: "Until 11:59 AM", icon: wn },
|
||||
{ key: "morning", name: "Morning", range: "Until 11:59 AM", icon: xn },
|
||||
{ key: "afternoon", name: "Afternoon", range: "12:00–5:00 PM", icon: Ye },
|
||||
{ key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: kn }
|
||||
{ key: "evening", name: "Evening", range: "5:00–9:00 PM", icon: wn }
|
||||
], W = class W extends C {
|
||||
constructor() {
|
||||
super(...arguments), this._now = /* @__PURE__ */ new Date(), this._confirm = null, this._optimistic = /* @__PURE__ */ new Map(), this._toast = null, this._countdown = null, this._unsubs = [], this._subscribed = !1, this._subGeneration = 0, this._onActivity = () => {
|
||||
@@ -3595,9 +3554,9 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
_renderMain(e) {
|
||||
switch (e.kind) {
|
||||
case "no-adventurer":
|
||||
return this._renderNotice(Sn);
|
||||
return this._renderNotice(An);
|
||||
case "not-set-up":
|
||||
return this._renderNotice(Tn);
|
||||
return this._renderNotice(Sn);
|
||||
case "unreachable":
|
||||
return this._renderNotice(zn);
|
||||
case "away": {
|
||||
@@ -3606,7 +3565,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
${this._renderHeader(e)}
|
||||
<div class="notice-wrap">
|
||||
<div class="notice away" role="status">
|
||||
${$n}
|
||||
${kn}
|
||||
<span class="notice-headline">${e.name} is on travels</span>
|
||||
${t.map((n) => d`<p class="notice-body">${n}</p>`)}
|
||||
</div>
|
||||
@@ -3710,7 +3669,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
_awayReturns(e) {
|
||||
const t = this._timeZone(), n = this._state(
|
||||
`binary_sensor.nestquest_${e}_present_today`
|
||||
), s = Re(
|
||||
), s = Ue(
|
||||
n?.attributes?.next_present,
|
||||
t
|
||||
);
|
||||
@@ -3856,7 +3815,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
if (!u) {
|
||||
const _ = this._state(
|
||||
`binary_sensor.nestquest_${l}_present_today`
|
||||
), v = Re(
|
||||
), v = Ue(
|
||||
_?.attributes?.next_present,
|
||||
n
|
||||
);
|
||||
@@ -3877,16 +3836,6 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
const t = this._childSlug(), n = e.kind === "away", s = n ? e.name : this._childName(t) ?? (t ? ie(t) : null), { due: a, completed: r } = this._childCounts(t), l = this._remaining(), o = this._partyCounts(), p = s ? `${s}'s Quest Log` : "Quest Log", u = (s?.charAt(0) || "?").toUpperCase();
|
||||
return d`
|
||||
<header class="header">
|
||||
${this._boardPath() ? d`
|
||||
<button
|
||||
class="return"
|
||||
type="button"
|
||||
@click=${() => this._returnToBoard()}
|
||||
>
|
||||
${xn}
|
||||
<span>The Party</span>
|
||||
</button>
|
||||
` : g}
|
||||
<span class="crest${n ? " away" : ""}" aria-hidden="true">
|
||||
<span class="crest-face">
|
||||
<span class="initial">${u}</span>
|
||||
@@ -4234,7 +4183,7 @@ const Ue = "polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%)", yn = "polygon(50%
|
||||
const t = gn(e.condition), n = _n(e.condition, t), s = e.temperature === null ? g : d`<span class="temp">${Math.round(e.temperature)}°</span>`, a = e.high === null || e.low === null ? null : `${Math.round(e.high)}° / ${Math.round(e.low)}°`;
|
||||
return d`
|
||||
<div class="dock">
|
||||
${An}
|
||||
${Cn}
|
||||
${s}
|
||||
<span class="divider"></span>
|
||||
<span class="condition">
|
||||
@@ -4269,7 +4218,7 @@ fe({
|
||||
name: "NestQuest Quest Log",
|
||||
description: "One child's daily quests, grouped by window."
|
||||
});
|
||||
const En = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}", G = class G extends C {
|
||||
const Tn = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));--nq-a-page: #F4F4F6;--nq-a-surface: #FFFFFF;--nq-a-surface-subtle: #FAFAFA;--nq-a-selected: #EBEBF8;--nq-a-border: #E4E4EA;--nq-a-divider: #EDEDF1;--nq-a-ink: #18181D;--nq-a-ink-secondary: #52525E;--nq-a-ink-tertiary: #70707E;--nq-a-success: #15803D;--nq-a-danger: #DC2626;--nq-a-danger-strong: #B91C1C;--nq-a-danger-bg: #FEE2E2;--nq-a-warning-ink: #92400E;--nq-a-warning-ink-2: #78350F;--nq-a-warning-bg: #FEF3C7;--nq-a-warning-border: rgba(217,119,6,.35);--nq-a-info-bg: #DBEAFE;--nq-a-info-ink: #1E3A8A;--nq-a-info-icon: #1D4ED8;--nq-a-reversal: #7D28A0;--nq-a-font: Nunito, system-ui, sans-serif;--nq-a-size-screen: 24px;--nq-a-size-hero: 32px;--nq-a-size-card-title: 19px;--nq-a-size-stat: 22px;--nq-a-size-row: 13.5px;--nq-a-size-meta: 11px;--nq-a-size-label: 10px;--nq-a-track-label: .1em;--nq-a-density-page-pad: 14px 16px 92px;--nq-a-density-gap: 10px;--nq-a-density-card-pad: 13px;--nq-a-density-row-pad: 11px 13px;--nq-a-density-radius: 8px;--nq-a-density-shadow: none;--nq-a-tabbar-height: 70px;--nq-a-tap-min: 44px;--nq-a-radius-pill: 9999px;--nq-a-radius-sheet: 20px 20px 0 0;--nq-a-sheet-shadow: 0 -8px 28px rgba(9,9,11,.18);--nq-a-progress-height: 6px;--nq-ease-out: cubic-bezier(0, 0, .2, 1);--nq-dur-micro: .12s;--nq-dur-base: .2s;--nq-dur-modal: .35s}", G = class G extends C {
|
||||
setConfig(e) {
|
||||
if (!e || typeof e != "object")
|
||||
throw new Error("Invalid configuration");
|
||||
@@ -4285,7 +4234,7 @@ const En = ":host{--nq-brand-blue: #3B3AB8;--nq-brand-purple: #A035CC;--nq-brand
|
||||
G.properties = {
|
||||
hass: { attribute: !1 },
|
||||
_config: { state: !0 }
|
||||
}, G.styles = [x(En)];
|
||||
}, G.styles = [x(Tn)];
|
||||
let le = G;
|
||||
customElements.define("nestquest-admin-card", le);
|
||||
fe({
|
||||
@@ -4296,29 +4245,29 @@ fe({
|
||||
function et(i) {
|
||||
return typeof i == "string" ? i.trim() : "";
|
||||
}
|
||||
function Nn(i) {
|
||||
function En(i) {
|
||||
if (i === null || typeof i != "object" || Array.isArray(i))
|
||||
return "";
|
||||
const e = i.slug;
|
||||
return typeof e == "string" ? e.trim() : "";
|
||||
}
|
||||
function Ln(i) {
|
||||
function Nn(i) {
|
||||
const e = et(i.url_path).replace(/^\/+|\/+$/g, "");
|
||||
return e || (window.location.pathname.split("/").filter(Boolean)[0] ?? "");
|
||||
}
|
||||
function On(i) {
|
||||
function Ln(i) {
|
||||
if (i === null || typeof i != "object" || Array.isArray(i))
|
||||
return "";
|
||||
const e = i.name;
|
||||
return typeof e == "string" ? e.trim() : "";
|
||||
}
|
||||
function Bn(i) {
|
||||
function On(i) {
|
||||
const n = i?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster;
|
||||
return Array.isArray(n) ? n.map((s) => ({ slug: Nn(s), name: On(s) })).filter((s) => s.slug.length > 0) : [];
|
||||
return Array.isArray(n) ? n.map((s) => ({ slug: En(s), name: Ln(s) })).filter((s) => s.slug.length > 0) : [];
|
||||
}
|
||||
class In {
|
||||
class Bn {
|
||||
static async generate(e, t) {
|
||||
const n = `/${Ln(e)}`, s = et(e.weather_entity), a = {
|
||||
const n = `/${Nn(e)}`, s = et(e.weather_entity), a = {
|
||||
type: "custom:nestquest-party-board-card",
|
||||
quest_log_path: n
|
||||
};
|
||||
@@ -4335,7 +4284,7 @@ class In {
|
||||
type: "panel",
|
||||
cards: [a]
|
||||
},
|
||||
...Bn(t).map((l) => ({
|
||||
...On(t).map((l) => ({
|
||||
path: l.slug,
|
||||
title: l.name ? `${l.name}'s Quest Log` : `${l.slug}'s Quest Log`,
|
||||
type: "panel",
|
||||
@@ -4346,9 +4295,9 @@ class In {
|
||||
}
|
||||
}
|
||||
window.customStrategies = window.customStrategies ?? {};
|
||||
window.customStrategies["nestquest-party"] = In;
|
||||
const Pn = "/nestquest-static/nestquest-fonts.css";
|
||||
window.customStrategies["nestquest-party"] = Bn;
|
||||
const In = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
const i = document.createElement("link");
|
||||
i.rel = "stylesheet", i.href = Pn, i.dataset.nqFonts = "", document.head.appendChild(i);
|
||||
i.rel = "stylesheet", i.href = In, i.dataset.nqFonts = "", document.head.appendChild(i);
|
||||
}
|
||||
|
||||
@@ -71,14 +71,6 @@ and time only rather than removing it (the strip is part of the frame).
|
||||
|
||||
Reached by tapping a present crest. Header at y=56, page inset 62px:
|
||||
|
||||
- **Return control** first in the header, left of the crest: a `--nq-p-button-height`
|
||||
72px button, radius 10px, `rgba(92,62,26,.1)` with a 2px `rgba(92,62,26,.5)` border
|
||||
(the confirm dialog's **Not yet** treatment), `chevron-left` 34px + `The Party`
|
||||
26px/700 Cinzel `--nq-p-ink`, 28px header gap to the crest. Tapping it takes the same
|
||||
path as the idle return (§3.5) — navigate to `board_path` — immediately. Press
|
||||
feedback `scale(.98)`, `--nq-dur-micro`. Not rendered when no `board_path` is
|
||||
configured. The party board is the main display and has no return control; the
|
||||
Quest complete screen (§5) keeps its auto-return.
|
||||
- Small crest 96×110 (initial 44px/700 display).
|
||||
- Title `<Name>'s Quest Log` 56px/700 display.
|
||||
- Sub `<weekday>, <Month D> · N of M claimed` 20px/600 Cinzel, tracking .24em, uppercase.
|
||||
|
||||
@@ -374,37 +374,6 @@ const logStyles = css`
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
button.return {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: var(--nq-p-button-height);
|
||||
padding: 0 28px 0 20px;
|
||||
border: 2px solid rgba(92, 62, 26, 0.5);
|
||||
border-radius: 10px;
|
||||
background: rgba(92, 62, 26, 0.1);
|
||||
color: var(--nq-p-ink);
|
||||
font-family: var(--nq-p-font-heading);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform var(--nq-dur-micro) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
button.return:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
button.return svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.crest {
|
||||
position: relative;
|
||||
flex: none;
|
||||
@@ -1230,18 +1199,6 @@ const ICON_X = html`<svg
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>`;
|
||||
|
||||
const ICON_CHEVRON_LEFT = html`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6"></path>
|
||||
</svg>`;
|
||||
|
||||
const ICON_STAR = html`<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -1987,18 +1944,6 @@ export class NestQuestQuestLogCard extends LitElement {
|
||||
const initial = (name?.charAt(0) || "?").toUpperCase();
|
||||
return html`
|
||||
<header class="header">
|
||||
${this._boardPath()
|
||||
? html`
|
||||
<button
|
||||
class="return"
|
||||
type="button"
|
||||
@click=${() => this._returnToBoard()}
|
||||
>
|
||||
${ICON_CHEVRON_LEFT}
|
||||
<span>The Party</span>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<span class="crest${away ? " away" : ""}" aria-hidden="true">
|
||||
<span class="crest-face">
|
||||
<span class="initial">${initial}</span>
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
* tap_complete: <instance id> the harness taps that card's Complete button,
|
||||
* records whether the confirm dialog opened, taps the dialog's Complete, and
|
||||
* reports every hass.callService call as tap: {dialog_opened, service_calls}.
|
||||
* With tap_return: true the harness taps the header's return control and
|
||||
* reports it as returned: {rendered, label} before the final snapshot.
|
||||
* The Python panel tests drive this harness so the
|
||||
* assertions run against the exact bundle HACS ships.
|
||||
*/
|
||||
@@ -77,17 +75,6 @@ if (spec.tap_complete !== undefined) {
|
||||
tap = { dialog_opened: confirm !== null, service_calls: serviceCalls };
|
||||
}
|
||||
|
||||
let returned = null;
|
||||
if (spec.tap_return) {
|
||||
const control = element.shadowRoot.querySelector(".header button.return");
|
||||
returned = {
|
||||
rendered: control !== null,
|
||||
label: control?.textContent?.trim() ?? "",
|
||||
};
|
||||
control?.click();
|
||||
await element.updateComplete;
|
||||
}
|
||||
|
||||
const snapshot = () => ({
|
||||
location: window.location.pathname,
|
||||
headline: element.shadowRoot.querySelector(".title")?.textContent?.trim() ?? "",
|
||||
@@ -123,6 +110,6 @@ if (spec.idle_ms !== undefined) {
|
||||
await new Promise((resolve) => setTimeout(resolve, spec.idle_ms));
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ ...snapshot(), probe, tap, returned });
|
||||
const payload = JSON.stringify({ ...snapshot(), probe, tap });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.9.4"
|
||||
version = "0.9.0"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -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 twelve documented fields at their defaults on a fresh database.
|
||||
— the eleven 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 twelve fields, asserted literally so the payload contract is
|
||||
#: documented eleven fields, asserted literally so the payload contract is
|
||||
#: pinned here, independent of the core constants.
|
||||
DEFAULT_SETTINGS = {
|
||||
"horizon_days": 14,
|
||||
@@ -74,7 +74,6 @@ DEFAULT_SETTINGS = {
|
||||
"end_of_day_report_enabled": True,
|
||||
"celebration_enabled": True,
|
||||
"timezone": "",
|
||||
"timezone_configured": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +104,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 twelve documented fields at their defaults."""
|
||||
"""GET returns the eleven documented fields at their defaults."""
|
||||
response = await settings_client.client.get(
|
||||
"/api/v1/admin/settings", headers=_admin_headers()
|
||||
)
|
||||
@@ -141,7 +140,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 twelve
|
||||
# ...and the DATABASE holds the merged document: the complete eleven
|
||||
# fields, keyed by field name, under the well-known meta_state key.
|
||||
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
|
||||
SETTINGS_META_KEY
|
||||
@@ -223,40 +222,6 @@ 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 -------
|
||||
|
||||
|
||||
@@ -273,8 +238,6 @@ async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
|
||||
({"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,25 +236,6 @@ 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 ----------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -302,15 +302,14 @@ def test_quest_log_entrances_use_the_design_timing_and_easing() -> None:
|
||||
".quest.tappable:active",
|
||||
"button.complete:active",
|
||||
"button.confirm-button:active",
|
||||
"button.return:active",
|
||||
):
|
||||
start = style.index(selector)
|
||||
block = style[start : style.index("}", start)]
|
||||
assert "transform: scale(0.98);" in block, selector
|
||||
assert (
|
||||
style.count("transition: transform var(--nq-dur-micro) var(--nq-ease-out);")
|
||||
== 4
|
||||
) # quest card, Complete button, confirm buttons, header return control
|
||||
== 3
|
||||
) # quest card, Complete button, confirm buttons
|
||||
board_style = _style_literal(_read(PARTY_BOARD), STYLE_CONSTANTS[PARTY_BOARD])
|
||||
start = board_style.index("button.plate:active")
|
||||
block = board_style[start : board_style.index("}", start)]
|
||||
@@ -830,7 +829,6 @@ def _render_quest_log(
|
||||
idle_ms: int | None = None,
|
||||
probe_ms: int | None = None,
|
||||
tap_complete: int | None = None,
|
||||
tap_return: bool = False,
|
||||
) -> dict:
|
||||
"""Render the built bundle's quest log and report its location.
|
||||
|
||||
@@ -856,8 +854,6 @@ def _render_quest_log(
|
||||
spec["probe_ms"] = probe_ms
|
||||
if tap_complete is not None:
|
||||
spec["tap_complete"] = tap_complete
|
||||
if tap_return:
|
||||
spec["tap_return"] = True
|
||||
completed = subprocess.run(
|
||||
["node", str(QUEST_LOG_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
@@ -927,40 +923,6 @@ def test_quest_log_idle_return_navigates_to_the_board() -> None:
|
||||
assert result["location"] == "/nestquest"
|
||||
|
||||
|
||||
def test_quest_log_return_control_navigates_to_the_board() -> None:
|
||||
"""PANEL-SPEC §3: the Quest Log header carries an explicit return
|
||||
control labelled for a child. Tapping it takes the same path as the
|
||||
idle return — the strategy-computed board_path (the dashboard root) —
|
||||
without waiting out the idle window."""
|
||||
generated = _generate_dashboard(
|
||||
{}, _three_child_states(), url="http://homeassistant.local/nestquest"
|
||||
)
|
||||
result = _render_quest_log(
|
||||
generated["views"][1]["cards"][0],
|
||||
_three_child_states(),
|
||||
url="http://homeassistant.local/nestquest/ada",
|
||||
tap_return=True,
|
||||
)
|
||||
assert result["headline"] == "Ada's Quest Log"
|
||||
assert result["returned"] == {"rendered": True, "label": "The Party"}
|
||||
assert result["location"] == "/nestquest"
|
||||
|
||||
|
||||
def test_quest_log_return_control_follows_the_panel_hard_rules() -> None:
|
||||
"""The return control is a 72px button with :active feedback only,
|
||||
lives in the header (never the dock), and reuses the idle return's
|
||||
_returnToBoard navigation rather than a second mechanism."""
|
||||
log = _read(QUEST_LOG)
|
||||
style = _style_literal(log, STYLE_CONSTANTS[QUEST_LOG])
|
||||
rules = {selector.strip(): body for selector, body in _css_rules(style)}
|
||||
assert "height: var(--nq-p-button-height);" in rules["button.return"]
|
||||
assert "transform: scale(0.98);" in rules["button.return:active"]
|
||||
header = log[log.index("private _renderHeader") : log.index("private _renderColumn")]
|
||||
assert 'class="return"' in header
|
||||
assert "@click=${() => this._returnToBoard()}" in header
|
||||
assert log.count("this._returnToBoard()") == 4
|
||||
|
||||
|
||||
def test_quest_complete_screen_returns_to_the_board_after_the_countdown() -> None:
|
||||
"""The 1c screen renders with its countdown line and auto-returns to
|
||||
the strategy-computed board path when it reaches zero. The design
|
||||
@@ -1073,12 +1035,9 @@ def test_quest_log_crest_keeps_its_shield_classes() -> None:
|
||||
)
|
||||
header = result["html"][result["html"].index('<header class="header">') :]
|
||||
crest = re.match(
|
||||
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*'
|
||||
r'(?:<button class="return"[^>]*>.*?</button>\s*(?:<!--[^>]*-->\s*)*)?'
|
||||
r'<span ([^>]*)>\s*'
|
||||
r'<header class="header">\s*(?:<!--[^>]*-->\s*)*<span ([^>]*)>\s*'
|
||||
r'<span class="crest-face">\s*<span class="initial">',
|
||||
header,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert crest is not None, header[:600]
|
||||
assert f'class="{crest_class}"' in crest.group(1), crest.group(1)
|
||||
|
||||
Reference in New Issue
Block a user