Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbe873eb2a | ||
|
|
706a7954d8 | ||
|
|
be4fd04754 | ||
|
|
4ff40f00a2 | ||
|
|
f476821e8e | ||
|
|
75ad9892b8 | ||
|
|
48d0c4ef86 |
@@ -1,27 +0,0 @@
|
|||||||
# Keep the image build context minimal: only api/ and the bundled
|
|
||||||
# custom_components/nestquest/core/ are copied (see the Dockerfile);
|
|
||||||
# everything else — VCS state, virtualenvs, tests, the frontend, and
|
|
||||||
# any local secrets or database files — never enters the build.
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.venv
|
|
||||||
**/__pycache__
|
|
||||||
**/*.py[cod]
|
|
||||||
.pytest_cache
|
|
||||||
**/*.db
|
|
||||||
**/*.db-wal
|
|
||||||
**/*.db-shm
|
|
||||||
# Local configuration — never baked into an image.
|
|
||||||
.env
|
|
||||||
**/.env
|
|
||||||
deploy
|
|
||||||
design
|
|
||||||
docs
|
|
||||||
frontend
|
|
||||||
orchestration_tests
|
|
||||||
scripts
|
|
||||||
templates
|
|
||||||
tests
|
|
||||||
uv.lock
|
|
||||||
hacs.json
|
|
||||||
*.md
|
|
||||||
-13
@@ -5,16 +5,3 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.metadata.json
|
*.metadata.json
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
|
||||||
# Feature 17 (API deployment): local secret config and runtime data
|
|
||||||
# are never committed — compose reads deploy/.env; SQLite/WAL sidecar
|
|
||||||
# files are container/volume artifacts.
|
|
||||||
.env
|
|
||||||
**/.env
|
|
||||||
*.db
|
|
||||||
*.db-wal
|
|
||||||
*.db-shm
|
|
||||||
|
|
||||||
# Dedicated Litestream SFTP backup keypair — lives only on the API box.
|
|
||||||
deploy/*key*
|
|
||||||
deploy/litestream_key*
|
|
||||||
|
|||||||
-51
@@ -1,51 +0,0 @@
|
|||||||
# Container image for the NestQuest API service (Feature 17).
|
|
||||||
#
|
|
||||||
# Build context is the REPOSITORY ROOT (compose sets it), because the
|
|
||||||
# API loads the bundled domain layer from
|
|
||||||
# custom_components/nestquest/core/ by file location
|
|
||||||
# (api/nestquest_core.py): the image must carry both api/ and the
|
|
||||||
# bundled core at their relative positions. Everything else in the
|
|
||||||
# repository is excluded via .dockerignore.
|
|
||||||
#
|
|
||||||
# Multi-stage so no pip/build toolchain survives into the runtime:
|
|
||||||
# the builder installs the deployable's own manifest
|
|
||||||
# (api/requirements.txt) into a staging prefix and the runtime copies
|
|
||||||
# only the installed packages. The service runs as a non-root user;
|
|
||||||
# the SQLite database lives on the mounted volume at
|
|
||||||
# /var/lib/nestquest (compose supplies NESTQUEST_DB_PATH pointing
|
|
||||||
# there — configuration is environment-supplied, never baked in).
|
|
||||||
#
|
|
||||||
# Both stages use the same base image pinned by digest, and
|
|
||||||
# api/requirements.txt pins exact versions, so a rebuild reproduces
|
|
||||||
# the deployed image instead of floating to whatever is newest.
|
|
||||||
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9 AS builder
|
|
||||||
|
|
||||||
COPY api/requirements.txt /tmp/requirements.txt
|
|
||||||
RUN pip install --no-cache-dir --prefix=/install -r /tmp/requirements.txt
|
|
||||||
|
|
||||||
FROM python:3.12-slim@sha256:2f17fc044b579bab302c2e8054d3a686e2cb9a83de48e70534b94cd8ebbe06a9
|
|
||||||
|
|
||||||
# The staged dependency install from the builder (no pip, no wheels).
|
|
||||||
COPY --from=builder /install /usr/local
|
|
||||||
|
|
||||||
# /app is the repository-root equivalent: api/ resolves imports as
|
|
||||||
# ``api.*`` and nestquest_core.py finds the bundled core one level up.
|
|
||||||
WORKDIR /app
|
|
||||||
COPY api/ ./api/
|
|
||||||
COPY custom_components/nestquest/core/ ./custom_components/nestquest/core/
|
|
||||||
|
|
||||||
# Non-root runtime user; owns the database volume mount point.
|
|
||||||
RUN useradd --system --create-home --home-dir /home/nestquest nestquest \
|
|
||||||
&& mkdir -p /var/lib/nestquest \
|
|
||||||
&& chown nestquest:nestquest /var/lib/nestquest
|
|
||||||
USER nestquest
|
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
|
||||||
|
|
||||||
# The database path and the service token / OIDC settings are supplied
|
|
||||||
# by the environment (compose env_file), never committed into the image.
|
|
||||||
ENV NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db
|
|
||||||
VOLUME /var/lib/nestquest
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
CMD ["uvicorn", "--factory", "api.app:create_app", "--host", "0.0.0.0", "--port", "8080"]
|
|
||||||
Generated
-10
@@ -8,7 +8,6 @@
|
|||||||
"name": "nestquest-admin",
|
"name": "nestquest-admin",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.47.0",
|
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
@@ -2258,15 +2257,6 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
|
||||||
"version": "1.47.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.47.0.tgz",
|
|
||||||
"integrity": "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
|
|||||||
+1
-2
@@ -10,7 +10,6 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^1.47.0",
|
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
@@ -25,4 +24,4 @@
|
|||||||
"vite": "^7.1.5",
|
"vite": "^7.1.5",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,17 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-placeholder {
|
||||||
|
margin: 0;
|
||||||
|
padding: 13px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-tabbar {
|
.admin-tabbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: auto 0 0;
|
inset: auto 0 0;
|
||||||
|
|||||||
+3
-11
@@ -29,19 +29,11 @@ describe("NestQuest Admin shell", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
|
expect(screen.getByRole("tabpanel").textContent).toContain(
|
||||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
"History placeholder",
|
||||||
|
);
|
||||||
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
|
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
|
||||||
"true",
|
"true",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the Tasks tab renders the definitions screen", () => {
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
|
||||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+38
-44
@@ -1,9 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { TransitionsProvider } from "./api/useTransitions";
|
|
||||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
|
||||||
import HistoryTab from "./history/HistoryTab";
|
|
||||||
import ScheduleTab from "./schedule/ScheduleTab";
|
|
||||||
import TodayTab from "./today/TodayTab";
|
import TodayTab from "./today/TodayTab";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -17,47 +13,45 @@ type TabId = (typeof TABS)[number]["id"];
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [active, setActive] = useState<TabId>("today");
|
const [active, setActive] = useState<TabId>("today");
|
||||||
|
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
||||||
|
// stays for placeholder tabs and is visually hidden otherwise.
|
||||||
|
const hasScreen = active === "today";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransitionsProvider>
|
<div className="admin-shell">
|
||||||
<div className="admin-shell">
|
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
|
||||||
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
|
<h1 className="admin-title">NestQuest Admin</h1>
|
||||||
generic shell header stays only as a visually hidden page heading. */}
|
<p className="admin-subline">Admin console</p>
|
||||||
<header className="admin-header visually-hidden">
|
</header>
|
||||||
<h1 className="admin-title">NestQuest Admin</h1>
|
<main
|
||||||
<p className="admin-subline">Admin console</p>
|
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
|
||||||
</header>
|
role="tabpanel"
|
||||||
<main
|
aria-labelledby={`tab-${active}`}
|
||||||
className="admin-panel admin-panel--screen"
|
>
|
||||||
role="tabpanel"
|
{active === "today" ? (
|
||||||
aria-labelledby={`tab-${active}`}
|
<TodayTab />
|
||||||
>
|
) : (
|
||||||
{active === "today" ? (
|
<p className="admin-placeholder">
|
||||||
<TodayTab />
|
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||||
) : active === "tasks" ? (
|
arrive in later tasks.
|
||||||
<DefinitionsTab />
|
</p>
|
||||||
) : active === "schedule" ? (
|
)}
|
||||||
<ScheduleTab />
|
</main>
|
||||||
) : (
|
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||||
<HistoryTab />
|
{TABS.map((tab) => (
|
||||||
)}
|
<button
|
||||||
</main>
|
key={tab.id}
|
||||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
id={`tab-${tab.id}`}
|
||||||
{TABS.map((tab) => (
|
type="button"
|
||||||
<button
|
role="tab"
|
||||||
key={tab.id}
|
aria-selected={tab.id === active}
|
||||||
id={`tab-${tab.id}`}
|
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
||||||
type="button"
|
onClick={() => setActive(tab.id)}
|
||||||
role="tab"
|
>
|
||||||
aria-selected={tab.id === active}
|
{tab.label}
|
||||||
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
</button>
|
||||||
onClick={() => setActive(tab.id)}
|
))}
|
||||||
>
|
</nav>
|
||||||
{tab.label}
|
</div>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</TransitionsProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import AppRoutes from "./AppRoutes";
|
import AppRoutes from "./AppRoutes";
|
||||||
import { storeTokens } from "./auth/oidc";
|
import { storeTokens } from "./auth/oidc";
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
@@ -32,32 +32,6 @@ describe("AppRoutes auth gating", () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
function probeCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
|
||||||
return fetchMock.mock.calls
|
|
||||||
.map(([url]) => String(url))
|
|
||||||
.filter((url) => url.includes("/api/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
it("probes exactly GET /api/v1/admin/ping and no non-admin path", async () => {
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
render(<AppRoutes />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
|
||||||
});
|
|
||||||
const calls = probeCalls(fetchMock);
|
|
||||||
expect(calls).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
|
|
||||||
const [, init] = fetchMock.mock.calls[0];
|
|
||||||
expect(init.method ?? "GET").toBe("GET");
|
|
||||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
|
||||||
for (const url of calls) {
|
|
||||||
expect(new URL(url, "http://localhost").pathname.startsWith("/api/v1/admin/")).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the four-tab shell after a successful authenticated probe", async () => {
|
it("renders the four-tab shell after a successful authenticated probe", async () => {
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||||
@@ -71,9 +45,6 @@ describe("AppRoutes auth gating", () => {
|
|||||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||||
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
expect(screen.getByRole("tab", { name: label })).toBeTruthy();
|
||||||
}
|
}
|
||||||
expect(String(fetchMock.mock.calls[0][0])).toBe(
|
|
||||||
`${config.apiBaseUrl}/api/v1/admin/ping`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the explicit refusal when the API answers 403", async () => {
|
it("renders the explicit refusal when the API answers 403", async () => {
|
||||||
@@ -87,97 +58,6 @@ describe("AppRoutes auth gating", () => {
|
|||||||
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
|
expect(screen.getByTestId("refusal-screen")).toBeTruthy();
|
||||||
});
|
});
|
||||||
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
|
expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
|
||||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the login flow when the probe answers 401", async () => {
|
|
||||||
storeTokens({ access_token: "stale", expires_in: 600 });
|
|
||||||
const assign = vi.fn();
|
|
||||||
vi.stubGlobal("location", { ...window.location, assign });
|
|
||||||
const fetchMock = vi.fn().mockImplementation(async (url: string) =>
|
|
||||||
String(url).includes("/api/v1/admin/ping")
|
|
||||||
? new Response("", { status: 401 })
|
|
||||||
: jsonResponse({
|
|
||||||
issuer: config.issuer,
|
|
||||||
authorization_endpoint: "https://auth.example.com/authorize/",
|
|
||||||
token_endpoint: "https://auth.example.com/token/",
|
|
||||||
userinfo_endpoint: "https://auth.example.com/userinfo/",
|
|
||||||
end_session_endpoint: "https://auth.example.com/end-session/",
|
|
||||||
jwks_uri: "https://auth.example.com/jwks/",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
render(<AppRoutes />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("login-screen")).toBeTruthy();
|
|
||||||
});
|
|
||||||
expect(assign).toHaveBeenCalledTimes(1);
|
|
||||||
const redirect = new URL(assign.mock.calls[0][0] as string);
|
|
||||||
expect(redirect.origin + redirect.pathname).toBe("https://auth.example.com/authorize/");
|
|
||||||
expect(probeCalls(fetchMock)).toEqual([`${config.apiBaseUrl}/api/v1/admin/ping`]);
|
|
||||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
|
||||||
expect(screen.queryByTestId("probe-error-screen")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const status of [404, 500]) {
|
|
||||||
it(`shows the error screen, not the shell, when the probe answers ${status}`, async () => {
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(new Response("", { status }));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
render(<AppRoutes />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
|
||||||
});
|
|
||||||
expect(screen.getByText(new RegExp(`answered ${status}`))).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Sign out" })).toBeTruthy();
|
|
||||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
|
||||||
expect(screen.queryByTestId("refusal-screen")).toBeNull();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("shows the error screen, not the shell, when the probe request fails", async () => {
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
render(<AppRoutes />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
|
||||||
});
|
|
||||||
expect(screen.getByText(/could not be reached/i)).toBeTruthy();
|
|
||||||
expect(screen.queryByRole("tab", { name: "Today" })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-probes on retry and enters the shell once the probe succeeds", async () => {
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
// After the retry the ping succeeds; the shell's own tab loads stay pending.
|
|
||||||
let pings = 0;
|
|
||||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
|
||||||
if (!String(url).endsWith("/api/v1/admin/ping")) return new Promise(() => {});
|
|
||||||
pings += 1;
|
|
||||||
return Promise.resolve(new Response("", { status: pings === 1 ? 500 : 200 }));
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
render(<AppRoutes />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("probe-error-screen")).toBeTruthy();
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByRole("tab", { name: "Today" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
expect(String(fetchMock.mock.calls[1][0])).toBe(
|
|
||||||
`${config.apiBaseUrl}/api/v1/admin/ping`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the configuration error screen when the client id is missing", () => {
|
it("shows the configuration error screen when the client id is missing", () => {
|
||||||
|
|||||||
+8
-43
@@ -12,7 +12,7 @@ import {
|
|||||||
handleCallback,
|
handleCallback,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
} from "./auth/oidc";
|
} from "./auth/oidc";
|
||||||
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
|
import { ApiForbiddenError, apiFetch } from "./api/client";
|
||||||
import AdminShell from "./App";
|
import AdminShell from "./App";
|
||||||
|
|
||||||
type Screen =
|
type Screen =
|
||||||
@@ -21,28 +21,18 @@ type Screen =
|
|||||||
| { kind: "config-error"; message: string }
|
| { kind: "config-error"; message: string }
|
||||||
| { kind: "auth-error"; message: string }
|
| { kind: "auth-error"; message: string }
|
||||||
| { kind: "refusal"; message: string }
|
| { kind: "refusal"; message: string }
|
||||||
| { kind: "probe-error"; message: string }
|
|
||||||
| { kind: "app" };
|
| { kind: "app" };
|
||||||
|
|
||||||
function currentPath(): string {
|
function currentPath(): string {
|
||||||
return window.location.pathname.replace(/\/+$/, "") || "/";
|
return window.location.pathname.replace(/\/+$/, "") || "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_PROBE_PATH = "/api/v1/admin/ping";
|
|
||||||
|
|
||||||
async function probeApi(setScreen: (screen: Screen) => void) {
|
async function probeApi(setScreen: (screen: Screen) => void) {
|
||||||
// The admin plane's authorization probe: only a 2xx enters the shell, so a
|
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
|
||||||
// non-admin (403) sees the explicit refusal and any other failure an error.
|
// right after login instead of a silent empty shell.
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
|
await apiFetch("/api/v1/children", {}, config);
|
||||||
if (response.ok) {
|
setScreen({ kind: "app" });
|
||||||
setScreen({ kind: "app" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setScreen({
|
|
||||||
kind: "probe-error",
|
|
||||||
message: `The admin API answered ${response.status}. The admin console cannot load.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ConfigError) {
|
if (error instanceof ConfigError) {
|
||||||
setScreen({ kind: "config-error", message: error.message });
|
setScreen({ kind: "config-error", message: error.message });
|
||||||
@@ -52,15 +42,9 @@ async function probeApi(setScreen: (screen: Screen) => void) {
|
|||||||
setScreen({ kind: "refusal", message: error.message });
|
setScreen({ kind: "refusal", message: error.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (error instanceof ApiUnauthorizedError) {
|
// Network or other failures still allow the shell to render; API screens
|
||||||
// apiFetch has already started the login redirect.
|
// handle their own errors in later tasks.
|
||||||
setScreen({ kind: "login" });
|
setScreen({ kind: "app" });
|
||||||
return;
|
|
||||||
}
|
|
||||||
setScreen({
|
|
||||||
kind: "probe-error",
|
|
||||||
message: "The admin API could not be reached. Check your connection and try again.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,25 +170,6 @@ export default function AppRoutes() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case "probe-error":
|
|
||||||
return (
|
|
||||||
<div className="admin-error" role="alert" data-testid="probe-error-screen">
|
|
||||||
<h1>Admin console unavailable</h1>
|
|
||||||
<p>{screen.message}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setScreen({ kind: "loading" });
|
|
||||||
void probeApi(setScreen);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case "app":
|
case "app":
|
||||||
return <AdminShell />;
|
return <AdminShell />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed client for the admin quest-definition routes and the children list
|
|
||||||
* the assignee picker reads (/api/v1/admin/quest-definitions, /children).
|
|
||||||
*/
|
|
||||||
import { apiFetch } from "./client";
|
|
||||||
|
|
||||||
export type WindowName = "morning" | "afternoon" | "evening";
|
|
||||||
|
|
||||||
/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */
|
|
||||||
export type RuleType =
|
|
||||||
| "daily"
|
|
||||||
| "weekly"
|
|
||||||
| "monthly_day"
|
|
||||||
| "monthly_weekday"
|
|
||||||
| "yearly"
|
|
||||||
| "custom_days";
|
|
||||||
|
|
||||||
export interface DefinitionRule {
|
|
||||||
rule_type: RuleType;
|
|
||||||
interval: number;
|
|
||||||
/** 0 = Monday .. 6 = Sunday. */
|
|
||||||
weekday_set: number[] | null;
|
|
||||||
day_of_month: number | null;
|
|
||||||
nth_weekday: number | null;
|
|
||||||
nth_weekday_weekday: number | null;
|
|
||||||
month: number | null;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DefinitionAssignee {
|
|
||||||
id: number;
|
|
||||||
display_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DefinitionWindow {
|
|
||||||
window: WindowName;
|
|
||||||
/** Local wall-clock "HH:MM", or null. */
|
|
||||||
due_time: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuestDefinition {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
icon: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
skip_on_away: boolean;
|
|
||||||
rule: DefinitionRule;
|
|
||||||
assignees: DefinitionAssignee[];
|
|
||||||
windows: DefinitionWindow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminChild {
|
|
||||||
id: number;
|
|
||||||
display_name: string;
|
|
||||||
colour: string | null;
|
|
||||||
avatar_ref: string | null;
|
|
||||||
sort_order: number;
|
|
||||||
is_active: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A window declaration as the create/edit bodies accept it. */
|
|
||||||
export type WindowEntry = WindowName | [WindowName, string | null];
|
|
||||||
|
|
||||||
export interface DefinitionCreateBody {
|
|
||||||
title: string;
|
|
||||||
rule: DefinitionRule;
|
|
||||||
assignee_child_ids: number[];
|
|
||||||
windows: WindowEntry[];
|
|
||||||
description?: string | null;
|
|
||||||
icon?: string | null;
|
|
||||||
skip_on_away?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DefinitionEditBody = Partial<DefinitionCreateBody>;
|
|
||||||
|
|
||||||
export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions";
|
|
||||||
export const CHILDREN_PATH = "/api/v1/admin/children";
|
|
||||||
|
|
||||||
/** A non-2xx save response; `detail` is the API's message when it sent one. */
|
|
||||||
export class ApiRequestError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly status: number,
|
|
||||||
readonly detail: string | null,
|
|
||||||
) {
|
|
||||||
super(detail ?? `Request failed (HTTP ${status}).`);
|
|
||||||
this.name = "ApiRequestError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */
|
|
||||||
function readDetail(body: unknown): string | null {
|
|
||||||
if (!body || typeof body !== "object" || !("detail" in body)) return null;
|
|
||||||
const { detail } = body as { detail: unknown };
|
|
||||||
if (typeof detail === "string") return detail;
|
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
const messages = detail
|
|
||||||
.map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null))
|
|
||||||
.filter((msg): msg is string => typeof msg === "string");
|
|
||||||
return messages.length > 0 ? messages.join("; ") : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readJson<T>(response: Response): Promise<T> {
|
|
||||||
if (!response.ok) {
|
|
||||||
let body: unknown = null;
|
|
||||||
try {
|
|
||||||
body = await response.json();
|
|
||||||
} catch {
|
|
||||||
// Non-JSON error body: report the status alone.
|
|
||||||
}
|
|
||||||
throw new ApiRequestError(response.status, readDetail(body));
|
|
||||||
}
|
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchDefinitions(): Promise<QuestDefinition[]> {
|
|
||||||
const body = await readJson<{ definitions: QuestDefinition[] }>(
|
|
||||||
await apiFetch(DEFINITIONS_PATH),
|
|
||||||
);
|
|
||||||
return body.definitions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchChildren(): Promise<AdminChild[]> {
|
|
||||||
const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH));
|
|
||||||
return body.children;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
|
|
||||||
return apiFetch(path, {
|
|
||||||
method,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createDefinition(body: DefinitionCreateBody): Promise<QuestDefinition> {
|
|
||||||
return readJson<QuestDefinition>(await sendJson(DEFINITIONS_PATH, "POST", body));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateDefinition(
|
|
||||||
id: number,
|
|
||||||
body: DefinitionEditBody,
|
|
||||||
): Promise<QuestDefinition> {
|
|
||||||
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OCCURRENCES_PREVIEW_PATH = `${DEFINITIONS_PATH}/occurrences-preview`;
|
|
||||||
|
|
||||||
export interface OccurrencesPreviewOptions {
|
|
||||||
/** "YYYY-MM-DD"; the API defaults to its host's today. */
|
|
||||||
startDate?: string;
|
|
||||||
/** 1..50; the API defaults to 10. */
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The next occurrence dates ("YYYY-MM-DD", ascending) for `rule`, computed by
|
|
||||||
* the same backend recurrence engine the materializer uses.
|
|
||||||
*/
|
|
||||||
export async function previewOccurrences(
|
|
||||||
rule: DefinitionRule,
|
|
||||||
options: OccurrencesPreviewOptions = {},
|
|
||||||
): Promise<string[]> {
|
|
||||||
const body: { rule: DefinitionRule; start_date?: string; count?: number } = { rule };
|
|
||||||
if (options.startDate !== undefined) body.start_date = options.startDate;
|
|
||||||
if (options.count !== undefined) body.count = options.count;
|
|
||||||
const response = await readJson<{ dates: string[] }>(
|
|
||||||
await sendJson(OCCURRENCES_PREVIEW_PATH, "POST", body),
|
|
||||||
);
|
|
||||||
return response.dates;
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { waitFor } from "@testing-library/react";
|
|
||||||
import { EVENTS_PATH, SseParser, openTransitionStream, type TransitionEvent } from "./events";
|
|
||||||
import { frame, testStream, type TestStream } from "./testStream";
|
|
||||||
import { storeTokens } from "../auth/oidc";
|
|
||||||
|
|
||||||
const COMPLETED = {
|
|
||||||
child_id: 1,
|
|
||||||
child_name: "Declan",
|
|
||||||
instance_id: 12,
|
|
||||||
quest_title: "Make bed",
|
|
||||||
window: "morning",
|
|
||||||
due_date: "2026-09-23",
|
|
||||||
due_time: null,
|
|
||||||
occurred_at: "2026-09-23T11:04:00Z",
|
|
||||||
};
|
|
||||||
const DAY_COMPLETE = {
|
|
||||||
child_id: 2,
|
|
||||||
child_name: "Maeve",
|
|
||||||
quests_due: 4,
|
|
||||||
quests_completed: 4,
|
|
||||||
occurred_at: "2026-09-23T12:00:00Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("SseParser", () => {
|
|
||||||
it("yields every frame from one chunk", () => {
|
|
||||||
const parser = new SseParser();
|
|
||||||
const events = parser.push(
|
|
||||||
frame("nestquest_quest_completed", COMPLETED) +
|
|
||||||
frame("nestquest_child_day_complete", DAY_COMPLETE),
|
|
||||||
);
|
|
||||||
expect(events).toEqual([
|
|
||||||
{ type: "nestquest_quest_completed", payload: COMPLETED },
|
|
||||||
{ type: "nestquest_child_day_complete", payload: DAY_COMPLETE },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("buffers a frame split across two chunks", () => {
|
|
||||||
const parser = new SseParser();
|
|
||||||
const text = frame("nestquest_quest_uncompleted", COMPLETED);
|
|
||||||
const cut = text.indexOf("Make");
|
|
||||||
expect(parser.push(text.slice(0, cut))).toEqual([]);
|
|
||||||
expect(parser.push(text.slice(cut))).toEqual([
|
|
||||||
{ type: "nestquest_quest_uncompleted", payload: COMPLETED },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("buffers a split inside the event name and the blank-line terminator", () => {
|
|
||||||
const parser = new SseParser();
|
|
||||||
const text = frame("nestquest_quest_missed", COMPLETED);
|
|
||||||
expect(parser.push(text.slice(0, 12))).toEqual([]);
|
|
||||||
expect(parser.push(text.slice(12, -1))).toEqual([]);
|
|
||||||
expect(parser.push(text.slice(-1))).toEqual([
|
|
||||||
{ type: "nestquest_quest_missed", payload: COMPLETED },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a CRLF split across chunks as one line break", () => {
|
|
||||||
const parser = new SseParser();
|
|
||||||
const text = `event: nestquest_quest_completed\r\ndata: ${JSON.stringify(COMPLETED)}\r\n\r\n`;
|
|
||||||
const cut = text.indexOf("\n"); // chunk ends on "\r"
|
|
||||||
expect(parser.push(text.slice(0, cut))).toEqual([]);
|
|
||||||
expect(parser.push(text.slice(cut))).toEqual([
|
|
||||||
{ type: "nestquest_quest_completed", payload: COMPLETED },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops malformed, unknown and comment frames without throwing", () => {
|
|
||||||
const parser = new SseParser();
|
|
||||||
const events = parser.push(
|
|
||||||
"event: nestquest_quest_completed\ndata: not-json\n\n" +
|
|
||||||
"event: nestquest_quest_completed\ndata: [1,2]\n\n" +
|
|
||||||
"event: something_else\ndata: {}\n\n" +
|
|
||||||
": keep-alive\n\n" +
|
|
||||||
"data: {}\n\n" +
|
|
||||||
frame("nestquest_quest_completed", COMPLETED),
|
|
||||||
);
|
|
||||||
expect(events).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("openTransitionStream", () => {
|
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
|
||||||
let streams: TestStream[];
|
|
||||||
let stop: (() => void) | null;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
streams = [];
|
|
||||||
stop = null;
|
|
||||||
fetchMock = vi.fn(() => {
|
|
||||||
const stream = testStream();
|
|
||||||
streams.push(stream);
|
|
||||||
return Promise.resolve(stream.response);
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
stop?.();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the token in the Authorization header, never in the URL", async () => {
|
|
||||||
stop = openTransitionStream({ onTransition: () => {} });
|
|
||||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
||||||
const [url, init] = fetchMock.mock.calls[0];
|
|
||||||
expect(String(url)).toMatch(new RegExp(`${EVENTS_PATH}$`));
|
|
||||||
expect(String(url)).not.toContain("at-123");
|
|
||||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
|
||||||
expect(init.headers.Accept).toBe("text/event-stream");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("delivers transitions from frames split across chunks", async () => {
|
|
||||||
const seen: TransitionEvent[] = [];
|
|
||||||
stop = openTransitionStream({ onTransition: (event) => seen.push(event) });
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
const text = frame("nestquest_quest_completed", COMPLETED);
|
|
||||||
streams[0].push(text.slice(0, 20));
|
|
||||||
streams[0].push(text.slice(20));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(seen).toEqual([{ type: "nestquest_quest_completed", payload: COMPLETED }]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stop aborts the request and cancels the body", async () => {
|
|
||||||
stop = openTransitionStream({ onTransition: () => {} });
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
const signal = fetchMock.mock.calls[0][1].signal as AbortSignal;
|
|
||||||
stop();
|
|
||||||
expect(signal.aborted).toBe(true);
|
|
||||||
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reconnects after the stream drops", async () => {
|
|
||||||
const seen: TransitionEvent[] = [];
|
|
||||||
stop = openTransitionStream({ onTransition: (event) => seen.push(event), minDelayMs: 5 });
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
streams[0].close();
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(2));
|
|
||||||
streams[1].push(frame("nestquest_quest_missed", COMPLETED));
|
|
||||||
await waitFor(() => expect(seen).toHaveLength(1));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not reconnect after stop, even with a reconnect pending", async () => {
|
|
||||||
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 20 });
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
streams[0].close();
|
|
||||||
stop();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives up on a 403 refusal instead of retrying", async () => {
|
|
||||||
fetchMock.mockImplementation(() => Promise.resolve(new Response("", { status: 403 })));
|
|
||||||
stop = openTransitionStream({ onTransition: () => {}, minDelayMs: 5 });
|
|
||||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
/**
|
|
||||||
* Live transition stream: GET /api/v1/admin/events (text/event-stream).
|
|
||||||
*
|
|
||||||
* The admin JWT travels in the Authorization header (via apiFetch), so the
|
|
||||||
* stream is read with fetch + a ReadableStream reader — EventSource cannot
|
|
||||||
* set headers, and the token must never go into a URL.
|
|
||||||
*/
|
|
||||||
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./client";
|
|
||||||
|
|
||||||
export const EVENTS_PATH = "/api/v1/admin/events";
|
|
||||||
|
|
||||||
/** The four documented transitions, as the API names them on the wire. */
|
|
||||||
export const TRANSITION_TYPES = [
|
|
||||||
"nestquest_quest_completed",
|
|
||||||
"nestquest_quest_uncompleted",
|
|
||||||
"nestquest_quest_missed",
|
|
||||||
"nestquest_child_day_complete",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type TransitionType = (typeof TRANSITION_TYPES)[number];
|
|
||||||
|
|
||||||
/** Quest transitions carry the instance; a day-complete carries the child's counts. */
|
|
||||||
export interface QuestTransitionPayload {
|
|
||||||
child_id: number;
|
|
||||||
child_name: string | null;
|
|
||||||
instance_id: number;
|
|
||||||
quest_title: string | null;
|
|
||||||
window: string;
|
|
||||||
due_date: string;
|
|
||||||
due_time: string | null;
|
|
||||||
occurred_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChildDayCompletePayload {
|
|
||||||
child_id: number;
|
|
||||||
child_name: string | null;
|
|
||||||
quests_due: number;
|
|
||||||
quests_completed: number;
|
|
||||||
occurred_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TransitionEvent =
|
|
||||||
| {
|
|
||||||
type:
|
|
||||||
| "nestquest_quest_completed"
|
|
||||||
| "nestquest_quest_uncompleted"
|
|
||||||
| "nestquest_quest_missed";
|
|
||||||
payload: QuestTransitionPayload;
|
|
||||||
}
|
|
||||||
| { type: "nestquest_child_day_complete"; payload: ChildDayCompletePayload };
|
|
||||||
|
|
||||||
function isTransitionType(value: string): value is TransitionType {
|
|
||||||
return (TRANSITION_TYPES as readonly string[]).includes(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Incremental SSE frame parser. Feed it decoded text in whatever chunks the
|
|
||||||
* network delivers; it buffers a partial line or frame until it completes.
|
|
||||||
* Comments (keep-alives), unknown event types and frames whose data is not a
|
|
||||||
* JSON object are dropped, never thrown.
|
|
||||||
*/
|
|
||||||
export class SseParser {
|
|
||||||
private buffer = "";
|
|
||||||
private eventType = "";
|
|
||||||
private data: string[] = [];
|
|
||||||
/** A chunk ended on "\r": a leading "\n" in the next chunk is the same line break. */
|
|
||||||
private pendingCr = false;
|
|
||||||
|
|
||||||
push(text: string): TransitionEvent[] {
|
|
||||||
if (this.pendingCr && text.startsWith("\n")) text = text.slice(1);
|
|
||||||
this.pendingCr = text.endsWith("\r");
|
|
||||||
this.buffer += text;
|
|
||||||
const lines = this.buffer.split(/\r\n|\r|\n/);
|
|
||||||
this.buffer = lines.pop() ?? "";
|
|
||||||
const events: TransitionEvent[] = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
const event = this.line(line);
|
|
||||||
if (event) events.push(event);
|
|
||||||
}
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
private line(line: string): TransitionEvent | null {
|
|
||||||
if (line === "") return this.dispatch();
|
|
||||||
if (line.startsWith(":")) return null;
|
|
||||||
const colon = line.indexOf(":");
|
|
||||||
const field = colon === -1 ? line : line.slice(0, colon);
|
|
||||||
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
||||||
if (value.startsWith(" ")) value = value.slice(1);
|
|
||||||
if (field === "event") this.eventType = value;
|
|
||||||
else if (field === "data") this.data.push(value);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatch(): TransitionEvent | null {
|
|
||||||
const type = this.eventType;
|
|
||||||
const data = this.data.join("\n");
|
|
||||||
this.eventType = "";
|
|
||||||
this.data = [];
|
|
||||||
if (!isTransitionType(type)) return null;
|
|
||||||
let payload: unknown;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(data);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
|
|
||||||
return { type, payload } as TransitionEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransitionStreamOptions {
|
|
||||||
onTransition: (event: TransitionEvent) => void;
|
|
||||||
/** Reconnect delay after a drop: starts at `minDelayMs`, doubles up to `maxDelayMs`. */
|
|
||||||
minDelayMs?: number;
|
|
||||||
maxDelayMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(ms: number, signal: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timer = setTimeout(done, ms);
|
|
||||||
signal.addEventListener("abort", done, { once: true });
|
|
||||||
function done() {
|
|
||||||
clearTimeout(timer);
|
|
||||||
signal.removeEventListener("abort", done);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Read one connection until the body ends (or the signal aborts). */
|
|
||||||
async function readOnce(
|
|
||||||
signal: AbortSignal,
|
|
||||||
onTransition: (event: TransitionEvent) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
const response = await apiFetch(EVENTS_PATH, {
|
|
||||||
signal,
|
|
||||||
headers: { Accept: "text/event-stream" },
|
|
||||||
});
|
|
||||||
if (!response.ok || !response.body) {
|
|
||||||
throw new Error(`Event stream request failed (HTTP ${response.status}).`);
|
|
||||||
}
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const parser = new SseParser();
|
|
||||||
const cancel = () => void reader.cancel().catch(() => {});
|
|
||||||
signal.addEventListener("abort", cancel, { once: true });
|
|
||||||
try {
|
|
||||||
for (;;) {
|
|
||||||
const { value, done } = await reader.read();
|
|
||||||
if (done || signal.aborted) break;
|
|
||||||
for (const event of parser.push(decoder.decode(value, { stream: true }))) {
|
|
||||||
if (signal.aborted) break;
|
|
||||||
onTransition(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
signal.removeEventListener("abort", cancel);
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to the admin transition stream, reconnecting with a bounded
|
|
||||||
* backoff after a drop. Returns a stop function that aborts the open request
|
|
||||||
* and any pending reconnect. A 401/403 stops the stream for good: apiFetch
|
|
||||||
* has already started a login (401) or the account is refused (403).
|
|
||||||
*/
|
|
||||||
export function openTransitionStream({
|
|
||||||
onTransition,
|
|
||||||
minDelayMs = 1_000,
|
|
||||||
maxDelayMs = 30_000,
|
|
||||||
}: TransitionStreamOptions): () => void {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const { signal } = controller;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
let delay = minDelayMs;
|
|
||||||
while (!signal.aborted) {
|
|
||||||
try {
|
|
||||||
await readOnce(signal, onTransition);
|
|
||||||
// A connection that was accepted and then ended starts the backoff over.
|
|
||||||
delay = minDelayMs;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (signal.aborted) return;
|
|
||||||
if (error instanceof ApiUnauthorizedError || error instanceof ApiForbiddenError) return;
|
|
||||||
// Dropped or refused connection: fall through to the backoff.
|
|
||||||
}
|
|
||||||
if (signal.aborted) return;
|
|
||||||
await wait(delay, signal);
|
|
||||||
delay = Math.min(delay * 2, maxDelayMs);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed client for the admin history route (/api/v1/admin/history): the
|
|
||||||
* append-only event log over a closed due-date range for one filter.
|
|
||||||
*/
|
|
||||||
import { apiFetch } from "./client";
|
|
||||||
import { ApiRequestError, readJson } from "./definitions";
|
|
||||||
|
|
||||||
export type HistoryFilter = "all" | "reversals" | "missed";
|
|
||||||
|
|
||||||
export type HistoryEventType = "completed" | "uncompleted" | "missed";
|
|
||||||
|
|
||||||
export interface HistoryRow {
|
|
||||||
/** UTC ISO stamp; null for a derived missed row (there is no event). */
|
|
||||||
occurred_at: string | null;
|
|
||||||
event_type: HistoryEventType;
|
|
||||||
child_name: string;
|
|
||||||
child_id: number;
|
|
||||||
quest_title: string;
|
|
||||||
instance_id: number;
|
|
||||||
window: string;
|
|
||||||
/** "YYYY-MM-DD", the day the task was owed. */
|
|
||||||
due_date: string;
|
|
||||||
/** Local wall-clock "HH:MM", or null. */
|
|
||||||
due_time: string | null;
|
|
||||||
/** e.g. "panel (Declan)", "joshua (admin)", "nightly sweep". */
|
|
||||||
actor: string;
|
|
||||||
was_on_time: boolean | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryResponse {
|
|
||||||
filter: HistoryFilter;
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
count: number;
|
|
||||||
rows: HistoryRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HISTORY_PATH = "/api/v1/admin/history";
|
|
||||||
|
|
||||||
/** `start`/`end` are inclusive "YYYY-MM-DD" due dates. */
|
|
||||||
export async function fetchHistory(
|
|
||||||
filter: HistoryFilter,
|
|
||||||
start: string,
|
|
||||||
end: string,
|
|
||||||
): Promise<HistoryResponse> {
|
|
||||||
const params = new URLSearchParams({ start, end, filter });
|
|
||||||
return readJson<HistoryResponse>(await apiFetch(`${HISTORY_PATH}?${params.toString()}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HISTORY_CSV_PATH = "/api/v1/admin/history.csv";
|
|
||||||
|
|
||||||
export interface HistoryCsv {
|
|
||||||
blob: Blob;
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The `filename` from a Content-Disposition header, or null. */
|
|
||||||
export function csvFilename(disposition: string | null): string | null {
|
|
||||||
const match = disposition?.match(/filename="?([^";]+)"?/i);
|
|
||||||
return match ? match[1].trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The CSV export of the same range and filter as `fetchHistory`. Goes through
|
|
||||||
* apiFetch so the admin token travels in the Authorization header.
|
|
||||||
*/
|
|
||||||
export async function exportHistoryCsv(
|
|
||||||
filter: HistoryFilter,
|
|
||||||
start: string,
|
|
||||||
end: string,
|
|
||||||
): Promise<HistoryCsv> {
|
|
||||||
const params = new URLSearchParams({ start, end, filter });
|
|
||||||
const response = await apiFetch(`${HISTORY_CSV_PATH}?${params.toString()}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
// readJson raises ApiRequestError with the API's `detail` for a non-OK response.
|
|
||||||
await readJson<never>(response);
|
|
||||||
throw new ApiRequestError(response.status, null);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blob: await response.blob(),
|
|
||||||
filename:
|
|
||||||
csvFilename(response.headers.get("Content-Disposition")) ??
|
|
||||||
`nestquest-history-${start}-to-${end}.csv`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed client for the admin instance mutation route
|
|
||||||
* (POST /api/v1/admin/instances/{id}/uncomplete).
|
|
||||||
*/
|
|
||||||
import { apiFetch } from "./client";
|
|
||||||
import { readJson } from "./definitions";
|
|
||||||
|
|
||||||
export interface UncompleteResult {
|
|
||||||
instance_id: number;
|
|
||||||
/** The derived state after the reversal: "open", or "missed" when past due. */
|
|
||||||
state: string;
|
|
||||||
/** False when the instance was already open (a no-op). */
|
|
||||||
appended: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uncompletePath(instanceId: number): string {
|
|
||||||
return `/api/v1/admin/instances/${instanceId}/uncomplete`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reverse one completion; raises ApiRequestError with the API's detail on failure. */
|
|
||||||
export async function uncompleteInstance(instanceId: number): Promise<UncompleteResult> {
|
|
||||||
return readJson<UncompleteResult>(await apiFetch(uncompletePath(instanceId), { method: "POST" }));
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed client for the admin presence routes: a child's repeating schedule
|
|
||||||
* (/api/v1/admin/children/{id}/presence-schedule) and the household's
|
|
||||||
* date-range overrides (/api/v1/admin/presence-overrides).
|
|
||||||
*/
|
|
||||||
import { apiFetch } from "./client";
|
|
||||||
import { CHILDREN_PATH, readJson } from "./definitions";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
|
|
||||||
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
|
|
||||||
*/
|
|
||||||
export type PresencePattern = Record<string, number[]>;
|
|
||||||
|
|
||||||
export interface PresenceScheduleBody {
|
|
||||||
cycle_length_weeks: number;
|
|
||||||
/** "YYYY-MM-DD"; pins cycle week 0. */
|
|
||||||
anchor_date: string;
|
|
||||||
pattern: PresencePattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceSchedule extends PresenceScheduleBody {
|
|
||||||
child_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceOverride {
|
|
||||||
id: number;
|
|
||||||
child_id: number;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string;
|
|
||||||
is_present: boolean;
|
|
||||||
note: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceOverrideFilter {
|
|
||||||
childId?: number;
|
|
||||||
/** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */
|
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
|
|
||||||
|
|
||||||
export function presenceSchedulePath(childId: number): string {
|
|
||||||
return `${CHILDREN_PATH}/${childId}/presence-schedule`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The child's schedule, or null when it has none (present every day). */
|
|
||||||
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> {
|
|
||||||
const body = await readJson<{ schedule: PresenceSchedule | null }>(
|
|
||||||
await apiFetch(presenceSchedulePath(childId)),
|
|
||||||
);
|
|
||||||
return body.schedule;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set (upsert) the child's schedule; resolves to the schedule as stored. */
|
|
||||||
export async function savePresenceSchedule(
|
|
||||||
childId: number,
|
|
||||||
body: PresenceScheduleBody,
|
|
||||||
): Promise<PresenceSchedule> {
|
|
||||||
return readJson<PresenceSchedule>(
|
|
||||||
await apiFetch(presenceSchedulePath(childId), {
|
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPresenceOverrides(
|
|
||||||
filter: PresenceOverrideFilter = {},
|
|
||||||
): Promise<PresenceOverride[]> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (filter.childId !== undefined) params.set("child_id", String(filter.childId));
|
|
||||||
if (filter.start !== undefined) params.set("start", filter.start);
|
|
||||||
if (filter.end !== undefined) params.set("end", filter.end);
|
|
||||||
const query = params.toString();
|
|
||||||
const body = await readJson<{ overrides: PresenceOverride[] }>(
|
|
||||||
await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH),
|
|
||||||
);
|
|
||||||
return body.overrides;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceOverrideRange {
|
|
||||||
child_id: number;
|
|
||||||
/** "YYYY-MM-DD", inclusive. */
|
|
||||||
start_date: string;
|
|
||||||
/** "YYYY-MM-DD", inclusive; never before start_date. */
|
|
||||||
end_date: string;
|
|
||||||
is_present: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceOverrideBody extends PresenceOverrideRange {
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PRESENCE_OVERRIDE_CONSEQUENCE_PATH = `${PRESENCE_OVERRIDES_PATH}/consequence`;
|
|
||||||
|
|
||||||
function postJson(path: string, body: unknown): Promise<Response> {
|
|
||||||
return apiFetch(path, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many open upcoming tasks saving this override would remove. Computed
|
|
||||||
* by the API before anything is saved; completed instances never count.
|
|
||||||
*/
|
|
||||||
export async function previewOverrideConsequence(range: PresenceOverrideRange): Promise<number> {
|
|
||||||
const body = await readJson<{ removed: number }>(
|
|
||||||
await postJson(PRESENCE_OVERRIDE_CONSEQUENCE_PATH, range),
|
|
||||||
);
|
|
||||||
return body.removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPresenceOverride(body: PresenceOverrideBody): Promise<PresenceOverride> {
|
|
||||||
return readJson<PresenceOverride>(await postJson(PRESENCE_OVERRIDES_PATH, body));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePresenceOverride(overrideId: number): Promise<void> {
|
|
||||||
await readJson<{ status: string }>(
|
|
||||||
await apiFetch(`${PRESENCE_OVERRIDES_PATH}/${overrideId}`, { method: "DELETE" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test helper: a controllable text/event-stream Response whose body the test
|
|
||||||
* feeds chunk by chunk (so a frame can be split across chunk boundaries).
|
|
||||||
*/
|
|
||||||
export interface TestStream {
|
|
||||||
response: Response;
|
|
||||||
push(text: string): void;
|
|
||||||
close(): void;
|
|
||||||
/** True once the reader cancelled the body (the stream was torn down). */
|
|
||||||
cancelled(): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function testStream(): TestStream {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
||||||
let cancelled = false;
|
|
||||||
const body = new ReadableStream<Uint8Array>({
|
|
||||||
start(c) {
|
|
||||||
controller = c;
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
cancelled = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
response: new Response(body, {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "text/event-stream" },
|
|
||||||
}),
|
|
||||||
push: (text) => controller.enqueue(encoder.encode(text)),
|
|
||||||
close: () => controller.close(),
|
|
||||||
cancelled: () => cancelled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One SSE frame exactly as api/sse.py writes it. */
|
|
||||||
export function frame(type: string, payload: unknown): string {
|
|
||||||
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* One live transition stream per signed-in shell, fanned out to the screens.
|
|
||||||
*
|
|
||||||
* <TransitionsProvider> opens the stream on mount and aborts it on unmount;
|
|
||||||
* useTransitions(handler) registers a screen's handler for as long as the
|
|
||||||
* screen is mounted. Without a provider the hook does nothing, so a screen
|
|
||||||
* rendered on its own (e.g. in a test) never opens a stream.
|
|
||||||
*/
|
|
||||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
|
||||||
import { openTransitionStream, type TransitionEvent } from "./events";
|
|
||||||
|
|
||||||
type Listener = (event: TransitionEvent) => void;
|
|
||||||
|
|
||||||
interface TransitionHub {
|
|
||||||
subscribe(listener: Listener): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TransitionsContext = createContext<TransitionHub | null>(null);
|
|
||||||
|
|
||||||
export function TransitionsProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [hub] = useState(() => {
|
|
||||||
const listeners = new Set<Listener>();
|
|
||||||
return {
|
|
||||||
listeners,
|
|
||||||
subscribe(listener: Listener) {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(listener);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() =>
|
|
||||||
openTransitionStream({
|
|
||||||
onTransition: (event) => {
|
|
||||||
for (const listener of [...hub.listeners]) {
|
|
||||||
try {
|
|
||||||
listener(event);
|
|
||||||
} catch {
|
|
||||||
// One screen's handler must not tear down the shared stream.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[hub],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <TransitionsContext.Provider value={hub}>{children}</TransitionsContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Call `handler` for every transition while the calling component is mounted. */
|
|
||||||
export function useTransitions(handler: Listener): void {
|
|
||||||
const hub = useContext(TransitionsContext);
|
|
||||||
const handlerRef = useRef(handler);
|
|
||||||
useEffect(() => {
|
|
||||||
handlerRef.current = handler;
|
|
||||||
});
|
|
||||||
useEffect(() => hub?.subscribe((event) => handlerRef.current(event)), [hub]);
|
|
||||||
}
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
|
||||||
Mobile-first at the 390x844 reference; the column is capped and centred wider. */
|
|
||||||
|
|
||||||
.defs-screen {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* §3.1 header */
|
|
||||||
.defs-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border-bottom: 1px solid var(--nq-a-border);
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--nq-a-size-screen);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-sub {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
min-height: 1.4em;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-new {
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
color: #fff;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-new:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
|
||||||
.defs-scroll {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--nq-a-density-gap);
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter chips */
|
|
||||||
.defs-chips {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-chip {
|
|
||||||
flex: none;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-radius-pill);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-chip--active {
|
|
||||||
border-color: #18181d;
|
|
||||||
background: #18181d;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rows */
|
|
||||||
.defs-card {
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-list > li + li {
|
|
||||||
border-top: 1px solid var(--nq-a-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: var(--nq-a-density-row-pad);
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row-glyph {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row-title {
|
|
||||||
font-size: var(--nq-a-size-row);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row-meta {
|
|
||||||
font-size: var(--nq-a-size-meta);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row-chevron {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
color: var(--nq-a-ink-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* An inactive definition renders the whole row tertiary (never hard-deleted). */
|
|
||||||
.defs-row--inactive,
|
|
||||||
.defs-row--inactive .defs-row-glyph,
|
|
||||||
.defs-row--inactive .defs-row-meta {
|
|
||||||
color: var(--nq-a-ink-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row--editing {
|
|
||||||
background: var(--nq-a-selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-row--editing .defs-row-glyph {
|
|
||||||
color: var(--nq-brand-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-empty {
|
|
||||||
padding: var(--nq-a-density-card-pad);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading / error */
|
|
||||||
.defs-status {
|
|
||||||
padding: 24px 13px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-status--error {
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-status--error p {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-retry {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* §3.2 edit sheet */
|
|
||||||
.defs-sheet-layer {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 20;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-scrim {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(9, 9, 11, 0.4);
|
|
||||||
animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-sheet {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
max-height: 92%;
|
|
||||||
margin: 0 auto;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 14px 18px 24px;
|
|
||||||
border-radius: var(--nq-a-radius-sheet);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
box-shadow: var(--nq-a-sheet-shadow);
|
|
||||||
animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes defs-fade {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes defs-rise {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.defs-scrim,
|
|
||||||
.defs-sheet {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-grabber {
|
|
||||||
width: 38px;
|
|
||||||
height: 4px;
|
|
||||||
margin: 0 auto 12px;
|
|
||||||
border-radius: var(--nq-a-radius-pill);
|
|
||||||
background: var(--nq-a-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-sheet-title {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-label {
|
|
||||||
font-size: var(--nq-a-size-label);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: var(--nq-a-track-label);
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: 0 11px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-select-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: 0 11px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-picker {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-picker > li + li {
|
|
||||||
border-top: 1px solid var(--nq-a-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-picker-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: 0 11px;
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-picker-check {
|
|
||||||
display: flex;
|
|
||||||
color: var(--nq-brand-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-picker-option--locked {
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-picker-note {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-repeats {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-interval {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-interval .defs-input {
|
|
||||||
width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-weekdays {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-day,
|
|
||||||
.defs-segment {
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-day--on,
|
|
||||||
.defs-segment--on {
|
|
||||||
border-color: transparent;
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-segmented {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-icon-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(var(--nq-a-tap-min), 1fr));
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-icon--on {
|
|
||||||
border-color: transparent;
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-due-times {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-due {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 84px 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-hint {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-toggle-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-toggle-title {
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-toggle-sub {
|
|
||||||
font-size: var(--nq-a-size-meta);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-switch {
|
|
||||||
flex: none;
|
|
||||||
position: relative;
|
|
||||||
width: 44px;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--nq-a-radius-pill);
|
|
||||||
background: var(--nq-a-border);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background var(--nq-dur-base) var(--nq-ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-switch--on {
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-switch-knob {
|
|
||||||
position: absolute;
|
|
||||||
top: 3px;
|
|
||||||
left: 3px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
|
|
||||||
transition: transform var(--nq-dur-base) var(--nq-ease-out);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-switch--on .defs-switch-knob {
|
|
||||||
transform: translateX(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-sheet-error {
|
|
||||||
margin: 12px 0 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-danger-bg);
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-sheet-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-confirm {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-danger-bg);
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-confirm p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-confirm .defs-sheet-footer {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-cancel,
|
|
||||||
.defs-save {
|
|
||||||
padding: 13px 0;
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-cancel {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-save {
|
|
||||||
flex: 2;
|
|
||||||
border: 0;
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-cancel:disabled,
|
|
||||||
.defs-save:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-preview {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.defs-preview--error {
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,968 +0,0 @@
|
|||||||
/**
|
|
||||||
* Definitions ("Tasks") tab — list + edit sheet with icon picker
|
|
||||||
* (design/ADMIN-SPEC.md §3.1–3.3).
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { ApiForbiddenError } from "../api/client";
|
|
||||||
import {
|
|
||||||
ApiRequestError,
|
|
||||||
createDefinition,
|
|
||||||
fetchChildren,
|
|
||||||
fetchDefinitions,
|
|
||||||
previewOccurrences,
|
|
||||||
updateDefinition,
|
|
||||||
type AdminChild,
|
|
||||||
type DefinitionCreateBody,
|
|
||||||
type DefinitionEditBody,
|
|
||||||
type DefinitionRule,
|
|
||||||
type QuestDefinition,
|
|
||||||
type WindowEntry,
|
|
||||||
type WindowName,
|
|
||||||
} from "../api/definitions";
|
|
||||||
import {
|
|
||||||
MONTH_SHORT,
|
|
||||||
WEEKDAY_SHORT,
|
|
||||||
WINDOW_LABELS,
|
|
||||||
WINDOW_ORDER,
|
|
||||||
assigneeSummary,
|
|
||||||
occurrenceLabel,
|
|
||||||
recurrenceSummary,
|
|
||||||
windowSummary,
|
|
||||||
} from "./format";
|
|
||||||
import {
|
|
||||||
CheckGlyph,
|
|
||||||
ChevronDownGlyph,
|
|
||||||
ChevronRightGlyph,
|
|
||||||
PlusGlyph,
|
|
||||||
TaskGlyph,
|
|
||||||
} from "./glyphs";
|
|
||||||
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
|
|
||||||
import "./DefinitionsTab.css";
|
|
||||||
|
|
||||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
|
||||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
|
||||||
|
|
||||||
type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
|
||||||
|
|
||||||
const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [
|
|
||||||
{ value: "daily", label: "Daily" },
|
|
||||||
{ value: "weekly", label: "Weekly" },
|
|
||||||
{ value: "monthly", label: "Monthly" },
|
|
||||||
{ value: "yearly", label: "Yearly" },
|
|
||||||
{ value: "custom", label: "Custom" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
|
||||||
daily: "daily",
|
|
||||||
weekly: "weekly",
|
|
||||||
monthly_day: "monthly",
|
|
||||||
monthly_weekday: "monthly",
|
|
||||||
yearly: "yearly",
|
|
||||||
custom_days: "custom",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** How many upcoming dates the edit sheet previews. */
|
|
||||||
const PREVIEW_COUNT = 5;
|
|
||||||
|
|
||||||
/** Quiet period after the last rule edit before the preview is requested. */
|
|
||||||
const PREVIEW_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
||||||
|
|
||||||
interface Draft {
|
|
||||||
title: string;
|
|
||||||
/** A Lucide name; a legacy value outside the subset is kept until replaced. */
|
|
||||||
icon: string | null;
|
|
||||||
assigneeIds: number[];
|
|
||||||
repeats: Repeats;
|
|
||||||
interval: string;
|
|
||||||
weekdays: number[];
|
|
||||||
dayOfMonth: string;
|
|
||||||
month: string;
|
|
||||||
/** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */
|
|
||||||
monthlyWeekday: { nth: number; weekday: number } | null;
|
|
||||||
windows: WindowName[];
|
|
||||||
dueTimes: Record<WindowName, string>;
|
|
||||||
skipOnAway: boolean;
|
|
||||||
startDate: string;
|
|
||||||
endDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoadState =
|
|
||||||
| { kind: "loading" }
|
|
||||||
| { kind: "error"; message: string }
|
|
||||||
| { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] };
|
|
||||||
|
|
||||||
/** `null` = closed, `"new"` = creating, a definition = editing it. */
|
|
||||||
type SheetTarget = null | "new" | QuestDefinition;
|
|
||||||
|
|
||||||
function pad2(n: number): string {
|
|
||||||
return String(n).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
function localIsoDate(date: Date): string {
|
|
||||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyDueTimes(): Record<WindowName, string> {
|
|
||||||
return { morning: "", afternoon: "", evening: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function newDraft(activeChildren: AdminChild[]): Draft {
|
|
||||||
const today = new Date();
|
|
||||||
return {
|
|
||||||
title: "",
|
|
||||||
icon: null,
|
|
||||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
|
||||||
repeats: "daily",
|
|
||||||
interval: "1",
|
|
||||||
weekdays: [(today.getDay() + 6) % 7],
|
|
||||||
dayOfMonth: String(today.getDate()),
|
|
||||||
month: String(today.getMonth() + 1),
|
|
||||||
monthlyWeekday: null,
|
|
||||||
windows: [],
|
|
||||||
dueTimes: emptyDueTimes(),
|
|
||||||
// Mirrors the API's create default: no instance on an away day.
|
|
||||||
skipOnAway: true,
|
|
||||||
startDate: localIsoDate(today),
|
|
||||||
endDate: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
|
||||||
const { rule } = definition;
|
|
||||||
const today = new Date();
|
|
||||||
const active = new Set(activeChildren.map((child) => child.id));
|
|
||||||
const dueTimes = emptyDueTimes();
|
|
||||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
|
||||||
return {
|
|
||||||
title: definition.title,
|
|
||||||
icon: definition.icon,
|
|
||||||
// Inactive children cannot be submitted (the API rejects them with 422).
|
|
||||||
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
|
||||||
repeats: REPEATS_BY_RULE[rule.rule_type],
|
|
||||||
interval: String(rule.interval),
|
|
||||||
weekdays:
|
|
||||||
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
|
|
||||||
? [...(rule.weekday_set ?? [])]
|
|
||||||
: [(today.getDay() + 6) % 7],
|
|
||||||
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
|
|
||||||
month: String(rule.month ?? today.getMonth() + 1),
|
|
||||||
monthlyWeekday:
|
|
||||||
rule.rule_type === "monthly_weekday"
|
|
||||||
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
|
|
||||||
: null,
|
|
||||||
windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)),
|
|
||||||
dueTimes,
|
|
||||||
skipOnAway: definition.skip_on_away,
|
|
||||||
startDate: rule.start_date,
|
|
||||||
endDate: rule.end_date,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseWhole(value: string, min: number, max: number): number | null {
|
|
||||||
if (!/^\d+$/.test(value.trim())) return null;
|
|
||||||
const n = Number(value);
|
|
||||||
return n >= min && n <= max ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the request rule from the draft, or return a message naming what to fix. */
|
|
||||||
function buildRule(draft: Draft): DefinitionRule | string {
|
|
||||||
const interval = parseWhole(draft.interval, 1, 999);
|
|
||||||
if (interval === null) return "Interval must be a whole number of at least 1.";
|
|
||||||
const rule: DefinitionRule = {
|
|
||||||
rule_type: "daily",
|
|
||||||
interval,
|
|
||||||
weekday_set: null,
|
|
||||||
day_of_month: null,
|
|
||||||
nth_weekday: null,
|
|
||||||
nth_weekday_weekday: null,
|
|
||||||
month: null,
|
|
||||||
start_date: draft.startDate,
|
|
||||||
end_date: draft.endDate,
|
|
||||||
};
|
|
||||||
switch (draft.repeats) {
|
|
||||||
case "daily":
|
|
||||||
return rule;
|
|
||||||
case "weekly":
|
|
||||||
case "custom":
|
|
||||||
if (draft.weekdays.length === 0) return "Pick at least one day.";
|
|
||||||
return {
|
|
||||||
...rule,
|
|
||||||
rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days",
|
|
||||||
weekday_set: [...draft.weekdays].sort((a, b) => a - b),
|
|
||||||
};
|
|
||||||
case "monthly": {
|
|
||||||
if (draft.monthlyWeekday) {
|
|
||||||
return {
|
|
||||||
...rule,
|
|
||||||
rule_type: "monthly_weekday",
|
|
||||||
nth_weekday: draft.monthlyWeekday.nth,
|
|
||||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
|
||||||
if (day === null) return "Day of month must be 1 to 31.";
|
|
||||||
return { ...rule, rule_type: "monthly_day", day_of_month: day };
|
|
||||||
}
|
|
||||||
case "yearly": {
|
|
||||||
const month = parseWhole(draft.month, 1, 12);
|
|
||||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
|
||||||
if (month === null || day === null) return "Pick a month and a day (1 to 31).";
|
|
||||||
return { ...rule, rule_type: "yearly", month, day_of_month: day };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `keepsAssignees`: an edit whose active selection is unchanged sends no
|
|
||||||
* assignee list, so an empty selection is fine when inactive children remain.
|
|
||||||
*/
|
|
||||||
function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string {
|
|
||||||
const title = draft.title.trim();
|
|
||||||
if (!title) return "Give the task a title.";
|
|
||||||
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
|
|
||||||
if (draft.windows.length === 0) return "Pick at least one window.";
|
|
||||||
const rule = buildRule(draft);
|
|
||||||
if (typeof rule === "string") return rule;
|
|
||||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
|
||||||
(w) => [w, draft.dueTimes[w] || null],
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
title,
|
|
||||||
icon: draft.icon,
|
|
||||||
rule,
|
|
||||||
assignee_child_ids: [...draft.assigneeIds],
|
|
||||||
windows,
|
|
||||||
skip_on_away: draft.skipOnAway,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveErrorMessage(error: unknown): string {
|
|
||||||
if (error instanceof ApiForbiddenError) return error.message;
|
|
||||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
|
||||||
return "The task could not be saved. Check your connection and try again.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle<T>(list: T[], value: T): T[] {
|
|
||||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameIds(a: number[], b: number[]): boolean {
|
|
||||||
return a.length === b.length && a.every((id) => b.includes(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const FOCUSABLE =
|
|
||||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
|
||||||
|
|
||||||
/* ── List ───────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function FilterChips({
|
|
||||||
options,
|
|
||||||
filter,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
options: AdminChild[];
|
|
||||||
filter: number | "all";
|
|
||||||
onChange: (filter: number | "all") => void;
|
|
||||||
}) {
|
|
||||||
const chips: { key: number | "all"; label: string }[] = [
|
|
||||||
{ key: "all", label: "All" },
|
|
||||||
...options.map((child) => ({ key: child.id, label: child.display_name })),
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className="defs-chips" role="group" aria-label="Filter by child">
|
|
||||||
{chips.map((chip) => (
|
|
||||||
<button
|
|
||||||
key={chip.key}
|
|
||||||
type="button"
|
|
||||||
className={chip.key === filter ? "defs-chip defs-chip--active" : "defs-chip"}
|
|
||||||
aria-pressed={chip.key === filter}
|
|
||||||
onClick={() => onChange(chip.key)}
|
|
||||||
>
|
|
||||||
{chip.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DefinitionRow({
|
|
||||||
definition,
|
|
||||||
activeChildren,
|
|
||||||
editing,
|
|
||||||
onOpen,
|
|
||||||
}: {
|
|
||||||
definition: QuestDefinition;
|
|
||||||
activeChildren: AdminChild[];
|
|
||||||
editing: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
}) {
|
|
||||||
let meta = [
|
|
||||||
assigneeSummary(definition.assignees, activeChildren),
|
|
||||||
recurrenceSummary(definition.rule),
|
|
||||||
windowSummary(definition.windows),
|
|
||||||
].join(" · ");
|
|
||||||
if (!definition.is_active) meta += " · inactive";
|
|
||||||
let className = "defs-row";
|
|
||||||
if (!definition.is_active) className += " defs-row--inactive";
|
|
||||||
if (editing) className += " defs-row--editing";
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={className}
|
|
||||||
data-testid={`definition-${definition.id}`}
|
|
||||||
aria-expanded={editing}
|
|
||||||
onClick={onOpen}
|
|
||||||
>
|
|
||||||
<span className="defs-row-glyph">
|
|
||||||
<DefinitionIcon name={definition.icon} />
|
|
||||||
</span>
|
|
||||||
<span className="defs-row-text">
|
|
||||||
<span className="defs-row-title">{definition.title}</span>
|
|
||||||
<span className="defs-row-meta">{editing ? "Editing" : meta}</span>
|
|
||||||
</span>
|
|
||||||
<span className="defs-row-chevron">
|
|
||||||
{editing ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Edit sheet ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
type PreviewState =
|
|
||||||
| { kind: "idle" }
|
|
||||||
| { kind: "ready"; ruleKey: string; dates: string[] }
|
|
||||||
| { kind: "error"; ruleKey: string; message: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The next few dates the backend would materialize for the draft's rule.
|
|
||||||
* `ruleKey` is the built rule serialized (null while the draft is invalid), so
|
|
||||||
* the request only goes out when the rule itself changes — debounced, and a
|
|
||||||
* superseded request's late response is dropped by its effect's cleanup.
|
|
||||||
* A result is tagged with the rule it answers and only shown while that rule
|
|
||||||
* is still the draft's, so a changed rule never shows the previous dates.
|
|
||||||
*/
|
|
||||||
function OccurrencePreview({ ruleKey }: { ruleKey: string | null }) {
|
|
||||||
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ruleKey === null) {
|
|
||||||
setPreview({ kind: "idle" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
previewOccurrences(JSON.parse(ruleKey) as DefinitionRule, { count: PREVIEW_COUNT })
|
|
||||||
.then((dates) => {
|
|
||||||
if (!cancelled) setPreview({ kind: "ready", ruleKey, dates });
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const detail =
|
|
||||||
error instanceof ApiForbiddenError ||
|
|
||||||
(error instanceof ApiRequestError && error.detail)
|
|
||||||
? `: ${error.message}`
|
|
||||||
: ".";
|
|
||||||
setPreview({
|
|
||||||
kind: "error",
|
|
||||||
ruleKey,
|
|
||||||
message: `Upcoming dates unavailable${detail}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, PREVIEW_DEBOUNCE_MS);
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [ruleKey]);
|
|
||||||
|
|
||||||
if (preview.kind === "idle" || preview.ruleKey !== ruleKey) return null;
|
|
||||||
if (preview.kind === "error") {
|
|
||||||
return (
|
|
||||||
<p className="defs-hint defs-preview defs-preview--error" data-testid="occurrence-preview">
|
|
||||||
{preview.message}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let text: string;
|
|
||||||
if (preview.dates.length === 0) {
|
|
||||||
text = "No upcoming dates in the next year.";
|
|
||||||
} else {
|
|
||||||
const labels = preview.dates.map(occurrenceLabel);
|
|
||||||
// A full page means the rule keeps going past the last shown date.
|
|
||||||
if (preview.dates.length >= PREVIEW_COUNT) labels.push("…");
|
|
||||||
text = `Next: ${labels.join(" · ")}`;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<p className="defs-hint defs-preview" data-testid="occurrence-preview" aria-live="polite">
|
|
||||||
{text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="defs-field" role="group" aria-labelledby={id}>
|
|
||||||
<span className="defs-label" id={id}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditSheet({
|
|
||||||
target,
|
|
||||||
activeChildren,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
target: "new" | QuestDefinition;
|
|
||||||
activeChildren: AdminChild[];
|
|
||||||
onCancel: () => void;
|
|
||||||
onSaved: () => void;
|
|
||||||
}) {
|
|
||||||
const [draft, setDraft] = useState<Draft>(() =>
|
|
||||||
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
|
||||||
);
|
|
||||||
// The active selection as loaded: while it is unchanged, the PATCH omits
|
|
||||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
|
||||||
// children cannot be sent back.
|
|
||||||
const [originalActiveIds] = useState(() => draft.assigneeIds);
|
|
||||||
const inactiveAssignees =
|
|
||||||
target === "new"
|
|
||||||
? []
|
|
||||||
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
// A ref, not state: two taps in one frame must not both see `saving === false`.
|
|
||||||
const inFlight = useRef(false);
|
|
||||||
const sheetRef = useRef<HTMLDivElement>(null);
|
|
||||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
|
||||||
const confirmRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Modal focus: move in on open, hand back to the trigger on close.
|
|
||||||
useEffect(() => {
|
|
||||||
const trigger = document.activeElement as HTMLElement | null;
|
|
||||||
titleRef.current?.focus();
|
|
||||||
return () => {
|
|
||||||
if (trigger && trigger.isConnected) trigger.focus();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (confirming) confirmRef.current?.focus();
|
|
||||||
}, [confirming]);
|
|
||||||
|
|
||||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!inFlight.current) onCancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "Tab" || !sheetRef.current) return;
|
|
||||||
const focusable = Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
|
||||||
if (focusable.length === 0) return;
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
const current = document.activeElement;
|
|
||||||
const outside = !focusable.includes(current as HTMLElement);
|
|
||||||
if (event.shiftKey && (current === first || outside)) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && (current === last || outside)) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
|
||||||
|
|
||||||
const selectedAssignees = activeChildren
|
|
||||||
.filter((child) => draft.assigneeIds.includes(child.id))
|
|
||||||
.map((child) => ({ id: child.id, display_name: child.display_name }));
|
|
||||||
const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w));
|
|
||||||
const previewRule = buildRule(draft);
|
|
||||||
const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule);
|
|
||||||
|
|
||||||
async function save(confirmed = false) {
|
|
||||||
if (inFlight.current) return;
|
|
||||||
const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds);
|
|
||||||
const body = buildBody(draft, keepsAssignees);
|
|
||||||
if (typeof body === "string") {
|
|
||||||
setError(body);
|
|
||||||
setConfirming(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) {
|
|
||||||
setError(null);
|
|
||||||
setConfirming(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfirming(false);
|
|
||||||
inFlight.current = true;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
if (target === "new") {
|
|
||||||
await createDefinition(body);
|
|
||||||
} else {
|
|
||||||
const edit: DefinitionEditBody = { ...body };
|
|
||||||
if (keepsAssignees) delete edit.assignee_child_ids;
|
|
||||||
await updateDefinition(target.id, edit);
|
|
||||||
}
|
|
||||||
onSaved();
|
|
||||||
} catch (err) {
|
|
||||||
setError(saveErrorMessage(err));
|
|
||||||
inFlight.current = false;
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="defs-sheet-layer">
|
|
||||||
<div
|
|
||||||
className="defs-scrim"
|
|
||||||
data-testid="sheet-scrim"
|
|
||||||
onClick={() => {
|
|
||||||
if (!inFlight.current) onCancel();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
ref={sheetRef}
|
|
||||||
className="defs-sheet"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="defs-sheet-title"
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
>
|
|
||||||
<div className="defs-grabber" aria-hidden="true" />
|
|
||||||
<h3 className="defs-sheet-title" id="defs-sheet-title" ref={titleRef} tabIndex={-1}>
|
|
||||||
{target === "new" ? "New task" : "Edit task"}
|
|
||||||
</h3>
|
|
||||||
<div className="defs-fields">
|
|
||||||
<Field label="Title" id="defs-label-title">
|
|
||||||
<input
|
|
||||||
className="defs-input"
|
|
||||||
aria-labelledby="defs-label-title"
|
|
||||||
value={draft.title}
|
|
||||||
onChange={(e) => update({ title: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Icon" id="defs-label-icon">
|
|
||||||
<div className="defs-icon-grid" role="radiogroup" aria-labelledby="defs-label-icon">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={draft.icon === null}
|
|
||||||
aria-label="No icon"
|
|
||||||
className={draft.icon === null ? "defs-icon defs-icon--on" : "defs-icon"}
|
|
||||||
onClick={() => update({ icon: null })}
|
|
||||||
>
|
|
||||||
<TaskGlyph size={18} />
|
|
||||||
</button>
|
|
||||||
{DEFINITION_ICONS.map(({ name, label, Icon }) => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={draft.icon === name}
|
|
||||||
aria-label={label}
|
|
||||||
data-icon-option={name}
|
|
||||||
className={draft.icon === name ? "defs-icon defs-icon--on" : "defs-icon"}
|
|
||||||
onClick={() => update({ icon: name })}
|
|
||||||
>
|
|
||||||
<Icon size={18} aria-hidden="true" focusable="false" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Assigned children" id="defs-label-children">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="defs-select-row"
|
|
||||||
aria-expanded={pickerOpen}
|
|
||||||
aria-labelledby="defs-label-children defs-children-summary"
|
|
||||||
onClick={() => setPickerOpen((open) => !open)}
|
|
||||||
>
|
|
||||||
<span id="defs-children-summary">
|
|
||||||
{selectedAssignees.length > 0
|
|
||||||
? assigneeSummary(selectedAssignees, activeChildren)
|
|
||||||
: "Choose children"}
|
|
||||||
</span>
|
|
||||||
{pickerOpen ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
|
||||||
</button>
|
|
||||||
{pickerOpen ? (
|
|
||||||
<ul className="defs-picker" aria-label="Children">
|
|
||||||
{activeChildren.map((child) => {
|
|
||||||
const checked = draft.assigneeIds.includes(child.id);
|
|
||||||
return (
|
|
||||||
<li key={child.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
className="defs-picker-option"
|
|
||||||
onClick={() => update({ assigneeIds: toggle(draft.assigneeIds, child.id) })}
|
|
||||||
>
|
|
||||||
<span>{child.display_name}</span>
|
|
||||||
<span className="defs-picker-check">{checked ? <CheckGlyph /> : null}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{inactiveAssignees.map((assignee) => (
|
|
||||||
<li
|
|
||||||
key={assignee.id}
|
|
||||||
className="defs-picker-option defs-picker-option--locked"
|
|
||||||
aria-disabled="true"
|
|
||||||
data-testid={`inactive-assignee-${assignee.id}`}
|
|
||||||
>
|
|
||||||
<span>{assignee.display_name}</span>
|
|
||||||
<span className="defs-picker-note">Inactive</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
{inactiveAssignees.length > 0 ? (
|
|
||||||
<p className="defs-hint">
|
|
||||||
Also assigned (inactive): {inactiveAssignees.map((a) => a.display_name).join(", ")}.
|
|
||||||
Kept unless you change the selection.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Repeats" id="defs-label-repeats">
|
|
||||||
<div className="defs-repeats">
|
|
||||||
<select
|
|
||||||
className="defs-input"
|
|
||||||
aria-label="Repeats"
|
|
||||||
value={draft.repeats}
|
|
||||||
onChange={(e) =>
|
|
||||||
update({
|
|
||||||
repeats: e.target.value as Repeats,
|
|
||||||
// Leaving Monthly drops a carried nth-weekday rule for good.
|
|
||||||
monthlyWeekday: e.target.value === "monthly" ? draft.monthlyWeekday : null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{REPEATS_OPTIONS.map((option) => (
|
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label className="defs-interval">
|
|
||||||
<span>Every</span>
|
|
||||||
<input
|
|
||||||
className="defs-input"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
aria-label="Interval"
|
|
||||||
value={draft.interval}
|
|
||||||
onChange={(e) => update({ interval: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
|
|
||||||
<div className="defs-weekdays" role="group" aria-label="Days">
|
|
||||||
{WEEKDAY_SHORT.map((label, day) => (
|
|
||||||
<button
|
|
||||||
key={label}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={draft.weekdays.includes(day)}
|
|
||||||
aria-label={WEEKDAY_LONG[day]}
|
|
||||||
className={
|
|
||||||
draft.weekdays.includes(day) ? "defs-day defs-day--on" : "defs-day"
|
|
||||||
}
|
|
||||||
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
|
|
||||||
>
|
|
||||||
{label.charAt(0)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{draft.repeats === "monthly" && draft.monthlyWeekday ? (
|
|
||||||
<p className="defs-hint">
|
|
||||||
{recurrenceSummary({
|
|
||||||
rule_type: "monthly_weekday",
|
|
||||||
interval: parseWhole(draft.interval, 1, 999) ?? 1,
|
|
||||||
weekday_set: null,
|
|
||||||
day_of_month: null,
|
|
||||||
nth_weekday: draft.monthlyWeekday.nth,
|
|
||||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
|
||||||
month: null,
|
|
||||||
start_date: draft.startDate,
|
|
||||||
end_date: draft.endDate,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{draft.repeats === "yearly" ? (
|
|
||||||
<select
|
|
||||||
className="defs-input"
|
|
||||||
aria-label="Month"
|
|
||||||
value={draft.month}
|
|
||||||
onChange={(e) => update({ month: e.target.value })}
|
|
||||||
>
|
|
||||||
{MONTH_SHORT.map((label, index) => (
|
|
||||||
<option key={label} value={String(index + 1)}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : null}
|
|
||||||
{(draft.repeats === "monthly" && !draft.monthlyWeekday) || draft.repeats === "yearly" ? (
|
|
||||||
<input
|
|
||||||
className="defs-input"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={31}
|
|
||||||
aria-label="Day of month"
|
|
||||||
value={draft.dayOfMonth}
|
|
||||||
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<OccurrencePreview ruleKey={previewKey} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Due time" id="defs-label-due">
|
|
||||||
{orderedWindows.length === 0 ? (
|
|
||||||
<p className="defs-hint">Pick a window to set its due time.</p>
|
|
||||||
) : (
|
|
||||||
<div className="defs-due-times">
|
|
||||||
{orderedWindows.map((w) => (
|
|
||||||
<label key={w} className="defs-due">
|
|
||||||
<span>{WINDOW_LABELS[w]}</span>
|
|
||||||
<input
|
|
||||||
className="defs-input"
|
|
||||||
type="time"
|
|
||||||
aria-label={`${WINDOW_LABELS[w]} due time`}
|
|
||||||
value={draft.dueTimes[w]}
|
|
||||||
onChange={(e) =>
|
|
||||||
update({ dueTimes: { ...draft.dueTimes, [w]: e.target.value } })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Window" id="defs-label-window">
|
|
||||||
<div className="defs-segmented">
|
|
||||||
{WINDOW_ORDER.map((w) => (
|
|
||||||
<button
|
|
||||||
key={w}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={draft.windows.includes(w)}
|
|
||||||
className={
|
|
||||||
draft.windows.includes(w) ? "defs-segment defs-segment--on" : "defs-segment"
|
|
||||||
}
|
|
||||||
onClick={() => update({ windows: toggle(draft.windows, w) })}
|
|
||||||
>
|
|
||||||
{WINDOW_LABELS[w]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="defs-toggle-row">
|
|
||||||
<div>
|
|
||||||
<div className="defs-toggle-title" id="defs-label-skip">
|
|
||||||
Skip on away days
|
|
||||||
</div>
|
|
||||||
<div className="defs-toggle-sub">No instance when the child is absent</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={draft.skipOnAway}
|
|
||||||
aria-labelledby="defs-label-skip"
|
|
||||||
className={draft.skipOnAway ? "defs-switch defs-switch--on" : "defs-switch"}
|
|
||||||
onClick={() => update({ skipOnAway: !draft.skipOnAway })}
|
|
||||||
>
|
|
||||||
<span className="defs-switch-knob" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="defs-sheet-error" role="alert" data-testid="sheet-error">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{confirming ? (
|
|
||||||
<div
|
|
||||||
className="defs-confirm"
|
|
||||||
role="group"
|
|
||||||
aria-labelledby="defs-confirm-text"
|
|
||||||
data-testid="unassign-confirm"
|
|
||||||
ref={confirmRef}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<p id="defs-confirm-text">
|
|
||||||
Saving will unassign{" "}
|
|
||||||
{inactiveAssignees.map((a) => a.display_name).join(", ")}. Inactive children can't be
|
|
||||||
assigned again until they are reactivated.
|
|
||||||
</p>
|
|
||||||
<div className="defs-sheet-footer">
|
|
||||||
<button type="button" className="defs-cancel" onClick={() => setConfirming(false)}>
|
|
||||||
Go back
|
|
||||||
</button>
|
|
||||||
<button type="button" className="defs-save" onClick={() => save(true)}>
|
|
||||||
Unassign and save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="defs-sheet-footer">
|
|
||||||
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
|
|
||||||
{saving ? "Saving…" : "Save changes"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Screen ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export default function DefinitionsTab() {
|
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
const [filter, setFilter] = useState<number | "all">("all");
|
|
||||||
const [sheet, setSheet] = useState<SheetTarget>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
// Keep the list on screen during a post-save re-fetch; spin only on first load.
|
|
||||||
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
|
|
||||||
Promise.all([fetchDefinitions(), fetchChildren()])
|
|
||||||
.then(([definitions, children]) => {
|
|
||||||
if (!cancelled) setState({ kind: "ready", definitions, children });
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const message =
|
|
||||||
error instanceof ApiForbiddenError
|
|
||||||
? error.message
|
|
||||||
: "Tasks could not be loaded. Check your connection and try again.";
|
|
||||||
setState({ kind: "error", message });
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [attempt]);
|
|
||||||
|
|
||||||
const activeChildren =
|
|
||||||
state.kind === "ready"
|
|
||||||
? [...state.children]
|
|
||||||
.filter((child) => child.is_active)
|
|
||||||
.sort((a, b) => a.sort_order - b.sort_order)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
let sub = "";
|
|
||||||
let body;
|
|
||||||
if (state.kind === "loading") {
|
|
||||||
body = (
|
|
||||||
<div className="defs-status" role="status" data-testid="definitions-loading">
|
|
||||||
Loading tasks…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (state.kind === "error") {
|
|
||||||
body = (
|
|
||||||
<div className="defs-status defs-status--error" role="alert" data-testid="definitions-error">
|
|
||||||
<p>{state.message}</p>
|
|
||||||
<button type="button" className="defs-retry" onClick={() => setAttempt((n) => n + 1)}>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const { definitions } = state;
|
|
||||||
sub = `${definitions.length} definitions · ${activeChildren.length} children`;
|
|
||||||
const shown =
|
|
||||||
filter === "all"
|
|
||||||
? definitions
|
|
||||||
: definitions.filter((d) => d.assignees.some((a) => a.id === filter));
|
|
||||||
const editingId = sheet !== null && sheet !== "new" ? sheet.id : null;
|
|
||||||
body = (
|
|
||||||
<>
|
|
||||||
<FilterChips options={activeChildren} filter={filter} onChange={setFilter} />
|
|
||||||
{shown.length > 0 ? (
|
|
||||||
<ul className="defs-card defs-list" aria-label="Task definitions">
|
|
||||||
{shown.map((definition) => (
|
|
||||||
<DefinitionRow
|
|
||||||
key={definition.id}
|
|
||||||
definition={definition}
|
|
||||||
activeChildren={activeChildren}
|
|
||||||
editing={definition.id === editingId}
|
|
||||||
onOpen={() => setSheet(definition)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<div className="defs-card defs-empty">No tasks yet.</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="defs-screen">
|
|
||||||
<header className="defs-header">
|
|
||||||
<div>
|
|
||||||
<h2 className="defs-title">Tasks</h2>
|
|
||||||
<p className="defs-sub">{sub}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="defs-new"
|
|
||||||
disabled={state.kind !== "ready"}
|
|
||||||
onClick={() => setSheet("new")}
|
|
||||||
>
|
|
||||||
<PlusGlyph />
|
|
||||||
New
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div
|
|
||||||
className="defs-scroll"
|
|
||||||
data-testid="definitions-scroll"
|
|
||||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</div>
|
|
||||||
{sheet !== null && state.kind === "ready" ? (
|
|
||||||
<EditSheet
|
|
||||||
key={sheet === "new" ? "new" : sheet.id}
|
|
||||||
target={sheet}
|
|
||||||
activeChildren={activeChildren}
|
|
||||||
onCancel={() => setSheet(null)}
|
|
||||||
onSaved={() => {
|
|
||||||
setSheet(null);
|
|
||||||
setAttempt((n) => n + 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* The shipped Lucide subset a definition's `icon` may name (ADMIN-SPEC §3.3).
|
|
||||||
* Only these named imports are bundled — no CDN, no runtime fetch. The stored
|
|
||||||
* value is the Lucide kebab-case name, so it stays meaningful to any surface.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
Apple,
|
|
||||||
Backpack,
|
|
||||||
Bath,
|
|
||||||
Bed,
|
|
||||||
Bike,
|
|
||||||
BookOpen,
|
|
||||||
Brush,
|
|
||||||
BrushCleaning,
|
|
||||||
Calendar,
|
|
||||||
Cat,
|
|
||||||
Clock,
|
|
||||||
CookingPot,
|
|
||||||
Dog,
|
|
||||||
Droplets,
|
|
||||||
Fish,
|
|
||||||
Gamepad2,
|
|
||||||
Heart,
|
|
||||||
House,
|
|
||||||
ListChecks,
|
|
||||||
Moon,
|
|
||||||
Music,
|
|
||||||
Pencil,
|
|
||||||
Recycle,
|
|
||||||
Shirt,
|
|
||||||
ShowerHead,
|
|
||||||
Smile,
|
|
||||||
Sofa,
|
|
||||||
SprayCan,
|
|
||||||
Sprout,
|
|
||||||
Star,
|
|
||||||
Sun,
|
|
||||||
Toilet,
|
|
||||||
Trash2,
|
|
||||||
Utensils,
|
|
||||||
Volleyball,
|
|
||||||
WashingMachine,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { TaskGlyph } from "./glyphs";
|
|
||||||
|
|
||||||
export interface DefinitionIconEntry {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
Icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFINITION_ICONS: readonly DefinitionIconEntry[] = [
|
|
||||||
{ name: "bed", label: "Bed", Icon: Bed },
|
|
||||||
{ name: "bath", label: "Bath", Icon: Bath },
|
|
||||||
{ name: "shower-head", label: "Shower", Icon: ShowerHead },
|
|
||||||
{ name: "toilet", label: "Toilet", Icon: Toilet },
|
|
||||||
{ name: "brush", label: "Brush", Icon: Brush },
|
|
||||||
{ name: "smile", label: "Smile", Icon: Smile },
|
|
||||||
{ name: "shirt", label: "Clothes", Icon: Shirt },
|
|
||||||
{ name: "washing-machine", label: "Laundry", Icon: WashingMachine },
|
|
||||||
{ name: "utensils", label: "Meal", Icon: Utensils },
|
|
||||||
{ name: "cooking-pot", label: "Cooking", Icon: CookingPot },
|
|
||||||
{ name: "apple", label: "Snack", Icon: Apple },
|
|
||||||
{ name: "brush-cleaning", label: "Sweep", Icon: BrushCleaning },
|
|
||||||
{ name: "spray-can", label: "Clean", Icon: SprayCan },
|
|
||||||
{ name: "trash-2", label: "Bins", Icon: Trash2 },
|
|
||||||
{ name: "recycle", label: "Recycling", Icon: Recycle },
|
|
||||||
{ name: "sofa", label: "Tidy", Icon: Sofa },
|
|
||||||
{ name: "house", label: "Home", Icon: House },
|
|
||||||
{ name: "backpack", label: "School bag", Icon: Backpack },
|
|
||||||
{ name: "book-open", label: "Reading", Icon: BookOpen },
|
|
||||||
{ name: "pencil", label: "Homework", Icon: Pencil },
|
|
||||||
{ name: "music", label: "Music", Icon: Music },
|
|
||||||
{ name: "dog", label: "Dog", Icon: Dog },
|
|
||||||
{ name: "cat", label: "Cat", Icon: Cat },
|
|
||||||
{ name: "fish", label: "Fish", Icon: Fish },
|
|
||||||
{ name: "sprout", label: "Plants", Icon: Sprout },
|
|
||||||
{ name: "droplets", label: "Water", Icon: Droplets },
|
|
||||||
{ name: "bike", label: "Bike", Icon: Bike },
|
|
||||||
{ name: "volleyball", label: "Sport", Icon: Volleyball },
|
|
||||||
{ name: "gamepad-2", label: "Games", Icon: Gamepad2 },
|
|
||||||
{ name: "star", label: "Star", Icon: Star },
|
|
||||||
{ name: "heart", label: "Heart", Icon: Heart },
|
|
||||||
{ name: "sun", label: "Morning", Icon: Sun },
|
|
||||||
{ name: "moon", label: "Bedtime", Icon: Moon },
|
|
||||||
{ name: "clock", label: "Clock", Icon: Clock },
|
|
||||||
{ name: "calendar", label: "Calendar", Icon: Calendar },
|
|
||||||
{ name: "list-checks", label: "Checklist", Icon: ListChecks },
|
|
||||||
];
|
|
||||||
|
|
||||||
const BY_NAME = new Map(DEFINITION_ICONS.map((entry) => [entry.name, entry]));
|
|
||||||
|
|
||||||
export function findDefinitionIcon(name: string | null): DefinitionIconEntry | null {
|
|
||||||
return name === null ? null : (BY_NAME.get(name) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A definition's glyph; no icon, or one outside the subset, gets the generic task glyph. */
|
|
||||||
export function DefinitionIcon({ name, size = 17 }: { name: string | null; size?: number }) {
|
|
||||||
const entry = findDefinitionIcon(name);
|
|
||||||
if (!entry) return <TaskGlyph size={size} />;
|
|
||||||
return <entry.Icon size={size} aria-hidden="true" focusable="false" data-icon={entry.name} />;
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* List-row summaries for the Definitions tab (ADMIN-SPEC §3.1):
|
|
||||||
* `<assignees> · <recurrence> · <windows>`.
|
|
||||||
*/
|
|
||||||
import type {
|
|
||||||
AdminChild,
|
|
||||||
DefinitionAssignee,
|
|
||||||
DefinitionRule,
|
|
||||||
DefinitionWindow,
|
|
||||||
WindowName,
|
|
||||||
} from "../api/definitions";
|
|
||||||
|
|
||||||
export const WINDOW_ORDER: WindowName[] = ["morning", "afternoon", "evening"];
|
|
||||||
|
|
||||||
export const WINDOW_LABELS: Record<WindowName, string> = {
|
|
||||||
morning: "Morning",
|
|
||||||
afternoon: "Afternoon",
|
|
||||||
evening: "Evening",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */
|
|
||||||
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
||||||
|
|
||||||
export const MONTH_SHORT = [
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `Declan`, `Declan, Maeve`, or `All three` when every active child (two or
|
|
||||||
* more) is assigned.
|
|
||||||
*/
|
|
||||||
export function assigneeSummary(
|
|
||||||
assignees: Pick<DefinitionAssignee, "id" | "display_name">[],
|
|
||||||
activeChildren: AdminChild[],
|
|
||||||
): string {
|
|
||||||
if (assignees.length === 0) return "Unassigned";
|
|
||||||
const assigned = new Set(assignees.map((a) => a.id));
|
|
||||||
const everyone =
|
|
||||||
activeChildren.length > 1 &&
|
|
||||||
assigned.size === activeChildren.length &&
|
|
||||||
activeChildren.every((child) => assigned.has(child.id));
|
|
||||||
if (everyone) {
|
|
||||||
const word = COUNT_WORDS[activeChildren.length] ?? String(activeChildren.length);
|
|
||||||
return `All ${word}`;
|
|
||||||
}
|
|
||||||
return assignees.map((a) => a.display_name).join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function weekdayList(days: number[] | null): string {
|
|
||||||
return [...(days ?? [])]
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
.map((day) => WEEKDAY_SHORT[day])
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function ordinal(n: number): string {
|
|
||||||
if (n === -1) return "last";
|
|
||||||
const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th";
|
|
||||||
return `${n}${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function every(interval: number, one: string, unit: string): string {
|
|
||||||
return interval === 1 ? one : `Every ${interval} ${unit}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function recurrenceSummary(rule: DefinitionRule): string {
|
|
||||||
const n = rule.interval;
|
|
||||||
switch (rule.rule_type) {
|
|
||||||
case "daily":
|
|
||||||
return every(n, "Daily", "days");
|
|
||||||
case "weekly":
|
|
||||||
return `${every(n, "Weekly", "weeks")} on ${weekdayList(rule.weekday_set)}`;
|
|
||||||
case "monthly_day":
|
|
||||||
return `${every(n, "Monthly", "months")} on day ${rule.day_of_month}`;
|
|
||||||
case "monthly_weekday":
|
|
||||||
return `${every(n, "Monthly", "months")} on the ${ordinal(rule.nth_weekday ?? 1)} ${
|
|
||||||
WEEKDAY_SHORT[rule.nth_weekday_weekday ?? 0]
|
|
||||||
}`;
|
|
||||||
case "yearly": {
|
|
||||||
const month = MONTH_SHORT[(rule.month ?? 1) - 1];
|
|
||||||
const when = rule.day_of_month ? `on ${month} ${rule.day_of_month}` : `in ${month}`;
|
|
||||||
return `${every(n, "Yearly", "years")} ${when}`;
|
|
||||||
}
|
|
||||||
case "custom_days":
|
|
||||||
return `Custom${n === 1 ? "" : ` every ${n} weeks`} (${weekdayList(rule.weekday_set)})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): string {
|
|
||||||
const names = new Set(windows.map((w) => w.window));
|
|
||||||
return WINDOW_ORDER.filter((w) => names.has(w))
|
|
||||||
.map((w) => WINDOW_LABELS[w])
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "2026-10-06" -> "Tue 6 Oct". The calendar date is read by its components
|
|
||||||
* and its weekday taken in UTC (as the Today tab does), so the printed day
|
|
||||||
* never shifts with the viewer's offset — `new Date("YYYY-MM-DD")` would.
|
|
||||||
*/
|
|
||||||
export function occurrenceLabel(iso: string): string {
|
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
||||||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
|
||||||
const [, year, month, day] = match.map(Number);
|
|
||||||
const date = new Date(Date.UTC(year, month - 1, day));
|
|
||||||
// getUTCDay: 0 = Sunday; WEEKDAY_SHORT: 0 = Monday.
|
|
||||||
return `${WEEKDAY_SHORT[(date.getUTCDay() + 6) % 7]} ${day} ${MONTH_SHORT[month - 1]}`;
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inline stroke glyphs for the Definitions tab (Lucide-style 24px grid),
|
|
||||||
* CDN-free like the Today tab's. Definition icons arrive with the icon
|
|
||||||
* picker task; until then every row carries the generic task glyph.
|
|
||||||
*/
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TaskGlyph({ size = 17 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
|
||||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
|
||||||
<path d="m9 14 2 2 4-4" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlusGlyph({ size = 16 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="M12 5v14M5 12h14" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="m9 18 6-6-6-6" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChevronDownGlyph({ size = 16 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="m6 9 6 6 6-6" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckGlyph({ size = 16 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="M20 6 9 17l-5-5" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
/* History tab — compact density (design/ADMIN-SPEC.md §5). Mobile-first at
|
|
||||||
the 390x844 reference; on wider viewports the column is capped and centred. */
|
|
||||||
|
|
||||||
.history-screen {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border-bottom: 1px solid var(--nq-a-border);
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--nq-a-size-screen);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-sub {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-csv {
|
|
||||||
flex: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-csv:disabled {
|
|
||||||
cursor: progress;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Non-blocking CSV export failure; the tab stays usable. */
|
|
||||||
.history-export-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
padding: var(--nq-a-density-row-pad);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-danger-bg);
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-export-dismiss {
|
|
||||||
flex: none;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
|
||||||
.history-scroll {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--nq-a-density-gap);
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-card {
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stat row */
|
|
||||||
.history-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-stat {
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-stat-value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-stat-value--on-time {
|
|
||||||
color: var(--nq-brand-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-stat-value--missed {
|
|
||||||
color: var(--nq-a-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-stat-label {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter chips */
|
|
||||||
.history-chips {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-chip {
|
|
||||||
flex: none;
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-radius-pill);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-chip--active {
|
|
||||||
border-color: #18181d;
|
|
||||||
background: #18181d;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Day-grouped event rows */
|
|
||||||
.history-day {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-section-label {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-list > li + li {
|
|
||||||
border-top: 1px solid var(--nq-a-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-event {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: var(--nq-a-density-row-pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-dot {
|
|
||||||
flex: none;
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-dot--completed {
|
|
||||||
background: var(--nq-a-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-dot--uncompleted {
|
|
||||||
background: var(--nq-a-reversal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-dot--missed {
|
|
||||||
background: var(--nq-a-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-event-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-event-title {
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-event-meta {
|
|
||||||
margin-top: 1px;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-event-time {
|
|
||||||
flex: none;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-empty {
|
|
||||||
padding: var(--nq-a-density-card-pad);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Append-only footer strip */
|
|
||||||
.history-note {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-page);
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-note-glyph {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading / error */
|
|
||||||
.history-status {
|
|
||||||
padding: 24px 13px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-status--error {
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-status--error p {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-retry {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
@@ -1,631 +0,0 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import HistoryTab, { FOOTER_NOTE, computeStats } from "./HistoryTab";
|
|
||||||
import type { HistoryFilter, HistoryRow } from "../api/history";
|
|
||||||
import { storeTokens } from "../auth/oidc";
|
|
||||||
import { TransitionsProvider } from "../api/useTransitions";
|
|
||||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
|
||||||
|
|
||||||
const TODAY = "2026-09-23";
|
|
||||||
|
|
||||||
function row(overrides: Partial<HistoryRow>): HistoryRow {
|
|
||||||
return {
|
|
||||||
occurred_at: null,
|
|
||||||
event_type: "completed",
|
|
||||||
child_name: "Declan",
|
|
||||||
child_id: 1,
|
|
||||||
quest_title: "Brush teeth",
|
|
||||||
instance_id: 1,
|
|
||||||
window: "morning",
|
|
||||||
due_date: TODAY,
|
|
||||||
due_time: null,
|
|
||||||
actor: "panel (Declan)",
|
|
||||||
was_on_time: true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// America/Los_Angeles is UTC-7 in September.
|
|
||||||
const BRUSH = row({
|
|
||||||
instance_id: 1,
|
|
||||||
occurred_at: "2026-09-23T14:04:00Z", // 7:04 AM
|
|
||||||
due_time: "07:30",
|
|
||||||
});
|
|
||||||
const BED_DONE = row({
|
|
||||||
instance_id: 2,
|
|
||||||
quest_title: "Make bed",
|
|
||||||
child_name: "Maeve",
|
|
||||||
child_id: 2,
|
|
||||||
occurred_at: "2026-09-23T14:50:00Z", // 7:50 AM
|
|
||||||
actor: "panel (Maeve)",
|
|
||||||
});
|
|
||||||
const BED_UNDONE = row({
|
|
||||||
instance_id: 2,
|
|
||||||
event_type: "uncompleted",
|
|
||||||
quest_title: "Make bed",
|
|
||||||
child_name: "Maeve",
|
|
||||||
child_id: 2,
|
|
||||||
occurred_at: "2026-09-23T15:10:00Z", // 8:10 AM
|
|
||||||
actor: "joshua (admin)",
|
|
||||||
was_on_time: null,
|
|
||||||
});
|
|
||||||
const CAT_LATE = row({
|
|
||||||
instance_id: 3,
|
|
||||||
quest_title: "Feed cat",
|
|
||||||
due_date: "2026-09-22",
|
|
||||||
due_time: "07:00",
|
|
||||||
occurred_at: "2026-09-22T14:22:00Z", // 7:22 AM, 22 min after due
|
|
||||||
was_on_time: false,
|
|
||||||
});
|
|
||||||
// 7:30 PM local on Sep 22 — already Sep 23 in UTC.
|
|
||||||
const HOMEWORK_LATE = row({
|
|
||||||
instance_id: 4,
|
|
||||||
quest_title: "Homework",
|
|
||||||
child_name: "Rory",
|
|
||||||
child_id: 3,
|
|
||||||
due_date: "2026-09-22",
|
|
||||||
due_time: null,
|
|
||||||
occurred_at: "2026-09-23T02:30:00Z",
|
|
||||||
actor: "panel (Rory)",
|
|
||||||
was_on_time: false,
|
|
||||||
});
|
|
||||||
const PLANTS_MISSED = row({
|
|
||||||
instance_id: 5,
|
|
||||||
event_type: "missed",
|
|
||||||
quest_title: "Water plants",
|
|
||||||
child_name: "Rory",
|
|
||||||
child_id: 3,
|
|
||||||
due_date: "2026-09-21",
|
|
||||||
due_time: "18:00",
|
|
||||||
actor: "nightly sweep",
|
|
||||||
was_on_time: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The API's order: due_date, then instance, then event id.
|
|
||||||
const ROWS: Record<HistoryFilter, HistoryRow[]> = {
|
|
||||||
all: [CAT_LATE, HOMEWORK_LATE, BRUSH, BED_DONE, BED_UNDONE],
|
|
||||||
reversals: [BED_UNDONE],
|
|
||||||
missed: [PLANTS_MISSED],
|
|
||||||
};
|
|
||||||
|
|
||||||
function jsonResponse(body: unknown, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const CSV_BODY = "occurred_at,event_type\n2026-09-23T14:04:00Z,completed\n";
|
|
||||||
|
|
||||||
function csvResponse(filter: string, start: string, end: string): Response {
|
|
||||||
return new Response(`${filter}\n${CSV_BODY}`, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/csv; charset=utf-8",
|
|
||||||
"Content-Disposition": `attachment; filename="nestquest-history-${start}-to-${end}.csv"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeFetch(url: string): Response {
|
|
||||||
const parsed = new URL(url, "http://x");
|
|
||||||
if (parsed.pathname.endsWith("/api/v1/admin/history.csv")) {
|
|
||||||
return csvResponse(
|
|
||||||
parsed.searchParams.get("filter") ?? "",
|
|
||||||
parsed.searchParams.get("start") ?? "",
|
|
||||||
parsed.searchParams.get("end") ?? "",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!parsed.pathname.endsWith("/api/v1/admin/history")) {
|
|
||||||
return jsonResponse({ detail: "unexpected" }, 500);
|
|
||||||
}
|
|
||||||
const filter = parsed.searchParams.get("filter") as HistoryFilter;
|
|
||||||
const rows = ROWS[filter];
|
|
||||||
return jsonResponse({
|
|
||||||
filter,
|
|
||||||
start: parsed.searchParams.get("start"),
|
|
||||||
end: parsed.searchParams.get("end"),
|
|
||||||
count: rows.length,
|
|
||||||
rows,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
function historyCalls(): URL[] {
|
|
||||||
return fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function metas(section: HTMLElement): string[] {
|
|
||||||
return within(section)
|
|
||||||
.getAllByTestId("history-meta")
|
|
||||||
.map((el) => el.textContent ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderReady() {
|
|
||||||
render(<HistoryTab today={TODAY} />);
|
|
||||||
await screen.findByTestId("stat-on-time");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("HistoryTab", () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
fetchMock = vi.fn((url: string) => Promise.resolve(routeFetch(url)));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the header and queries the last 14 days with the Bearer token", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
|
|
||||||
expect(screen.getByText("Append-only event log · last 14 days")).toBeTruthy();
|
|
||||||
|
|
||||||
const calls = historyCalls();
|
|
||||||
expect(calls.length).toBeGreaterThan(0);
|
|
||||||
for (const url of calls) {
|
|
||||||
expect(url.pathname).toBe("/api/v1/admin/history");
|
|
||||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
|
||||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
|
||||||
}
|
|
||||||
expect(calls.map((url) => url.searchParams.get("filter")).sort()).toEqual(["all", "missed"]);
|
|
||||||
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe("Bearer at-123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stat row shows On time %, Completed and Missed for the period", async () => {
|
|
||||||
await renderReady();
|
|
||||||
// 4 completions (2 on time) in `all`; 1 derived missed row.
|
|
||||||
expect(screen.getByTestId("stat-on-time").textContent).toBe("50%On time");
|
|
||||||
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
|
|
||||||
expect(screen.getByTestId("stat-missed").textContent).toBe("1Missed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("On time shows — when the period has no completions", () => {
|
|
||||||
expect(computeStats([BED_UNDONE], [])).toEqual({ onTimePct: null, completed: 0, missed: 0 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("groups rows by local day, newest first, with event type, actor and on-time meta", async () => {
|
|
||||||
await renderReady();
|
|
||||||
|
|
||||||
const today = screen.getByTestId(`day-${TODAY}`);
|
|
||||||
expect(within(today).getByRole("heading").textContent).toBe("Today");
|
|
||||||
expect(metas(today)).toEqual([
|
|
||||||
"uncompleted · joshua (admin)",
|
|
||||||
"completed · panel (Maeve) · on time",
|
|
||||||
"completed · panel (Declan) · on time",
|
|
||||||
]);
|
|
||||||
expect(within(today).getAllByText("Make bed · Maeve")).toHaveLength(2);
|
|
||||||
expect(within(today).getByText("8:10 AM")).toBeTruthy();
|
|
||||||
|
|
||||||
// The 7:30 PM completion is Sep 23 in UTC but Sep 22 locally.
|
|
||||||
const yesterday = screen.getByTestId("day-2026-09-22");
|
|
||||||
expect(within(yesterday).getByRole("heading").textContent).toBe("Yesterday");
|
|
||||||
expect(metas(yesterday)).toEqual([
|
|
||||||
"completed · panel (Rory) · late",
|
|
||||||
"completed · panel (Declan) · late 22 min",
|
|
||||||
]);
|
|
||||||
expect(within(yesterday).getByText("7:30 PM")).toBeTruthy();
|
|
||||||
|
|
||||||
const dots = screen.getAllByTestId("history-dot").map((el) => el.className);
|
|
||||||
expect(dots[0]).toContain("history-dot--uncompleted");
|
|
||||||
expect(dots[1]).toContain("history-dot--completed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("each filter chip re-queries the API with its filter and re-renders", async () => {
|
|
||||||
await renderReady();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(historyCalls().some((url) => url.searchParams.get("filter") === "reversals")).toBe(true),
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
|
|
||||||
expect(screen.getByTestId("history-meta").textContent).toBe("uncompleted · joshua (admin)");
|
|
||||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Missed" }));
|
|
||||||
const missedDay = await screen.findByTestId("day-2026-09-21");
|
|
||||||
expect(within(missedDay).getByRole("heading").textContent).toBe("Mon, Sep 21");
|
|
||||||
expect(metas(missedDay)).toEqual(["missed · nightly sweep"]);
|
|
||||||
expect(within(missedDay).getByTestId("history-dot").className).toContain("history-dot--missed");
|
|
||||||
expect(within(missedDay).getByText("6:00 PM")).toBeTruthy();
|
|
||||||
// Stats stay the period's numbers whichever chip is selected.
|
|
||||||
expect(screen.getByTestId("stat-completed").textContent).toBe("4Completed");
|
|
||||||
|
|
||||||
fetchMock.mockClear();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "All events" }));
|
|
||||||
await screen.findByTestId(`day-${TODAY}`);
|
|
||||||
expect(historyCalls().map((url) => url.searchParams.get("filter"))).toContain("all");
|
|
||||||
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the append-only footer", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(FOOTER_NOTE).toBe("Events are never edited or deleted. A reversal is its own row.");
|
|
||||||
expect(screen.getByTestId("history-footer").textContent).toBe(FOOTER_NOTE);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("CSV export", () => {
|
|
||||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
|
||||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
|
||||||
let clicked: { href: string; download: string; attached: boolean }[];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
createObjectURL = vi.fn(() => "blob:nestquest/csv-1");
|
|
||||||
revokeObjectURL = vi.fn();
|
|
||||||
// jsdom has no object URLs; install stubs and remove them afterwards.
|
|
||||||
Object.assign(URL, { createObjectURL, revokeObjectURL });
|
|
||||||
clicked = [];
|
|
||||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
|
|
||||||
clicked.push({
|
|
||||||
href: this.getAttribute("href") ?? "",
|
|
||||||
download: this.download,
|
|
||||||
attached: document.body.contains(this),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
const stubbed = URL as unknown as Record<string, unknown>;
|
|
||||||
delete stubbed.createObjectURL;
|
|
||||||
delete stubbed.revokeObjectURL;
|
|
||||||
});
|
|
||||||
|
|
||||||
function csvCalls(): [URL, RequestInit][] {
|
|
||||||
return fetchMock.mock.calls
|
|
||||||
.map(([url, init]) => [new URL(String(url), "http://x"), init] as [URL, RequestInit])
|
|
||||||
.filter(([url]) => url.pathname === "/api/v1/admin/history.csv");
|
|
||||||
}
|
|
||||||
|
|
||||||
it("requests the CSV for the active filter and displayed range with the Bearer token", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
|
||||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
|
||||||
|
|
||||||
const calls = csvCalls();
|
|
||||||
expect(calls).toHaveLength(1);
|
|
||||||
const [url, init] = calls[0];
|
|
||||||
expect(url.searchParams.get("filter")).toBe("all");
|
|
||||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
|
||||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
|
||||||
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer at-123");
|
|
||||||
// The token never travels in the URL.
|
|
||||||
expect(url.toString()).not.toContain("at-123");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers the returned CSV for download, then revokes the URL and removes the anchor", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
|
||||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
|
||||||
|
|
||||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
||||||
const blob = createObjectURL.mock.calls[0][0] as Blob;
|
|
||||||
expect(await blob.text()).toBe(`all\n${CSV_BODY}`);
|
|
||||||
expect(clicked[0]).toEqual({
|
|
||||||
href: "blob:nestquest/csv-1",
|
|
||||||
download: "nestquest-history-2026-09-10-to-2026-09-23.csv",
|
|
||||||
attached: true,
|
|
||||||
});
|
|
||||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:nestquest/csv-1");
|
|
||||||
expect(document.querySelector("a[download]")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("history-export-error")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the range filename without a Content-Disposition header", async () => {
|
|
||||||
fetchMock.mockImplementation((url: string) =>
|
|
||||||
Promise.resolve(
|
|
||||||
new URL(url, "http://x").pathname.endsWith(".csv")
|
|
||||||
? new Response(CSV_BODY, { status: 200, headers: { "Content-Type": "text/csv" } })
|
|
||||||
: routeFetch(url),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
|
||||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
|
||||||
expect(clicked[0].download).toBe("nestquest-history-2026-09-10-to-2026-09-23.csv");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switching the filter chip then exporting uses the new filter", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
|
||||||
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
|
||||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
|
||||||
const calls = csvCalls();
|
|
||||||
expect(calls).toHaveLength(1);
|
|
||||||
expect(calls[0][0].searchParams.get("filter")).toBe("reversals");
|
|
||||||
expect(calls[0][0].searchParams.get("start")).toBe("2026-09-10");
|
|
||||||
expect(calls[0][0].searchParams.get("end")).toBe(TODAY);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a failed export shows a non-blocking error and does not download", async () => {
|
|
||||||
fetchMock.mockImplementation((url: string) =>
|
|
||||||
Promise.resolve(
|
|
||||||
new URL(url, "http://x").pathname.endsWith(".csv")
|
|
||||||
? jsonResponse({ detail: "boom" }, 500)
|
|
||||||
: routeFetch(url),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "CSV" }));
|
|
||||||
|
|
||||||
const error = await screen.findByTestId("history-export-error");
|
|
||||||
expect(error.textContent).toContain("The CSV export failed");
|
|
||||||
expect(createObjectURL).not.toHaveBeenCalled();
|
|
||||||
expect(clicked).toHaveLength(0);
|
|
||||||
// The tab stays usable: rows remain, the button is re-enabled, chips still work.
|
|
||||||
expect(screen.getAllByTestId("history-row")).toHaveLength(5);
|
|
||||||
expect((screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement).disabled).toBe(false);
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
|
||||||
await waitFor(() => expect(screen.getAllByTestId("history-row")).toHaveLength(1));
|
|
||||||
|
|
||||||
fireEvent.click(within(error).getByRole("button", { name: "Dismiss" }));
|
|
||||||
expect(screen.queryByTestId("history-export-error")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the button is disabled while the request is in flight (no double-submit)", async () => {
|
|
||||||
let release: (response: Response) => void = () => {};
|
|
||||||
fetchMock.mockImplementation((url: string) =>
|
|
||||||
new URL(url, "http://x").pathname.endsWith(".csv")
|
|
||||||
? new Promise<Response>((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
})
|
|
||||||
: Promise.resolve(routeFetch(url)),
|
|
||||||
);
|
|
||||||
await renderReady();
|
|
||||||
const csv = screen.getByRole("button", { name: "CSV" }) as HTMLButtonElement;
|
|
||||||
fireEvent.click(csv);
|
|
||||||
await waitFor(() => expect(csv.disabled).toBe(true));
|
|
||||||
fireEvent.click(csv);
|
|
||||||
expect(csvCalls()).toHaveLength(1);
|
|
||||||
|
|
||||||
release(csvResponse("all", "2026-09-10", TODAY));
|
|
||||||
await waitFor(() => expect(clicked).toHaveLength(1));
|
|
||||||
await waitFor(() => expect(csv.disabled).toBe(false));
|
|
||||||
expect(csvCalls()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rows are read-only: no row carries a control", async () => {
|
|
||||||
await renderReady();
|
|
||||||
for (const el of screen.getAllByTestId("history-row")) {
|
|
||||||
expect(within(el).queryByRole("button")).toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a failed fetch shows the error state and Try again reloads", async () => {
|
|
||||||
fetchMock.mockImplementation(() => Promise.resolve(jsonResponse({ detail: "boom" }, 500)));
|
|
||||||
render(<HistoryTab today={TODAY} />);
|
|
||||||
|
|
||||||
const error = await screen.findByTestId("history-error");
|
|
||||||
expect(error.textContent).toContain("The history could not be loaded");
|
|
||||||
expect(screen.getByTestId("history-footer")).toBeTruthy();
|
|
||||||
|
|
||||||
fetchMock.mockImplementation((url: string) => Promise.resolve(routeFetch(url)));
|
|
||||||
fireEvent.click(within(error).getByRole("button", { name: "Try again" }));
|
|
||||||
await screen.findByTestId("stat-on-time");
|
|
||||||
expect(screen.queryByTestId("history-error")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the scroll column ends with 92px bottom padding", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(screen.getByTestId("history-scroll").style.paddingBottom).toBe("92px");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("HistoryTab live updates", () => {
|
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
|
||||||
let streams: TestStream[];
|
|
||||||
|
|
||||||
function historyRequests(): URL[] {
|
|
||||||
return fetchMock.mock.calls
|
|
||||||
.map(([url]) => new URL(String(url), "http://x"))
|
|
||||||
.filter((url) => url.pathname === "/api/v1/admin/history");
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAYLOAD = {
|
|
||||||
child_id: 2,
|
|
||||||
child_name: "Maeve",
|
|
||||||
instance_id: 2,
|
|
||||||
quest_title: "Make bed",
|
|
||||||
window: "morning",
|
|
||||||
due_date: TODAY,
|
|
||||||
due_time: null,
|
|
||||||
occurred_at: "2026-09-23T15:10:00Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
streams = [];
|
|
||||||
fetchMock = vi.fn((url: string) => {
|
|
||||||
if (new URL(url, "http://x").pathname.endsWith("/api/v1/admin/events")) {
|
|
||||||
const stream = testStream();
|
|
||||||
streams.push(stream);
|
|
||||||
return Promise.resolve(stream.response);
|
|
||||||
}
|
|
||||||
return Promise.resolve(routeFetch(url));
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a quest transition refetches the active filter; a day-complete does not", async () => {
|
|
||||||
render(
|
|
||||||
<TransitionsProvider>
|
|
||||||
<HistoryTab today={TODAY} />
|
|
||||||
</TransitionsProvider>,
|
|
||||||
);
|
|
||||||
await screen.findByTestId("stat-on-time");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(historyRequests().some((u) => u.searchParams.get("filter") === "reversals")).toBe(true),
|
|
||||||
);
|
|
||||||
await screen.findByTestId("stat-on-time");
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
const before = historyRequests().length;
|
|
||||||
|
|
||||||
streams[0].push(
|
|
||||||
frame("nestquest_child_day_complete", {
|
|
||||||
child_id: 2,
|
|
||||||
child_name: "Maeve",
|
|
||||||
quests_due: 4,
|
|
||||||
quests_completed: 4,
|
|
||||||
occurred_at: "2026-09-23T15:00:00Z",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
||||||
expect(historyRequests().length).toBe(before);
|
|
||||||
|
|
||||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
|
||||||
|
|
||||||
await waitFor(() => expect(historyRequests().length).toBe(before + 3));
|
|
||||||
const refetched = historyRequests().slice(before);
|
|
||||||
// One refetch (reversals + the two stats queries), same range and filter.
|
|
||||||
expect(refetched.map((u) => u.searchParams.get("filter")).sort()).toEqual([
|
|
||||||
"all",
|
|
||||||
"missed",
|
|
||||||
"reversals",
|
|
||||||
]);
|
|
||||||
for (const url of refetched) {
|
|
||||||
expect(url.searchParams.get("start")).toBe("2026-09-10");
|
|
||||||
expect(url.searchParams.get("end")).toBe(TODAY);
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
||||||
expect(historyRequests().length).toBe(before + 3);
|
|
||||||
expect(screen.queryByTestId("history-loading")).toBeNull();
|
|
||||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
|
||||||
// Rows stay read-only (D-005): no row carries a control.
|
|
||||||
for (const row of screen.getAllByTestId("history-row")) {
|
|
||||||
expect(within(row).queryByRole("button")).toBeNull();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("a filter change interleaved with a quest transition", () => {
|
|
||||||
// Both land before the load effect runs, so one load serves both.
|
|
||||||
async function interleave(order: "sse-first" | "chip-first") {
|
|
||||||
await act(async () => {
|
|
||||||
const click = () => fireEvent.click(screen.getByRole("button", { name: "Reversals" }));
|
|
||||||
if (order === "chip-first") click();
|
|
||||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
||||||
if (order === "sse-first") click();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderLive() {
|
|
||||||
render(
|
|
||||||
<TransitionsProvider>
|
|
||||||
<HistoryTab today={TODAY} />
|
|
||||||
</TransitionsProvider>,
|
|
||||||
);
|
|
||||||
await screen.findByTestId("stat-on-time");
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function titles(): string[] {
|
|
||||||
return screen.getAllByTestId("history-row").map((el) => el.textContent ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const order of ["sse-first", "chip-first"] as const) {
|
|
||||||
it(`${order}: shows loading, then only the active filter's rows`, async () => {
|
|
||||||
let release!: () => void;
|
|
||||||
const gate = new Promise<void>((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
const route = fetchMock.getMockImplementation()!;
|
|
||||||
await renderLive();
|
|
||||||
fetchMock.mockImplementation((url: string) => {
|
|
||||||
const parsed = new URL(url, "http://x");
|
|
||||||
if (parsed.searchParams.get("filter") === "reversals") {
|
|
||||||
return gate.then(() => route(url));
|
|
||||||
}
|
|
||||||
return route(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await interleave(order);
|
|
||||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
|
||||||
// The previous filter's rows never show under the new chip.
|
|
||||||
expect(screen.getByTestId("history-loading")).toBeTruthy();
|
|
||||||
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
|
|
||||||
|
|
||||||
release();
|
|
||||||
await screen.findByTestId("stat-on-time");
|
|
||||||
expect(titles()).toHaveLength(1);
|
|
||||||
expect(titles()[0]).toContain("Make bed · Maeve");
|
|
||||||
expect(screen.getAllByTestId("history-dot")[0].className).toContain("history-dot--uncompleted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it(`${order}: a failed filter-change load shows the error state, not stale rows`, async () => {
|
|
||||||
const route = fetchMock.getMockImplementation()!;
|
|
||||||
await renderLive();
|
|
||||||
fetchMock.mockImplementation((url: string) => {
|
|
||||||
const parsed = new URL(url, "http://x");
|
|
||||||
if (parsed.pathname === "/api/v1/admin/history") {
|
|
||||||
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
|
||||||
}
|
|
||||||
return route(url);
|
|
||||||
});
|
|
||||||
|
|
||||||
await interleave(order);
|
|
||||||
const error = await screen.findByTestId("history-error");
|
|
||||||
expect(error.textContent).toContain("The history could not be loaded");
|
|
||||||
expect(screen.getByRole("button", { name: "Reversals" }).getAttribute("aria-pressed")).toBe("true");
|
|
||||||
expect(screen.queryAllByTestId("history-row")).toHaveLength(0);
|
|
||||||
expect(screen.queryByTestId("stat-on-time")).toBeNull();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a failed transition refresh keeps the active filter's rows on screen", async () => {
|
|
||||||
render(
|
|
||||||
<TransitionsProvider>
|
|
||||||
<HistoryTab today={TODAY} />
|
|
||||||
</TransitionsProvider>,
|
|
||||||
);
|
|
||||||
await screen.findByTestId("stat-on-time");
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
const route = fetchMock.getMockImplementation()!;
|
|
||||||
fetchMock.mockImplementation((url: string) => {
|
|
||||||
if (new URL(url, "http://x").pathname === "/api/v1/admin/history") {
|
|
||||||
return Promise.resolve(jsonResponse({ detail: "boom" }, 500));
|
|
||||||
}
|
|
||||||
return route(url);
|
|
||||||
});
|
|
||||||
const before = historyRequests().length;
|
|
||||||
|
|
||||||
streams[0].push(frame("nestquest_quest_uncompleted", PAYLOAD));
|
|
||||||
// The "all" filter plus the missed stats query.
|
|
||||||
await waitFor(() => expect(historyRequests().length).toBe(before + 2));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
||||||
expect(screen.queryByTestId("history-error")).toBeNull();
|
|
||||||
expect(screen.queryByTestId("history-loading")).toBeNull();
|
|
||||||
expect(screen.getAllByTestId("history-row")).toHaveLength(ROWS.all.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
/**
|
|
||||||
* History tab — the append-only event log (design/ADMIN-SPEC.md §5).
|
|
||||||
*
|
|
||||||
* Rows are read-only: events are never edited or deleted, and a reversal is
|
|
||||||
* its own row (D-005).
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { ApiForbiddenError } from "../api/client";
|
|
||||||
import type { TransitionEvent } from "../api/events";
|
|
||||||
import { useTransitions } from "../api/useTransitions";
|
|
||||||
import { exportHistoryCsv, fetchHistory, type HistoryFilter, type HistoryRow } from "../api/history";
|
|
||||||
import { localTodayIso } from "../schedule/cycle";
|
|
||||||
import { dayLabel, groupByDay, rowMeta, rowTime, shiftIso } from "./format";
|
|
||||||
import { DownloadGlyph, LockGlyph } from "./glyphs";
|
|
||||||
import "./HistoryTab.css";
|
|
||||||
|
|
||||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
|
||||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
|
||||||
|
|
||||||
export const RANGE_DAYS = 14;
|
|
||||||
|
|
||||||
export const FOOTER_NOTE = "Events are never edited or deleted. A reversal is its own row.";
|
|
||||||
|
|
||||||
export const FILTERS: { id: HistoryFilter; label: string }[] = [
|
|
||||||
{ id: "all", label: "All events" },
|
|
||||||
{ id: "reversals", label: "Reversals" },
|
|
||||||
{ id: "missed", label: "Missed" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface HistoryStats {
|
|
||||||
/** Whole percent, or null when the period has no completions. */
|
|
||||||
onTimePct: number | null;
|
|
||||||
completed: number;
|
|
||||||
missed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Per-period stats from the `all` rows (completions) and the `missed` rows. */
|
|
||||||
export function computeStats(allRows: HistoryRow[], missedRows: HistoryRow[]): HistoryStats {
|
|
||||||
const completions = allRows.filter((row) => row.event_type === "completed");
|
|
||||||
const onTime = completions.filter((row) => row.was_on_time === true).length;
|
|
||||||
return {
|
|
||||||
onTimePct: completions.length > 0 ? Math.round((onTime / completions.length) * 100) : null,
|
|
||||||
completed: completions.length,
|
|
||||||
missed: missedRows.filter((row) => row.event_type === "missed").length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Loaded {
|
|
||||||
rows: HistoryRow[];
|
|
||||||
stats: HistoryStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoadState =
|
|
||||||
| { kind: "loading" }
|
|
||||||
| { kind: "error"; message: string }
|
|
||||||
/** `key` is the (filter, start, end) the rows were loaded for. */
|
|
||||||
| { kind: "ready"; data: Loaded; key: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The selected filter's rows plus the period stats. Stats always come from
|
|
||||||
* the `all` and `missed` queries, whichever chip is selected, so switching
|
|
||||||
* chips never changes the period's numbers.
|
|
||||||
*/
|
|
||||||
async function loadHistory(filter: HistoryFilter, start: string, end: string): Promise<Loaded> {
|
|
||||||
const [selected, all, missed] = await Promise.all([
|
|
||||||
fetchHistory(filter, start, end),
|
|
||||||
filter === "all" ? null : fetchHistory("all", start, end),
|
|
||||||
filter === "missed" ? null : fetchHistory("missed", start, end),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
rows: selected.rows,
|
|
||||||
stats: computeStats((all ?? selected).rows, (missed ?? selected).rows),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Offer a Blob as a file download without navigating the page. */
|
|
||||||
export function offerDownload(blob: Blob, filename: string): void {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement("a");
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
anchor.style.display = "none";
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
try {
|
|
||||||
anchor.click();
|
|
||||||
} finally {
|
|
||||||
anchor.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScreenHeader({ exporting, onExport }: { exporting: boolean; onExport: () => void }) {
|
|
||||||
return (
|
|
||||||
<header className="history-header">
|
|
||||||
<div>
|
|
||||||
<h2 className="history-title">History</h2>
|
|
||||||
<p className="history-sub">Append-only event log · last {RANGE_DAYS} days</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="history-csv"
|
|
||||||
data-testid="history-csv"
|
|
||||||
disabled={exporting}
|
|
||||||
aria-busy={exporting}
|
|
||||||
onClick={onExport}
|
|
||||||
>
|
|
||||||
<DownloadGlyph />
|
|
||||||
CSV
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatRow({ stats }: { stats: HistoryStats }) {
|
|
||||||
const items = [
|
|
||||||
{ key: "on-time", label: "On time", value: stats.onTimePct === null ? "—" : `${stats.onTimePct}%` },
|
|
||||||
{ key: "completed", label: "Completed", value: String(stats.completed) },
|
|
||||||
{ key: "missed", label: "Missed", value: String(stats.missed) },
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className="history-stats">
|
|
||||||
{items.map((item) => (
|
|
||||||
<div key={item.key} className="history-stat" data-testid={`stat-${item.key}`}>
|
|
||||||
<div className={`history-stat-value history-stat-value--${item.key}`}>{item.value}</div>
|
|
||||||
<div className="history-stat-label">{item.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventRow({ row }: { row: HistoryRow }) {
|
|
||||||
return (
|
|
||||||
<li className="history-event" data-testid="history-row">
|
|
||||||
<span
|
|
||||||
className={`history-dot history-dot--${row.event_type}`}
|
|
||||||
data-testid="history-dot"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className="history-event-text">
|
|
||||||
<div className="history-event-title">
|
|
||||||
{row.quest_title} · {row.child_name}
|
|
||||||
</div>
|
|
||||||
<div className="history-event-meta" data-testid="history-meta">
|
|
||||||
{rowMeta(row)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="history-event-time">{rowTime(row)}</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FooterNote() {
|
|
||||||
return (
|
|
||||||
<div className="history-note" data-testid="history-footer">
|
|
||||||
<span className="history-note-glyph">
|
|
||||||
<LockGlyph />
|
|
||||||
</span>
|
|
||||||
<span>{FOOTER_NOTE}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HistoryTab({ today = localTodayIso() }: { today?: string }) {
|
|
||||||
const [filter, setFilter] = useState<HistoryFilter>("all");
|
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
const [exportError, setExportError] = useState<string | null>(null);
|
|
||||||
const exportingRef = useRef(false);
|
|
||||||
const start = shiftIso(today, -(RANGE_DAYS - 1));
|
|
||||||
const key = `${filter}|${start}|${today}`;
|
|
||||||
|
|
||||||
// A quest transition is a new history row (or changes a derived missed row);
|
|
||||||
// a day-complete adds none. The refetch keeps the active filter and range.
|
|
||||||
useTransitions((event: TransitionEvent) => {
|
|
||||||
if (event.type === "nestquest_child_day_complete") return;
|
|
||||||
setAttempt((n) => n + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Download the CSV for the active filter over the displayed range. */
|
|
||||||
async function exportCsv() {
|
|
||||||
if (exportingRef.current) return;
|
|
||||||
exportingRef.current = true;
|
|
||||||
setExporting(true);
|
|
||||||
setExportError(null);
|
|
||||||
try {
|
|
||||||
const { blob, filename } = await exportHistoryCsv(filter, start, today);
|
|
||||||
offerDownload(blob, filename);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
setExportError(
|
|
||||||
error instanceof ApiForbiddenError
|
|
||||||
? error.message
|
|
||||||
: "The CSV export failed. Check your connection and try again.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
exportingRef.current = false;
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
// Rows already loaded for this (filter, range) stay on screen while they
|
|
||||||
// refresh; any other rows give way to the loading state.
|
|
||||||
const isCurrent = (prev: LoadState) => prev.kind === "ready" && prev.key === key;
|
|
||||||
setState((prev) => (isCurrent(prev) ? prev : { kind: "loading" }));
|
|
||||||
loadHistory(filter, start, today)
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setState({ kind: "ready", data, key });
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const message =
|
|
||||||
error instanceof ApiForbiddenError
|
|
||||||
? error.message
|
|
||||||
: "The history could not be loaded. Check your connection and try again.";
|
|
||||||
// A failed refresh keeps the rows already on screen.
|
|
||||||
setState((prev) => (isCurrent(prev) ? prev : { kind: "error", message }));
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [filter, start, today, key, attempt]);
|
|
||||||
|
|
||||||
// Never render one filter's rows under another's chip, even for the render
|
|
||||||
// before the load effect runs.
|
|
||||||
const shown: LoadState = state.kind === "ready" && state.key !== key ? { kind: "loading" } : state;
|
|
||||||
|
|
||||||
let body;
|
|
||||||
if (shown.kind === "loading") {
|
|
||||||
body = (
|
|
||||||
<div className="history-status" role="status" data-testid="history-loading">
|
|
||||||
Loading history…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (shown.kind === "error") {
|
|
||||||
body = (
|
|
||||||
<div className="history-status history-status--error" role="alert" data-testid="history-error">
|
|
||||||
<p>{shown.message}</p>
|
|
||||||
<button type="button" className="history-retry" onClick={() => setAttempt((n) => n + 1)}>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const groups = groupByDay(shown.data.rows);
|
|
||||||
body =
|
|
||||||
groups.length > 0 ? (
|
|
||||||
groups.map((group) => (
|
|
||||||
<section key={group.day} className="history-day" data-testid={`day-${group.day}`}>
|
|
||||||
<h3 className="history-section-label">{dayLabel(group.day, today)}</h3>
|
|
||||||
<ul className="history-card history-list">
|
|
||||||
{group.rows.map((row, index) => (
|
|
||||||
<EventRow key={`${row.instance_id}-${row.event_type}-${row.occurred_at ?? ""}-${index}`} row={row} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="history-card history-empty">No events in the last {RANGE_DAYS} days.</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="history-screen">
|
|
||||||
<ScreenHeader exporting={exporting} onExport={() => void exportCsv()} />
|
|
||||||
<div
|
|
||||||
className="history-scroll"
|
|
||||||
data-testid="history-scroll"
|
|
||||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
|
||||||
>
|
|
||||||
{exportError !== null ? (
|
|
||||||
<div className="history-export-error" role="alert" data-testid="history-export-error">
|
|
||||||
<span>{exportError}</span>
|
|
||||||
<button type="button" className="history-export-dismiss" onClick={() => setExportError(null)}>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{shown.kind === "ready" ? <StatRow stats={shown.data.stats} /> : null}
|
|
||||||
<div className="history-chips" role="group" aria-label="Filter events">
|
|
||||||
{FILTERS.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={item.id === filter ? "history-chip history-chip--active" : "history-chip"}
|
|
||||||
aria-pressed={item.id === filter}
|
|
||||||
onClick={() => setFilter(item.id)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{body}
|
|
||||||
<FooterNote />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/**
|
|
||||||
* Day grouping and row wording for the History tab.
|
|
||||||
*
|
|
||||||
* Calendar dates ("YYYY-MM-DD") are handled by their components (never
|
|
||||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
|
||||||
* day in negative-offset zones); event stamps are UTC and shown in the
|
|
||||||
* viewer's local time.
|
|
||||||
*/
|
|
||||||
import type { HistoryRow } from "../api/history";
|
|
||||||
import { dayNumber, isoFromDayNumber, localTodayIso } from "../schedule/cycle";
|
|
||||||
import { shortDate } from "../schedule/format";
|
|
||||||
import { formatClockTime, formatDueTime } from "../today/format";
|
|
||||||
|
|
||||||
const MS_PER_MINUTE = 60_000;
|
|
||||||
|
|
||||||
/** `iso` shifted by `days` calendar days. */
|
|
||||||
export function shiftIso(iso: string, days: number): string {
|
|
||||||
return isoFromDayNumber(dayNumber(iso) + days);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The local calendar day an event belongs to (due_date for missed rows). */
|
|
||||||
export function rowDay(row: HistoryRow): string {
|
|
||||||
return row.occurred_at ? localTodayIso(new Date(row.occurred_at)) : row.due_date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The row's due moment as local wall-clock time, or null without a due time. */
|
|
||||||
function dueMoment(row: HistoryRow): Date | null {
|
|
||||||
const match = /^(\d{2}):(\d{2})/.exec(row.due_time ?? "");
|
|
||||||
if (!match) return null;
|
|
||||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
|
||||||
return new Date(year, month - 1, day, Number(match[1]), Number(match[2]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Newest first; a missed row sorts at its due moment (or the end of its day). */
|
|
||||||
function sortKey(row: HistoryRow): number {
|
|
||||||
if (row.occurred_at) return Date.parse(row.occurred_at);
|
|
||||||
const due = dueMoment(row);
|
|
||||||
if (due) return due.getTime();
|
|
||||||
const [year, month, day] = row.due_date.split("-").map(Number);
|
|
||||||
return new Date(year, month - 1, day, 23, 59).getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DayGroup {
|
|
||||||
day: string;
|
|
||||||
rows: HistoryRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rows grouped by day, newest day first and newest row first within a day. */
|
|
||||||
export function groupByDay(rows: HistoryRow[]): DayGroup[] {
|
|
||||||
const groups = new Map<string, HistoryRow[]>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const day = rowDay(row);
|
|
||||||
groups.set(day, [...(groups.get(day) ?? []), row]);
|
|
||||||
}
|
|
||||||
return [...groups.entries()]
|
|
||||||
.sort(([a], [b]) => b.localeCompare(a))
|
|
||||||
.map(([day, dayRows]) => ({
|
|
||||||
day,
|
|
||||||
// Stable sort: rows at the same moment keep the API's append order.
|
|
||||||
rows: [...dayRows].sort((a, b) => sortKey(b) - sortKey(a)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Today", "Yesterday", else "Mon, Sep 21" (the CSS uppercases it). */
|
|
||||||
export function dayLabel(day: string, today: string): string {
|
|
||||||
if (day === today) return "Today";
|
|
||||||
if (day === shiftIso(today, -1)) return "Yesterday";
|
|
||||||
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(
|
|
||||||
new Date(dayNumber(day) * 86_400_000),
|
|
||||||
);
|
|
||||||
return `${weekday}, ${shortDate(day)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "on time", "late 22 min", or "late" when the lateness is not derivable. */
|
|
||||||
function onTimeFlag(row: HistoryRow): string | null {
|
|
||||||
if (row.event_type !== "completed" || row.was_on_time === null) return null;
|
|
||||||
if (row.was_on_time) return "on time";
|
|
||||||
const due = dueMoment(row);
|
|
||||||
if (!due || !row.occurred_at) return "late";
|
|
||||||
const minutes = Math.round((Date.parse(row.occurred_at) - due.getTime()) / MS_PER_MINUTE);
|
|
||||||
return minutes > 0 ? `late ${minutes} min` : "late";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `<event type> · <actor>` plus the on-time flag for completions. */
|
|
||||||
export function rowMeta(row: HistoryRow): string {
|
|
||||||
const flag = onTimeFlag(row);
|
|
||||||
return [row.event_type, row.actor, flag].filter(Boolean).join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The event's local clock time; a missed row shows its due time, if any. */
|
|
||||||
export function rowTime(row: HistoryRow): string {
|
|
||||||
if (row.occurred_at) return formatClockTime(row.occurred_at);
|
|
||||||
return row.due_time ? formatDueTime(row.due_time) : "";
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inline stroke glyphs for the History tab (Lucide-style 24px grid),
|
|
||||||
* CDN-free like the other tabs' glyphs.
|
|
||||||
*/
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadGlyph({ size = 15 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LockGlyph({ size = 15 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" />
|
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
--nq-a-page: #f4f4f6;
|
--nq-a-page: #f4f4f6;
|
||||||
--nq-a-surface: #ffffff;
|
--nq-a-surface: #ffffff;
|
||||||
--nq-a-selected: #ebebf8;
|
|
||||||
--nq-a-border: #e4e4ea;
|
--nq-a-border: #e4e4ea;
|
||||||
--nq-a-divider: #ededf1;
|
--nq-a-divider: #ededf1;
|
||||||
|
|
||||||
@@ -22,23 +21,14 @@
|
|||||||
--nq-a-success: #15803d;
|
--nq-a-success: #15803d;
|
||||||
--nq-a-danger: #dc2626;
|
--nq-a-danger: #dc2626;
|
||||||
--nq-a-danger-strong: #b91c1c;
|
--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, 0.35);
|
|
||||||
--nq-a-info-bg: #dbeafe;
|
--nq-a-info-bg: #dbeafe;
|
||||||
--nq-a-info-ink: #1e3a8a;
|
--nq-a-info-ink: #1e3a8a;
|
||||||
--nq-a-info-icon: #1d4ed8;
|
--nq-a-info-icon: #1d4ed8;
|
||||||
--nq-a-reversal: #7d28a0;
|
|
||||||
|
|
||||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||||
--nq-a-size-screen: 24px;
|
--nq-a-size-screen: 24px;
|
||||||
--nq-a-size-stat: 22px;
|
--nq-a-size-stat: 22px;
|
||||||
--nq-a-size-row: 13.5px;
|
|
||||||
--nq-a-size-meta: 11px;
|
--nq-a-size-meta: 11px;
|
||||||
--nq-a-size-label: 10px;
|
|
||||||
--nq-a-track-label: 0.1em;
|
|
||||||
|
|
||||||
--nq-a-density-gap: 10px;
|
--nq-a-density-gap: 10px;
|
||||||
--nq-a-density-card-pad: 13px;
|
--nq-a-density-card-pad: 13px;
|
||||||
@@ -48,13 +38,7 @@
|
|||||||
--nq-a-tabbar-height: 70px;
|
--nq-a-tabbar-height: 70px;
|
||||||
--nq-a-tap-min: 44px;
|
--nq-a-tap-min: 44px;
|
||||||
--nq-a-radius-pill: 9999px;
|
--nq-a-radius-pill: 9999px;
|
||||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
|
||||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9, 9, 11, 0.18);
|
|
||||||
--nq-a-progress-height: 6px;
|
--nq-a-progress-height: 6px;
|
||||||
|
|
||||||
--nq-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
|
||||||
--nq-dur-base: 200ms;
|
|
||||||
--nq-dur-modal: 350ms;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
/* Presence override editor — roomy density pushed screen
|
|
||||||
(design/ADMIN-SPEC.md §6). Covers the tab bar like a pushed route. */
|
|
||||||
|
|
||||||
.override-screen {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 30;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--nq-a-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border-bottom: 1px solid var(--nq-a-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-back {
|
|
||||||
flex: none;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: var(--nq-a-tap-min);
|
|
||||||
height: var(--nq-a-tap-min);
|
|
||||||
margin: -6px 0 -6px -12px;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--nq-a-size-screen);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-sub {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-scroll {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 16px 16px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form card: roomy density, 18px padding, 16px gaps. */
|
|
||||||
.override-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 18px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-label {
|
|
||||||
font-size: var(--nq-a-size-label);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: var(--nq-a-track-label);
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-segments {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-segments--3 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-segments--2 {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-segment {
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-segment--gradient[aria-pressed="true"] {
|
|
||||||
border-color: transparent;
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-segment--ink[aria-pressed="true"] {
|
|
||||||
border-color: #18181d;
|
|
||||||
background: #18181d;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-dates {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: 13px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-date {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Consequence warning */
|
|
||||||
.override-warning {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px;
|
|
||||||
border: 1px solid var(--nq-a-warning-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--nq-a-warning-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-warning-glyph {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
color: var(--nq-a-warning-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-warning-headline {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nq-a-warning-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-warning-body {
|
|
||||||
margin: 3px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-warning-ink-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-error {
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-danger-bg);
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer: Cancel (flex 1) + Save override (flex 2), 15px vertical padding. */
|
|
||||||
.override-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-cancel,
|
|
||||||
.override-save {
|
|
||||||
padding: 15px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-cancel {
|
|
||||||
flex: 1;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-save {
|
|
||||||
flex: 2;
|
|
||||||
border: 0;
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.override-cancel:disabled,
|
|
||||||
.override-save:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
@@ -1,533 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
||||||
import App from "../App";
|
|
||||||
import ScheduleTab from "./ScheduleTab";
|
|
||||||
import { CONSEQUENCE_DEBOUNCE_MS, isIsoDate, isValidRange } from "./OverrideEditor";
|
|
||||||
import type { AdminChild } from "../api/definitions";
|
|
||||||
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
|
|
||||||
import { storeTokens } from "../auth/oidc";
|
|
||||||
|
|
||||||
function child(id: number, display_name: string, sort_order: number): AdminChild {
|
|
||||||
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHILDREN = [child(1, "Declan", 1), child(2, "Maeve", 2), child(3, "Rory", 3)];
|
|
||||||
|
|
||||||
const DECLAN: PresenceSchedule = {
|
|
||||||
child_id: 1,
|
|
||||||
cycle_length_weeks: 2,
|
|
||||||
anchor_date: "2026-09-07",
|
|
||||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const INITIAL_OVERRIDES: PresenceOverride[] = [
|
|
||||||
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TODAY = "2026-09-23";
|
|
||||||
const OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
|
|
||||||
const CONSEQUENCE_PATH = `${OVERRIDES_PATH}/consequence`;
|
|
||||||
|
|
||||||
function jsonResponse(body: unknown, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Call {
|
|
||||||
method: string;
|
|
||||||
path: string;
|
|
||||||
body: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
let overrides: PresenceOverride[];
|
|
||||||
let consequence: (body: Record<string, unknown>) => Response | Promise<Response>;
|
|
||||||
let createResponse: (body: Record<string, unknown>) => Response;
|
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
function calls(): Call[] {
|
|
||||||
return fetchMock.mock.calls.map(([url, init]: [string, RequestInit | undefined]) => ({
|
|
||||||
method: init?.method ?? "GET",
|
|
||||||
path: new URL(String(url), "http://x").pathname,
|
|
||||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function consequenceBodies(): unknown[] {
|
|
||||||
return calls()
|
|
||||||
.filter((c) => c.path === CONSEQUENCE_PATH)
|
|
||||||
.map((c) => c.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function routeFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
|
||||||
const path = new URL(url, "http://x").pathname;
|
|
||||||
const method = init.method ?? "GET";
|
|
||||||
const body = init.body ? JSON.parse(String(init.body)) : undefined;
|
|
||||||
if (path === CONSEQUENCE_PATH && method === "POST") return consequence(body);
|
|
||||||
if (path === OVERRIDES_PATH && method === "POST") return createResponse(body);
|
|
||||||
const deleteMatch = /\/presence-overrides\/(\d+)$/.exec(path);
|
|
||||||
if (deleteMatch && method === "DELETE") {
|
|
||||||
overrides = overrides.filter((o) => o.id !== Number(deleteMatch[1]));
|
|
||||||
return jsonResponse({ status: "ok" });
|
|
||||||
}
|
|
||||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
|
||||||
const scheduleMatch = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
|
||||||
if (scheduleMatch) {
|
|
||||||
return jsonResponse({ schedule: Number(scheduleMatch[1]) === 1 ? DECLAN : null });
|
|
||||||
}
|
|
||||||
if (path === OVERRIDES_PATH) return jsonResponse({ overrides });
|
|
||||||
return jsonResponse({ detail: "unexpected" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deferred() {
|
|
||||||
let resolve!: (response: Response) => void;
|
|
||||||
const promise = new Promise<Response>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
const pastDebounce = () => new Promise((r) => setTimeout(r, CONSEQUENCE_DEBOUNCE_MS + 150));
|
|
||||||
|
|
||||||
function headline(): string {
|
|
||||||
return screen.getByTestId("override-consequence-headline").textContent ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function warningBody(): string {
|
|
||||||
return screen.getByTestId("override-consequence").querySelector(".override-warning-body")?.textContent ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveButton(): HTMLButtonElement {
|
|
||||||
return screen.getByRole("button", { name: "Save override" }) as HTMLButtonElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDate(label: "From" | "Through", value: string) {
|
|
||||||
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditor() {
|
|
||||||
render(<ScheduleTab today={TODAY} />);
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
|
||||||
await screen.findByRole("heading", { name: "Presence override" });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("OverrideEditor", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
overrides = [...INITIAL_OVERRIDES];
|
|
||||||
consequence = () => jsonResponse({ removed: 4 });
|
|
||||||
createResponse = (body) => {
|
|
||||||
const created: PresenceOverride = {
|
|
||||||
id: 50,
|
|
||||||
child_id: body.child_id as number,
|
|
||||||
start_date: body.start_date as string,
|
|
||||||
end_date: body.end_date as string,
|
|
||||||
is_present: body.is_present as boolean,
|
|
||||||
note: (body.note as string | undefined) ?? null,
|
|
||||||
};
|
|
||||||
overrides = [...overrides, created];
|
|
||||||
return jsonResponse(created, 201);
|
|
||||||
};
|
|
||||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the pushed screen per ADMIN-SPEC §6", async () => {
|
|
||||||
await openEditor();
|
|
||||||
expect(screen.getByText("Beats the custody pattern for these dates")).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Back" })).toBeTruthy();
|
|
||||||
const kids = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
|
||||||
expect(kids.map((b) => [b.textContent, b.getAttribute("aria-pressed")])).toEqual([
|
|
||||||
["Declan", "true"],
|
|
||||||
["Maeve", "false"],
|
|
||||||
["Rory", "false"],
|
|
||||||
]);
|
|
||||||
const status = within(screen.getByRole("group", { name: "Status for these dates" }));
|
|
||||||
expect(status.getByRole("button", { name: "Away" }).getAttribute("aria-pressed")).toBe("true");
|
|
||||||
expect(status.getByRole("button", { name: "Home" }).getAttribute("aria-pressed")).toBe("false");
|
|
||||||
expect((screen.getByLabelText("From") as HTMLInputElement).value).toBe(TODAY);
|
|
||||||
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe(TODAY);
|
|
||||||
expect(screen.getByLabelText("Reason (optional)")).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the live consequence count from the API and recounts on every change", async () => {
|
|
||||||
await openEditor();
|
|
||||||
// Save is off until the count is on screen: the warning precedes saving.
|
|
||||||
expect(saveButton().disabled).toBe(true);
|
|
||||||
await waitFor(() => expect(headline()).toBe("This removes 4 upcoming tasks"));
|
|
||||||
expect(consequenceBodies()).toEqual([
|
|
||||||
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
|
|
||||||
]);
|
|
||||||
expect(warningBody()).toBe(
|
|
||||||
"Declan's open tasks Sep 23 are removed while away. Completed days are untouched.",
|
|
||||||
);
|
|
||||||
expect(saveButton().disabled).toBe(false);
|
|
||||||
|
|
||||||
consequence = () => jsonResponse({ removed: 9 });
|
|
||||||
setDate("Through", "2026-09-27");
|
|
||||||
await waitFor(() => expect(headline()).toBe("This removes 9 upcoming tasks"));
|
|
||||||
expect(consequenceBodies()[1]).toEqual({
|
|
||||||
child_id: 1,
|
|
||||||
start_date: TODAY,
|
|
||||||
end_date: "2026-09-27",
|
|
||||||
is_present: false,
|
|
||||||
});
|
|
||||||
expect(warningBody()).toBe(
|
|
||||||
"Declan's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
|
|
||||||
);
|
|
||||||
|
|
||||||
consequence = () => jsonResponse({ removed: 2 });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
|
||||||
await waitFor(() => expect(headline()).toBe("This removes 2 upcoming tasks"));
|
|
||||||
expect(consequenceBodies()[2]).toEqual({
|
|
||||||
child_id: 2,
|
|
||||||
start_date: TODAY,
|
|
||||||
end_date: "2026-09-27",
|
|
||||||
is_present: false,
|
|
||||||
});
|
|
||||||
expect(warningBody()).toBe(
|
|
||||||
"Maeve's open tasks Sep 23 – 27 are removed while away. Completed days are untouched.",
|
|
||||||
);
|
|
||||||
|
|
||||||
// A Home override removes nothing by definition: no request, count 0.
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
|
||||||
expect(headline()).toBe("This removes 0 upcoming tasks");
|
|
||||||
expect(warningBody()).toBe(
|
|
||||||
"Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
|
|
||||||
);
|
|
||||||
await pastDebounce();
|
|
||||||
expect(consequenceBodies()).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders the API's count verbatim — completed instances are excluded server-side", async () => {
|
|
||||||
// The range holds 3 open instances and 1 already completed. The API
|
|
||||||
// excludes the completed one (D-005; proven by
|
|
||||||
// tests/test_api_admin_presence_consequence.py::test_completed_instance_in_range_is_not_counted
|
|
||||||
// and ::test_count_matches_a_direct_materialise_comparison), so it answers 3,
|
|
||||||
// and the editor shows exactly that — it never counts days on its own.
|
|
||||||
consequence = () => jsonResponse({ removed: 3 });
|
|
||||||
await openEditor();
|
|
||||||
setDate("Through", "2026-09-25");
|
|
||||||
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
|
|
||||||
expect(warningBody()).toBe(
|
|
||||||
"Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a stale consequence response never overwrites a newer one", async () => {
|
|
||||||
const first = deferred();
|
|
||||||
const second = deferred();
|
|
||||||
const pending = [first, second];
|
|
||||||
consequence = () => pending.shift()!.promise;
|
|
||||||
await openEditor();
|
|
||||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
|
||||||
|
|
||||||
setDate("Through", "2026-09-26");
|
|
||||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(2));
|
|
||||||
|
|
||||||
second.resolve(jsonResponse({ removed: 5 }));
|
|
||||||
await waitFor(() => expect(headline()).toBe("This removes 5 upcoming tasks"));
|
|
||||||
first.resolve(jsonResponse({ removed: 99 }));
|
|
||||||
await pastDebounce();
|
|
||||||
expect(headline()).toBe("This removes 5 upcoming tasks");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("debounces rapid edits into one request for the final range", async () => {
|
|
||||||
await openEditor();
|
|
||||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
|
||||||
setDate("Through", "2026-09-24");
|
|
||||||
setDate("Through", "2026-09-25");
|
|
||||||
setDate("Through", "2026-09-26");
|
|
||||||
await pastDebounce();
|
|
||||||
expect(consequenceBodies().slice(1)).toEqual([
|
|
||||||
{ child_id: 1, start_date: TODAY, end_date: "2026-09-26", is_present: false },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an incomplete or reversed range sends no consequence request and cannot be saved", async () => {
|
|
||||||
await openEditor();
|
|
||||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
|
||||||
|
|
||||||
setDate("Through", "");
|
|
||||||
expect(headline()).toBe("Choose a valid date range");
|
|
||||||
expect(saveButton().disabled).toBe(true);
|
|
||||||
await pastDebounce();
|
|
||||||
expect(consequenceBodies()).toHaveLength(1);
|
|
||||||
|
|
||||||
setDate("Through", "2026-09-20"); // before From
|
|
||||||
expect(headline()).toBe("Choose a valid date range");
|
|
||||||
await pastDebounce();
|
|
||||||
expect(consequenceBodies()).toHaveLength(1);
|
|
||||||
fireEvent.click(saveButton());
|
|
||||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a malformed From date sends no consequence request and cannot be saved", async () => {
|
|
||||||
await openEditor();
|
|
||||||
await waitFor(() => expect(consequenceBodies()).toHaveLength(1));
|
|
||||||
|
|
||||||
setDate("From", "2026-02-30");
|
|
||||||
expect(headline()).toBe("Choose a valid date range");
|
|
||||||
expect(saveButton().disabled).toBe(true);
|
|
||||||
await pastDebounce();
|
|
||||||
expect(consequenceBodies()).toHaveLength(1);
|
|
||||||
fireEvent.click(saveButton());
|
|
||||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Save sends the exact POST body, closes the editor and shows the new row", async () => {
|
|
||||||
await openEditor();
|
|
||||||
setDate("From", "2026-09-24");
|
|
||||||
setDate("Through", "2026-09-26");
|
|
||||||
fireEvent.change(screen.getByLabelText("Reason (optional)"), {
|
|
||||||
target: { value: " Trip with grandparents " },
|
|
||||||
});
|
|
||||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
|
||||||
const before = calls().length;
|
|
||||||
const schedulesBefore = calls().filter((c) => c.path.endsWith("/presence-schedule")).length;
|
|
||||||
|
|
||||||
fireEvent.click(saveButton());
|
|
||||||
const row = await screen.findByTestId("override-50");
|
|
||||||
expect(row.textContent).toContain("Declan away · Sep 24 – 26");
|
|
||||||
expect(row.textContent).toContain("Trip with grandparents");
|
|
||||||
expect(screen.queryByRole("heading", { name: "Presence override" })).toBeNull();
|
|
||||||
|
|
||||||
const after = calls().slice(before);
|
|
||||||
const writes = after.filter((c) => c.method !== "GET");
|
|
||||||
// D-005: saving sends ONLY the create request — never a delete of an
|
|
||||||
// instance or an override. The API's regenerate path keeps completed
|
|
||||||
// instances (tests/test_materialize.py::
|
|
||||||
// test_regenerate_for_definition_rebuilds_horizon_and_preserves_completed).
|
|
||||||
expect(writes).toEqual([
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: OVERRIDES_PATH,
|
|
||||||
body: {
|
|
||||||
child_id: 1,
|
|
||||||
start_date: "2026-09-24",
|
|
||||||
end_date: "2026-09-26",
|
|
||||||
is_present: false,
|
|
||||||
note: "Trip with grandparents",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(after.some((c) => c.method === "DELETE")).toBe(false);
|
|
||||||
// The overrides and the presence schedules are refetched for the grid.
|
|
||||||
expect(after.some((c) => c.method === "GET" && c.path === OVERRIDES_PATH)).toBe(true);
|
|
||||||
expect(calls().filter((c) => c.path.endsWith("/presence-schedule")).length).toBeGreaterThan(
|
|
||||||
schedulesBefore,
|
|
||||||
);
|
|
||||||
// The grid reflects the new override.
|
|
||||||
expect(document.querySelector('[data-date="2026-09-25"]')?.getAttribute("data-state")).toBe(
|
|
||||||
"override-away",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits an empty reason and saves a Home override", async () => {
|
|
||||||
await openEditor();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Home" }));
|
|
||||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
|
||||||
fireEvent.click(saveButton());
|
|
||||||
await screen.findByTestId("override-50");
|
|
||||||
const post = calls().find((c) => c.method === "POST" && c.path === OVERRIDES_PATH);
|
|
||||||
expect(post?.body).toEqual({
|
|
||||||
child_id: 1,
|
|
||||||
start_date: TODAY,
|
|
||||||
end_date: TODAY,
|
|
||||||
is_present: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a 422 shows the API detail and keeps the form", async () => {
|
|
||||||
createResponse = () => jsonResponse({ detail: "overlaps an existing override" }, 422);
|
|
||||||
await openEditor();
|
|
||||||
setDate("Through", "2026-09-25");
|
|
||||||
fireEvent.change(screen.getByLabelText("Reason (optional)"), { target: { value: "Camp" } });
|
|
||||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
|
||||||
|
|
||||||
fireEvent.click(saveButton());
|
|
||||||
const alert = await screen.findByTestId("override-error");
|
|
||||||
expect(alert.textContent).toBe("overlaps an existing override");
|
|
||||||
expect(screen.getByRole("heading", { name: "Presence override" })).toBeTruthy();
|
|
||||||
expect((screen.getByLabelText("Through") as HTMLInputElement).value).toBe("2026-09-25");
|
|
||||||
expect((screen.getByLabelText("Reason (optional)") as HTMLInputElement).value).toBe("Camp");
|
|
||||||
expect(saveButton().disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Cancel and Back close the editor without writing", async () => {
|
|
||||||
await openEditor();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Back" }));
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Add opens the editor for the selected child", async () => {
|
|
||||||
render(<ScheduleTab today={TODAY} />);
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
|
||||||
const kids = within(await screen.findByRole("group", { name: "Child" }));
|
|
||||||
expect(kids.getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe("true");
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(consequenceBodies()).toEqual([
|
|
||||||
{ child_id: 2, start_date: TODAY, end_date: TODAY, is_present: false },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the trash button deletes the override only after confirm", async () => {
|
|
||||||
render(<ScheduleTab today={TODAY} />);
|
|
||||||
const row = await screen.findByTestId("override-41");
|
|
||||||
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: "Keep" }));
|
|
||||||
expect(calls().some((c) => c.method === "DELETE")).toBe(false);
|
|
||||||
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: "Delete override" }));
|
|
||||||
const confirm = within(row).getByRole("group", { name: "Confirm delete" });
|
|
||||||
fireEvent.click(within(confirm).getByRole("button", { name: "Delete" }));
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("override-41")).toBeNull());
|
|
||||||
expect(calls().filter((c) => c.method === "DELETE")).toEqual([
|
|
||||||
{ method: "DELETE", path: `${OVERRIDES_PATH}/41`, body: undefined },
|
|
||||||
]);
|
|
||||||
expect(screen.getByText("No overrides for Declan.")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("OverrideEditor focus", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
overrides = [...INITIAL_OVERRIDES];
|
|
||||||
consequence = () => jsonResponse({ removed: 4 });
|
|
||||||
createResponse = (body) => {
|
|
||||||
const created = { id: 50, note: null, ...body } as PresenceOverride;
|
|
||||||
overrides = [...overrides, created];
|
|
||||||
return jsonResponse(created, 201);
|
|
||||||
};
|
|
||||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function dialog(): HTMLElement {
|
|
||||||
return screen.getByRole("dialog", { name: "Presence override" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function addButton(): HTMLElement {
|
|
||||||
return screen.getByRole("button", { name: "Add" });
|
|
||||||
}
|
|
||||||
|
|
||||||
it("moves focus into the editor on open", async () => {
|
|
||||||
await openEditor();
|
|
||||||
expect(document.activeElement).toBe(screen.getByRole("heading", { name: "Presence override" }));
|
|
||||||
expect(dialog().contains(document.activeElement)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps Tab and Shift+Tab inside the editor", async () => {
|
|
||||||
await openEditor();
|
|
||||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
|
||||||
const back = screen.getByRole("button", { name: "Back" });
|
|
||||||
|
|
||||||
saveButton().focus();
|
|
||||||
fireEvent.keyDown(saveButton(), { key: "Tab" });
|
|
||||||
expect(document.activeElement).toBe(back);
|
|
||||||
|
|
||||||
fireEvent.keyDown(back, { key: "Tab", shiftKey: true });
|
|
||||||
expect(document.activeElement).toBe(saveButton());
|
|
||||||
|
|
||||||
// From the focused heading (not in the tab order) Shift+Tab wraps to the end.
|
|
||||||
const title = screen.getByRole("heading", { name: "Presence override" });
|
|
||||||
title.focus();
|
|
||||||
fireEvent.keyDown(title, { key: "Tab", shiftKey: true });
|
|
||||||
expect(document.activeElement).toBe(saveButton());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("makes the tab bar inert while open and restores it on close", async () => {
|
|
||||||
render(<App />);
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Schedule" }));
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
fireEvent.click(addButton());
|
|
||||||
await screen.findByRole("heading", { name: "Presence override" });
|
|
||||||
|
|
||||||
const tabbar = document.querySelector(".admin-tabbar") as HTMLElement;
|
|
||||||
expect(tabbar.hasAttribute("inert")).toBe(true);
|
|
||||||
expect(tabbar.getAttribute("aria-hidden")).toBe("true");
|
|
||||||
expect(dialog().closest("[inert]")).toBeNull();
|
|
||||||
|
|
||||||
fireEvent.keyDown(dialog(), { key: "Escape" });
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
expect(tabbar.hasAttribute("inert")).toBe(false);
|
|
||||||
expect(tabbar.hasAttribute("aria-hidden")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Escape cancels without writing and returns focus to Add", async () => {
|
|
||||||
await openEditor();
|
|
||||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: "Escape" });
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
|
||||||
expect(document.activeElement).toBe(addButton());
|
|
||||||
expect(calls().some((c) => c.method === "POST" && c.path === OVERRIDES_PATH)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Escape does nothing while saving; focus returns to Add after the save", async () => {
|
|
||||||
const gate = deferred();
|
|
||||||
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
|
||||||
const path = new URL(url, "http://x").pathname;
|
|
||||||
if (path === OVERRIDES_PATH && init?.method === "POST") await gate.promise;
|
|
||||||
return routeFetch(url, init);
|
|
||||||
});
|
|
||||||
await openEditor();
|
|
||||||
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
|
||||||
fireEvent.click(saveButton());
|
|
||||||
expect(screen.getByRole("button", { name: "Saving…" })).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.keyDown(dialog(), { key: "Escape" });
|
|
||||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
|
||||||
|
|
||||||
gate.resolve(new Response(null));
|
|
||||||
await screen.findByTestId("override-50");
|
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
|
||||||
expect(document.activeElement).toBe(addButton());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Back and Cancel return focus to Add", async () => {
|
|
||||||
await openEditor();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
expect(document.activeElement).toBe(addButton());
|
|
||||||
|
|
||||||
fireEvent.click(addButton());
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
expect(document.activeElement).toBe(addButton());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("range validation", () => {
|
|
||||||
it("accepts only strict ISO calendar dates in order", () => {
|
|
||||||
expect(isIsoDate("2026-09-23")).toBe(true);
|
|
||||||
expect(isIsoDate("2026-02-30")).toBe(false);
|
|
||||||
expect(isIsoDate("2026-9-3")).toBe(false);
|
|
||||||
expect(isIsoDate("")).toBe(false);
|
|
||||||
expect(isValidRange("2026-09-23", "2026-09-23")).toBe(true);
|
|
||||||
expect(isValidRange("2026-09-23", "2026-09-22")).toBe(false);
|
|
||||||
expect(isValidRange("2026-09-23", "")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
/**
|
|
||||||
* Presence override editor — the pushed screen of design/ADMIN-SPEC.md §6.
|
|
||||||
*
|
|
||||||
* The consequence warning is counted by the API (POST
|
|
||||||
* /presence-overrides/consequence) before anything is saved; Save stays off
|
|
||||||
* until that count is on screen. Saving only ever sends the create request:
|
|
||||||
* the API never removes an instance that already has a completion event (D-005).
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
|
||||||
import { ApiForbiddenError } from "../api/client";
|
|
||||||
import { ApiRequestError, type AdminChild } from "../api/definitions";
|
|
||||||
import {
|
|
||||||
createPresenceOverride,
|
|
||||||
previewOverrideConsequence,
|
|
||||||
type PresenceOverride,
|
|
||||||
type PresenceOverrideBody,
|
|
||||||
} from "../api/presence";
|
|
||||||
import { dayNumber, isoFromDayNumber } from "./cycle";
|
|
||||||
import { dateRange } from "./format";
|
|
||||||
import { AlertCircleGlyph, ChevronLeftGlyph } from "./glyphs";
|
|
||||||
import "./OverrideEditor.css";
|
|
||||||
|
|
||||||
/** How long the form must sit still before the consequence is recounted. */
|
|
||||||
export const CONSEQUENCE_DEBOUNCE_MS = 300;
|
|
||||||
|
|
||||||
/** Strict "YYYY-MM-DD" naming a real calendar day (no 2026-02-30). */
|
|
||||||
export function isIsoDate(value: string): boolean {
|
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
|
||||||
return isoFromDayNumber(dayNumber(value)) === value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidRange(start: string, end: string): boolean {
|
|
||||||
return isIsoDate(start) && isIsoDate(end) && start <= end;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Consequence =
|
|
||||||
| { kind: "invalid" }
|
|
||||||
| { kind: "counting" }
|
|
||||||
| { kind: "ready"; removed: number }
|
|
||||||
| { kind: "error"; message: string };
|
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string): string {
|
|
||||||
if (error instanceof ApiForbiddenError) return error.message;
|
|
||||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FOCUSABLE =
|
|
||||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make everything outside `root` inert (siblings of each ancestor up to
|
|
||||||
* <body>), so the tab bar behind the pushed screen cannot take focus.
|
|
||||||
* Returns the undo.
|
|
||||||
*/
|
|
||||||
function inertOutside(root: HTMLElement): () => void {
|
|
||||||
const made: HTMLElement[] = [];
|
|
||||||
for (let node: HTMLElement = root; node.parentElement && node !== document.body; node = node.parentElement) {
|
|
||||||
for (const sibling of Array.from(node.parentElement.children)) {
|
|
||||||
if (sibling === node || !(sibling instanceof HTMLElement) || sibling.hasAttribute("inert")) continue;
|
|
||||||
sibling.setAttribute("inert", "");
|
|
||||||
sibling.setAttribute("aria-hidden", "true");
|
|
||||||
made.push(sibling);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
for (const el of made) {
|
|
||||||
el.removeAttribute("inert");
|
|
||||||
el.removeAttribute("aria-hidden");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tasksPhrase(count: number): string {
|
|
||||||
return `${count} upcoming ${count === 1 ? "task" : "tasks"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverrideEditorProps {
|
|
||||||
/** Active children, in display order. */
|
|
||||||
childOptions: AdminChild[];
|
|
||||||
initialChildId: number;
|
|
||||||
/** "YYYY-MM-DD"; the date fields start here. */
|
|
||||||
today: string;
|
|
||||||
onCancel: () => void;
|
|
||||||
onSaved: (override: PresenceOverride) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OverrideEditor({
|
|
||||||
childOptions,
|
|
||||||
initialChildId,
|
|
||||||
today,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
}: OverrideEditorProps) {
|
|
||||||
const [childId, setChildId] = useState(initialChildId);
|
|
||||||
const [isPresent, setIsPresent] = useState(false);
|
|
||||||
const [startDate, setStartDate] = useState(today);
|
|
||||||
const [endDate, setEndDate] = useState(today);
|
|
||||||
const [note, setNote] = useState("");
|
|
||||||
const [consequence, setConsequence] = useState<Consequence>({ kind: "counting" });
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
// Every form change bumps the sequence; only the latest request may render.
|
|
||||||
const requestSeq = useRef(0);
|
|
||||||
const screenRef = useRef<HTMLDivElement>(null);
|
|
||||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
|
||||||
|
|
||||||
// Modal focus: move in on open and keep the background out of reach. The
|
|
||||||
// opener hands focus back to its Add control once this screen closes.
|
|
||||||
useEffect(() => {
|
|
||||||
titleRef.current?.focus();
|
|
||||||
return screenRef.current ? inertOutside(screenRef.current) : undefined;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!saving) onCancel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== "Tab" || !screenRef.current) return;
|
|
||||||
const focusable = Array.from(screenRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
|
||||||
if (focusable.length === 0) return;
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
const current = document.activeElement;
|
|
||||||
const outside = !focusable.includes(current as HTMLElement);
|
|
||||||
if (event.shiftKey && (current === first || outside)) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && (current === last || outside)) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = childOptions.find((c) => c.id === childId) ?? childOptions[0];
|
|
||||||
const rangeValid = isValidRange(startDate, endDate);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const seq = ++requestSeq.current;
|
|
||||||
if (!rangeValid) {
|
|
||||||
setConsequence({ kind: "invalid" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isPresent) {
|
|
||||||
// Keeping a child home removes nothing by definition.
|
|
||||||
setConsequence({ kind: "ready", removed: 0 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConsequence({ kind: "counting" });
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
previewOverrideConsequence({
|
|
||||||
child_id: childId,
|
|
||||||
start_date: startDate,
|
|
||||||
end_date: endDate,
|
|
||||||
is_present: false,
|
|
||||||
})
|
|
||||||
.then((removed) => {
|
|
||||||
if (seq === requestSeq.current) setConsequence({ kind: "ready", removed });
|
|
||||||
})
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
if (seq !== requestSeq.current) return;
|
|
||||||
setConsequence({
|
|
||||||
kind: "error",
|
|
||||||
message: errorMessage(err, "The upcoming tasks could not be counted. Try again."),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, CONSEQUENCE_DEBOUNCE_MS);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [childId, startDate, endDate, isPresent, rangeValid]);
|
|
||||||
|
|
||||||
const canSave = rangeValid && consequence.kind === "ready" && !saving;
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
if (!canSave) return;
|
|
||||||
const body: PresenceOverrideBody = {
|
|
||||||
child_id: childId,
|
|
||||||
start_date: startDate,
|
|
||||||
end_date: endDate,
|
|
||||||
is_present: isPresent,
|
|
||||||
};
|
|
||||||
const reason = note.trim();
|
|
||||||
if (reason) body.note = reason;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
createPresenceOverride(body)
|
|
||||||
.then(onSaved)
|
|
||||||
.catch((err: unknown) => {
|
|
||||||
setError(errorMessage(err, "The override could not be saved. Check your connection and try again."));
|
|
||||||
setSaving(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let headline: string;
|
|
||||||
let detail: string;
|
|
||||||
const range = rangeValid ? dateRange(startDate, endDate) : "";
|
|
||||||
if (consequence.kind === "invalid") {
|
|
||||||
headline = "Choose a valid date range";
|
|
||||||
detail = "Through must be on or after From.";
|
|
||||||
} else if (consequence.kind === "counting") {
|
|
||||||
headline = "Counting upcoming tasks…";
|
|
||||||
detail = `${child.display_name} away ${range}. Completed days are untouched.`;
|
|
||||||
} else if (consequence.kind === "error") {
|
|
||||||
headline = "Upcoming tasks not counted";
|
|
||||||
detail = consequence.message;
|
|
||||||
} else {
|
|
||||||
headline = `This removes ${tasksPhrase(consequence.removed)}`;
|
|
||||||
detail = isPresent
|
|
||||||
? `${child.display_name} is home ${range}, so no tasks are removed. Completed days are untouched.`
|
|
||||||
: `${child.display_name}'s open tasks ${range} are removed while away. Completed days are untouched.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={screenRef}
|
|
||||||
className="override-screen"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="override-title"
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
>
|
|
||||||
<header className="override-header">
|
|
||||||
<button type="button" className="override-back" aria-label="Back" onClick={onCancel}>
|
|
||||||
<ChevronLeftGlyph size={20} />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h2 className="override-title" id="override-title" ref={titleRef} tabIndex={-1}>
|
|
||||||
Presence override
|
|
||||||
</h2>
|
|
||||||
<p className="override-sub">Beats the custody pattern for these dates</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="override-scroll">
|
|
||||||
<section className="override-card" aria-label="Override details">
|
|
||||||
<div className="override-field">
|
|
||||||
<span className="override-label" id="override-label-child">
|
|
||||||
Child
|
|
||||||
</span>
|
|
||||||
<div className="override-segments override-segments--3" role="group" aria-labelledby="override-label-child">
|
|
||||||
{childOptions.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
type="button"
|
|
||||||
className="override-segment override-segment--gradient"
|
|
||||||
aria-pressed={c.id === childId}
|
|
||||||
onClick={() => setChildId(c.id)}
|
|
||||||
>
|
|
||||||
{c.display_name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="override-field">
|
|
||||||
<span className="override-label" id="override-label-status">
|
|
||||||
Status for these dates
|
|
||||||
</span>
|
|
||||||
<div className="override-segments override-segments--2" role="group" aria-labelledby="override-label-status">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="override-segment override-segment--ink"
|
|
||||||
aria-pressed={!isPresent}
|
|
||||||
onClick={() => setIsPresent(false)}
|
|
||||||
>
|
|
||||||
Away
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="override-segment override-segment--ink"
|
|
||||||
aria-pressed={isPresent}
|
|
||||||
onClick={() => setIsPresent(true)}
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="override-dates">
|
|
||||||
<label className="override-field">
|
|
||||||
<span className="override-label">From</span>
|
|
||||||
<input
|
|
||||||
className="override-input override-date"
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(event) => setStartDate(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="override-field">
|
|
||||||
<span className="override-label">Through</span>
|
|
||||||
<input
|
|
||||||
className="override-input override-date"
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
min={isIsoDate(startDate) ? startDate : undefined}
|
|
||||||
onChange={(event) => setEndDate(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="override-field">
|
|
||||||
<span className="override-label">Reason (optional)</span>
|
|
||||||
<input
|
|
||||||
className="override-input"
|
|
||||||
type="text"
|
|
||||||
value={note}
|
|
||||||
onChange={(event) => setNote(event.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className="override-warning"
|
|
||||||
aria-label="Consequence"
|
|
||||||
aria-live="polite"
|
|
||||||
data-testid="override-consequence"
|
|
||||||
data-state={consequence.kind}
|
|
||||||
>
|
|
||||||
<span className="override-warning-glyph">
|
|
||||||
<AlertCircleGlyph />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="override-warning-headline" data-testid="override-consequence-headline">
|
|
||||||
{headline}
|
|
||||||
</p>
|
|
||||||
<p className="override-warning-body">{detail}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<p className="override-error" role="alert" data-testid="override-error">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="override-footer">
|
|
||||||
<button type="button" className="override-cancel" onClick={onCancel} disabled={saving}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="button" className="override-save" onClick={save} disabled={!canSave}>
|
|
||||||
{saving ? "Saving…" : "Save override"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at
|
|
||||||
the 390x844 reference; on wider viewports the column is capped and centred. */
|
|
||||||
|
|
||||||
.schedule-screen {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-header {
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border-bottom: 1px solid var(--nq-a-border);
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--nq-a-size-screen);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-sub {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
min-height: 1.4em;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
|
||||||
.schedule-scroll {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--nq-a-density-gap);
|
|
||||||
padding: 14px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-card {
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Child selector */
|
|
||||||
.schedule-children {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-child-chip {
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-radius-pill);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-child-chip[aria-pressed="true"] {
|
|
||||||
border-color: var(--nq-brand-blue);
|
|
||||||
background: var(--nq-a-selected);
|
|
||||||
color: var(--nq-brand-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Month grid card */
|
|
||||||
.schedule-month {
|
|
||||||
padding: var(--nq-a-density-card-pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-month-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-month-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-chevrons {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-chevron {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(7, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-weekday {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nq-a-ink-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day--home {
|
|
||||||
background: var(--nq-a-selected);
|
|
||||||
color: #201f7a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day--away {
|
|
||||||
background: var(--nq-a-page);
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day--override-away,
|
|
||||||
.schedule-day--override-home {
|
|
||||||
background: var(--nq-a-warning-bg);
|
|
||||||
color: var(--nq-a-warning-ink);
|
|
||||||
border-color: var(--nq-a-warning-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* An override that keeps the child home reads with a dashed border. */
|
|
||||||
.schedule-day--override-home {
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-day--today {
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
border-color: transparent;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px 12px;
|
|
||||||
margin: 10px 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 10.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-swatch {
|
|
||||||
display: inline-block;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pattern editing controls */
|
|
||||||
.schedule-controls {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px solid var(--nq-a-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
font-size: var(--nq-a-size-label);
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-field input,
|
|
||||||
.schedule-field select {
|
|
||||||
min-height: 36px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-save {
|
|
||||||
min-height: 36px;
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0 14px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-brand-gradient);
|
|
||||||
color: #fff;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-save:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-save-status {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-save-status--error {
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Presence pattern + overrides cards */
|
|
||||||
.schedule-section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-section-label {
|
|
||||||
margin: 6px 0 -2px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-text-action {
|
|
||||||
padding: 4px 0 0;
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--nq-brand-blue);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-list {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-list > li + li {
|
|
||||||
border-top: 1px solid var(--nq-a-divider);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: var(--nq-a-tap-min);
|
|
||||||
padding: var(--nq-a-density-row-pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-row-glyph {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
color: var(--nq-a-warning-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-row-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-row-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-row-meta {
|
|
||||||
font-size: var(--nq-a-size-meta);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-rule {
|
|
||||||
flex: none;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-icon-action {
|
|
||||||
flex: none;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: var(--nq-a-tap-min);
|
|
||||||
height: var(--nq-a-tap-min);
|
|
||||||
margin: -8px -10px -8px 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: none;
|
|
||||||
color: var(--nq-a-ink-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-empty {
|
|
||||||
padding: var(--nq-a-density-card-pad);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading / error */
|
|
||||||
.schedule-status {
|
|
||||||
padding: 24px 13px;
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-status--error {
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-status--error p {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-retry {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline delete confirmation in an override row */
|
|
||||||
.schedule-confirm {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-confirm-text {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nq-a-ink-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-confirm-keep,
|
|
||||||
.schedule-confirm-delete {
|
|
||||||
min-height: 32px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-confirm-keep {
|
|
||||||
border: 1px solid var(--nq-a-border);
|
|
||||||
background: var(--nq-a-surface);
|
|
||||||
color: var(--nq-a-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.schedule-confirm-delete {
|
|
||||||
border: 0;
|
|
||||||
background: var(--nq-a-danger);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
||||||
import ScheduleTab from "./ScheduleTab";
|
|
||||||
import type { AdminChild } from "../api/definitions";
|
|
||||||
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
|
|
||||||
import { storeTokens } from "../auth/oidc";
|
|
||||||
|
|
||||||
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
|
|
||||||
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHILDREN = [
|
|
||||||
child(3, "Rory", 3),
|
|
||||||
child(1, "Declan", 1),
|
|
||||||
child(2, "Maeve", 2),
|
|
||||||
child(9, "Former", 4, false),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
|
|
||||||
// Keys arrive as JSON strings, exactly as the API serializes them.
|
|
||||||
const DECLAN: PresenceSchedule = {
|
|
||||||
child_id: 1,
|
|
||||||
cycle_length_weeks: 2,
|
|
||||||
anchor_date: "2026-09-07",
|
|
||||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAEVE: PresenceSchedule = {
|
|
||||||
child_id: 2,
|
|
||||||
cycle_length_weeks: 4,
|
|
||||||
anchor_date: "2026-09-07",
|
|
||||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const OVERRIDES: PresenceOverride[] = [
|
|
||||||
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
|
|
||||||
{ id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
|
|
||||||
{ id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TODAY = "2026-09-23";
|
|
||||||
|
|
||||||
function jsonResponse(body: unknown, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
|
||||||
let putResponse: (body: unknown) => Response;
|
|
||||||
|
|
||||||
function routeFetch(url: string, init: RequestInit = {}): Response {
|
|
||||||
const path = new URL(url, "http://x").pathname;
|
|
||||||
if (init.method === "PUT") return putResponse(JSON.parse(String(init.body)));
|
|
||||||
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
|
||||||
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
|
||||||
if (match) {
|
|
||||||
const id = Number(match[1]);
|
|
||||||
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
|
|
||||||
}
|
|
||||||
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
|
|
||||||
return jsonResponse({ detail: "unexpected" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function day(date: string): HTMLElement {
|
|
||||||
const cell = document.querySelector<HTMLElement>(`[data-date="${date}"]`);
|
|
||||||
if (!cell) throw new Error(`no cell for ${date}`);
|
|
||||||
return cell;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renderReady() {
|
|
||||||
render(<ScheduleTab today={TODAY} />);
|
|
||||||
await screen.findByTestId("pattern-1");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ScheduleTab", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) });
|
|
||||||
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads active children, their schedules and the overrides", async () => {
|
|
||||||
await renderReady();
|
|
||||||
const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname);
|
|
||||||
expect(urls).toContain("/api/v1/admin/children");
|
|
||||||
expect(urls).toContain("/api/v1/admin/children/1/presence-schedule");
|
|
||||||
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
|
|
||||||
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
|
|
||||||
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
|
|
||||||
expect(urls).toContain("/api/v1/admin/presence-overrides");
|
|
||||||
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
|
||||||
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
|
|
||||||
expect(chips[0].getAttribute("aria-pressed")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("header shows the cycle length and anchor", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
|
|
||||||
expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("month grid renders home, away, override and today states from the anchor", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
|
||||||
const states: Record<string, string> = {
|
|
||||||
"2026-09-01": "away", // before the anchor: cycle week 1, Tuesday
|
|
||||||
"2026-09-05": "home", // before the anchor: cycle week 1, Saturday
|
|
||||||
"2026-09-07": "home", // anchor: week 0
|
|
||||||
"2026-09-11": "override-away",
|
|
||||||
"2026-09-15": "away", // week 1, Tuesday
|
|
||||||
"2026-09-16": "override-home",
|
|
||||||
"2026-09-19": "home", // week 1, Saturday
|
|
||||||
"2026-09-22": "home", // wrap back to week 0
|
|
||||||
"2026-09-29": "away", // week 1 again
|
|
||||||
};
|
|
||||||
for (const [date, state] of Object.entries(states)) {
|
|
||||||
expect(day(date).dataset.state, date).toBe(state);
|
|
||||||
expect(day(date).className).toContain(`schedule-day--${state}`);
|
|
||||||
expect(day(date).className).not.toContain("schedule-day--today");
|
|
||||||
}
|
|
||||||
const today = day(TODAY);
|
|
||||||
expect(today.className).toContain("schedule-day--today");
|
|
||||||
expect(today.dataset.state).toBe("home");
|
|
||||||
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
|
|
||||||
// September 2026 starts on a Tuesday: one leading pad after the initials.
|
|
||||||
const grid = screen.getByRole("group", { name: "Month grid" });
|
|
||||||
expect(grid.children[7].tagName).toBe("SPAN");
|
|
||||||
expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
|
|
||||||
|
|
||||||
const legend = screen.getByRole("list", { name: "Legend" });
|
|
||||||
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
|
|
||||||
"Home",
|
|
||||||
"Away",
|
|
||||||
"Override · away",
|
|
||||||
"Override · home",
|
|
||||||
"Today",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("navigates months with the chevrons", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Next month" }));
|
|
||||||
expect(screen.getByTestId("month-label").textContent).toBe("October 2026");
|
|
||||||
// Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away.
|
|
||||||
expect(day("2026-10-01").dataset.state).toBe("away");
|
|
||||||
expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday
|
|
||||||
expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
|
|
||||||
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("presence pattern card shows a rule per child", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
|
||||||
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
|
|
||||||
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("overrides card lists the selected child's overrides with inert actions", async () => {
|
|
||||||
await renderReady();
|
|
||||||
const list = screen.getByRole("list", { name: "Overrides" });
|
|
||||||
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
|
||||||
const away = within(list).getByTestId("override-41");
|
|
||||||
expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy();
|
|
||||||
expect(within(away).getByText("Dentist trip")).toBeTruthy();
|
|
||||||
expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy();
|
|
||||||
expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy();
|
|
||||||
expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
|
||||||
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
|
|
||||||
expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
|
|
||||||
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
|
|
||||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
|
||||||
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
|
|
||||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(day("2026-09-01").dataset.state).toBe("away");
|
|
||||||
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
|
|
||||||
|
|
||||||
// The draft already shows the edit, and every other week-1 Tuesday follows.
|
|
||||||
expect(day("2026-09-01").dataset.state).toBe("home");
|
|
||||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
|
||||||
expect(day("2026-09-08").dataset.state).toBe("home");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
|
||||||
expect(await screen.findByText("Pattern saved.")).toBeTruthy();
|
|
||||||
|
|
||||||
const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT");
|
|
||||||
expect(put).toBeTruthy();
|
|
||||||
const [url, init] = put!;
|
|
||||||
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
|
|
||||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
|
||||||
expect(JSON.parse(init.body)).toEqual({
|
|
||||||
cycle_length_weeks: 2,
|
|
||||||
anchor_date: "2026-09-07",
|
|
||||||
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(day("2026-09-15").dataset.state).toBe("home");
|
|
||||||
expect(day("2026-09-29").dataset.state).toBe("home");
|
|
||||||
expect(day("2026-09-30").dataset.state).toBe("away");
|
|
||||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves an edited anchor date and cycle length", async () => {
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } });
|
|
||||||
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } });
|
|
||||||
expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy();
|
|
||||||
// Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added).
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
|
||||||
await screen.findByText("Pattern saved.");
|
|
||||||
const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!;
|
|
||||||
expect(JSON.parse(init.body)).toEqual({
|
|
||||||
cycle_length_weeks: 3,
|
|
||||||
anchor_date: "2026-09-14",
|
|
||||||
pattern: {
|
|
||||||
"0": [0, 1, 2, 3, 4, 5, 6],
|
|
||||||
"1": [5, 6],
|
|
||||||
"2": [0, 1, 2, 3, 4, 5, 6],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a 422 shows the API detail and keeps the draft", async () => {
|
|
||||||
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
|
|
||||||
await renderReady();
|
|
||||||
fireEvent.click(day("2026-09-01"));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
|
||||||
|
|
||||||
const alert = await screen.findByRole("alert");
|
|
||||||
expect(alert.textContent).toBe("anchor_date must be a Monday");
|
|
||||||
expect(day("2026-09-01").dataset.state).toBe("home");
|
|
||||||
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
// The saved rule is unchanged.
|
|
||||||
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scroll column ends with 92px bottom padding", async () => {
|
|
||||||
await renderReady();
|
|
||||||
expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error state on a failed load and retries", async () => {
|
|
||||||
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
|
||||||
render(<ScheduleTab today={TODAY} />);
|
|
||||||
const error = await screen.findByTestId("schedule-error");
|
|
||||||
expect(error.textContent).toContain("could not be loaded");
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
|
||||||
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,557 +0,0 @@
|
|||||||
/**
|
|
||||||
* Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4).
|
|
||||||
*
|
|
||||||
* Every day's cycle position comes from the anchor date (./cycle.ts), never
|
|
||||||
* from ISO week numbers or week parity (D-004).
|
|
||||||
*/
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { ApiForbiddenError } from "../api/client";
|
|
||||||
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
|
||||||
import {
|
|
||||||
deletePresenceOverride,
|
|
||||||
fetchPresenceOverrides,
|
|
||||||
fetchPresenceSchedule,
|
|
||||||
savePresenceSchedule,
|
|
||||||
type PresenceOverride,
|
|
||||||
type PresenceSchedule,
|
|
||||||
type PresenceScheduleBody,
|
|
||||||
} from "../api/presence";
|
|
||||||
import {
|
|
||||||
cycleWeekIndex,
|
|
||||||
dayState,
|
|
||||||
fullPattern,
|
|
||||||
localTodayIso,
|
|
||||||
monthDates,
|
|
||||||
monthStart,
|
|
||||||
patternIncludes,
|
|
||||||
presenceRule,
|
|
||||||
resizePattern,
|
|
||||||
togglePatternDay,
|
|
||||||
weekdayOf,
|
|
||||||
type DayState,
|
|
||||||
} from "./cycle";
|
|
||||||
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
|
|
||||||
import {
|
|
||||||
CalendarCheckGlyph,
|
|
||||||
CalendarXGlyph,
|
|
||||||
ChevronLeftGlyph,
|
|
||||||
ChevronRightGlyph,
|
|
||||||
Trash2Glyph,
|
|
||||||
} from "./glyphs";
|
|
||||||
import OverrideEditor from "./OverrideEditor";
|
|
||||||
import "./ScheduleTab.css";
|
|
||||||
|
|
||||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
|
||||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
|
||||||
|
|
||||||
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
|
|
||||||
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
|
|
||||||
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
|
|
||||||
|
|
||||||
export const STATE_LABELS: Record<DayState, string> = {
|
|
||||||
home: "Home",
|
|
||||||
away: "Away",
|
|
||||||
"override-away": "Override · away",
|
|
||||||
"override-home": "Override · home",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Loaded {
|
|
||||||
children: AdminChild[];
|
|
||||||
schedules: Record<number, PresenceSchedule | null>;
|
|
||||||
overrides: PresenceOverride[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoadState =
|
|
||||||
| { kind: "loading" }
|
|
||||||
| { kind: "error"; message: string }
|
|
||||||
| { kind: "ready"; data: Loaded };
|
|
||||||
|
|
||||||
type DeleteState =
|
|
||||||
| { kind: "idle" }
|
|
||||||
| { kind: "confirming"; id: number }
|
|
||||||
| { kind: "deleting"; id: number }
|
|
||||||
| { kind: "error"; id: number; message: string };
|
|
||||||
|
|
||||||
type SaveState =
|
|
||||||
| { kind: "idle" }
|
|
||||||
| { kind: "saving" }
|
|
||||||
| { kind: "saved" }
|
|
||||||
| { kind: "error"; message: string };
|
|
||||||
|
|
||||||
async function loadSchedule(): Promise<Loaded> {
|
|
||||||
const children = (await fetchChildren())
|
|
||||||
.filter((child) => child.is_active)
|
|
||||||
.sort((a, b) => a.sort_order - b.sort_order);
|
|
||||||
const [schedules, overrides] = await Promise.all([
|
|
||||||
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
|
|
||||||
fetchPresenceOverrides(),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
children,
|
|
||||||
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
|
|
||||||
overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadErrorMessage(error: unknown): string {
|
|
||||||
return error instanceof ApiForbiddenError
|
|
||||||
? error.message
|
|
||||||
: "The schedule could not be loaded. Check your connection and try again.";
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveErrorMessage(
|
|
||||||
error: unknown,
|
|
||||||
fallback = "The pattern could not be saved. Check your connection and try again.",
|
|
||||||
): string {
|
|
||||||
if (error instanceof ApiForbiddenError) return error.message;
|
|
||||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScreenHeader({ sub }: { sub: string }) {
|
|
||||||
return (
|
|
||||||
<header className="schedule-header">
|
|
||||||
<h2 className="schedule-title">Schedule</h2>
|
|
||||||
<p className="schedule-sub">{sub}</p>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonthGridProps {
|
|
||||||
month: string;
|
|
||||||
today: string;
|
|
||||||
schedule: PresenceScheduleBody;
|
|
||||||
/** Null when the child has no saved schedule and no edits: home every day. */
|
|
||||||
effective: PresenceScheduleBody | null;
|
|
||||||
overrides: PresenceOverride[];
|
|
||||||
onMonth: (delta: number) => void;
|
|
||||||
onToggle: (date: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) {
|
|
||||||
const dates = monthDates(month);
|
|
||||||
const leading = weekdayOf(month);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="schedule-month-head">
|
|
||||||
<span className="schedule-month-label" data-testid="month-label">
|
|
||||||
{monthLabel(month)}
|
|
||||||
</span>
|
|
||||||
<div className="schedule-chevrons">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="schedule-chevron"
|
|
||||||
aria-label="Previous month"
|
|
||||||
onClick={() => onMonth(-1)}
|
|
||||||
>
|
|
||||||
<ChevronLeftGlyph />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="schedule-chevron"
|
|
||||||
aria-label="Next month"
|
|
||||||
onClick={() => onMonth(1)}
|
|
||||||
>
|
|
||||||
<ChevronRightGlyph />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="schedule-grid" role="group" aria-label="Month grid">
|
|
||||||
{WEEKDAY_INITIALS.map((initial, i) => (
|
|
||||||
<span key={`wd-${i}`} className="schedule-weekday" aria-hidden="true">
|
|
||||||
{initial}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{Array.from({ length: leading }, (_, i) => (
|
|
||||||
<span key={`pad-${i}`} aria-hidden="true" />
|
|
||||||
))}
|
|
||||||
{dates.map((date) => {
|
|
||||||
const state = dayState(effective, overrides, date);
|
|
||||||
const isToday = date === today;
|
|
||||||
let className = `schedule-day schedule-day--${state}`;
|
|
||||||
if (isToday) className += " schedule-day--today";
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={date}
|
|
||||||
type="button"
|
|
||||||
className={className}
|
|
||||||
data-date={date}
|
|
||||||
data-state={state}
|
|
||||||
aria-pressed={patternIncludes(schedule, date)}
|
|
||||||
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
|
|
||||||
onClick={() => onToggle(date)}
|
|
||||||
>
|
|
||||||
{Number(date.slice(8))}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<ul className="schedule-legend" aria-label="Legend">
|
|
||||||
{(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
|
|
||||||
<li key={state} className="schedule-legend-item">
|
|
||||||
<span className={`schedule-swatch schedule-day--${state}`} aria-hidden="true" />
|
|
||||||
{STATE_LABELS[state]}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
<li className="schedule-legend-item">
|
|
||||||
<span className="schedule-swatch schedule-day--today" aria-hidden="true" />
|
|
||||||
Today
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OverrideRowProps {
|
|
||||||
override: PresenceOverride;
|
|
||||||
childName: string;
|
|
||||||
deletion: DeleteState;
|
|
||||||
onAskDelete: () => void;
|
|
||||||
onKeep: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverrideRow({ override, childName, deletion, onAskDelete, onKeep, onDelete }: OverrideRowProps) {
|
|
||||||
const mine =
|
|
||||||
(deletion.kind === "confirming" || deletion.kind === "deleting") && deletion.id === override.id;
|
|
||||||
return (
|
|
||||||
<li className="schedule-row" data-testid={`override-${override.id}`}>
|
|
||||||
<span className="schedule-row-glyph">
|
|
||||||
{override.is_present ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
|
|
||||||
</span>
|
|
||||||
<div className="schedule-row-text">
|
|
||||||
<div className="schedule-row-title">
|
|
||||||
{childName} {override.is_present ? "home" : "away"} ·{" "}
|
|
||||||
{dateRange(override.start_date, override.end_date)}
|
|
||||||
</div>
|
|
||||||
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
|
|
||||||
</div>
|
|
||||||
{mine ? (
|
|
||||||
<div className="schedule-confirm" role="group" aria-label="Confirm delete">
|
|
||||||
<span className="schedule-confirm-text">Delete override?</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="schedule-confirm-keep"
|
|
||||||
onClick={onKeep}
|
|
||||||
disabled={deletion.kind === "deleting"}
|
|
||||||
>
|
|
||||||
Keep
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="schedule-confirm-delete"
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={deletion.kind === "deleting"}
|
|
||||||
>
|
|
||||||
{deletion.kind === "deleting" ? "Deleting…" : "Delete"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="schedule-icon-action"
|
|
||||||
aria-label="Delete override"
|
|
||||||
onClick={onAskDelete}
|
|
||||||
>
|
|
||||||
<Trash2Glyph />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduleTabProps {
|
|
||||||
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
|
|
||||||
today?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
|
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
|
||||||
const [attempt, setAttempt] = useState(0);
|
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
||||||
const [month, setMonth] = useState(() => monthStart(today));
|
|
||||||
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
|
|
||||||
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
|
||||||
const [deletion, setDeletion] = useState<DeleteState>({ kind: "idle" });
|
|
||||||
const addRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const editorWasOpen = useRef(false);
|
|
||||||
|
|
||||||
// The editor replaces this screen, so Add is remounted when it closes;
|
|
||||||
// hand focus back to it then.
|
|
||||||
useEffect(() => {
|
|
||||||
if (editorWasOpen.current && !editorOpen) addRef.current?.focus();
|
|
||||||
editorWasOpen.current = editorOpen;
|
|
||||||
}, [editorOpen]);
|
|
||||||
|
|
||||||
/** Reload children, schedules and overrides in place (no loading flash). */
|
|
||||||
const refresh = () =>
|
|
||||||
loadSchedule()
|
|
||||||
.then((data) => setState({ kind: "ready", data }))
|
|
||||||
.catch((error: unknown) => setState({ kind: "error", message: loadErrorMessage(error) }));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setState({ kind: "loading" });
|
|
||||||
loadSchedule()
|
|
||||||
.then((data) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setState({ kind: "ready", data });
|
|
||||||
setSelectedId((current) => current ?? data.children[0]?.id ?? null);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setState({ kind: "error", message: loadErrorMessage(error) });
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [attempt]);
|
|
||||||
|
|
||||||
if (editorOpen && state.kind === "ready" && state.data.children.length > 0) {
|
|
||||||
const { children } = state.data;
|
|
||||||
return (
|
|
||||||
<OverrideEditor
|
|
||||||
childOptions={children}
|
|
||||||
initialChildId={children.find((c) => c.id === selectedId)?.id ?? children[0].id}
|
|
||||||
today={today}
|
|
||||||
onCancel={() => setEditorOpen(false)}
|
|
||||||
onSaved={(override) => {
|
|
||||||
setEditorOpen(false);
|
|
||||||
setSelectedId(override.child_id);
|
|
||||||
void refresh();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sub = "";
|
|
||||||
let body;
|
|
||||||
if (state.kind === "loading") {
|
|
||||||
body = (
|
|
||||||
<div className="schedule-status" role="status" data-testid="schedule-loading">
|
|
||||||
Loading schedule…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (state.kind === "error") {
|
|
||||||
body = (
|
|
||||||
<div className="schedule-status schedule-status--error" role="alert" data-testid="schedule-error">
|
|
||||||
<p>{state.message}</p>
|
|
||||||
<button type="button" className="schedule-retry" onClick={() => setAttempt((n) => n + 1)}>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (state.data.children.length === 0) {
|
|
||||||
body = <div className="schedule-card schedule-empty">No children yet.</div>;
|
|
||||||
} else {
|
|
||||||
const { data } = state;
|
|
||||||
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
|
|
||||||
const saved = data.schedules[child.id] ?? null;
|
|
||||||
const draft = drafts[child.id];
|
|
||||||
// Edits start from the saved schedule, or — with none — from "home every
|
|
||||||
// day" on a 2-week cycle anchored at the displayed month's 1st.
|
|
||||||
const schedule: PresenceScheduleBody = draft ??
|
|
||||||
saved ?? {
|
|
||||||
cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS,
|
|
||||||
anchor_date: month,
|
|
||||||
pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS),
|
|
||||||
};
|
|
||||||
const effective = draft ?? saved;
|
|
||||||
const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
|
|
||||||
sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`;
|
|
||||||
|
|
||||||
const edit = (next: PresenceScheduleBody) => {
|
|
||||||
setDrafts((all) => ({ ...all, [child.id]: next }));
|
|
||||||
setSave({ kind: "idle" });
|
|
||||||
};
|
|
||||||
const toggle = (date: string) => {
|
|
||||||
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
|
||||||
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
|
|
||||||
};
|
|
||||||
const confirmDelete = (id: number) => {
|
|
||||||
setDeletion({ kind: "deleting", id });
|
|
||||||
deletePresenceOverride(id)
|
|
||||||
.then(() => refresh())
|
|
||||||
.then(() => setDeletion({ kind: "idle" }))
|
|
||||||
.catch((error: unknown) =>
|
|
||||||
setDeletion({
|
|
||||||
kind: "error",
|
|
||||||
id,
|
|
||||||
message: saveErrorMessage(
|
|
||||||
error,
|
|
||||||
"The override could not be deleted. Check your connection and try again.",
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const submit = () => {
|
|
||||||
if (!draft) return;
|
|
||||||
const childId = child.id;
|
|
||||||
setSave({ kind: "saving" });
|
|
||||||
const { cycle_length_weeks, anchor_date, pattern } = draft;
|
|
||||||
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
|
|
||||||
.then((stored) => {
|
|
||||||
setState((current) =>
|
|
||||||
current.kind === "ready"
|
|
||||||
? {
|
|
||||||
kind: "ready",
|
|
||||||
data: {
|
|
||||||
...current.data,
|
|
||||||
schedules: { ...current.data.schedules, [childId]: stored },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: current,
|
|
||||||
);
|
|
||||||
setDrafts((all) => {
|
|
||||||
const rest = { ...all };
|
|
||||||
delete rest[childId];
|
|
||||||
return rest;
|
|
||||||
});
|
|
||||||
setSave({ kind: "saved" });
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) }));
|
|
||||||
};
|
|
||||||
|
|
||||||
body = (
|
|
||||||
<>
|
|
||||||
<div className="schedule-children" role="group" aria-label="Child">
|
|
||||||
{data.children.map((c) => (
|
|
||||||
<button
|
|
||||||
key={c.id}
|
|
||||||
type="button"
|
|
||||||
className="schedule-child-chip"
|
|
||||||
aria-pressed={c.id === child.id}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedId(c.id);
|
|
||||||
setSave({ kind: "idle" });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.display_name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="schedule-card schedule-month" aria-label="Month grid card">
|
|
||||||
<MonthGrid
|
|
||||||
month={month}
|
|
||||||
today={today}
|
|
||||||
schedule={schedule}
|
|
||||||
effective={effective}
|
|
||||||
overrides={childOverrides}
|
|
||||||
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
|
|
||||||
onToggle={toggle}
|
|
||||||
/>
|
|
||||||
<div className="schedule-controls">
|
|
||||||
<label className="schedule-field">
|
|
||||||
<span>Anchor date</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={schedule.anchor_date}
|
|
||||||
onChange={(event) => {
|
|
||||||
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="schedule-field">
|
|
||||||
<span>Cycle length</span>
|
|
||||||
<select
|
|
||||||
value={schedule.cycle_length_weeks}
|
|
||||||
onChange={(event) => {
|
|
||||||
const weeks = Number(event.target.value);
|
|
||||||
edit({
|
|
||||||
...schedule,
|
|
||||||
cycle_length_weeks: weeks,
|
|
||||||
pattern: resizePattern(schedule.pattern, weeks),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{CYCLE_LENGTH_CHOICES.map((weeks) => (
|
|
||||||
<option key={weeks} value={weeks}>
|
|
||||||
{weeks} {weeks === 1 ? "week" : "weeks"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="schedule-save"
|
|
||||||
disabled={!draft || save.kind === "saving"}
|
|
||||||
onClick={submit}
|
|
||||||
>
|
|
||||||
{save.kind === "saving" ? "Saving…" : "Save pattern"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{save.kind === "error" ? (
|
|
||||||
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
|
||||||
{save.message}
|
|
||||||
</p>
|
|
||||||
) : save.kind === "saved" ? (
|
|
||||||
<p className="schedule-save-status" role="status">
|
|
||||||
Pattern saved.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<h3 className="schedule-section-label">Presence pattern</h3>
|
|
||||||
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
|
|
||||||
{data.children.map((c) => (
|
|
||||||
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
|
|
||||||
<div className="schedule-row-text">
|
|
||||||
<div className="schedule-row-title">{c.display_name}</div>
|
|
||||||
</div>
|
|
||||||
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="schedule-section-head">
|
|
||||||
<h3 className="schedule-section-label">Overrides</h3>
|
|
||||||
<button
|
|
||||||
ref={addRef}
|
|
||||||
type="button"
|
|
||||||
className="schedule-text-action"
|
|
||||||
onClick={() => setEditorOpen(true)}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{childOverrides.length > 0 ? (
|
|
||||||
<ul className="schedule-card schedule-list" aria-label="Overrides">
|
|
||||||
{childOverrides.map((override) => (
|
|
||||||
<OverrideRow
|
|
||||||
key={override.id}
|
|
||||||
override={override}
|
|
||||||
childName={child.display_name}
|
|
||||||
deletion={deletion}
|
|
||||||
onAskDelete={() => setDeletion({ kind: "confirming", id: override.id })}
|
|
||||||
onKeep={() => setDeletion({ kind: "idle" })}
|
|
||||||
onDelete={() => confirmDelete(override.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
|
|
||||||
)}
|
|
||||||
{deletion.kind === "error" ? (
|
|
||||||
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
|
||||||
{deletion.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="schedule-screen">
|
|
||||||
<ScreenHeader sub={sub} />
|
|
||||||
<div
|
|
||||||
className="schedule-scroll"
|
|
||||||
data-testid="schedule-scroll"
|
|
||||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
cycleWeekIndex,
|
|
||||||
dayState,
|
|
||||||
monthDates,
|
|
||||||
monthStart,
|
|
||||||
presenceRule,
|
|
||||||
resizePattern,
|
|
||||||
togglePatternDay,
|
|
||||||
weekdayOf,
|
|
||||||
} from "./cycle";
|
|
||||||
import type { PresenceOverride, PresenceScheduleBody } from "../api/presence";
|
|
||||||
|
|
||||||
// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only.
|
|
||||||
const SCHEDULE: PresenceScheduleBody = {
|
|
||||||
cycle_length_weeks: 2,
|
|
||||||
anchor_date: "2026-09-07",
|
|
||||||
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("cycleWeekIndex", () => {
|
|
||||||
it("follows the anchor across a month, wrapping every 2 weeks", () => {
|
|
||||||
const expected: Record<string, number> = {};
|
|
||||||
for (const date of monthDates("2026-09-01")) {
|
|
||||||
const day = Number(date.slice(8));
|
|
||||||
// Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1;
|
|
||||||
// Sep 1-6 lie before the anchor and walk back into week 1.
|
|
||||||
expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2;
|
|
||||||
}
|
|
||||||
for (const [date, week] of Object.entries(expected)) {
|
|
||||||
expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week);
|
|
||||||
}
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0);
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("walks the cycle backwards before the anchor (floor division)", () => {
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores ISO week parity across a 53-week year", () => {
|
|
||||||
// 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so
|
|
||||||
// parity would call them the same week. From the anchor they alternate.
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0);
|
|
||||||
expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("calendar helpers", () => {
|
|
||||||
it("weekdayOf is Monday=0", () => {
|
|
||||||
expect(weekdayOf("2026-09-07")).toBe(0);
|
|
||||||
expect(weekdayOf("2026-09-01")).toBe(1);
|
|
||||||
expect(weekdayOf("2026-09-06")).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("monthStart shifts across year ends", () => {
|
|
||||||
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
|
|
||||||
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
|
|
||||||
expect(monthDates("2026-02-01")).toHaveLength(28);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("dayState", () => {
|
|
||||||
const overrides: PresenceOverride[] = [
|
|
||||||
{ id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null },
|
|
||||||
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("applies overrides first, then the anchor-based pattern", () => {
|
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
|
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
|
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
|
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
|
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
|
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
|
|
||||||
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a child with no schedule as home every day", () => {
|
|
||||||
expect(dayState(null, [], "2026-09-15")).toBe("home");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("pattern editing and rule text", () => {
|
|
||||||
it("toggles one weekday of one cycle week", () => {
|
|
||||||
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
|
|
||||||
0: [0, 1, 2, 3, 4, 5, 6],
|
|
||||||
1: [1, 5, 6],
|
|
||||||
});
|
|
||||||
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resizes the pattern to the cycle length", () => {
|
|
||||||
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
|
|
||||||
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("describes the presence rule in plain language", () => {
|
|
||||||
expect(presenceRule(null)).toBe("Every day");
|
|
||||||
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
|
|
||||||
"Every day",
|
|
||||||
);
|
|
||||||
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
|
|
||||||
expect(
|
|
||||||
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
|
|
||||||
).toBe("Weeks 1 & 3 of cycle");
|
|
||||||
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
|
|
||||||
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure presence-cycle math for the Schedule tab.
|
|
||||||
*
|
|
||||||
* Cycle position comes from the anchor date ONLY, mirroring
|
|
||||||
* core/presence.py `cycle_week_index`:
|
|
||||||
* `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
|
|
||||||
* so dates before the anchor walk the cycle backwards. Never ISO week
|
|
||||||
* numbers or week parity (D-004).
|
|
||||||
*
|
|
||||||
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
|
|
||||||
* viewer offset or DST change can shift a day.
|
|
||||||
*/
|
|
||||||
import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence";
|
|
||||||
|
|
||||||
const MS_PER_DAY = 86_400_000;
|
|
||||||
|
|
||||||
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
||||||
|
|
||||||
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
|
|
||||||
export function dayNumber(iso: string): number {
|
|
||||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
||||||
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
|
||||||
const [, year, month, day] = match;
|
|
||||||
return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isoFromDayNumber(days: number): string {
|
|
||||||
return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
|
|
||||||
function mod(value: number, divisor: number): number {
|
|
||||||
return ((value % divisor) + divisor) % divisor;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
|
|
||||||
export function weekdayOf(iso: string): number {
|
|
||||||
return mod(dayNumber(iso) + 3, 7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
|
|
||||||
export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
|
|
||||||
const days = dayNumber(date) - dayNumber(anchorDate);
|
|
||||||
return mod(Math.floor(days / 7), cycleLengthWeeks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The viewer's local calendar date. */
|
|
||||||
export function localTodayIso(now: Date = new Date()): string {
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
return `${now.getFullYear()}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
|
|
||||||
export function monthStart(iso: string, delta = 0): string {
|
|
||||||
const [year, month] = iso.split("-").map(Number);
|
|
||||||
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
|
|
||||||
return date.toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every date of the month starting at `firstIso`. */
|
|
||||||
export function monthDates(firstIso: string): string[] {
|
|
||||||
const start = dayNumber(firstIso);
|
|
||||||
const end = dayNumber(monthStart(firstIso, 1));
|
|
||||||
const dates: string[] = [];
|
|
||||||
for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
|
|
||||||
return dates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Present every day for every week of the cycle (the no-schedule case). */
|
|
||||||
export function fullPattern(cycleLengthWeeks: number): PresencePattern {
|
|
||||||
const pattern: PresencePattern = {};
|
|
||||||
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
|
|
||||||
return pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DayState = "home" | "away" | "override-home" | "override-away";
|
|
||||||
|
|
||||||
export function patternIncludes(
|
|
||||||
schedule: PresenceScheduleBody,
|
|
||||||
date: string,
|
|
||||||
): boolean {
|
|
||||||
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
|
||||||
return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A date's state for one child: an override covering the date wins;
|
|
||||||
* otherwise the schedule's pattern decides (no schedule = home every day).
|
|
||||||
*/
|
|
||||||
export function dayState(
|
|
||||||
schedule: PresenceScheduleBody | null,
|
|
||||||
overrides: PresenceOverride[],
|
|
||||||
date: string,
|
|
||||||
): DayState {
|
|
||||||
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
|
|
||||||
if (override) return override.is_present ? "override-home" : "override-away";
|
|
||||||
if (!schedule) return "home";
|
|
||||||
return patternIncludes(schedule, date) ? "home" : "away";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
|
|
||||||
export function togglePatternDay(
|
|
||||||
pattern: PresencePattern,
|
|
||||||
week: number,
|
|
||||||
weekday: number,
|
|
||||||
): PresencePattern {
|
|
||||||
const days = pattern[week] ?? [];
|
|
||||||
const next = days.includes(weekday)
|
|
||||||
? days.filter((d) => d !== weekday)
|
|
||||||
: [...days, weekday].sort((a, b) => a - b);
|
|
||||||
return { ...pattern, [week]: next };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fit the pattern to a new cycle length; added weeks start present every day. */
|
|
||||||
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
|
|
||||||
const next: PresencePattern = {};
|
|
||||||
for (let week = 0; week < cycleLengthWeeks; week += 1) {
|
|
||||||
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinWeeks(weeks: number[]): string {
|
|
||||||
if (weeks.length === 1) return String(weeks[0]);
|
|
||||||
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */
|
|
||||||
export function presenceRule(schedule: PresenceScheduleBody | null): string {
|
|
||||||
if (!schedule) return "Every day";
|
|
||||||
const weeks: number[] = [];
|
|
||||||
let full = true;
|
|
||||||
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
|
|
||||||
const days = schedule.pattern[week] ?? [];
|
|
||||||
if (days.length > 0) weeks.push(week + 1);
|
|
||||||
if (days.length < 7) full = false;
|
|
||||||
}
|
|
||||||
if (full) return "Every day";
|
|
||||||
if (weeks.length === 0) return "Never home";
|
|
||||||
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Date labels for the Schedule tab. Calendar dates are formatted in UTC from
|
|
||||||
* their components, so the printed day never shifts with the viewer's offset.
|
|
||||||
*/
|
|
||||||
import { dayNumber } from "./cycle";
|
|
||||||
|
|
||||||
const LOCALE = "en-US";
|
|
||||||
|
|
||||||
function utcDate(iso: string): Date {
|
|
||||||
return new Date(dayNumber(iso) * 86_400_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function format(iso: string, options: Intl.DateTimeFormatOptions): string {
|
|
||||||
return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Tuesday, Sep 1" */
|
|
||||||
export function anchorLabel(iso: string): string {
|
|
||||||
return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "September 2026" */
|
|
||||||
export function monthLabel(iso: string): string {
|
|
||||||
return format(iso, { month: "long", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Sep 4" */
|
|
||||||
export function shortDate(iso: string): string {
|
|
||||||
return format(iso, { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */
|
|
||||||
export function dateRange(start: string, end: string): string {
|
|
||||||
if (start === end) return shortDate(start);
|
|
||||||
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
|
|
||||||
return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid),
|
|
||||||
* CDN-free like the Today and Tasks tabs' glyphs.
|
|
||||||
*/
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="m15 18-6-6 6-6" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="m9 18 6-6-6-6" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarFrame() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<path d="M8 2v4M16 2v4" />
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
||||||
<path d="M3 10h18" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarXGlyph({ size = 18 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<CalendarFrame />
|
|
||||||
<path d="m14 14-4 4M10 14l4 4" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<CalendarFrame />
|
|
||||||
<path d="m9 16 2 2 4-4" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Trash2Glyph({ size = 16 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
|
|
||||||
return (
|
|
||||||
<Glyph size={size}>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 8v4M12 16h.01" />
|
|
||||||
</Glyph>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -241,37 +241,6 @@
|
|||||||
color: var(--nq-brand-purple);
|
color: var(--nq-brand-purple);
|
||||||
}
|
}
|
||||||
|
|
||||||
.today-action:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Non-blocking Undo failure message. */
|
|
||||||
.today-action-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
padding: var(--nq-a-density-row-pad);
|
|
||||||
border-radius: var(--nq-a-density-radius);
|
|
||||||
background: var(--nq-a-danger-bg);
|
|
||||||
color: var(--nq-a-danger-strong);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-action-dismiss {
|
|
||||||
flex: none;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 12.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-empty {
|
.today-empty {
|
||||||
padding: var(--nq-a-density-card-pad);
|
padding: var(--nq-a-density-card-pad);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
|
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
|
||||||
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||||
import { TransitionsProvider } from "../api/useTransitions";
|
|
||||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
|
||||||
import { storeTokens } from "../auth/oidc";
|
import { storeTokens } from "../auth/oidc";
|
||||||
|
|
||||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||||
@@ -223,186 +221,3 @@ describe("TodayTab", () => {
|
|||||||
expect(error.textContent).toContain("nestquest-admins");
|
expect(error.textContent).toContain("nestquest-admins");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** SNAPSHOT after instance 12 ("Make bed") is reversed back to open. */
|
|
||||||
const AFTER_UNDO: AdminSnapshot = {
|
|
||||||
...SNAPSHOT,
|
|
||||||
children: SNAPSHOT.children.map((child) =>
|
|
||||||
child.child_id !== 1
|
|
||||||
? child
|
|
||||||
: {
|
|
||||||
...child,
|
|
||||||
completed_today: 2,
|
|
||||||
remaining_today: 5,
|
|
||||||
completion_pct: 29,
|
|
||||||
instances: child.instances.map((i) =>
|
|
||||||
i.id === 12 ? { ...i, state: "open" as const, completed_at: null, on_time: null } : i,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
function deferred<T>() {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((r) => {
|
|
||||||
resolve = r;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("TodayTab Undo and live updates", () => {
|
|
||||||
let fetchMock: ReturnType<typeof vi.fn>;
|
|
||||||
let snapshots: AdminSnapshot[];
|
|
||||||
let uncomplete: () => Promise<Response>;
|
|
||||||
let streams: TestStream[];
|
|
||||||
|
|
||||||
function calls(pathSuffix: string, method = "GET") {
|
|
||||||
return fetchMock.mock.calls.filter(
|
|
||||||
([url, init]) =>
|
|
||||||
new URL(String(url), "http://x").pathname.endsWith(pathSuffix) &&
|
|
||||||
(init?.method ?? "GET") === method,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
sessionStorage.clear();
|
|
||||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
|
||||||
snapshots = [SNAPSHOT, AFTER_UNDO];
|
|
||||||
uncomplete = () =>
|
|
||||||
Promise.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
|
|
||||||
streams = [];
|
|
||||||
fetchMock = vi.fn((url: string) => {
|
|
||||||
const path = new URL(url, "http://x").pathname;
|
|
||||||
if (path.endsWith("/api/v1/admin/snapshot")) {
|
|
||||||
const next = snapshots.length > 1 ? snapshots.shift()! : snapshots[0];
|
|
||||||
return Promise.resolve(jsonResponse(next));
|
|
||||||
}
|
|
||||||
if (path.endsWith("/api/v1/admin/instances/12/uncomplete")) return uncomplete();
|
|
||||||
if (path.endsWith("/api/v1/admin/events")) {
|
|
||||||
const stream = testStream();
|
|
||||||
streams.push(stream);
|
|
||||||
return Promise.resolve(stream.response);
|
|
||||||
}
|
|
||||||
return Promise.resolve(jsonResponse({ detail: "unexpected" }, 500));
|
|
||||||
});
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Undo POSTs uncomplete for the completed instance and refetches the snapshot", async () => {
|
|
||||||
render(<TodayTab />);
|
|
||||||
const row = await screen.findByTestId("attention-12");
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
|
|
||||||
const posts = calls("/api/v1/admin/instances/12/uncomplete", "POST");
|
|
||||||
expect(posts).toHaveLength(1);
|
|
||||||
expect(posts[0][1].headers.Authorization).toBe("Bearer at-123");
|
|
||||||
expect(calls("/api/v1/admin/snapshot")).toHaveLength(2);
|
|
||||||
// The reversed instance is open again: one fewer done, one more remaining.
|
|
||||||
expect(screen.getByTestId("stat-done").textContent).toBe("6Done");
|
|
||||||
expect(screen.getByTestId("stat-remaining").textContent).toBe("5Remaining");
|
|
||||||
expect(screen.queryByTestId("today-undo-error")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables Undo while the request is in flight (no double submit)", async () => {
|
|
||||||
const pending = deferred<Response>();
|
|
||||||
uncomplete = () => pending.promise;
|
|
||||||
render(<TodayTab />);
|
|
||||||
const row = await screen.findByTestId("attention-12");
|
|
||||||
const button = within(row).getByRole("button", { name: "Undo" }) as HTMLButtonElement;
|
|
||||||
|
|
||||||
fireEvent.click(button);
|
|
||||||
await waitFor(() => expect(button.disabled).toBe(true));
|
|
||||||
expect(button.textContent).toBe("Undoing…");
|
|
||||||
// Every Undo is disabled while one reversal is in flight.
|
|
||||||
const other = within(screen.getByTestId("attention-14")).getByRole("button", {
|
|
||||||
name: "Undo",
|
|
||||||
}) as HTMLButtonElement;
|
|
||||||
expect(other.disabled).toBe(true);
|
|
||||||
fireEvent.click(button);
|
|
||||||
fireEvent.click(other);
|
|
||||||
expect(calls("/api/v1/admin/instances/12/uncomplete", "POST")).toHaveLength(1);
|
|
||||||
expect(calls("/uncomplete", "POST")).toHaveLength(1);
|
|
||||||
|
|
||||||
pending.resolve(jsonResponse({ instance_id: 12, state: "open", appended: true }));
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
|
|
||||||
expect(other.disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a failed Undo shows a message and leaves the view intact", async () => {
|
|
||||||
uncomplete = () => Promise.resolve(jsonResponse({ detail: "instance 12 not found" }, 404));
|
|
||||||
render(<TodayTab />);
|
|
||||||
const row = await screen.findByTestId("attention-12");
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: "Undo" }));
|
|
||||||
|
|
||||||
const alert = await screen.findByTestId("today-undo-error");
|
|
||||||
expect(alert.textContent).toContain("The completion could not be undone: instance 12 not found");
|
|
||||||
// Still the original snapshot: nothing refetched, the row and its Undo remain usable.
|
|
||||||
expect(calls("/api/v1/admin/snapshot")).toHaveLength(1);
|
|
||||||
const button = within(screen.getByTestId("attention-12")).getByRole("button", {
|
|
||||||
name: "Undo",
|
|
||||||
}) as HTMLButtonElement;
|
|
||||||
expect(button.disabled).toBe(false);
|
|
||||||
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
|
|
||||||
|
|
||||||
fireEvent.click(within(alert).getByRole("button", { name: "Dismiss" }));
|
|
||||||
expect(screen.queryByTestId("today-undo-error")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Mark done stays inert", async () => {
|
|
||||||
render(<TodayTab />);
|
|
||||||
const row = await screen.findByTestId("attention-11");
|
|
||||||
fireEvent.click(within(row).getByRole("button", { name: "Mark done" }));
|
|
||||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a transition on the live stream refetches the snapshot without a reload", async () => {
|
|
||||||
const { unmount } = render(
|
|
||||||
<TransitionsProvider>
|
|
||||||
<TodayTab />
|
|
||||||
</TransitionsProvider>,
|
|
||||||
);
|
|
||||||
await screen.findByTestId("attention-12");
|
|
||||||
await waitFor(() => expect(streams).toHaveLength(1));
|
|
||||||
const [, init] = calls("/api/v1/admin/events")[0];
|
|
||||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
|
||||||
|
|
||||||
// An uncompletion made elsewhere, its frame split across two chunks.
|
|
||||||
const text = frame("nestquest_quest_uncompleted", {
|
|
||||||
child_id: 1,
|
|
||||||
child_name: "Declan",
|
|
||||||
instance_id: 12,
|
|
||||||
quest_title: "Make bed",
|
|
||||||
window: "morning",
|
|
||||||
due_date: "2026-09-23",
|
|
||||||
due_time: null,
|
|
||||||
occurred_at: "2026-09-23T12:00:00Z",
|
|
||||||
});
|
|
||||||
streams[0].push(text.slice(0, 25));
|
|
||||||
streams[0].push(text.slice(25));
|
|
||||||
|
|
||||||
await waitFor(() => expect(calls("/api/v1/admin/snapshot")).toHaveLength(2));
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("attention-12")).toBeNull());
|
|
||||||
// Refreshed in place: the loading state never replaced the view.
|
|
||||||
expect(screen.queryByTestId("today-loading")).toBeNull();
|
|
||||||
|
|
||||||
const signal = init.signal as AbortSignal;
|
|
||||||
expect(signal.aborted).toBe(false);
|
|
||||||
unmount();
|
|
||||||
expect(signal.aborted).toBe(true);
|
|
||||||
await waitFor(() => expect(streams[0].cancelled()).toBe(true));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
|
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
fetchSnapshot,
|
fetchSnapshot,
|
||||||
type AdminSnapshot,
|
type AdminSnapshot,
|
||||||
@@ -9,9 +9,6 @@ import {
|
|||||||
type SnapshotInstance,
|
type SnapshotInstance,
|
||||||
} from "../api/snapshot";
|
} from "../api/snapshot";
|
||||||
import { ApiForbiddenError } from "../api/client";
|
import { ApiForbiddenError } from "../api/client";
|
||||||
import { ApiRequestError } from "../api/definitions";
|
|
||||||
import { uncompleteInstance } from "../api/instances";
|
|
||||||
import { useTransitions } from "../api/useTransitions";
|
|
||||||
import {
|
import {
|
||||||
formatClockTime,
|
formatClockTime,
|
||||||
formatDueTime,
|
formatDueTime,
|
||||||
@@ -147,18 +144,11 @@ function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
|
|||||||
return [...overdue, ...completed];
|
return [...overdue, ...completed];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UndoControl {
|
function AttentionRow({ item }: { item: AttentionItem }) {
|
||||||
/** The instance whose Undo is in flight, or null. */
|
|
||||||
pendingId: number | null;
|
|
||||||
onUndo: (instanceId: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) {
|
|
||||||
const { instance, childName } = item;
|
const { instance, childName } = item;
|
||||||
const overdue = isOverdueOpen(instance);
|
const overdue = isOverdueOpen(instance);
|
||||||
const undoing = undo.pendingId === instance.id;
|
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
|
||||||
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
|
// The admin plane has no complete route yet, so both actions are inert here.
|
||||||
// yet, so that action is inert here.
|
|
||||||
return (
|
return (
|
||||||
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
||||||
<span
|
<span
|
||||||
@@ -183,21 +173,12 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{overdue ? (
|
<button
|
||||||
<button type="button" className="today-action today-action--done">
|
type="button"
|
||||||
Mark done
|
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
|
||||||
</button>
|
>
|
||||||
) : (
|
{overdue ? "Mark done" : "Undo"}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
className="today-action today-action--undo"
|
|
||||||
disabled={undo.pendingId !== null}
|
|
||||||
aria-busy={undoing}
|
|
||||||
onClick={() => undo.onUndo(instance.id)}
|
|
||||||
>
|
|
||||||
{undoing ? "Undoing…" : "Undo"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -216,25 +197,10 @@ function PermissionNote() {
|
|||||||
export default function TodayTab() {
|
export default function TodayTab() {
|
||||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||||
const [attempt, setAttempt] = useState(0);
|
const [attempt, setAttempt] = useState(0);
|
||||||
/** The next load is a refresh in place: keep the current view, no loading state. */
|
|
||||||
const silentNext = useRef(false);
|
|
||||||
const [undoingId, setUndoingId] = useState<number | null>(null);
|
|
||||||
const undoingRef = useRef(false);
|
|
||||||
const [undoError, setUndoError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
silentNext.current = true;
|
|
||||||
setAttempt((n) => n + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any transition (completed, uncompleted, missed, day complete) changes the snapshot.
|
|
||||||
useTransitions(refresh);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const silent = silentNext.current;
|
setState({ kind: "loading" });
|
||||||
silentNext.current = false;
|
|
||||||
if (!silent) setState({ kind: "loading" });
|
|
||||||
fetchSnapshot()
|
fetchSnapshot()
|
||||||
.then((snapshot) => {
|
.then((snapshot) => {
|
||||||
if (!cancelled) setState({ kind: "ready", snapshot });
|
if (!cancelled) setState({ kind: "ready", snapshot });
|
||||||
@@ -245,37 +211,13 @@ export default function TodayTab() {
|
|||||||
error instanceof ApiForbiddenError
|
error instanceof ApiForbiddenError
|
||||||
? error.message
|
? error.message
|
||||||
: "Today's snapshot could not be loaded. Check your connection and try again.";
|
: "Today's snapshot could not be loaded. Check your connection and try again.";
|
||||||
// A failed refresh keeps the snapshot already on screen.
|
setState({ kind: "error", message });
|
||||||
setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message }));
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [attempt]);
|
}, [attempt]);
|
||||||
|
|
||||||
/** Reverse one completion, then refetch the snapshot. One request at a time. */
|
|
||||||
async function undo(instanceId: number) {
|
|
||||||
if (undoingRef.current) return;
|
|
||||||
undoingRef.current = true;
|
|
||||||
setUndoingId(instanceId);
|
|
||||||
setUndoError(null);
|
|
||||||
try {
|
|
||||||
await uncompleteInstance(instanceId);
|
|
||||||
refresh();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
setUndoError(
|
|
||||||
error instanceof ApiForbiddenError
|
|
||||||
? error.message
|
|
||||||
: error instanceof ApiRequestError && error.detail
|
|
||||||
? `The completion could not be undone: ${error.detail}`
|
|
||||||
: "The completion could not be undone. Check your connection and try again.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
undoingRef.current = false;
|
|
||||||
setUndoingId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sub = "";
|
let sub = "";
|
||||||
let body;
|
let body;
|
||||||
if (state.kind === "loading") {
|
if (state.kind === "loading") {
|
||||||
@@ -309,11 +251,7 @@ export default function TodayTab() {
|
|||||||
{attention.length > 0 ? (
|
{attention.length > 0 ? (
|
||||||
<ul className="today-card today-list" aria-label="Needs attention">
|
<ul className="today-card today-list" aria-label="Needs attention">
|
||||||
{attention.map((item) => (
|
{attention.map((item) => (
|
||||||
<AttentionRow
|
<AttentionRow key={item.instance.id} item={item} />
|
||||||
key={item.instance.id}
|
|
||||||
item={item}
|
|
||||||
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
@@ -332,14 +270,6 @@ export default function TodayTab() {
|
|||||||
data-testid="today-scroll"
|
data-testid="today-scroll"
|
||||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||||
>
|
>
|
||||||
{undoError !== null ? (
|
|
||||||
<div className="today-action-error" role="alert" data-testid="today-undo-error">
|
|
||||||
<span>{undoError}</span>
|
|
||||||
<button type="button" className="today-action-dismiss" onClick={() => setUndoError(null)}>
|
|
||||||
Dismiss
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{body}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
# NestQuest API
|
|
||||||
|
|
||||||
Standalone FastAPI service owning the NestQuest database (Feature 16).
|
|
||||||
Served with ``uvicorn --factory api.app:create_app``; configuration is
|
|
||||||
environment-supplied (``api/config.py``) and every variable is required.
|
|
||||||
|
|
||||||
## Runtime dependencies
|
|
||||||
|
|
||||||
``api/requirements.txt`` is the API deployable's OWN dependency
|
|
||||||
manifest (fastapi, uvicorn, pyjwt[crypto], httpx) — deliberately kept
|
|
||||||
out of the integration's ``pyproject.toml`` dev group, which exists only
|
|
||||||
so the monorepo test venv can run the API tests.
|
|
||||||
|
|
||||||
## Container deployment (scaffold)
|
|
||||||
|
|
||||||
The image is built from the repository ROOT context (the API loads the
|
|
||||||
bundled domain core from ``custom_components/nestquest/core/`` by file
|
|
||||||
location, so both trees must be in the build context):
|
|
||||||
|
|
||||||
docker build -t nestquest-api:latest .
|
|
||||||
|
|
||||||
Run it with compose — the SQLite file lives on the named volume
|
|
||||||
``nestquest-db`` so recreating the container keeps household data:
|
|
||||||
|
|
||||||
cp deploy/.env.example deploy/.env # then fill in real values
|
|
||||||
docker compose -f deploy/compose.yaml up -d --build
|
|
||||||
|
|
||||||
``deploy/.env`` is git-ignored and supplies the secrets (panel service
|
|
||||||
token, Authentik OIDC settings); ``NESTQUEST_DB_PATH`` is fixed to
|
|
||||||
``/var/lib/nestquest/nestquest.db`` by compose.
|
|
||||||
|
|
||||||
The published port binds ``NESTQUEST_BIND_ADDRESS`` from
|
|
||||||
``deploy/.env`` and defaults to ``127.0.0.1`` (localhost only). In
|
|
||||||
production TLS is terminated by the shared Traefik reverse proxy,
|
|
||||||
which serves ``https://nestquest.cubecraftlabs.com`` (Let's Encrypt,
|
|
||||||
auto-renewed, HTTP redirected to HTTPS) and forwards to the API box
|
|
||||||
on port 8080 — so on that box set ``NESTQUEST_BIND_ADDRESS`` to its
|
|
||||||
LAN address (``10.60.1.14``). Traefik only allows
|
|
||||||
``/api/v1/panel`` from the Home Assistant host. The full deployment,
|
|
||||||
restore and verification procedure is in ``deploy/RUNBOOK.md``.
|
|
||||||
|
|
||||||
## Continuous backup (Litestream)
|
|
||||||
|
|
||||||
Compose also runs a ``litestream`` sidecar
|
|
||||||
(``litestream/litestream:0.5.17``, config ``deploy/litestream.yml``).
|
|
||||||
It mounts the same ``nestquest-db`` volume and continuously replicates
|
|
||||||
``/var/lib/nestquest/nestquest.db`` over SFTP to the Synology NAS
|
|
||||||
``CubeCraft-SRVR1`` (``joshua@10.60.1.50``), directory
|
|
||||||
``/volume1/homes/Joshua/nestquest-backups`` (``/homes/Joshua/...`` in
|
|
||||||
the NAS's chrooted SFTP view) — so the backup is outside the Docker
|
|
||||||
host and its volume. A snapshot is taken daily and a week of
|
|
||||||
snapshots plus incremental changes is retained; the NAS host key is
|
|
||||||
pinned in the config.
|
|
||||||
|
|
||||||
The SFTP login uses a dedicated keypair that lives only on the API
|
|
||||||
box and is git-ignored. Create it once, before ``up``:
|
|
||||||
|
|
||||||
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
|
|
||||||
|
|
||||||
then append ``restrict `` + the contents of ``deploy/litestream_key.pub``
|
|
||||||
to ``joshua@10.60.1.50:~/.ssh/authorized_keys``. Compose mounts the
|
|
||||||
private key read-only at ``/etc/litestream/sftp_key``. The sidecar runs
|
|
||||||
as uid/gid 999 (the API's ``nestquest`` user), so give it the key:
|
|
||||||
|
|
||||||
sudo chown 999:999 deploy/litestream_key && chmod 600 deploy/litestream_key
|
|
||||||
|
|
||||||
The sidecar needs WRITE access to the database volume: Litestream adds
|
|
||||||
its own ``_litestream_seq``/``_litestream_lock`` tables to the database,
|
|
||||||
writes the ``-wal``/``-shm`` files and checkpoints the WAL, and fails
|
|
||||||
with "unable to open database file" on a read-only mount. That access
|
|
||||||
is contained rather than removed — the sidecar runs as the non-root
|
|
||||||
volume owner with a read-only root filesystem, ``no-new-privileges``
|
|
||||||
and all Linux capabilities dropped.
|
|
||||||
|
|
||||||
Check replication with
|
|
||||||
``docker compose -f deploy/compose.yaml logs litestream`` or
|
|
||||||
``docker compose -f deploy/compose.yaml exec litestream litestream ltx /var/lib/nestquest/nestquest.db``.
|
|
||||||
To restore (high level): stop the ``api`` service, run
|
|
||||||
``litestream restore -config /etc/litestream.yml -o <new path>
|
|
||||||
/var/lib/nestquest/nestquest.db`` from a litestream container with the
|
|
||||||
same mounts, verify the restored file, move it into place on the
|
|
||||||
volume (removing any stale ``-wal``/``-shm`` files), and start ``api``
|
|
||||||
again.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# Runtime dependency manifest for the NestQuest API deployable.
|
|
||||||
#
|
|
||||||
# The monorepo's pyproject.toml carries fastapi/uvicorn/pyjwt/httpx in
|
|
||||||
# the INTEGRATION's dev group only (so the test venv can exercise the
|
|
||||||
# API); they are deliberately NOT integration runtime dependencies.
|
|
||||||
# Per that manifest's own comment, the API deployable declares its own
|
|
||||||
# runtime dependencies HERE — this file is what the container image
|
|
||||||
# installs (see the Dockerfile at the repository root).
|
|
||||||
#
|
|
||||||
# EXACT pins: the versions installed in the deployed image
|
|
||||||
# (nestquest-api:latest on the API box, read with importlib.metadata),
|
|
||||||
# so a rebuild resolves the same packages. The four direct
|
|
||||||
# dependencies come first; the rest is their resolved transitive
|
|
||||||
# closure, pinned too so nothing floats. The dev group's floors in
|
|
||||||
# pyproject.toml are unchanged. Bump deliberately, together with the
|
|
||||||
# Dockerfile's digest-pinned base image.
|
|
||||||
fastapi==0.141.1
|
|
||||||
uvicorn==0.53.0
|
|
||||||
pyjwt[crypto]==2.15.0
|
|
||||||
httpx==0.28.1
|
|
||||||
|
|
||||||
# Transitive dependencies of the above.
|
|
||||||
annotated-doc==0.0.5
|
|
||||||
annotated-types==0.8.0
|
|
||||||
anyio==4.15.1
|
|
||||||
certifi==2026.7.22
|
|
||||||
cffi==2.1.1
|
|
||||||
click==8.5.0
|
|
||||||
cryptography==50.0.1
|
|
||||||
h11==0.16.0
|
|
||||||
httpcore==1.0.9
|
|
||||||
idna==3.20
|
|
||||||
pycparser==3.0
|
|
||||||
pydantic==2.13.5
|
|
||||||
pydantic_core==2.46.5
|
|
||||||
starlette==1.7.0
|
|
||||||
typing-inspection==0.4.4
|
|
||||||
typing_extensions==4.16.0
|
|
||||||
@@ -58,24 +58,6 @@ layer the HA services share, so the two planes cannot drift:
|
|||||||
(children are never hard-deleted; overrides are deletable by
|
(children are never hard-deleted; overrides are deletable by
|
||||||
design) and regenerates that override's child. It answers
|
design) and regenerates that override's child. It answers
|
||||||
``{"status": "ok"}``.
|
``{"status": "ok"}``.
|
||||||
- ``POST /presence-overrides/consequence`` previews an override
|
|
||||||
BEFORE it is saved: ONE ``preview_presence_override_consequence``
|
|
||||||
call answers ``{removed: N}``, the number of the child's open
|
|
||||||
upcoming instances the materialization walk would stop generating
|
|
||||||
(instances with a completion event are never counted, D-005).
|
|
||||||
Nothing is written; an unknown child is 404, a malformed or inverted
|
|
||||||
range 422.
|
|
||||||
- ``GET /children/{child_id}/presence-schedule`` reads the child's
|
|
||||||
schedule through ONE ``get_presence_schedule`` call, wrapped as
|
|
||||||
``{"schedule": ...}`` in the PUT response's shape — ``null`` when the
|
|
||||||
child has none (present every day), never a 404; an unknown child is
|
|
||||||
404.
|
|
||||||
- ``GET /presence-overrides`` lists the household's overrides through
|
|
||||||
ONE ``list_presence_overrides`` call, wrapped as
|
|
||||||
``{"overrides": [...]}`` in the POST response's shape, ordered by
|
|
||||||
start date, child id, id; the optional ``child_id``, ``start`` and
|
|
||||||
``end`` query filters (strict dates, ``end >= start``) are the
|
|
||||||
core's to validate.
|
|
||||||
|
|
||||||
The uncomplete and regenerate routes (task da0226b3) follow the same
|
The uncomplete and regenerate routes (task da0226b3) follow the same
|
||||||
pattern over :mod:`nestquest_core.completion` and
|
pattern over :mod:`nestquest_core.completion` and
|
||||||
@@ -190,33 +172,6 @@ integration's rollover listener shares:
|
|||||||
read-only-over-the-domain-tables guarantee all live in the core; the
|
read-only-over-the-domain-tables guarantee all live in the core; the
|
||||||
response reports how many transitions were published.
|
response reports how many transitions were published.
|
||||||
|
|
||||||
The occurrence-preview route (task 5f843564) lets the admin PWA's
|
|
||||||
Definitions edit sheet show the next dates a rule fires on WITHOUT
|
|
||||||
reimplementing the recurrence rules client-side:
|
|
||||||
|
|
||||||
- ``POST /quest-definitions/occurrences-preview`` takes the SAME
|
|
||||||
``rule`` object create/edit take (:class:`AdminRuleRequest`, built
|
|
||||||
through the SAME :func:`_rule_from_request` / core ``from_dict``
|
|
||||||
validation, so a rejected rule is 422 exactly as on create), an
|
|
||||||
optional ``start_date`` (default: the API host's local ``today``,
|
|
||||||
ONE :func:`_local_now` clock read) and an optional ``count`` (1..50,
|
|
||||||
default 10). The dates come from ONE
|
|
||||||
:func:`nestquest_core.recurrence.occurrences_between` call — the
|
|
||||||
same engine the materializer walks — so the preview can only agree
|
|
||||||
with what would be generated. The scan is BOUNDED server-side: the
|
|
||||||
window is ``[start_date, start_date + 366 days]`` inclusive,
|
|
||||||
whatever the caller sends, so every rule shape (a yearly rule
|
|
||||||
included) shows at least its next occurrence, and a caller can never
|
|
||||||
ask for an unbounded walk. Any strict ``start_date`` is accepted
|
|
||||||
rather than clamped or range-limited — the fixed window already
|
|
||||||
bounds the work — EXCEPT one whose window would reach 9999-12-31,
|
|
||||||
which is 422 (the core's day walk cannot step past the calendar's
|
|
||||||
last date). ``start_date`` strictness
|
|
||||||
(``YYYY-MM-DD``) is the core's own date validator; a non-strict or
|
|
||||||
unparseable value is 422. A ``count`` outside 1..50 is the body
|
|
||||||
model's 422 before any handler code runs. Nothing is read or
|
|
||||||
written: the route touches no database.
|
|
||||||
|
|
||||||
Error mapping (every children, quest-definition and presence route,
|
Error mapping (every children, quest-definition and presence route,
|
||||||
deliberately narrow):
|
deliberately narrow):
|
||||||
|
|
||||||
@@ -290,16 +245,6 @@ an omitted field is never passed to
|
|||||||
:func:`nestquest_core.children.edit_child` and stays unchanged — while
|
:func:`nestquest_core.children.edit_child` and stays unchanged — while
|
||||||
an explicit ``null`` IS passed and is rejected by the core rather than
|
an explicit ``null`` IS passed and is rejected by the core rather than
|
||||||
silently ignored.
|
silently ignored.
|
||||||
|
|
||||||
The EVENTS route (``GET /events``) is the admin plane's live
|
|
||||||
transition stream for the Admin PWA: the SAME four transitions
|
|
||||||
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
|
|
||||||
off the SAME in-process publisher as the panel plane, framed by the
|
|
||||||
shared :func:`api.sse.transition_event_stream` helper. It inherits the
|
|
||||||
router's ONE :func:`~api.auth.require_admin` check (the Authorization
|
|
||||||
header — the PWA reads it with a fetch-based reader because
|
|
||||||
``EventSource`` cannot set headers), so the panel service token is
|
|
||||||
refused here exactly as on every other admin route.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -307,7 +252,6 @@ import datetime
|
|||||||
from typing import Annotated, Literal, NoReturn
|
from typing import Annotated, Literal, NoReturn
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
StrictBool,
|
StrictBool,
|
||||||
@@ -336,7 +280,6 @@ from api.nestquest_core import (
|
|||||||
core_snapshot,
|
core_snapshot,
|
||||||
core_sweep,
|
core_sweep,
|
||||||
)
|
)
|
||||||
from api.sse import transition_event_stream
|
|
||||||
|
|
||||||
#: All admin-plane routes share this router; the ONE admin dependency
|
#: All admin-plane routes share this router; the ONE admin dependency
|
||||||
#: (service-token refusal + JWT verification + nestquest-admins group
|
#: (service-token refusal + JWT verification + nestquest-admins group
|
||||||
@@ -509,45 +452,6 @@ class AdminRuleRequest(BaseModel):
|
|||||||
end_date: str | None = None
|
end_date: str | None = None
|
||||||
|
|
||||||
|
|
||||||
#: Upper bound on ``count`` for the occurrence-preview route.
|
|
||||||
_PREVIEW_MAX_COUNT = 50
|
|
||||||
|
|
||||||
#: Days after ``start_date`` the occurrence-preview window spans
|
|
||||||
#: (inclusive), so a yearly rule always shows its next occurrence.
|
|
||||||
_PREVIEW_WINDOW_DAYS = 366
|
|
||||||
|
|
||||||
|
|
||||||
class AdminOccurrencesPreviewRequest(BaseModel):
|
|
||||||
"""The body of ``POST /api/v1/admin/quest-definitions/occurrences-preview``.
|
|
||||||
|
|
||||||
``rule`` is the SAME object create/edit take. ``start_date`` is an
|
|
||||||
optional strict ``YYYY-MM-DD`` (validated by the core; default the
|
|
||||||
API host's today). ``count`` is a strict JSON integer in
|
|
||||||
1..``_PREVIEW_MAX_COUNT`` — anything else is 422 before any handler
|
|
||||||
code runs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
rule: AdminRuleRequest
|
|
||||||
start_date: str | None = None
|
|
||||||
count: StrictInt = 10
|
|
||||||
|
|
||||||
@field_validator("count")
|
|
||||||
@classmethod
|
|
||||||
def _count_in_range(cls, value: int) -> int:
|
|
||||||
"""Reject a count outside 1.._PREVIEW_MAX_COUNT."""
|
|
||||||
if not 1 <= value <= _PREVIEW_MAX_COUNT:
|
|
||||||
raise ValueError(
|
|
||||||
f"count must be between 1 and {_PREVIEW_MAX_COUNT}"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class AdminOccurrencesPreviewResponse(BaseModel):
|
|
||||||
"""The next occurrence dates (``YYYY-MM-DD``, ascending)."""
|
|
||||||
|
|
||||||
dates: list[str]
|
|
||||||
|
|
||||||
|
|
||||||
class AdminQuestDefinitionCreateRequest(BaseModel):
|
class AdminQuestDefinitionCreateRequest(BaseModel):
|
||||||
"""The body of ``POST /api/v1/admin/quest-definitions``.
|
"""The body of ``POST /api/v1/admin/quest-definitions``.
|
||||||
|
|
||||||
@@ -669,50 +573,6 @@ class AdminPresenceOverrideResponse(BaseModel):
|
|||||||
note: str | None
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
class AdminPresenceScheduleReadResponse(BaseModel):
|
|
||||||
"""The ``GET /children/{child_id}/presence-schedule`` payload.
|
|
||||||
|
|
||||||
``schedule`` is ``None`` when the child has no schedule — the child
|
|
||||||
is present every day (Feature 05); absence is state, not an error.
|
|
||||||
"""
|
|
||||||
|
|
||||||
schedule: AdminPresenceScheduleResponse | None
|
|
||||||
|
|
||||||
|
|
||||||
class AdminPresenceOverrideListResponse(BaseModel):
|
|
||||||
"""The ``GET /presence-overrides`` payload: the matching overrides."""
|
|
||||||
|
|
||||||
overrides: list[AdminPresenceOverrideResponse]
|
|
||||||
|
|
||||||
|
|
||||||
class AdminPresenceOverrideConsequenceRequest(BaseModel):
|
|
||||||
"""The body of ``POST /api/v1/admin/presence-overrides/consequence``.
|
|
||||||
|
|
||||||
The create body's shape minus the note (which cannot change what is
|
|
||||||
generated): the strict dates, their order and the real
|
|
||||||
``is_present`` bool are enforced by the SAME
|
|
||||||
:class:`~nestquest_core.presence.PresenceOverride` constructor the
|
|
||||||
create route's core path builds; this model only guards the SHAPE.
|
|
||||||
Strict fields: a JSON ``true`` is not child id 1, nor ``1`` a bool.
|
|
||||||
"""
|
|
||||||
|
|
||||||
child_id: StrictInt
|
|
||||||
start_date: str
|
|
||||||
end_date: str
|
|
||||||
is_present: StrictBool
|
|
||||||
|
|
||||||
|
|
||||||
class AdminPresenceOverrideConsequenceResponse(BaseModel):
|
|
||||||
"""The consequence preview: ``removed`` upcoming instances.
|
|
||||||
|
|
||||||
The number of open (no completion event) upcoming quest instances
|
|
||||||
of the child that saving the override would remove — the "This
|
|
||||||
removes N upcoming tasks" warning. Always 0 for a present override.
|
|
||||||
"""
|
|
||||||
|
|
||||||
removed: int
|
|
||||||
|
|
||||||
|
|
||||||
class AdminUncompleteResponse(BaseModel):
|
class AdminUncompleteResponse(BaseModel):
|
||||||
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
"""The result of ``POST /api/v1/admin/instances/{id}/uncomplete``.
|
||||||
|
|
||||||
@@ -1078,40 +938,6 @@ def _raise_sweep_error(error: ValueError) -> NoReturn:
|
|||||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
raise HTTPException(status_code=422, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
def _raise_preview_error(error: ValueError) -> NoReturn:
|
|
||||||
"""Map a core recurrence ``ValueError`` from the preview route to 422.
|
|
||||||
|
|
||||||
The preview route has no path ids, so it has no 404 case: a
|
|
||||||
rejected rule (:class:`~nestquest_core.recurrence.RuleValidationError`)
|
|
||||||
or a non-strict ``start_date`` is 422 with the core's message as
|
|
||||||
the detail. Only ``ValueError`` is caught, so an unexpected
|
|
||||||
failure re-raises rather than becoming a 4xx.
|
|
||||||
"""
|
|
||||||
raise HTTPException(status_code=422, detail=str(error)) from error
|
|
||||||
|
|
||||||
|
|
||||||
def _preview_window_end(start_date: str) -> str:
|
|
||||||
"""Return the preview window's inclusive end for ``start_date``.
|
|
||||||
|
|
||||||
``start_date`` + :data:`_PREVIEW_WINDOW_DAYS`. Parsing here only
|
|
||||||
locates the window; the STRICT ``YYYY-MM-DD`` check stays the
|
|
||||||
core's — :func:`~nestquest_core.recurrence.occurrences_between`
|
|
||||||
re-validates the same string and rejects any lenient form this
|
|
||||||
parse accepted. Raises ``ValueError`` for an unparseable value and
|
|
||||||
for a start whose window would reach the calendar's last date (the
|
|
||||||
core's day walk steps one day PAST its end, which overflows there).
|
|
||||||
"""
|
|
||||||
start = datetime.date.fromisoformat(start_date)
|
|
||||||
if datetime.date.max - start <= datetime.timedelta(
|
|
||||||
days=_PREVIEW_WINDOW_DAYS
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
f"start_date {start_date!r} is too close to the end of the "
|
|
||||||
"calendar for a preview window"
|
|
||||||
)
|
|
||||||
return (start + datetime.timedelta(days=_PREVIEW_WINDOW_DAYS)).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _local_now() -> datetime.datetime:
|
def _local_now() -> datetime.datetime:
|
||||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
"""Return the API host's local time as ONE timezone-aware clock read.
|
||||||
|
|
||||||
@@ -1433,42 +1259,6 @@ async def admin_create_quest_definition(
|
|||||||
return _quest_definition_response(bundle)
|
return _quest_definition_response(bundle)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/quest-definitions/occurrences-preview",
|
|
||||||
summary="Preview the next occurrence dates of a schedule rule",
|
|
||||||
response_model=AdminOccurrencesPreviewResponse,
|
|
||||||
)
|
|
||||||
async def admin_preview_occurrences(
|
|
||||||
body: AdminOccurrencesPreviewRequest,
|
|
||||||
) -> AdminOccurrencesPreviewResponse:
|
|
||||||
"""Return the first ``count`` dates the supplied rule fires on.
|
|
||||||
|
|
||||||
Requires a valid admin JWT (the router's shared
|
|
||||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
||||||
handler performs NO auth of its own). Thin adapter: the ``rule``
|
|
||||||
becomes ONE :class:`~nestquest_core.recurrence.ScheduleRule`
|
|
||||||
(:func:`_rule_from_request`, the create/edit path), and the dates
|
|
||||||
come from ONE :func:`nestquest_core.recurrence.occurrences_between`
|
|
||||||
call over the bounded window ``[start_date, start_date + 366
|
|
||||||
days]`` — ``start_date`` defaulting to ONE :func:`_local_now` clock
|
|
||||||
read — truncated to ``count``. Every core ``ValueError`` is 422
|
|
||||||
(:func:`_raise_preview_error`). No database access.
|
|
||||||
"""
|
|
||||||
start_date = (
|
|
||||||
body.start_date
|
|
||||||
if body.start_date is not None
|
|
||||||
else _local_now().date().isoformat()
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
rule = _rule_from_request(body.rule)
|
|
||||||
dates = core_recurrence.occurrences_between(
|
|
||||||
rule, start_date, _preview_window_end(start_date)
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
_raise_preview_error(error)
|
|
||||||
return AdminOccurrencesPreviewResponse(dates=dates[: body.count])
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/quest-definitions/{definition_id}",
|
"/quest-definitions/{definition_id}",
|
||||||
summary="Edit a quest definition",
|
summary="Edit a quest definition",
|
||||||
@@ -1655,114 +1445,6 @@ async def admin_delete_presence_override(
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/presence-overrides/consequence",
|
|
||||||
summary="Count the upcoming instances a presence override would remove",
|
|
||||||
response_model=AdminPresenceOverrideConsequenceResponse,
|
|
||||||
)
|
|
||||||
async def admin_presence_override_consequence(
|
|
||||||
body: AdminPresenceOverrideConsequenceRequest, request: Request
|
|
||||||
) -> AdminPresenceOverrideConsequenceResponse:
|
|
||||||
"""Return how many upcoming instances saving the override removes.
|
|
||||||
|
|
||||||
Requires a valid admin JWT (the router's shared
|
|
||||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
||||||
handler performs NO auth of its own). Thin adapter: ONE
|
|
||||||
:func:`nestquest_core.presence_management.preview_presence_override_consequence`
|
|
||||||
call, anchored on ONE :func:`_local_now` clock read, which compares
|
|
||||||
the materialization walk's output for the child with and without
|
|
||||||
the proposed override and excludes instances that already have a
|
|
||||||
completion event (D-005). Nothing is written.
|
|
||||||
|
|
||||||
Errors are mapped by :func:`_raise_child_error`: an unknown child
|
|
||||||
is 404; a malformed or inverted range is 422.
|
|
||||||
"""
|
|
||||||
state: DatabaseState = request.app.state.db
|
|
||||||
try:
|
|
||||||
removed = await (
|
|
||||||
core_presence_management.preview_presence_override_consequence(
|
|
||||||
state.database,
|
|
||||||
body.child_id,
|
|
||||||
body.start_date,
|
|
||||||
body.end_date,
|
|
||||||
body.is_present,
|
|
||||||
today=_local_now().date(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
_raise_child_error(error)
|
|
||||||
return AdminPresenceOverrideConsequenceResponse(removed=removed)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/children/{child_id}/presence-schedule",
|
|
||||||
summary="Read a child's repeating presence schedule",
|
|
||||||
response_model=AdminPresenceScheduleReadResponse,
|
|
||||||
)
|
|
||||||
async def admin_get_presence_schedule(
|
|
||||||
child_id: int, request: Request
|
|
||||||
) -> AdminPresenceScheduleReadResponse:
|
|
||||||
"""Return the child's presence schedule, or ``schedule: null``.
|
|
||||||
|
|
||||||
Requires a valid admin JWT (the router's shared
|
|
||||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
||||||
handler performs NO auth of its own). Thin adapter: one
|
|
||||||
:func:`nestquest_core.presence_management.get_presence_schedule`
|
|
||||||
call; a stored schedule is serialized exactly as the PUT response
|
|
||||||
is, and a child without one answers ``{"schedule": null}`` (present
|
|
||||||
every day). Errors are mapped by :func:`_raise_child_error`: an
|
|
||||||
unknown child is 404.
|
|
||||||
"""
|
|
||||||
state: DatabaseState = request.app.state.db
|
|
||||||
try:
|
|
||||||
schedule = await core_presence_management.get_presence_schedule(
|
|
||||||
state.database, child_id
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
_raise_child_error(error)
|
|
||||||
return AdminPresenceScheduleReadResponse(
|
|
||||||
schedule=(
|
|
||||||
None if schedule is None else _presence_schedule_response(schedule)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/presence-overrides",
|
|
||||||
summary="List presence overrides",
|
|
||||||
response_model=AdminPresenceOverrideListResponse,
|
|
||||||
)
|
|
||||||
async def admin_list_presence_overrides(
|
|
||||||
request: Request,
|
|
||||||
child_id: int | None = None,
|
|
||||||
start: str | None = None,
|
|
||||||
end: str | None = None,
|
|
||||||
) -> AdminPresenceOverrideListResponse:
|
|
||||||
"""Return the household's presence overrides, optionally filtered.
|
|
||||||
|
|
||||||
Requires a valid admin JWT (the router's shared
|
|
||||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
||||||
handler performs NO auth of its own). Thin adapter: the optional
|
|
||||||
``child_id``, ``start`` and ``end`` query parameters go straight to
|
|
||||||
:func:`nestquest_core.presence_management.list_presence_overrides`
|
|
||||||
— the date policy (strict ``YYYY-MM-DD``, ``end >= start``) and the
|
|
||||||
overlap rule live in the core — and each record is serialized
|
|
||||||
exactly as the POST response is, ordered by start date, child id,
|
|
||||||
id. Errors are mapped by :func:`_raise_child_error`: an unknown
|
|
||||||
``child_id`` is 404, a rejected date or range is 422.
|
|
||||||
"""
|
|
||||||
state: DatabaseState = request.app.state.db
|
|
||||||
try:
|
|
||||||
records = await core_presence_management.list_presence_overrides(
|
|
||||||
state.database, child_id=child_id, start=start, end=end
|
|
||||||
)
|
|
||||||
except ValueError as error:
|
|
||||||
_raise_child_error(error)
|
|
||||||
return AdminPresenceOverrideListResponse(
|
|
||||||
overrides=[_presence_override_response(record) for record in records]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/instances/{instance_id}/uncomplete",
|
"/instances/{instance_id}/uncomplete",
|
||||||
summary="Reverse one completion (append a reversal event)",
|
summary="Reverse one completion (append a reversal event)",
|
||||||
@@ -2217,25 +1899,4 @@ async def admin_snapshot(request: Request) -> AdminSnapshotResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/events",
|
|
||||||
summary="Stream admin transition events (SSE)",
|
|
||||||
)
|
|
||||||
async def admin_events(request: Request) -> StreamingResponse:
|
|
||||||
"""Stream the app's transition events to an admin client
|
|
||||||
(Server-Sent Events).
|
|
||||||
|
|
||||||
Requires a valid admin JWT (the router's shared
|
|
||||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
|
||||||
handler performs NO auth of its own), so an unauthenticated or
|
|
||||||
panel-token request is refused before any stream is established.
|
|
||||||
|
|
||||||
Returns the shared :func:`api.sse.transition_event_stream` response
|
|
||||||
off the app's single in-process publisher (``app.state.publisher``)
|
|
||||||
— the same publisher, framing and teardown as the panel stream.
|
|
||||||
"""
|
|
||||||
return transition_event_stream(request)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
+30
-7
@@ -53,6 +53,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import importlib
|
import importlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@@ -67,7 +70,6 @@ from api.nestquest_core import (
|
|||||||
core_snapshot,
|
core_snapshot,
|
||||||
)
|
)
|
||||||
from api import transitions as api_transitions
|
from api import transitions as api_transitions
|
||||||
from api.sse import transition_event_stream
|
|
||||||
|
|
||||||
#: The instance DAO, imported on the app's core copy for the
|
#: The instance DAO, imported on the app's core copy for the
|
||||||
#: child-mismatch guard on the complete route.
|
#: child-mismatch guard on the complete route.
|
||||||
@@ -326,6 +328,23 @@ async def panel_complete_instance(
|
|||||||
return {"status": str(result)}
|
return {"status": str(result)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _sse_stream(request: Request) -> AsyncIterator[str]:
|
||||||
|
"""Yield the SSE frames for every published transition event.
|
||||||
|
|
||||||
|
One subscriber of the app's single publisher (resolved off
|
||||||
|
``app.state.publisher``); each event becomes one
|
||||||
|
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
|
||||||
|
publisher's subscription (on client disconnect) tears the
|
||||||
|
subscription down cleanly — no queue is leaked.
|
||||||
|
"""
|
||||||
|
publisher = request.app.state.publisher
|
||||||
|
async for event_type, payload in publisher.subscribe():
|
||||||
|
yield (
|
||||||
|
f"event: {event_type}\n"
|
||||||
|
f"data: {json.dumps(payload)}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/events",
|
"/events",
|
||||||
summary="Stream panel transition events (SSE)",
|
summary="Stream panel transition events (SSE)",
|
||||||
@@ -338,13 +357,17 @@ async def panel_events(request: Request) -> StreamingResponse:
|
|||||||
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
:func:`~api.dependencies.require_panel_token` dependency — the ONE
|
||||||
token check; this handler performs NO auth of its own).
|
token check; this handler performs NO auth of its own).
|
||||||
|
|
||||||
Returns the shared :func:`api.sse.transition_event_stream` response
|
Returns a ``text/event-stream`` response whose body is produced by
|
||||||
off the app's single in-process publisher (``app.state.publisher``,
|
:func:`_sse_stream` off the app's single in-process publisher
|
||||||
installed in the lifespan). The stream ends when the client
|
(``app.state.publisher``, installed in the lifespan). The stream
|
||||||
disconnects, at which point the subscription is torn down (its
|
ends when the client disconnects, at which point the subscription
|
||||||
queue removed) with nothing leaked.
|
is torn down (its queue removed) with nothing leaked.
|
||||||
"""
|
"""
|
||||||
return transition_event_stream(request)
|
return StreamingResponse(
|
||||||
|
_sse_stream(request),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["router"]
|
__all__ = ["router"]
|
||||||
|
|||||||
-50
@@ -1,50 +0,0 @@
|
|||||||
"""The ONE Server-Sent Events response helper for the API service.
|
|
||||||
|
|
||||||
Both SSE planes — the panel plane (``/api/v1/panel/events``, panel
|
|
||||||
service token, LAN-only) and the admin plane (``/api/v1/admin/events``,
|
|
||||||
Authentik admin JWT) — stream the SAME transitions off the SAME
|
|
||||||
in-process publisher (``app.state.publisher``, see :mod:`api.events`)
|
|
||||||
with the SAME framing and teardown, so the stream lives here once and
|
|
||||||
each plane's route only supplies its own router-level auth.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
|
|
||||||
__all__ = ["transition_event_stream"]
|
|
||||||
|
|
||||||
|
|
||||||
async def _sse_stream(request: Request) -> AsyncIterator[str]:
|
|
||||||
"""Yield the SSE frames for every published transition event.
|
|
||||||
|
|
||||||
One subscriber of the app's single publisher (resolved off
|
|
||||||
``app.state.publisher``); each event becomes one
|
|
||||||
``event: <type>\\ndata: <json>\\n\\n`` frame. Breaking out of the
|
|
||||||
publisher's subscription (on client disconnect) tears the
|
|
||||||
subscription down cleanly — no queue is leaked.
|
|
||||||
"""
|
|
||||||
publisher = request.app.state.publisher
|
|
||||||
async for event_type, payload in publisher.subscribe():
|
|
||||||
yield (
|
|
||||||
f"event: {event_type}\n"
|
|
||||||
f"data: {json.dumps(payload)}\n\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def transition_event_stream(request: Request) -> StreamingResponse:
|
|
||||||
"""A ``text/event-stream`` response over the app's transition events.
|
|
||||||
|
|
||||||
Performs NO auth of its own: the calling route's router-level
|
|
||||||
dependency is the ONE check for its plane. The stream ends when
|
|
||||||
the client disconnects, at which point the subscription is torn
|
|
||||||
down (its queue removed) with nothing leaked.
|
|
||||||
"""
|
|
||||||
return StreamingResponse(
|
|
||||||
_sse_stream(request),
|
|
||||||
media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache"},
|
|
||||||
)
|
|
||||||
@@ -270,57 +270,6 @@ class PresenceOverridesDao:
|
|||||||
)
|
)
|
||||||
return [_override_from_row(row) for row in rows]
|
return [_override_from_row(row) for row in rows]
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
child_id: int | None = None,
|
|
||||||
start: str | None = None,
|
|
||||||
end: str | None = None,
|
|
||||||
) -> list[PresenceOverrideRecord]:
|
|
||||||
"""Return the household's overrides, optionally narrowed.
|
|
||||||
|
|
||||||
Every filter is optional and they combine: ``child_id`` keeps
|
|
||||||
that child's overrides only (an unknown child raises ValueError
|
|
||||||
rather than reading as "no overrides"); ``start`` keeps
|
|
||||||
overrides ending on or after it and ``end`` keeps overrides
|
|
||||||
starting on or before it, so both together select the overrides
|
|
||||||
overlapping the closed range ``[start, end]`` (the
|
|
||||||
:meth:`list_by_child_and_range` boundary rule). Dates are
|
|
||||||
strict YYYY-MM-DD and ``end`` must not precede ``start``.
|
|
||||||
Ordered stably by start date, then child id, then id.
|
|
||||||
"""
|
|
||||||
if start is not None:
|
|
||||||
_validate_date(start, "start")
|
|
||||||
if end is not None:
|
|
||||||
_validate_date(end, "end")
|
|
||||||
if start is not None and end is not None and end < start:
|
|
||||||
raise ValueError(
|
|
||||||
f"end must be on or after start, got {end!r} < {start!r}"
|
|
||||||
)
|
|
||||||
clauses: list[str] = []
|
|
||||||
params: list[object] = []
|
|
||||||
if child_id is not None:
|
|
||||||
child = await self._database.fetch_one(
|
|
||||||
"SELECT 1 FROM children WHERE id = ?", (child_id,)
|
|
||||||
)
|
|
||||||
if child is None:
|
|
||||||
raise ValueError(f"child {child_id} does not exist")
|
|
||||||
clauses.append("child_id = ?")
|
|
||||||
params.append(child_id)
|
|
||||||
if start is not None:
|
|
||||||
clauses.append("end_date >= ?")
|
|
||||||
params.append(start)
|
|
||||||
if end is not None:
|
|
||||||
clauses.append("start_date <= ?")
|
|
||||||
params.append(end)
|
|
||||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
||||||
rows = await self._database.fetch_all(
|
|
||||||
f"SELECT {_OVERRIDE_COLUMNS} FROM presence_overrides{where} "
|
|
||||||
"ORDER BY start_date, child_id, id",
|
|
||||||
tuple(params),
|
|
||||||
)
|
|
||||||
return [_override_from_row(row) for row in rows]
|
|
||||||
|
|
||||||
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
async def get(self, override_id: int) -> PresenceOverrideRecord | None:
|
||||||
"""Return one override by id, or None when it does not exist."""
|
"""Return one override by id, or None when it does not exist."""
|
||||||
row = await self._database.fetch_one(
|
row = await self._database.fetch_one(
|
||||||
|
|||||||
@@ -64,14 +64,9 @@ the presence DAO (``dao_presence.py``) must stay free of business rules
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
from .const import DEFAULT_HORIZON_DAYS
|
from .const import DEFAULT_HORIZON_DAYS
|
||||||
from .dao_instances import (
|
from .dao_instances import QuestInstancesDao, _resolve_today
|
||||||
CompletionEventsDao,
|
|
||||||
QuestInstancesDao,
|
|
||||||
_resolve_today,
|
|
||||||
)
|
|
||||||
from .dao_rules import (
|
from .dao_rules import (
|
||||||
_validate_date,
|
_validate_date,
|
||||||
load_materialization_input,
|
load_materialization_input,
|
||||||
@@ -131,15 +126,8 @@ def _decode_rule(snapshot) -> ScheduleRule:
|
|||||||
def _build_engine(
|
def _build_engine(
|
||||||
schedules_records,
|
schedules_records,
|
||||||
overrides_records,
|
overrides_records,
|
||||||
*,
|
|
||||||
extra_overrides: tuple[PresenceOverride, ...] = (),
|
|
||||||
) -> PresenceEngine:
|
) -> PresenceEngine:
|
||||||
"""Convert raw presence records into an immutable PresenceEngine.
|
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||||
|
|
||||||
``extra_overrides`` layers already-validated, NOT-yet-stored
|
|
||||||
overrides on top of the stored ones — the consequence preview's
|
|
||||||
proposed override (:func:`count_removed_by_override`).
|
|
||||||
"""
|
|
||||||
schedules: dict[int, PresenceSchedule] = {}
|
schedules: dict[int, PresenceSchedule] = {}
|
||||||
for child_id, record in schedules_records.items():
|
for child_id, record in schedules_records.items():
|
||||||
schedules[child_id] = PresenceSchedule.decode(
|
schedules[child_id] = PresenceSchedule.decode(
|
||||||
@@ -157,75 +145,9 @@ def _build_engine(
|
|||||||
)
|
)
|
||||||
for record in records
|
for record in records
|
||||||
]
|
]
|
||||||
for override in extra_overrides:
|
|
||||||
overrides.setdefault(override.child_id, []).append(override)
|
|
||||||
return PresenceEngine(schedules, overrides)
|
return PresenceEngine(schedules, overrides)
|
||||||
|
|
||||||
|
|
||||||
class GeneratedTuple(NamedTuple):
|
|
||||||
"""One instance the walk WOULD write: its upsert key plus due time."""
|
|
||||||
|
|
||||||
definition_id: int
|
|
||||||
child_id: int
|
|
||||||
due_date: str
|
|
||||||
window: str
|
|
||||||
due_time: str | None
|
|
||||||
|
|
||||||
|
|
||||||
def _generation_preview(
|
|
||||||
snapshots,
|
|
||||||
engine: PresenceEngine,
|
|
||||||
start: datetime.date,
|
|
||||||
end: datetime.date,
|
|
||||||
*,
|
|
||||||
child_filter: set[int] | None = None,
|
|
||||||
) -> list[GeneratedTuple]:
|
|
||||||
"""Return the tuples the walk would write over ``[start, end]``.
|
|
||||||
|
|
||||||
The pure, read-only half of :func:`materialize`: for each date, each
|
|
||||||
definition whose decoded rule :func:`~.recurrence.occurs_on` it,
|
|
||||||
each assignee in ``child_filter`` (every assignee when None) who is
|
|
||||||
``is_present`` on it — or every assignee when the definition's
|
|
||||||
``skip_on_away`` is off — and each declared window, one tuple, in
|
|
||||||
the walk's write order. :func:`materialize` writes exactly these
|
|
||||||
tuples and :func:`count_removed_by_override` compares two of these
|
|
||||||
previews, so the write and the consequence preview can never
|
|
||||||
diverge. No database access.
|
|
||||||
"""
|
|
||||||
definitions = [
|
|
||||||
(snapshot.definition.id, _decode_rule(snapshot),
|
|
||||||
snapshot.assignees, snapshot.windows,
|
|
||||||
snapshot.definition.skip_on_away)
|
|
||||||
for snapshot in snapshots
|
|
||||||
]
|
|
||||||
generated: list[GeneratedTuple] = []
|
|
||||||
cursor = start
|
|
||||||
while cursor <= end:
|
|
||||||
iso = cursor.isoformat()
|
|
||||||
for (
|
|
||||||
definition_id, rule, assignees, windows, skip_on_away
|
|
||||||
) in definitions:
|
|
||||||
if not occurs_on(rule, cursor):
|
|
||||||
continue
|
|
||||||
for child in assignees:
|
|
||||||
if child_filter is not None and child.id not in child_filter:
|
|
||||||
continue
|
|
||||||
if skip_on_away and not engine.is_present(child.id, cursor):
|
|
||||||
continue
|
|
||||||
for window in windows:
|
|
||||||
generated.append(
|
|
||||||
GeneratedTuple(
|
|
||||||
definition_id,
|
|
||||||
child.id,
|
|
||||||
iso,
|
|
||||||
window.window,
|
|
||||||
window.due_time,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
cursor += datetime.timedelta(days=1)
|
|
||||||
return generated
|
|
||||||
|
|
||||||
|
|
||||||
async def materialize(
|
async def materialize(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
start_date: str,
|
start_date: str,
|
||||||
@@ -285,6 +207,13 @@ async def materialize(
|
|||||||
engine = _build_engine(schedules_records, overrides_records)
|
engine = _build_engine(schedules_records, overrides_records)
|
||||||
child_filter = None if child_ids is None else set(child_ids)
|
child_filter = None if child_ids is None else set(child_ids)
|
||||||
|
|
||||||
|
definitions = [
|
||||||
|
(snapshot.definition.id, _decode_rule(snapshot),
|
||||||
|
snapshot.assignees, snapshot.windows,
|
||||||
|
snapshot.definition.skip_on_away)
|
||||||
|
for snapshot in snapshots
|
||||||
|
]
|
||||||
|
|
||||||
generated_at = _now_stamp()
|
generated_at = _now_stamp()
|
||||||
instances = QuestInstancesDao(database)
|
instances = QuestInstancesDao(database)
|
||||||
|
|
||||||
@@ -299,20 +228,32 @@ async def materialize(
|
|||||||
start = max(start, today_date)
|
start = max(start, today_date)
|
||||||
|
|
||||||
count = 0
|
count = 0
|
||||||
for generated in _generation_preview(
|
cursor = start
|
||||||
snapshots, engine, start, end, child_filter=child_filter
|
while cursor <= end:
|
||||||
):
|
iso = cursor.isoformat()
|
||||||
written = await instances.upsert_if_valid(
|
for (
|
||||||
generated.definition_id,
|
definition_id, rule, assignees, windows, skip_on_away
|
||||||
generated.child_id,
|
) in definitions:
|
||||||
generated.due_date,
|
if not occurs_on(rule, cursor):
|
||||||
generated_at,
|
continue
|
||||||
window=generated.window,
|
for child in assignees:
|
||||||
due_time=generated.due_time,
|
if child_filter is not None and child.id not in child_filter:
|
||||||
today=today,
|
continue
|
||||||
)
|
if skip_on_away and not engine.is_present(child.id, cursor):
|
||||||
if written is not None:
|
continue
|
||||||
count += 1
|
for window in windows:
|
||||||
|
written = await instances.upsert_if_valid(
|
||||||
|
definition_id,
|
||||||
|
child.id,
|
||||||
|
iso,
|
||||||
|
generated_at,
|
||||||
|
window=window.window,
|
||||||
|
due_time=window.due_time,
|
||||||
|
today=today,
|
||||||
|
)
|
||||||
|
if written is not None:
|
||||||
|
count += 1
|
||||||
|
cursor += datetime.timedelta(days=1)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
@@ -438,93 +379,3 @@ async def regenerate_for_child(
|
|||||||
return await materialize(
|
return await materialize(
|
||||||
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
database, start.isoformat(), end_date, child_ids=[child_id], today=today
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def count_removed_by_override(
|
|
||||||
database: NestQuestDatabase,
|
|
||||||
override: PresenceOverride,
|
|
||||||
*,
|
|
||||||
today: datetime.date | None = None,
|
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Count the upcoming instances saving ``override`` would remove.
|
|
||||||
|
|
||||||
Read-only: nothing is written. The window is the part of the
|
|
||||||
override's range inside the rolling horizon ``[today, today +
|
|
||||||
horizon_days]`` that :func:`regenerate_for_child` rebuilds after the
|
|
||||||
override is saved (outside the range the two presence answers agree,
|
|
||||||
so nothing there can change). The materialization input is read
|
|
||||||
ONCE (:func:`~.dao_rules.load_materialization_input`) and
|
|
||||||
:func:`_generation_preview` runs twice over it for the override's
|
|
||||||
child — with the stored presence, and with ``override`` layered on
|
|
||||||
top (:func:`_build_engine`'s ``extra_overrides``). The count is the
|
|
||||||
tuples the first preview generates and the second does not, MINUS
|
|
||||||
every (definition, date, window) whose stored instance already has a
|
|
||||||
completion event (D-005: regeneration never deletes those). A
|
|
||||||
definition with ``skip_on_away`` off generates regardless of
|
|
||||||
presence, so it contributes nothing; a present override only ever
|
|
||||||
ADDS tuples, so it counts 0.
|
|
||||||
|
|
||||||
An override overlapping a stored one (which the create path refuses)
|
|
||||||
resolves through the engine's latest-start-wins rule. ``today`` and
|
|
||||||
``horizon_days`` resolve exactly as :func:`regenerate_for_child`
|
|
||||||
resolves them; a malformed horizon raises ValueError.
|
|
||||||
"""
|
|
||||||
horizon = _resolve_horizon_days(horizon_days)
|
|
||||||
today_date = _resolve_today(today)
|
|
||||||
start = max(today_date, override.start_date)
|
|
||||||
end = min(
|
|
||||||
today_date + datetime.timedelta(days=horizon), override.end_date
|
|
||||||
)
|
|
||||||
if end < start:
|
|
||||||
return 0
|
|
||||||
start_date, end_date = start.isoformat(), end.isoformat()
|
|
||||||
child_id = override.child_id
|
|
||||||
|
|
||||||
snapshots, schedules_records, overrides_records = await (
|
|
||||||
load_materialization_input(database, start_date, end_date)
|
|
||||||
)
|
|
||||||
current = _generation_preview(
|
|
||||||
snapshots,
|
|
||||||
_build_engine(schedules_records, overrides_records),
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
child_filter={child_id},
|
|
||||||
)
|
|
||||||
proposed = set(
|
|
||||||
_generation_preview(
|
|
||||||
snapshots,
|
|
||||||
_build_engine(
|
|
||||||
schedules_records,
|
|
||||||
overrides_records,
|
|
||||||
extra_overrides=(override,),
|
|
||||||
),
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
child_filter={child_id},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
removed = [generated for generated in current if generated not in proposed]
|
|
||||||
if not removed:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
stored = await QuestInstancesDao(database).list_by_date_range(
|
|
||||||
child_id, start_date, end_date
|
|
||||||
)
|
|
||||||
touched = {
|
|
||||||
event.instance_id
|
|
||||||
for event in await CompletionEventsDao(
|
|
||||||
database
|
|
||||||
).list_by_child_and_date_range(child_id, start_date, end_date)
|
|
||||||
}
|
|
||||||
completed = {
|
|
||||||
(instance.definition_id, instance.due_date, instance.window)
|
|
||||||
for instance in stored
|
|
||||||
if instance.id in touched
|
|
||||||
}
|
|
||||||
return sum(
|
|
||||||
1
|
|
||||||
for generated in removed
|
|
||||||
if (generated.definition_id, generated.due_date, generated.window)
|
|
||||||
not in completed
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -22,23 +22,18 @@ the children layer owns that guardrail). Deleting a nonexistent
|
|||||||
override raises ValueError prefixed ``override_id:`` so a caller can
|
override raises ValueError prefixed ``override_id:`` so a caller can
|
||||||
distinguish "unknown id" from a rejected argument the same way the
|
distinguish "unknown id" from a rejected argument the same way the
|
||||||
quest-definitions layer prefixes ``definition_id:``.
|
quest-definitions layer prefixes ``definition_id:``.
|
||||||
|
|
||||||
The admin plane's presence READS go through here too
|
|
||||||
(:func:`get_presence_schedule`, :func:`list_presence_overrides`), so a
|
|
||||||
reader never touches the DAO directly either.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from .dao_children import ChildrenDao
|
|
||||||
from .dao_presence import (
|
from .dao_presence import (
|
||||||
PresenceOverrideRecord,
|
PresenceOverrideRecord,
|
||||||
PresenceOverridesDao,
|
PresenceOverridesDao,
|
||||||
PresenceSchedulesDao,
|
PresenceSchedulesDao,
|
||||||
)
|
)
|
||||||
from .db import NestQuestDatabase
|
from .db import NestQuestDatabase
|
||||||
from .materialize import count_removed_by_override, regenerate_for_child
|
from .materialize import regenerate_for_child
|
||||||
from .presence import PresenceOverride, PresenceSchedule
|
from .presence import PresenceOverride, PresenceSchedule
|
||||||
|
|
||||||
|
|
||||||
@@ -135,78 +130,6 @@ async def create_presence_override(
|
|||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
async def preview_presence_override_consequence(
|
|
||||||
database: NestQuestDatabase,
|
|
||||||
child_id: int,
|
|
||||||
start_date: object,
|
|
||||||
end_date: object,
|
|
||||||
is_present: bool,
|
|
||||||
*,
|
|
||||||
today: datetime.date | None = None,
|
|
||||||
horizon_days: int | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Return how many upcoming instances saving this override removes.
|
|
||||||
|
|
||||||
The admin override editor's live consequence warning, computed
|
|
||||||
BEFORE anything is saved: the arguments build the SAME
|
|
||||||
:class:`~.presence.PresenceOverride` :func:`create_presence_override`
|
|
||||||
builds (so a malformed or inverted range, or a non-bool
|
|
||||||
``is_present``, raises the same ValueError), then
|
|
||||||
:func:`~.materialize.count_removed_by_override` compares what the
|
|
||||||
materialization walk generates for the child with and without it.
|
|
||||||
Nothing is written. An unknown child reads ``child N does not
|
|
||||||
exist``; an INACTIVE child has no instances to remove (the walk's
|
|
||||||
write path never materializes one), so it counts 0.
|
|
||||||
``today``/``horizon_days`` resolve as the post-save regeneration
|
|
||||||
resolves them.
|
|
||||||
"""
|
|
||||||
override = PresenceOverride(child_id, start_date, end_date, is_present)
|
|
||||||
child = await ChildrenDao(database).get(override.child_id)
|
|
||||||
if child is None:
|
|
||||||
raise ValueError(f"child {override.child_id} does not exist")
|
|
||||||
if not child.is_active:
|
|
||||||
return 0
|
|
||||||
return await count_removed_by_override(
|
|
||||||
database, override, today=today, horizon_days=horizon_days
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_presence_schedule(
|
|
||||||
database: NestQuestDatabase, child_id: int
|
|
||||||
) -> PresenceSchedule | None:
|
|
||||||
"""Return the child's presence schedule decoded, or None when unset.
|
|
||||||
|
|
||||||
None means the child exists and has no schedule row — present
|
|
||||||
every day (Feature 05). An unknown child raises ValueError
|
|
||||||
(``child N does not exist``) rather than reading as present.
|
|
||||||
"""
|
|
||||||
record = await PresenceSchedulesDao(database).get_by_child(child_id)
|
|
||||||
if record is None:
|
|
||||||
return None
|
|
||||||
return PresenceSchedule.decode(
|
|
||||||
record.child_id, record.anchor_date, record.pattern
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_presence_overrides(
|
|
||||||
database: NestQuestDatabase,
|
|
||||||
*,
|
|
||||||
child_id: int | None = None,
|
|
||||||
start: str | None = None,
|
|
||||||
end: str | None = None,
|
|
||||||
) -> list[PresenceOverrideRecord]:
|
|
||||||
"""Return the household's presence overrides, optionally filtered.
|
|
||||||
|
|
||||||
``child_id`` narrows to one child (unknown: ``child N does not
|
|
||||||
exist``); ``start``/``end`` keep the overrides overlapping that
|
|
||||||
closed range (strict YYYY-MM-DD, ``end >= start``). Ordered by
|
|
||||||
start date, then child id, then id.
|
|
||||||
"""
|
|
||||||
return await PresenceOverridesDao(database).list_filtered(
|
|
||||||
child_id=child_id, start=start, end=end
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_presence_override(
|
async def delete_presence_override(
|
||||||
database: NestQuestDatabase,
|
database: NestQuestDatabase,
|
||||||
override_id: int,
|
override_id: int,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Example environment for the NestQuest API container deployment.
|
|
||||||
#
|
|
||||||
# Copy to ``deploy/.env`` (git-ignored) and fill in REAL values there;
|
|
||||||
# this example carries placeholder names only and is never a source of
|
|
||||||
# secrets. Compose loads ``deploy/.env`` into the api service; the
|
|
||||||
# API fails fast on startup when any of these is unset or empty
|
|
||||||
# (api/config.py — there are no silent defaults).
|
|
||||||
|
|
||||||
# The static panel-plane service token the HA integration presents as
|
|
||||||
# a Bearer credential on /api/v1/panel/* routes. Generate one, e.g.:
|
|
||||||
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
NESTQUEST_PANEL_TOKEN=
|
|
||||||
|
|
||||||
# The Authentik OIDC settings the admin plane verifies JWTs against.
|
|
||||||
NESTQUEST_OIDC_ISSUER=
|
|
||||||
NESTQUEST_OIDC_AUDIENCE=
|
|
||||||
NESTQUEST_OIDC_JWKS_URL=
|
|
||||||
|
|
||||||
# The host interface the API's port 8080 is published on (compose
|
|
||||||
# interpolation, not read by the API). Defaults to 127.0.0.1 when
|
|
||||||
# unset; on the API box set the LAN address the reverse proxy
|
|
||||||
# forwards to, e.g. 10.60.1.14.
|
|
||||||
NESTQUEST_BIND_ADDRESS=
|
|
||||||
@@ -1,455 +0,0 @@
|
|||||||
# NestQuest API — deployment and restore runbook
|
|
||||||
|
|
||||||
Operator procedure to rebuild the production NestQuest API deployment end
|
|
||||||
to end from this repository, restore its database from backup, and verify
|
|
||||||
it. Every value below is the real, non-secret production value. **No
|
|
||||||
secret appears in this file** — secrets are named and their location is
|
|
||||||
given, never their content.
|
|
||||||
|
|
||||||
| Piece | Where | Real value |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| API box (Docker host) | this repository's `deploy/` | `10.60.1.14`, port `8080` |
|
|
||||||
| Reverse proxy / TLS | shared Traefik on `nukapi-03` | `10.60.1.33` |
|
|
||||||
| Public hostname | Cloudflare DNS | `nestquest.cubecraftlabs.com` |
|
|
||||||
| Identity | Authentik | `https://auth.cubecraftlabs.com` |
|
|
||||||
| Home Assistant box (panel client) | LAN | `10.60.1.80` |
|
|
||||||
| Local DNS | Pi-hole | `10.60.1.101` |
|
|
||||||
| Backup target | Synology NAS `CubeCraft-SRVR1`, SFTP | `10.60.1.50:/homes/Joshua/nestquest-backups` |
|
|
||||||
|
|
||||||
All `docker compose` commands below are run **on the API box, from the
|
|
||||||
repository root**. The shorthand `dc` means:
|
|
||||||
|
|
||||||
alias dc='docker compose -f deploy/compose.yaml'
|
|
||||||
|
|
||||||
Compose v2.24 or newer is required (`env_file` uses the `path`/`required`
|
|
||||||
form).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Image build
|
|
||||||
|
|
||||||
The image is defined by the root `Dockerfile` and is built from the
|
|
||||||
**repository root** as the build context: the API (`api/`) loads the
|
|
||||||
bundled domain core from `custom_components/nestquest/core/` by file
|
|
||||||
location, so both trees must be in the context. `.dockerignore` keeps
|
|
||||||
everything else (including `deploy/`, `.env` files and `*.db`) out of the
|
|
||||||
build.
|
|
||||||
|
|
||||||
- Multi-stage on `python:3.12-slim`: the builder installs
|
|
||||||
`api/requirements.txt`, the runtime copies only the installed packages
|
|
||||||
plus `api/` and `custom_components/nestquest/core/` into `/app`.
|
|
||||||
- Runs as the non-root system user `nestquest` (uid/gid `999`), which owns
|
|
||||||
`/var/lib/nestquest`.
|
|
||||||
- Command: `uvicorn --factory api.app:create_app --host 0.0.0.0 --port 8080`.
|
|
||||||
|
|
||||||
Get the code and build:
|
|
||||||
|
|
||||||
git clone ssh://code.cubecraftlabs.com:2288/Maniacs_Mansion/NestQuest.git
|
|
||||||
cd NestQuest
|
|
||||||
git checkout <release branch or tag being deployed>
|
|
||||||
dc build api # equivalent: docker build -t nestquest-api:latest .
|
|
||||||
|
|
||||||
Confirm the runtime user (the Litestream sidecar and the key ownership in
|
|
||||||
section 5 depend on uid 999):
|
|
||||||
|
|
||||||
docker run --rm --entrypoint id nestquest-api:latest
|
|
||||||
# uid=999(nestquest) gid=999(nestquest) groups=999(nestquest)
|
|
||||||
|
|
||||||
## 2. Compose
|
|
||||||
|
|
||||||
`deploy/compose.yaml` defines two services and one named volume:
|
|
||||||
|
|
||||||
| Service | Image | Role |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `api` | `nestquest-api:latest` (built, section 1) | FastAPI service on `8080`, healthcheck on `/health` |
|
|
||||||
| `litestream` | `litestream/litestream:0.5.17` | Continuous SFTP backup of the database (section 7); starts only once `api` is healthy |
|
|
||||||
|
|
||||||
- **Volume `nestquest-db`** is mounted at `/var/lib/nestquest` in both
|
|
||||||
services. The database is `NESTQUEST_DB_PATH=/var/lib/nestquest/nestquest.db`
|
|
||||||
(fixed by compose, not a secret). `docker compose down` keeps named
|
|
||||||
volumes; **never run `down -v`** on the API box. Docker prefixes the
|
|
||||||
volume with the compose project name — with the default project
|
|
||||||
(`deploy`, the compose file's directory) it is `deploy_nestquest-db`;
|
|
||||||
confirm with `docker volume ls | grep nestquest-db`.
|
|
||||||
- **Published port**: `${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080`.
|
|
||||||
The default binds localhost only, so an unconfigured host is never
|
|
||||||
exposed. On the API box `deploy/.env` sets
|
|
||||||
`NESTQUEST_BIND_ADDRESS=10.60.1.14` so Traefik on nukapi-03 can reach
|
|
||||||
`http://10.60.1.14:8080`.
|
|
||||||
- **Secrets** come from `deploy/.env` (git-ignored, section 5) via
|
|
||||||
`env_file`.
|
|
||||||
|
|
||||||
Bring the stack up (after section 5's secrets exist):
|
|
||||||
|
|
||||||
dc up -d --build
|
|
||||||
dc ps # api "healthy", litestream "Up"
|
|
||||||
curl -s http://10.60.1.14:8080/health # {"status":"ok"}
|
|
||||||
|
|
||||||
## 3. TLS and reverse proxy (Traefik on nukapi-03)
|
|
||||||
|
|
||||||
TLS is terminated by the **shared** Traefik instance on `nukapi-03`
|
|
||||||
(`10.60.1.33`), not by this repository. Its NestQuest routing is the
|
|
||||||
owner-authored dynamic file-provider config
|
|
||||||
**`config/dynamic/nestquest-ccl.yml`** in the Traefik deployment on
|
|
||||||
nukapi-03 — that live file is authoritative; it is not stored in this
|
|
||||||
repository. What it must contain:
|
|
||||||
|
|
||||||
| Router | Rule | Middleware | Service |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| panel | ``Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)`` | `ipAllowList`, `sourceRange: ["10.60.1.80/32"]` (HA box only) | `http://10.60.1.14:8080` |
|
|
||||||
| admin (everything else: `/health`, `/docs`, `/openapi.json`, `/api/v1/admin/*`) | ``Host(`nestquest.cubecraftlabs.com`)`` | none — public; the API itself enforces Authentik JWTs (section 4) | `http://10.60.1.14:8080` |
|
|
||||||
|
|
||||||
Both routers use the HTTPS entrypoint `websecure` with `tls.certResolver: myresolver`
|
|
||||||
(HTTP is redirected to HTTPS by the shared Traefik). The panel router's
|
|
||||||
rule is longer, so Traefik's default rule-length priority evaluates it
|
|
||||||
before the catch-all admin router; if you set explicit priorities, keep
|
|
||||||
panel higher.
|
|
||||||
|
|
||||||
Reference shape, to rebuild the file if it is ever lost (entrypoint names
|
|
||||||
must match the shared Traefik's static config on nukapi-03):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
http:
|
|
||||||
routers:
|
|
||||||
nestquest-panel:
|
|
||||||
rule: "Host(`nestquest.cubecraftlabs.com`) && PathPrefix(`/api/v1/panel`)"
|
|
||||||
entryPoints: [websecure]
|
|
||||||
middlewares: [nestquest-panel-allowlist]
|
|
||||||
service: nestquest-api
|
|
||||||
tls:
|
|
||||||
certResolver: myresolver
|
|
||||||
nestquest-admin:
|
|
||||||
rule: "Host(`nestquest.cubecraftlabs.com`)"
|
|
||||||
entryPoints: [websecure]
|
|
||||||
service: nestquest-api
|
|
||||||
tls:
|
|
||||||
certResolver: myresolver
|
|
||||||
middlewares:
|
|
||||||
nestquest-panel-allowlist:
|
|
||||||
ipAllowList:
|
|
||||||
sourceRange: ["10.60.1.80/32"]
|
|
||||||
services:
|
|
||||||
nestquest-api:
|
|
||||||
loadBalancer:
|
|
||||||
servers:
|
|
||||||
- url: "http://10.60.1.14:8080"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Certificates.** `myresolver` is the shared Traefik's ACME (Let's
|
|
||||||
Encrypt) resolver using the **Cloudflare DNS-01 challenge**; the
|
|
||||||
Cloudflare API token is held by the Traefik deployment on
|
|
||||||
nukapi-03, never in this repository. Because DNS-01 proves control
|
|
||||||
through a DNS TXT record, issuance and renewal need no inbound HTTP and
|
|
||||||
work regardless of the Cloudflare proxy setting. Certificates renew
|
|
||||||
automatically.
|
|
||||||
|
|
||||||
**Cloudflare proxy note.** Public traffic for
|
|
||||||
`nestquest.cubecraftlabs.com` resolves through Cloudflare, so a request
|
|
||||||
from the internet reaches Traefik from a Cloudflare edge address, never
|
|
||||||
from `10.60.1.80` — which is exactly why the panel allowlist refuses it
|
|
||||||
(403). The HA box does not go through Cloudflare at all: the local DNS
|
|
||||||
override in section 6 sends it straight to Traefik on the LAN, so Traefik
|
|
||||||
sees its real address. Do **not** add Cloudflare ranges to the panel
|
|
||||||
allowlist or make the allowlist trust `X-Forwarded-For`; that would let
|
|
||||||
any internet client through to the panel plane.
|
|
||||||
|
|
||||||
## 4. Authentik (admin identity)
|
|
||||||
|
|
||||||
Admin-plane requests (`/api/v1/admin/*`) carry an Authentik OIDC access
|
|
||||||
token (JWT). The API verifies issuer, audience, RS256 signature against
|
|
||||||
the JWKS, and expiry, then requires the `groups` claim to contain
|
|
||||||
`nestquest-admins` (`api/auth.py`). Configure in Authentik
|
|
||||||
(`https://auth.cubecraftlabs.com`, admin interface):
|
|
||||||
|
|
||||||
1. **Provider** — *OAuth2/OpenID Provider*:
|
|
||||||
- Client type: **Public** (no client secret; the admin PWA is a browser
|
|
||||||
SPA).
|
|
||||||
- PKCE: the PWA always uses the authorization-code flow with PKCE,
|
|
||||||
`code_challenge_method=S256`, and never sends a client secret.
|
|
||||||
- Redirect URI (strict): `https://nestquest.cubecraftlabs.com/auth/callback`.
|
|
||||||
- Signing key: an **RSA** certificate (the API only accepts `RS256`).
|
|
||||||
- Scopes: the default `openid`, `email`, `profile` mappings (the PWA
|
|
||||||
requests `openid email profile`; Authentik's default `profile`
|
|
||||||
mapping emits the `groups` claim the API checks).
|
|
||||||
- Note the generated **Client ID** — it is the audience below and the
|
|
||||||
PWA's `VITE_AUTHENTIK_CLIENT_ID`.
|
|
||||||
2. **Application** — slug **`nestquest`**, bound to the provider above.
|
|
||||||
The slug fixes the issuer URL.
|
|
||||||
3. **Group** — **`nestquest-admins`**. Add each parent's Authentik user.
|
|
||||||
Membership is the single source of truth for "who is an admin"; the
|
|
||||||
API refuses a signed-in non-member with 403.
|
|
||||||
|
|
||||||
API settings (in `deploy/.env`, section 5):
|
|
||||||
|
|
||||||
| Variable | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| `NESTQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/nestquest/` |
|
|
||||||
| `NESTQUEST_OIDC_AUDIENCE` | the provider's Client ID |
|
|
||||||
| `NESTQUEST_OIDC_JWKS_URL` | `https://auth.cubecraftlabs.com/application/o/nestquest/jwks/` |
|
|
||||||
|
|
||||||
Check them against Authentik's discovery document (the issuer must match
|
|
||||||
byte for byte, including the trailing slash):
|
|
||||||
|
|
||||||
curl -s https://auth.cubecraftlabs.com/application/o/nestquest/.well-known/openid-configuration \
|
|
||||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["issuer"]); print(d["jwks_uri"])'
|
|
||||||
|
|
||||||
## 5. Secrets
|
|
||||||
|
|
||||||
Nothing below is ever committed; `.gitignore` excludes `.env`,
|
|
||||||
`**/.env`, `deploy/*key*` and `deploy/litestream_key*`.
|
|
||||||
|
|
||||||
**`deploy/.env`** — created on the API box from the committed example:
|
|
||||||
|
|
||||||
cp deploy/.env.example deploy/.env
|
|
||||||
chmod 600 deploy/.env
|
|
||||||
# edit deploy/.env and fill in:
|
|
||||||
# NESTQUEST_PANEL_TOKEN = a new random token, e.g.
|
|
||||||
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
# (the same value is configured in the HA integration)
|
|
||||||
# NESTQUEST_OIDC_ISSUER = section 4
|
|
||||||
# NESTQUEST_OIDC_AUDIENCE = section 4 (Authentik Client ID)
|
|
||||||
# NESTQUEST_OIDC_JWKS_URL = section 4
|
|
||||||
# NESTQUEST_BIND_ADDRESS = 10.60.1.14
|
|
||||||
|
|
||||||
The API fails fast at startup if any of the four API
|
|
||||||
variables is unset or empty (`api/config.py`); `dc logs api` names the
|
|
||||||
missing one. `NESTQUEST_BIND_ADDRESS` is read by compose only.
|
|
||||||
|
|
||||||
**Litestream SFTP key `deploy/litestream_key`** — a dedicated keypair that
|
|
||||||
lives only on the API box. Create it once, before the first `up`:
|
|
||||||
|
|
||||||
ssh-keygen -t ed25519 -N "" -C nestquest-litestream -f deploy/litestream_key
|
|
||||||
sudo chown 999:999 deploy/litestream_key
|
|
||||||
chmod 600 deploy/litestream_key
|
|
||||||
|
|
||||||
Then append `restrict ` followed by the contents of
|
|
||||||
`deploy/litestream_key.pub` as one line to
|
|
||||||
`joshua@10.60.1.50:~/.ssh/authorized_keys` on the NAS. Compose mounts the
|
|
||||||
private key read-only at `/etc/litestream/sftp_key` in the sidecar, which
|
|
||||||
runs as uid 999 — hence the ownership. The NAS host key is pinned in the
|
|
||||||
committed `deploy/litestream.yml` (`host-key`, the NAS's ECDSA key), so no
|
|
||||||
`known_hosts` is involved.
|
|
||||||
|
|
||||||
| Secret | Supplied by | Consumed by |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `NESTQUEST_PANEL_TOKEN` | `deploy/.env` → `env_file` | `api` (panel plane); also set in the HA integration |
|
|
||||||
| `NESTQUEST_OIDC_*` (not secret, but environment-supplied) | `deploy/.env` → `env_file` | `api` (admin plane) |
|
|
||||||
| SFTP private key | `deploy/litestream_key` → read-only bind mount | `litestream` |
|
|
||||||
| Cloudflare DNS API token | Traefik deployment on nukapi-03 | Traefik ACME `myresolver` |
|
|
||||||
|
|
||||||
## 6. Network separation and firewall
|
|
||||||
|
|
||||||
- **Panel plane** (`/api/v1/panel/*`, static service token): reachable
|
|
||||||
only from the HA box. Traefik's `ipAllowList` on the panel router allows
|
|
||||||
`10.60.1.80/32` and returns **403** to every other source, before the
|
|
||||||
request reaches the API. The API additionally requires the panel token
|
|
||||||
(401 without it).
|
|
||||||
- **Admin plane** (`/api/v1/admin/*`) and `/health`, `/docs`: public
|
|
||||||
through Cloudflare and Traefik; admin routes are protected by the API's
|
|
||||||
Authentik JWT check (401 without a valid token, 403 without
|
|
||||||
`nestquest-admins` or when a panel token is presented).
|
|
||||||
- **Local DNS override** so the HA box reaches Traefik directly, keeping
|
|
||||||
its real source address: in Pi-hole (`10.60.1.101`) local DNS records,
|
|
||||||
add `nestquest.cubecraftlabs.com` → `10.60.1.33`. The HA box
|
|
||||||
must use Pi-hole as its resolver. Check from the HA box:
|
|
||||||
|
|
||||||
nslookup nestquest.cubecraftlabs.com 10.60.1.101 # Address: 10.60.1.33
|
|
||||||
|
|
||||||
- **Direct `:8080` access is firewalled.** The API box publishes `8080`
|
|
||||||
on `10.60.1.14`, which alone would let any LAN host bypass Traefik and
|
|
||||||
the panel `ipAllowList`. This is enforced on the API box in Docker's
|
|
||||||
`DOCKER-USER` chain (which Docker evaluates before its own forwarding
|
|
||||||
rules for published ports), in this order:
|
|
||||||
|
|
||||||
-A DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN # Traefik on nukapi-03
|
|
||||||
-A DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN # the API box itself
|
|
||||||
-A DOCKER-USER -p tcp --dport 8080 -j DROP # every other source
|
|
||||||
|
|
||||||
The rules are persisted by the systemd oneshot unit
|
|
||||||
**`nestquest-api-firewall.service`**, committed as
|
|
||||||
`deploy/systemd/nestquest-api-firewall.service` (ordered after
|
|
||||||
`docker.service`, it idempotently applies the three rules at every
|
|
||||||
boot). Install and enable it on the API box, from the repository
|
|
||||||
checkout, **before** exposing the API:
|
|
||||||
|
|
||||||
sudo cp deploy/systemd/nestquest-api-firewall.service /etc/systemd/system/ \
|
|
||||||
&& sudo systemctl daemon-reload \
|
|
||||||
&& sudo systemctl enable --now nestquest-api-firewall.service
|
|
||||||
|
|
||||||
The HA box and every other client must use the Traefik hostname,
|
|
||||||
never `:8080` directly. Check it:
|
|
||||||
|
|
||||||
sudo systemctl is-enabled nestquest-api-firewall.service # enabled
|
|
||||||
sudo systemctl is-active nestquest-api-firewall.service # active
|
|
||||||
sudo iptables -S DOCKER-USER # the three rules above, in that order
|
|
||||||
|
|
||||||
# on nukapi-03 (10.60.1.33) — allowed:
|
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 200
|
|
||||||
# on any other LAN host, e.g. 10.60.1.101 — dropped:
|
|
||||||
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://10.60.1.14:8080/health # 000, timeout
|
|
||||||
# public path still works:
|
|
||||||
curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health # 200
|
|
||||||
|
|
||||||
On a rebuilt API box, install the unit with the commands above before
|
|
||||||
exposing the API. The committed file is the authoritative copy; the
|
|
||||||
deployed unit must match it:
|
|
||||||
|
|
||||||
diff deploy/systemd/nestquest-api-firewall.service \
|
|
||||||
/etc/systemd/system/nestquest-api-firewall.service # no output
|
|
||||||
|
|
||||||
## 7. Backup (Litestream to the Synology NAS)
|
|
||||||
|
|
||||||
The `litestream` sidecar (config `deploy/litestream.yml`, mounted at
|
|
||||||
`/etc/litestream.yml`) replicates `/var/lib/nestquest/nestquest.db` to:
|
|
||||||
|
|
||||||
- SFTP `joshua@10.60.1.50`, path `/homes/Joshua/nestquest-backups` (the
|
|
||||||
NAS's SFTP view is chrooted to the shared folders; over SSH the same
|
|
||||||
directory is `/volume1/homes/Joshua/nestquest-backups`).
|
|
||||||
- Every committed change is shipped as an LTX file within about a second.
|
|
||||||
Litestream 0.5.17 compacts them into coarser levels on its built-in
|
|
||||||
schedule (level 1 every 30s, level 2 every 5m, level 3 every 1h).
|
|
||||||
- **Snapshots**: `snapshot.interval: 24h` (a full snapshot daily);
|
|
||||||
**retention**: `snapshot.retention: 168h` — a week of snapshots plus the
|
|
||||||
changes between them, i.e. a 7-day point-in-time restore window.
|
|
||||||
|
|
||||||
The sidecar runs as uid 999 with a read-only root filesystem,
|
|
||||||
`no-new-privileges` and all capabilities dropped. It needs the database
|
|
||||||
volume read-write (it keeps its `_litestream_*` tables and writes the
|
|
||||||
WAL); its local state lives beside the database in
|
|
||||||
`/var/lib/nestquest/.nestquest.db-litestream/`.
|
|
||||||
|
|
||||||
Check it:
|
|
||||||
|
|
||||||
dc ps litestream # Up
|
|
||||||
dc logs --tail 50 litestream # no "error" lines
|
|
||||||
dc exec litestream litestream status # status "ok", local txid advancing
|
|
||||||
dc exec litestream litestream ltx /var/lib/nestquest/nestquest.db | tail # newest LTX files and timestamps
|
|
||||||
|
|
||||||
A change made in the app should appear as a new level-0 LTX file within
|
|
||||||
seconds. On the NAS, the files are under
|
|
||||||
`/volume1/homes/Joshua/nestquest-backups/ltx/`.
|
|
||||||
|
|
||||||
## 8. Restore
|
|
||||||
|
|
||||||
Restores the database from the SFTP replica into the `nestquest-db`
|
|
||||||
volume at `NESTQUEST_DB_PATH` (`/var/lib/nestquest/nestquest.db`). The
|
|
||||||
same procedure serves both a damaged database on the existing box and a
|
|
||||||
rebuilt box. The restore runs in one-off `litestream` containers so it
|
|
||||||
has the same user (999), volume, config and SFTP key as the sidecar.
|
|
||||||
|
|
||||||
**0. Fresh box only.** Complete sections 1 and 5, then start **only the
|
|
||||||
API** once so the volume exists and is owned by uid 999:
|
|
||||||
|
|
||||||
dc up -d api
|
|
||||||
|
|
||||||
Do not start `litestream` yet — it would begin replicating the new,
|
|
||||||
empty database the API just created. The swap in step 4 moves that empty
|
|
||||||
file aside, and step 5 creates and starts the sidecar with
|
|
||||||
`dc up -d litestream` once the API is healthy.
|
|
||||||
|
|
||||||
**1. Stop writers** — both the API and the sidecar (Litestream must not
|
|
||||||
replicate while the file is being replaced):
|
|
||||||
|
|
||||||
dc stop api litestream
|
|
||||||
|
|
||||||
**2. Restore beside the live database** (never over it), with a full
|
|
||||||
integrity check:
|
|
||||||
|
|
||||||
dc run --rm --no-deps litestream restore \
|
|
||||||
-config /etc/litestream.yml \
|
|
||||||
-integrity-check full \
|
|
||||||
-o /var/lib/nestquest/nestquest.db.restored \
|
|
||||||
/var/lib/nestquest/nestquest.db
|
|
||||||
|
|
||||||
Expect `post-restore integrity check passed` and exit code 0. For a
|
|
||||||
point in time within the retention window, add
|
|
||||||
`-timestamp 2026-09-24T12:00:00Z` (UTC). To preview without writing, add
|
|
||||||
`-dry-run`.
|
|
||||||
|
|
||||||
**3. Inspect the restored file** before switching to it — confirm known
|
|
||||||
children and definitions are present:
|
|
||||||
|
|
||||||
dc run --rm --no-deps --entrypoint sqlite3 litestream \
|
|
||||||
/var/lib/nestquest/nestquest.db.restored \
|
|
||||||
'PRAGMA integrity_check;' \
|
|
||||||
'SELECT id, display_name FROM children;' \
|
|
||||||
'SELECT id, title FROM quest_definitions;'
|
|
||||||
|
|
||||||
Expect `ok`, then the household's children and quest definitions.
|
|
||||||
|
|
||||||
**4. Swap it into place**, keeping the old files (database, its
|
|
||||||
`-wal`/`-shm`, and Litestream's local state) in a timestamped directory
|
|
||||||
on the volume:
|
|
||||||
|
|
||||||
dc run --rm --no-deps --entrypoint sh litestream -c '
|
|
||||||
set -e
|
|
||||||
cd /var/lib/nestquest
|
|
||||||
ts=$(date -u +%Y%m%dT%H%M%SZ)
|
|
||||||
mkdir -p "pre-restore-$ts"
|
|
||||||
for f in nestquest.db nestquest.db-wal nestquest.db-shm .nestquest.db-litestream; do
|
|
||||||
if [ -e "$f" ]; then mv "$f" "pre-restore-$ts/"; fi
|
|
||||||
done
|
|
||||||
mv nestquest.db.restored nestquest.db
|
|
||||||
ls -la'
|
|
||||||
|
|
||||||
**5. Start the API, then the sidecar:**
|
|
||||||
|
|
||||||
dc start api
|
|
||||||
dc ps api # wait for "healthy"
|
|
||||||
dc up -d litestream
|
|
||||||
dc ps litestream # Up
|
|
||||||
dc logs --tail 20 litestream
|
|
||||||
|
|
||||||
Use `dc up -d litestream`, not `dc start`: on a fresh box step 0 never
|
|
||||||
created the sidecar container, and `start` cannot start a container that
|
|
||||||
does not exist. `up -d` creates it if needed (or starts the existing one),
|
|
||||||
so continuous replication is back online on both paths.
|
|
||||||
|
|
||||||
The sidecar resumes replication from the restored position (it logs
|
|
||||||
`detected database behind replica` / `fetched latest L0 file from
|
|
||||||
replica`, then continues normally).
|
|
||||||
|
|
||||||
**6. Verify through the API** — the panel snapshot lists the restored
|
|
||||||
children. On the API box against `10.60.1.14:8080` directly (other LAN
|
|
||||||
hosts, including the HA box, are dropped on `:8080` by the section 6
|
|
||||||
firewall; from the HA box use the Traefik hostname instead):
|
|
||||||
|
|
||||||
PANEL_TOKEN=$(grep '^NESTQUEST_PANEL_TOKEN=' deploy/.env | cut -d= -f2-)
|
|
||||||
curl -s -H "Authorization: Bearer $PANEL_TOKEN" \
|
|
||||||
http://10.60.1.14:8080/api/v1/panel/snapshot
|
|
||||||
|
|
||||||
Expect HTTP 200 and `children` naming the known child(ren). Then run
|
|
||||||
section 9.
|
|
||||||
|
|
||||||
Once satisfied, remove the old copy:
|
|
||||||
`dc run --rm --no-deps --entrypoint rm litestream -rf /var/lib/nestquest/pre-restore-<ts>`.
|
|
||||||
|
|
||||||
This exact sequence (stop, restore with integrity check, inspect, swap,
|
|
||||||
start, snapshot shows the restored child, replication resumes) was
|
|
||||||
rehearsed against a local file replica with the committed compose file;
|
|
||||||
the live proof against the SFTP replica is recorded separately by the
|
|
||||||
controller.
|
|
||||||
|
|
||||||
## 9. Verification checklist
|
|
||||||
|
|
||||||
Run after every deploy and every restore. `$PANEL_TOKEN` is the value from
|
|
||||||
`deploy/.env`; never paste it into shared logs.
|
|
||||||
|
|
||||||
| # | Check | Command | Expect |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 1 | Health over TLS | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/health` | `200` |
|
|
||||||
| 2 | Valid certificate | `curl -sv https://nestquest.cubecraftlabs.com/health 2>&1 \| grep -E 'issuer\|expire date'` | Let's Encrypt issuer, future expiry (no `-k` needed) |
|
|
||||||
| 3 | Docs public | `curl -s -o /dev/null -w '%{http_code}\n' https://nestquest.cubecraftlabs.com/docs` | `200` |
|
|
||||||
| 4 | Panel refused externally | from any host other than `10.60.1.80` (e.g. a phone off Wi-Fi, or a LAN machine): `curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $PANEL_TOKEN" https://nestquest.cubecraftlabs.com/api/v1/panel/snapshot` | `403` (Traefik allowlist) |
|
|
||||||
| 5 | Panel allowed from HA box | on `10.60.1.80`: same command | `200`, snapshot JSON |
|
|
||||||
| 6 | Panel needs the token | on `10.60.1.80`, without the header | `401` |
|
|
||||||
| 7 | Admin JWT accepted | `curl -s -w '\n%{http_code}\n' -H "Authorization: Bearer $ADMIN_JWT" https://nestquest.cubecraftlabs.com/api/v1/admin/ping` with a token of a `nestquest-admins` member | `200`, `{"status":"ok","sub":...}` |
|
|
||||||
| 8 | Non-admin refused | same, with a token of a user **not** in `nestquest-admins` | `403`, "Admin access requires membership in the nestquest-admins group" |
|
|
||||||
| 9 | No/invalid token refused | same, without a header or with garbage | `401` |
|
|
||||||
| 10 | Panel token refused on admin | same, with `$PANEL_TOKEN` | `403` |
|
|
||||||
| 11 | Backup running | `dc exec litestream litestream status` | status `ok` |
|
|
||||||
|
|
||||||
To obtain `$ADMIN_JWT`: sign in to the admin PWA as the user, open the
|
|
||||||
browser developer tools, and copy the session-storage value
|
|
||||||
`nestquest.access_token`. Tokens are short-lived and are credentials —
|
|
||||||
do not store them.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Compose deployment scaffold for the NestQuest API service (Feature
|
|
||||||
# 17). The SQLite database lives on the NAMED VOLUME ``nestquest-db``
|
|
||||||
# mounted at /var/lib/nestquest — recreating the container keeps the
|
|
||||||
# household data (``docker compose down`` leaves named volumes alone).
|
|
||||||
#
|
|
||||||
# Configuration is supplied by the environment: the fixed database
|
|
||||||
# path here, and the secret values (panel service token, Authentik
|
|
||||||
# OIDC settings) from ``deploy/.env`` — a git-ignored file you create
|
|
||||||
# from ``deploy/.env.example`` (placeholder names only; never commit
|
|
||||||
# real values).
|
|
||||||
#
|
|
||||||
# TLS is terminated by the shared Traefik reverse proxy on another
|
|
||||||
# host, which forwards to this service on port 8080. The published
|
|
||||||
# port binds NESTQUEST_BIND_ADDRESS (read from deploy/.env, the
|
|
||||||
# project directory's env file): it defaults to 127.0.0.1 so an
|
|
||||||
# unconfigured deployment is never exposed; set it to the host's LAN
|
|
||||||
# address on the API box so the proxy can reach it.
|
|
||||||
services:
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: nestquest-api:latest
|
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "${NESTQUEST_BIND_ADDRESS:-127.0.0.1}:8080:8080"
|
|
||||||
environment:
|
|
||||||
# The SQLite file inside the named volume; fixed, not a secret.
|
|
||||||
NESTQUEST_DB_PATH: /var/lib/nestquest/nestquest.db
|
|
||||||
env_file:
|
|
||||||
# Secret values (NESTQUEST_PANEL_TOKEN, NESTQUEST_OIDC_*); absent
|
|
||||||
# until you create deploy/.env from the example.
|
|
||||||
- path: .env
|
|
||||||
required: false
|
|
||||||
volumes:
|
|
||||||
- nestquest-db:/var/lib/nestquest
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 15s
|
|
||||||
|
|
||||||
# Continuous backup: streams the SQLite database on the shared named
|
|
||||||
# volume to the Synology NAS over SFTP (config: deploy/litestream.yml).
|
|
||||||
# The SFTP private key deploy/litestream_key is git-ignored and must be
|
|
||||||
# created on the API box before starting (see api/README.md).
|
|
||||||
#
|
|
||||||
# The database volume is mounted READ-WRITE on purpose: Litestream
|
|
||||||
# cannot run against a read-only mount ("unable to open database file
|
|
||||||
# (14)", even with its metadata moved elsewhere) because it writes its
|
|
||||||
# _litestream_seq/_litestream_lock tables into the database, opens the
|
|
||||||
# -wal/-shm files for writing, and checkpoints the WAL. The write
|
|
||||||
# access is contained instead: it runs as the API's own non-root user
|
|
||||||
# (uid/gid 999, owner of the volume) with a read-only root filesystem,
|
|
||||||
# no privilege escalation and no Linux capabilities.
|
|
||||||
litestream:
|
|
||||||
image: litestream/litestream:0.5.17@sha256:4b02b9859a6b6b4087d8b8944e15f7e984bd7957cba322bbeee38b0e27b9656a
|
|
||||||
restart: unless-stopped
|
|
||||||
command: ["replicate", "-config", "/etc/litestream.yml"]
|
|
||||||
user: "999:999"
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
depends_on:
|
|
||||||
api:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- nestquest-db:/var/lib/nestquest
|
|
||||||
- ./litestream.yml:/etc/litestream.yml:ro
|
|
||||||
- ./litestream_key:/etc/litestream/sftp_key:ro
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
nestquest-db:
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Litestream continuous backup of the NestQuest SQLite database (Feature
|
|
||||||
# 17). The ``litestream`` compose service mounts the same named volume
|
|
||||||
# as the API and streams every committed change to the Synology NAS
|
|
||||||
# over SFTP, so the backup lives OUTSIDE the container's volume.
|
|
||||||
#
|
|
||||||
# Nothing here is secret. The SFTP private key is a dedicated,
|
|
||||||
# git-ignored keypair (deploy/litestream_key) mounted read-only by
|
|
||||||
# compose at /etc/litestream/sftp_key; its public half is in
|
|
||||||
# joshua@10.60.1.50:~/.ssh/authorized_keys. ``host-key`` pins the
|
|
||||||
# NAS's host key so the connection is verified; it must be the ECDSA
|
|
||||||
# key, because that is the algorithm Litestream's SSH client negotiates
|
|
||||||
# with this NAS (pinning the ed25519 key fails with "host key mismatch").
|
|
||||||
#
|
|
||||||
# The Synology SFTP server is chrooted to the shared folders, so the
|
|
||||||
# directory seen over SSH as /volume1/homes/Joshua/nestquest-backups is
|
|
||||||
# /homes/Joshua/nestquest-backups over SFTP.
|
|
||||||
|
|
||||||
# Take a full snapshot daily and keep a week of snapshots plus the
|
|
||||||
# incremental changes between them (point-in-time restore window).
|
|
||||||
snapshot:
|
|
||||||
interval: 24h
|
|
||||||
retention: 168h
|
|
||||||
|
|
||||||
dbs:
|
|
||||||
- path: /var/lib/nestquest/nestquest.db
|
|
||||||
replicas:
|
|
||||||
- type: sftp
|
|
||||||
host: 10.60.1.50
|
|
||||||
user: joshua
|
|
||||||
path: /homes/Joshua/nestquest-backups
|
|
||||||
key-path: /etc/litestream/sftp_key
|
|
||||||
host-key: ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNujay9nYEiXuRQf4wDRDU1gp+CDvpeMdhuhC2XypIihzKGEtB654kwrUGfA2kDWAoSPGlmojeS3OIRtHPkj6GY=
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Restrict NestQuest API port 8080 to Traefik (DOCKER-USER)
|
|
||||||
After=docker.service
|
|
||||||
Requires=docker.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
RemainAfterExit=yes
|
|
||||||
ExecStart=/bin/sh -c "iptables -C DOCKER-USER -s 10.60.1.33/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 1 -s 10.60.1.33 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -s 10.60.1.14/32 -p tcp --dport 8080 -j RETURN 2>/dev/null || iptables -I DOCKER-USER 2 -s 10.60.1.14 -p tcp --dport 8080 -j RETURN; iptables -C DOCKER-USER -p tcp --dport 8080 -j DROP 2>/dev/null || iptables -A DOCKER-USER -p tcp --dport 8080 -j DROP"
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
"""Admin-plane occurrence-preview route tests (task 5f843564).
|
|
||||||
|
|
||||||
These tests exercise ``POST /api/v1/admin/quest-definitions/occurrences-preview``
|
|
||||||
against the ASGI app with httpx, reusing the shared local-RSA-key/
|
|
||||||
stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so an admin JWT
|
|
||||||
genuinely authenticates with NO network access.
|
|
||||||
|
|
||||||
- Authentication runs through the router's ONE ``require_admin``
|
|
||||||
dependency: no credential is 401, the panel service token is 403
|
|
||||||
(refused as a credential type), a JWT without the admin group is
|
|
||||||
403, an admin JWT is 200.
|
|
||||||
- The returned dates are asserted EXACTLY for a weekly rule with an
|
|
||||||
interval and a start_date, a daily rule with an interval, a rule
|
|
||||||
with an end_date (the list stops at it), a month-end-clamped monthly
|
|
||||||
rule, and the default start (the pinned host clock's today); one
|
|
||||||
case also asserts parity with the core's own ``occurrences_between``
|
|
||||||
over the documented window.
|
|
||||||
- A rejected rule (the same core ``from_dict`` validation create/edit
|
|
||||||
use), a non-strict ``start_date`` and a ``count`` outside 1..50 are
|
|
||||||
422.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import importlib
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from api import routes_admin
|
|
||||||
from tests.admin_jwt_harness import (
|
|
||||||
ADMIN_KEY,
|
|
||||||
KID,
|
|
||||||
AdminRunner,
|
|
||||||
jwks_for,
|
|
||||||
make_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The recurrence engine through the SAME core copy the app uses
|
|
||||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
|
||||||
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
|
||||||
|
|
||||||
PREVIEW_URL = "/api/v1/admin/quest-definitions/occurrences-preview"
|
|
||||||
|
|
||||||
#: The panel service token the harness app is configured with.
|
|
||||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
|
||||||
|
|
||||||
#: The 403 detail for the panel service token on an admin route.
|
|
||||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
|
||||||
"This credential type is not accepted for admin access"
|
|
||||||
)
|
|
||||||
|
|
||||||
#: The 403 detail for a valid JWT without the admin group.
|
|
||||||
FORBIDDEN_DETAIL = (
|
|
||||||
"Admin access requires membership in the nestquest-admins group"
|
|
||||||
)
|
|
||||||
|
|
||||||
#: The pinned host instant: Wednesday 2026-09-23, noon UTC.
|
|
||||||
PINNED_NOW = datetime.datetime(2026, 9, 23, 12, 0, 0, tzinfo=ZoneInfo("UTC"))
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_headers() -> dict[str, str]:
|
|
||||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
||||||
return {"Authorization": f"Bearer {make_token()}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _weekly_rule() -> dict[str, object]:
|
|
||||||
"""Every OTHER week's Tuesday and Friday, anchored on Tue 2026-03-03."""
|
|
||||||
return {
|
|
||||||
"rule_type": "weekly",
|
|
||||||
"interval": 2,
|
|
||||||
"weekday_set": [1, 4],
|
|
||||||
"start_date": "2026-03-03",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def preview(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
|
||||||
"""An admin-plane app client with the host clock pinned (and counted)."""
|
|
||||||
clock_reads: list[datetime.datetime] = []
|
|
||||||
|
|
||||||
def _admin_now() -> datetime.datetime:
|
|
||||||
clock_reads.append(PINNED_NOW)
|
|
||||||
return PINNED_NOW
|
|
||||||
|
|
||||||
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
|
|
||||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
||||||
async with runner as client:
|
|
||||||
yield SimpleNamespace(client=client, clock_reads=clock_reads)
|
|
||||||
|
|
||||||
|
|
||||||
async def _post(client: object, body: dict[str, object]) -> object:
|
|
||||||
"""POST a preview body with an admin JWT."""
|
|
||||||
return await client.post(PREVIEW_URL, json=body, headers=_admin_headers())
|
|
||||||
|
|
||||||
|
|
||||||
# --- authentication: the ONE require_admin dependency ----------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_without_credential_is_401(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""No Authorization header is the uniform 401."""
|
|
||||||
response = await preview.client.post(
|
|
||||||
PREVIEW_URL, json={"rule": _weekly_rule()}
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_refuses_panel_service_token(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""The panel service token is refused as a credential type (403)."""
|
|
||||||
response = await preview.client.post(
|
|
||||||
PREVIEW_URL,
|
|
||||||
json={"rule": _weekly_rule()},
|
|
||||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_refuses_non_admin_jwt(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A valid JWT without the nestquest-admins group is 403."""
|
|
||||||
non_admin = make_token(groups=("some-other-group",))
|
|
||||||
response = await preview.client.post(
|
|
||||||
PREVIEW_URL,
|
|
||||||
json={"rule": _weekly_rule()},
|
|
||||||
headers={"Authorization": f"Bearer {non_admin}"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_admin_jwt_is_200(preview: SimpleNamespace) -> None:
|
|
||||||
"""An admin JWT is 200 with the documented ``dates`` shape."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert set(response.json()) == {"dates"}
|
|
||||||
|
|
||||||
|
|
||||||
# --- the returned dates ----------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_weekly_interval_rule_from_before_its_start(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""Every other week's Tue/Fri, starting at the rule's own start_date.
|
|
||||||
|
|
||||||
The preview starts on Sun 2026-03-01, before the rule's anchor, so
|
|
||||||
the first date is the anchor itself; the off weeks (Mar 10-16,
|
|
||||||
Mar 24-30) are skipped.
|
|
||||||
"""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": 6},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["dates"] == [
|
|
||||||
"2026-03-03",
|
|
||||||
"2026-03-06",
|
|
||||||
"2026-03-17",
|
|
||||||
"2026-03-20",
|
|
||||||
"2026-03-31",
|
|
||||||
"2026-04-03",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_weekly_interval_rule_from_mid_cadence(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A start inside an on-week keeps the rule's anchor cadence."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{"rule": _weekly_rule(), "start_date": "2026-03-18", "count": 4},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["dates"] == [
|
|
||||||
"2026-03-20",
|
|
||||||
"2026-03-31",
|
|
||||||
"2026-04-03",
|
|
||||||
"2026-04-14",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_daily_interval_rule(preview: SimpleNamespace) -> None:
|
|
||||||
"""Every third day from 2026-01-01, previewed from 2026-01-05."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{
|
|
||||||
"rule": {
|
|
||||||
"rule_type": "daily",
|
|
||||||
"interval": 3,
|
|
||||||
"start_date": "2026-01-01",
|
|
||||||
},
|
|
||||||
"start_date": "2026-01-05",
|
|
||||||
"count": 4,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["dates"] == [
|
|
||||||
"2026-01-07",
|
|
||||||
"2026-01-10",
|
|
||||||
"2026-01-13",
|
|
||||||
"2026-01-16",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_rule_with_end_date_stops_at_it(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A daily rule ending 2026-05-04 yields only its four dates."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{
|
|
||||||
"rule": {
|
|
||||||
"rule_type": "daily",
|
|
||||||
"start_date": "2026-05-01",
|
|
||||||
"end_date": "2026-05-04",
|
|
||||||
},
|
|
||||||
"start_date": "2026-04-28",
|
|
||||||
"count": 10,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["dates"] == [
|
|
||||||
"2026-05-01",
|
|
||||||
"2026-05-02",
|
|
||||||
"2026-05-03",
|
|
||||||
"2026-05-04",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_monthly_day_rule_clamps_to_month_end(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""The core's month-end policy: the 31st fires on Feb 28th."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{
|
|
||||||
"rule": {
|
|
||||||
"rule_type": "monthly_day",
|
|
||||||
"day_of_month": 31,
|
|
||||||
"start_date": "2026-01-31",
|
|
||||||
},
|
|
||||||
"start_date": "2026-01-01",
|
|
||||||
"count": 3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["dates"] == [
|
|
||||||
"2026-01-31",
|
|
||||||
"2026-02-28",
|
|
||||||
"2026-03-31",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_default_start_is_host_today_with_default_count(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""No start_date: ONE clock read's date; no count: ten dates."""
|
|
||||||
response = await _post(preview.client, {"rule": {"rule_type": "daily"}})
|
|
||||||
assert response.status_code == 200
|
|
||||||
expected = [
|
|
||||||
(datetime.date(2026, 9, 23) + datetime.timedelta(days=n)).isoformat()
|
|
||||||
for n in range(10)
|
|
||||||
]
|
|
||||||
assert response.json()["dates"] == expected
|
|
||||||
assert preview.clock_reads == [PINNED_NOW]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_supplied_start_date_reads_no_clock(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A supplied start_date is used as-is: the host clock is not read."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client, {"rule": _weekly_rule(), "start_date": "2026-03-01"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert preview.clock_reads == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_dates_match_core_occurrences_between_over_window(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""The route's dates ARE the core's walk over the 366-day window."""
|
|
||||||
rule_body = {
|
|
||||||
"rule_type": "monthly_weekday",
|
|
||||||
"nth_weekday": -1,
|
|
||||||
"nth_weekday_weekday": 4,
|
|
||||||
"start_date": "2026-01-01",
|
|
||||||
}
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{"rule": rule_body, "start_date": "2026-02-10", "count": 50},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
core_dates = _recurrence.occurrences_between(
|
|
||||||
_recurrence.ScheduleRule.from_dict(rule_body),
|
|
||||||
"2026-02-10",
|
|
||||||
"2027-02-11",
|
|
||||||
)
|
|
||||||
assert response.json()["dates"] == core_dates
|
|
||||||
assert len(core_dates) == 12
|
|
||||||
|
|
||||||
|
|
||||||
async def test_yearly_rule_shows_next_occurrence_within_window(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""The 366-day window always reaches a yearly rule's next date."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{
|
|
||||||
"rule": {
|
|
||||||
"rule_type": "yearly",
|
|
||||||
"month": 9,
|
|
||||||
"day_of_month": 22,
|
|
||||||
"start_date": "2020-09-22",
|
|
||||||
},
|
|
||||||
"start_date": "2026-09-23",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["dates"] == ["2027-09-22"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_start_near_calendar_end_is_422(
|
|
||||||
preview: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A window that would reach 9999-12-31 is rejected, not clamped."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{"rule": {"rule_type": "daily"}, "start_date": "9999-12-30"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert "too close to the end of the calendar" in response.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_latest_accepted_start_date(preview: SimpleNamespace) -> None:
|
|
||||||
"""The last start whose window ends before 9999-12-31 is accepted."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"start_date": "9998-12-29",
|
|
||||||
"count": 1,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["dates"] == ["9998-12-29"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- rejections: 422 -------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"rule",
|
|
||||||
[
|
|
||||||
{"rule_type": "weekly"}, # weekly without weekday_set
|
|
||||||
{"rule_type": "fortnightly"}, # unknown rule_type
|
|
||||||
{"rule_type": "daily", "interval": 0},
|
|
||||||
{"rule_type": "daily", "weekday_set": [1]}, # forbidden field
|
|
||||||
{"rule_type": "daily", "start_date": "2026-3-1"},
|
|
||||||
{
|
|
||||||
"rule_type": "daily",
|
|
||||||
"start_date": "2026-05-04",
|
|
||||||
"end_date": "2026-05-01",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_malformed_rule_is_422(
|
|
||||||
preview: SimpleNamespace, rule: dict[str, object]
|
|
||||||
) -> None:
|
|
||||||
"""A rule the core's from_dict rejects is 422, as on create/edit."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client, {"rule": rule, "start_date": "2026-03-01"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
async def test_missing_rule_is_422(preview: SimpleNamespace) -> None:
|
|
||||||
"""The rule is required: the body model's 422."""
|
|
||||||
response = await _post(preview.client, {"start_date": "2026-03-01"})
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"start_date", ["2026-3-1", "20260301", "tomorrow", "2026-02-30", ""]
|
|
||||||
)
|
|
||||||
async def test_non_strict_start_date_is_422(
|
|
||||||
preview: SimpleNamespace, start_date: str
|
|
||||||
) -> None:
|
|
||||||
"""start_date must be a strict YYYY-MM-DD date (the core's check)."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client, {"rule": _weekly_rule(), "start_date": start_date}
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("count", [0, -1, 51, 1000, True, "5", 2.5])
|
|
||||||
async def test_count_out_of_range_is_422(
|
|
||||||
preview: SimpleNamespace, count: object
|
|
||||||
) -> None:
|
|
||||||
"""count must be a strict JSON integer in 1..50."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{"rule": _weekly_rule(), "start_date": "2026-03-01", "count": count},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("count", [1, 50])
|
|
||||||
async def test_count_bounds_are_inclusive(
|
|
||||||
preview: SimpleNamespace, count: int
|
|
||||||
) -> None:
|
|
||||||
"""count 1 and 50 are accepted and truncate the list exactly."""
|
|
||||||
response = await _post(
|
|
||||||
preview.client,
|
|
||||||
{
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"start_date": "2026-01-01",
|
|
||||||
"count": count,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert len(response.json()["dates"]) == count
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
"""Admin override consequence-count route tests (task 7616e3eb).
|
|
||||||
|
|
||||||
``POST /api/v1/admin/presence-overrides/consequence`` answers, BEFORE an
|
|
||||||
override is saved, how many of the child's upcoming open quest
|
|
||||||
instances saving it would remove (``{"removed": N}``) — the ADMIN-SPEC
|
|
||||||
§6 "This removes N upcoming tasks" warning. These tests exercise it
|
|
||||||
against the ASGI app with the shared stubbed-JWKS harness
|
|
||||||
(:mod:`tests.admin_jwt_harness`):
|
|
||||||
|
|
||||||
- a two-week custody pattern (present the whole first week of the
|
|
||||||
cycle, absent the whole second) where an absent override over a
|
|
||||||
known span removes an EXACT number of instances;
|
|
||||||
- ranges that remove nothing (already-absent days, a present
|
|
||||||
override) count 0;
|
|
||||||
- an instance with a completion event inside the range is NOT counted
|
|
||||||
(D-005 — regeneration never deletes it);
|
|
||||||
- a ``skip_on_away = false`` definition contributes nothing;
|
|
||||||
- the count MATCHES a direct materialise comparison: the override is
|
|
||||||
then actually created (which regenerates the child) and the removed
|
|
||||||
open future instances are counted from the database;
|
|
||||||
- the preview writes nothing;
|
|
||||||
- the four auth outcomes, an unknown child 404 and a malformed range
|
|
||||||
422.
|
|
||||||
|
|
||||||
The cycle is anchored on the REAL host "today" (the create route's
|
|
||||||
regeneration reads the host clock, so the comparison test cannot pin
|
|
||||||
it): days today..today+6 are cycle week 0 (present every day), days
|
|
||||||
today+7..today+13 week 1 (absent every day), today+14 week 0 again.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import importlib
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.admin_jwt_harness import (
|
|
||||||
ADMIN_KEY,
|
|
||||||
KID,
|
|
||||||
AdminRunner,
|
|
||||||
jwks_for,
|
|
||||||
make_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The DAOs and materializer through the SAME core copy the app uses
|
|
||||||
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
|
||||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
|
||||||
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
|
||||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
|
||||||
|
|
||||||
CONSEQUENCE_URL = "/api/v1/admin/presence-overrides/consequence"
|
|
||||||
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
|
|
||||||
|
|
||||||
#: The panel service token the harness app is configured with.
|
|
||||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
|
||||||
|
|
||||||
#: The 403 detail for the panel service token on an admin route.
|
|
||||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
|
||||||
"This credential type is not accepted for admin access"
|
|
||||||
)
|
|
||||||
|
|
||||||
#: The 403 detail for a valid JWT without the admin group.
|
|
||||||
FORBIDDEN_DETAIL = (
|
|
||||||
"Admin access requires membership in the nestquest-admins group"
|
|
||||||
)
|
|
||||||
|
|
||||||
#: The two-week custody pattern: week 0 every weekday, week 1 none.
|
|
||||||
CUSTODY_PATTERN = {"0": [0, 1, 2, 3, 4, 5, 6], "1": []}
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_headers() -> dict[str, str]:
|
|
||||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
||||||
return {"Authorization": f"Bearer {make_token()}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _day(offset: int) -> str:
|
|
||||||
"""The ISO date ``offset`` days after the host's today."""
|
|
||||||
return (datetime.date.today() + datetime.timedelta(days=offset)).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _body(
|
|
||||||
child_id: int, start: int, end: int, *, is_present: bool = False
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""A consequence body over ``[today+start, today+end]``."""
|
|
||||||
return {
|
|
||||||
"child_id": child_id,
|
|
||||||
"start_date": _day(start),
|
|
||||||
"end_date": _day(end),
|
|
||||||
"is_present": is_present,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_child(client: object, name: str = "Ada") -> int:
|
|
||||||
created = await client.post(
|
|
||||||
"/api/v1/admin/children",
|
|
||||||
json={"display_name": name},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert created.status_code == 201
|
|
||||||
return created.json()["id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_daily_definition(
|
|
||||||
client: object,
|
|
||||||
child_id: int,
|
|
||||||
title: str,
|
|
||||||
windows: list[str],
|
|
||||||
*,
|
|
||||||
skip_on_away: bool = True,
|
|
||||||
) -> int:
|
|
||||||
created = await client.post(
|
|
||||||
"/api/v1/admin/quest-definitions",
|
|
||||||
json={
|
|
||||||
"title": title,
|
|
||||||
"rule": {"rule_type": "daily"},
|
|
||||||
"assignee_child_ids": [child_id],
|
|
||||||
"windows": windows,
|
|
||||||
"skip_on_away": skip_on_away,
|
|
||||||
},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert created.status_code == 201
|
|
||||||
return created.json()["id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def _set_custody_schedule(client: object, child_id: int) -> None:
|
|
||||||
"""Anchor the two-week custody cycle on today (week 0 starts today)."""
|
|
||||||
response = await client.put(
|
|
||||||
f"/api/v1/admin/children/{child_id}/presence-schedule",
|
|
||||||
json={
|
|
||||||
"cycle_length_weeks": 2,
|
|
||||||
"anchor_date": _day(0),
|
|
||||||
"pattern": CUSTODY_PATTERN,
|
|
||||||
},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
async def _materialize_horizon(database: object) -> None:
|
|
||||||
"""Materialize the household horizon so the stored state is current."""
|
|
||||||
today = datetime.date.today()
|
|
||||||
await _materialize.materialize(
|
|
||||||
database, _day(0), _day(14), today=today
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _open_future_keys(database: object, child_id: int) -> set[tuple]:
|
|
||||||
"""The child's stored open (no completion event) future instance keys."""
|
|
||||||
instances = await _dao_instances.QuestInstancesDao(
|
|
||||||
database
|
|
||||||
).list_by_date_range(child_id, _day(0), _day(60))
|
|
||||||
events = _dao_instances.CompletionEventsDao(database)
|
|
||||||
keys = set()
|
|
||||||
for instance in instances:
|
|
||||||
if not await events.list_by_instance(instance.id):
|
|
||||||
keys.add(
|
|
||||||
(instance.definition_id, instance.due_date, instance.window)
|
|
||||||
)
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
async def _complete(
|
|
||||||
database: object, child_id: int, definition_id: int, due_date: str,
|
|
||||||
window: str,
|
|
||||||
) -> None:
|
|
||||||
"""Append a completion event to the stored instance."""
|
|
||||||
instance = await _dao_instances.QuestInstancesDao(database).get(
|
|
||||||
definition_id, child_id, due_date, window
|
|
||||||
)
|
|
||||||
assert instance is not None
|
|
||||||
await _dao_instances.CompletionEventsDao(database).append(
|
|
||||||
instance.id,
|
|
||||||
child_id,
|
|
||||||
"completed",
|
|
||||||
"user",
|
|
||||||
datetime.datetime.now(datetime.timezone.utc).isoformat(
|
|
||||||
timespec="seconds"
|
|
||||||
),
|
|
||||||
True,
|
|
||||||
actor_user_id="admin-user-1",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _consequence(client: object, body: dict[str, object]) -> int:
|
|
||||||
response = await client.post(
|
|
||||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200, response.text
|
|
||||||
return response.json()["removed"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def custody(temp_db_path: str) -> SimpleNamespace:
|
|
||||||
"""A child on the two-week custody cycle with a two-window chore.
|
|
||||||
|
|
||||||
Also assigns a ``skip_on_away = false`` daily chore (generated
|
|
||||||
regardless of presence) and materializes the horizon, so the stored
|
|
||||||
instances are the walk's current output.
|
|
||||||
"""
|
|
||||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
||||||
async with runner as client:
|
|
||||||
database = runner.app.state.db.database
|
|
||||||
child_id = await _create_child(client)
|
|
||||||
chore_id = await _create_daily_definition(
|
|
||||||
client, child_id, "Brush teeth", ["morning", "evening"]
|
|
||||||
)
|
|
||||||
always_id = await _create_daily_definition(
|
|
||||||
client, child_id, "Feed the fish", ["afternoon"],
|
|
||||||
skip_on_away=False,
|
|
||||||
)
|
|
||||||
await _set_custody_schedule(client, child_id)
|
|
||||||
await _materialize_horizon(database)
|
|
||||||
yield SimpleNamespace(
|
|
||||||
client=client,
|
|
||||||
database=database,
|
|
||||||
child_id=child_id,
|
|
||||||
chore_id=chore_id,
|
|
||||||
always_id=always_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- the count ---------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_custody_override_removes_a_known_number(
|
|
||||||
custody: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""Absent over today+2..today+8 removes days +2..+6 x two windows.
|
|
||||||
|
|
||||||
Days +7 and +8 are already absent (cycle week 1), and the
|
|
||||||
skip_on_away=false chore is generated regardless of presence, so
|
|
||||||
exactly 5 present days x 2 windows = 10 instances would go.
|
|
||||||
"""
|
|
||||||
removed = await _consequence(
|
|
||||||
custody.client, _body(custody.child_id, 2, 8)
|
|
||||||
)
|
|
||||||
assert removed == 10
|
|
||||||
|
|
||||||
|
|
||||||
async def test_already_absent_range_removes_nothing(
|
|
||||||
custody: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""An absent override over cycle week 1 changes nothing: 0."""
|
|
||||||
assert await _consequence(
|
|
||||||
custody.client, _body(custody.child_id, 7, 13)
|
|
||||||
) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_present_override_removes_nothing(
|
|
||||||
custody: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A present override only ever adds instances: 0."""
|
|
||||||
assert await _consequence(
|
|
||||||
custody.client, _body(custody.child_id, 2, 8, is_present=True)
|
|
||||||
) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_past_range_removes_nothing(custody: SimpleNamespace) -> None:
|
|
||||||
"""A range entirely before today touches no upcoming instance: 0."""
|
|
||||||
assert await _consequence(
|
|
||||||
custody.client, _body(custody.child_id, -20, -1)
|
|
||||||
) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_completed_instance_in_range_is_not_counted(
|
|
||||||
custody: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""D-005: an instance with a completion event is never removed."""
|
|
||||||
await _complete(
|
|
||||||
custody.database, custody.child_id, custody.chore_id, _day(3),
|
|
||||||
"morning",
|
|
||||||
)
|
|
||||||
removed = await _consequence(
|
|
||||||
custody.client, _body(custody.child_id, 2, 8)
|
|
||||||
)
|
|
||||||
assert removed == 9
|
|
||||||
|
|
||||||
|
|
||||||
async def test_skip_on_away_false_definition_contributes_nothing(
|
|
||||||
temp_db_path: str,
|
|
||||||
) -> None:
|
|
||||||
"""A child whose only chore ignores presence: absent removes 0."""
|
|
||||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
||||||
async with runner as client:
|
|
||||||
database = runner.app.state.db.database
|
|
||||||
child_id = await _create_child(client)
|
|
||||||
await _create_daily_definition(
|
|
||||||
client, child_id, "Feed the fish", ["morning"],
|
|
||||||
skip_on_away=False,
|
|
||||||
)
|
|
||||||
await _set_custody_schedule(client, child_id)
|
|
||||||
await _materialize_horizon(database)
|
|
||||||
assert await _consequence(client, _body(child_id, 0, 14)) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_count_matches_a_direct_materialise_comparison(
|
|
||||||
custody: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""Saving the override removes EXACTLY the previewed instances.
|
|
||||||
|
|
||||||
With a completed instance inside the range, the preview is taken,
|
|
||||||
then the override is really created (its core path regenerates the
|
|
||||||
child) and the open future instances that disappeared are counted
|
|
||||||
from the database. The completed instance survives the regeneration.
|
|
||||||
"""
|
|
||||||
client, database = custody.client, custody.database
|
|
||||||
await _complete(
|
|
||||||
database, custody.child_id, custody.chore_id, _day(4), "evening"
|
|
||||||
)
|
|
||||||
body = _body(custody.child_id, 1, 9)
|
|
||||||
removed = await _consequence(client, body)
|
|
||||||
|
|
||||||
before = await _open_future_keys(database, custody.child_id)
|
|
||||||
created = await client.post(
|
|
||||||
OVERRIDES_URL, json=body, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert created.status_code == 201
|
|
||||||
after = await _open_future_keys(database, custody.child_id)
|
|
||||||
|
|
||||||
assert after <= before
|
|
||||||
assert len(before - after) == removed
|
|
||||||
# Days +1..+6 present x two windows, minus the completed one.
|
|
||||||
assert removed == 11
|
|
||||||
completed = await _dao_instances.QuestInstancesDao(database).get(
|
|
||||||
custody.chore_id, custody.child_id, _day(4), "evening"
|
|
||||||
)
|
|
||||||
assert completed is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_preview_writes_nothing(custody: SimpleNamespace) -> None:
|
|
||||||
"""No override is stored and no instance is removed by the preview."""
|
|
||||||
client, database = custody.client, custody.database
|
|
||||||
before = await _open_future_keys(database, custody.child_id)
|
|
||||||
assert await _consequence(client, _body(custody.child_id, 0, 14)) > 0
|
|
||||||
assert await _open_future_keys(database, custody.child_id) == before
|
|
||||||
overrides = await _dao_presence.PresenceOverridesDao(
|
|
||||||
database
|
|
||||||
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
|
|
||||||
assert overrides == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- errors ------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_unknown_child_is_404(custody: SimpleNamespace) -> None:
|
|
||||||
response = await custody.client.post(
|
|
||||||
CONSEQUENCE_URL, json=_body(9999, 1, 2), headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"overrides",
|
|
||||||
[
|
|
||||||
{"start_date": "2026-02-30"},
|
|
||||||
{"end_date": "2026-1-5"},
|
|
||||||
{"start_date": "2026-03-10", "end_date": "2026-03-01"},
|
|
||||||
{"is_present": "sometimes"},
|
|
||||||
{"child_id": "one"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_malformed_range_or_status_is_422(
|
|
||||||
custody: SimpleNamespace, overrides: dict[str, object]
|
|
||||||
) -> None:
|
|
||||||
body = {**_body(custody.child_id, 1, 2), **overrides}
|
|
||||||
response = await custody.client.post(
|
|
||||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"overrides",
|
|
||||||
[
|
|
||||||
{"child_id": True},
|
|
||||||
{"child_id": False},
|
|
||||||
{"child_id": "1"},
|
|
||||||
{"child_id": 1.0},
|
|
||||||
{"is_present": 1},
|
|
||||||
{"is_present": 0},
|
|
||||||
{"is_present": "true"},
|
|
||||||
{"is_present": "false"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_coercible_child_id_or_status_is_422(
|
|
||||||
custody: SimpleNamespace, overrides: dict[str, object]
|
|
||||||
) -> None:
|
|
||||||
"""Strict fields: a bool id or a numeric/string status is rejected.
|
|
||||||
|
|
||||||
The fixture child's id is 1, so a coerced ``true`` would otherwise
|
|
||||||
target it and return 200. Nothing is stored and nothing removed.
|
|
||||||
"""
|
|
||||||
assert custody.child_id == 1
|
|
||||||
database = custody.database
|
|
||||||
before = await _open_future_keys(database, custody.child_id)
|
|
||||||
body = {**_body(custody.child_id, 0, 14), **overrides}
|
|
||||||
response = await custody.client.post(
|
|
||||||
CONSEQUENCE_URL, json=body, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert await _open_future_keys(database, custody.child_id) == before
|
|
||||||
overrides_stored = await _dao_presence.PresenceOverridesDao(
|
|
||||||
database
|
|
||||||
).list_by_child_and_range(custody.child_id, "2000-01-01", "2100-01-01")
|
|
||||||
assert overrides_stored == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- authentication: the router's ONE require_admin --------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_no_credential_is_401(custody: SimpleNamespace) -> None:
|
|
||||||
response = await custody.client.post(
|
|
||||||
CONSEQUENCE_URL, json=_body(custody.child_id, 1, 2)
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_panel_service_token_is_403(custody: SimpleNamespace) -> None:
|
|
||||||
response = await custody.client.post(
|
|
||||||
CONSEQUENCE_URL,
|
|
||||||
json=_body(custody.child_id, 1, 2),
|
|
||||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_non_admin_jwt_is_403(custody: SimpleNamespace) -> None:
|
|
||||||
non_admin = make_token(groups=("some-other-group",))
|
|
||||||
response = await custody.client.post(
|
|
||||||
CONSEQUENCE_URL,
|
|
||||||
json=_body(custody.child_id, 1, 2),
|
|
||||||
headers={"Authorization": f"Bearer {non_admin}"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_jwt_is_200(custody: SimpleNamespace) -> None:
|
|
||||||
response = await custody.client.post(
|
|
||||||
CONSEQUENCE_URL,
|
|
||||||
json=_body(custody.child_id, 1, 2),
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"removed": 4}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
"""Admin-plane presence READ route tests (task 07fde892).
|
|
||||||
|
|
||||||
``GET /api/v1/admin/children/{child_id}/presence-schedule`` and
|
|
||||||
``GET /api/v1/admin/presence-overrides`` against the ASGI app with
|
|
||||||
httpx, through the shared stubbed-JWKS harness
|
|
||||||
(:mod:`tests.admin_jwt_harness`) — no network access.
|
|
||||||
|
|
||||||
- The schedule read answers ``{"schedule": {...}}`` in the PUT
|
|
||||||
response's shape for a seeded schedule, ``{"schedule": null}`` for a
|
|
||||||
child with none (present every day — NOT a 404), and 404 for an
|
|
||||||
unknown child.
|
|
||||||
- The overrides read answers ``{"overrides": [...]}`` in the POST
|
|
||||||
response's shape, ordered by start date, child id, id; ``child_id``
|
|
||||||
and the ``start``/``end`` range each narrow the list; a malformed or
|
|
||||||
inverted range is 422 and an unknown ``child_id`` is 404.
|
|
||||||
- Both routes inherit the router's ONE ``require_admin``: no
|
|
||||||
credential 401, the panel service token 403, a non-admin JWT 403, an
|
|
||||||
admin JWT 200.
|
|
||||||
|
|
||||||
Seeding goes through the admin write routes, so the reads are proven
|
|
||||||
against rows the real write path stored.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for, make_token
|
|
||||||
|
|
||||||
OVERRIDES_URL = "/api/v1/admin/presence-overrides"
|
|
||||||
|
|
||||||
#: The panel service token the harness app is configured with.
|
|
||||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
|
||||||
|
|
||||||
#: The 403 detail for the panel service token on an admin route.
|
|
||||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
|
||||||
"This credential type is not accepted for admin access"
|
|
||||||
)
|
|
||||||
|
|
||||||
#: The 403 detail for a valid JWT without the admin group.
|
|
||||||
FORBIDDEN_DETAIL = (
|
|
||||||
"Admin access requires membership in the nestquest-admins group"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _schedule_url(child_id: int) -> str:
|
|
||||||
return f"/api/v1/admin/children/{child_id}/presence-schedule"
|
|
||||||
|
|
||||||
|
|
||||||
def _admin_headers() -> dict[str, str]:
|
|
||||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
|
||||||
return {"Authorization": f"Bearer {make_token()}"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_child(client: object, name: str) -> int:
|
|
||||||
"""Create one child through the admin children route; return the id."""
|
|
||||||
created = await client.post(
|
|
||||||
"/api/v1/admin/children",
|
|
||||||
json={"display_name": name},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert created.status_code == 201
|
|
||||||
return created.json()["id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_override(
|
|
||||||
client: object,
|
|
||||||
child_id: int,
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
*,
|
|
||||||
is_present: bool = False,
|
|
||||||
note: str | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Create one override through the admin write route; return it."""
|
|
||||||
created = await client.post(
|
|
||||||
OVERRIDES_URL,
|
|
||||||
json={
|
|
||||||
"child_id": child_id,
|
|
||||||
"start_date": start_date,
|
|
||||||
"end_date": end_date,
|
|
||||||
"is_present": is_present,
|
|
||||||
"note": note,
|
|
||||||
},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert created.status_code == 201, created.text
|
|
||||||
return created.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def reads(temp_db_path: str) -> SimpleNamespace:
|
|
||||||
"""An admin-plane client over a fresh database."""
|
|
||||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
|
||||||
async with runner as client:
|
|
||||||
yield SimpleNamespace(client=client)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def seeded(reads: SimpleNamespace) -> SimpleNamespace:
|
|
||||||
"""Two children with interleaved overrides (Ada has a schedule)."""
|
|
||||||
client = reads.client
|
|
||||||
ada = await _create_child(client, "Ada")
|
|
||||||
ben = await _create_child(client, "Ben")
|
|
||||||
put = await client.put(
|
|
||||||
_schedule_url(ada),
|
|
||||||
json={
|
|
||||||
"cycle_length_weeks": 2,
|
|
||||||
"anchor_date": "2026-01-05",
|
|
||||||
"pattern": {"0": [4, 0, 2], "1": []},
|
|
||||||
},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert put.status_code == 200
|
|
||||||
ada_march = await _create_override(
|
|
||||||
client, ada, "2026-03-01", "2026-03-07", note="grandparents"
|
|
||||||
)
|
|
||||||
ben_january = await _create_override(
|
|
||||||
client, ben, "2026-01-10", "2026-01-12", is_present=True
|
|
||||||
)
|
|
||||||
ben_march = await _create_override(client, ben, "2026-03-01", "2026-03-01")
|
|
||||||
ada_may = await _create_override(client, ada, "2026-05-20", "2026-05-20")
|
|
||||||
return SimpleNamespace(
|
|
||||||
client=client,
|
|
||||||
ada=ada,
|
|
||||||
ben=ben,
|
|
||||||
ada_march=ada_march,
|
|
||||||
ben_january=ben_january,
|
|
||||||
ben_march=ben_march,
|
|
||||||
ada_may=ada_may,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# --- presence schedule read ------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_schedule_read_returns_the_stored_schedule(
|
|
||||||
seeded: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A seeded schedule reads back in the PUT response's shape."""
|
|
||||||
response = await seeded.client.get(
|
|
||||||
_schedule_url(seeded.ada), headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"schedule": {
|
|
||||||
"child_id": seeded.ada,
|
|
||||||
"cycle_length_weeks": 2,
|
|
||||||
"anchor_date": "2026-01-05",
|
|
||||||
"pattern": {"0": [0, 2, 4], "1": []},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_schedule_read_without_schedule_is_null_not_404(
|
|
||||||
seeded: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A child with no schedule (present every day) is schedule: null."""
|
|
||||||
response = await seeded.client.get(
|
|
||||||
_schedule_url(seeded.ben), headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"schedule": None}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_schedule_read_unknown_child_is_404(
|
|
||||||
reads: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""An unknown child is 404 with the children routes' detail."""
|
|
||||||
response = await reads.client.get(
|
|
||||||
_schedule_url(999), headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"detail": "Child not found"}
|
|
||||||
|
|
||||||
|
|
||||||
# --- presence overrides read -----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_overrides_read_lists_all_in_stable_order(
|
|
||||||
seeded: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""Unfiltered: every override, by start date, then child id, then id."""
|
|
||||||
response = await seeded.client.get(
|
|
||||||
OVERRIDES_URL, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"overrides": [
|
|
||||||
seeded.ben_january,
|
|
||||||
seeded.ada_march,
|
|
||||||
seeded.ben_march,
|
|
||||||
seeded.ada_may,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
assert seeded.ada_march == {
|
|
||||||
"id": seeded.ada_march["id"],
|
|
||||||
"child_id": seeded.ada,
|
|
||||||
"start_date": "2026-03-01",
|
|
||||||
"end_date": "2026-03-07",
|
|
||||||
"is_present": False,
|
|
||||||
"note": "grandparents",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_overrides_read_empty_household(reads: SimpleNamespace) -> None:
|
|
||||||
"""No overrides at all is an empty list."""
|
|
||||||
response = await reads.client.get(OVERRIDES_URL, headers=_admin_headers())
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"overrides": []}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_overrides_read_filters_by_child(
|
|
||||||
seeded: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""child_id keeps only that child's overrides."""
|
|
||||||
response = await seeded.client.get(
|
|
||||||
OVERRIDES_URL,
|
|
||||||
params={"child_id": seeded.ada},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_overrides_read_filters_by_overlapping_range(
|
|
||||||
seeded: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""start/end keep overrides overlapping the closed range (inclusive)."""
|
|
||||||
# 2026-03-07 is Ada's March override's LAST day: the boundary counts.
|
|
||||||
response = await seeded.client.get(
|
|
||||||
OVERRIDES_URL,
|
|
||||||
params={"start": "2026-03-07", "end": "2026-05-20"},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {
|
|
||||||
"overrides": [seeded.ada_march, seeded.ada_may]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_overrides_read_open_ended_range_filters(
|
|
||||||
seeded: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""start alone keeps later-ending, end alone keeps earlier-starting."""
|
|
||||||
from_april = await seeded.client.get(
|
|
||||||
OVERRIDES_URL, params={"start": "2026-04-01"}, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert from_april.json() == {"overrides": [seeded.ada_may]}
|
|
||||||
until_january = await seeded.client.get(
|
|
||||||
OVERRIDES_URL, params={"end": "2026-01-31"}, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert until_january.json() == {"overrides": [seeded.ben_january]}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_overrides_read_combines_child_and_range(
|
|
||||||
seeded: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""child_id and the range narrow together."""
|
|
||||||
response = await seeded.client.get(
|
|
||||||
OVERRIDES_URL,
|
|
||||||
params={"child_id": seeded.ben, "start": "2026-02-01", "end": "2026-12-31"},
|
|
||||||
headers=_admin_headers(),
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"overrides": [seeded.ben_march]}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"params",
|
|
||||||
[
|
|
||||||
{"start": "2026-03-10", "end": "2026-03-01"},
|
|
||||||
{"start": "2026-3-1"},
|
|
||||||
{"end": "not-a-date"},
|
|
||||||
{"start": "2026-02-30"},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_overrides_read_invalid_range_is_422(
|
|
||||||
seeded: SimpleNamespace, params: dict[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""A malformed date or an inverted range is the core's 422."""
|
|
||||||
response = await seeded.client.get(
|
|
||||||
OVERRIDES_URL, params=params, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
async def test_overrides_read_unknown_child_is_404(
|
|
||||||
reads: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""An unknown child_id filter is 404, never an empty list."""
|
|
||||||
response = await reads.client.get(
|
|
||||||
OVERRIDES_URL, params={"child_id": 999}, headers=_admin_headers()
|
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"detail": "Child not found"}
|
|
||||||
|
|
||||||
|
|
||||||
# --- authentication: the router's ONE require_admin --------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=["schedule", "overrides"])
|
|
||||||
async def read_url(request: pytest.FixtureRequest, reads: SimpleNamespace) -> str:
|
|
||||||
"""Each read route's URL (the schedule route for a real child)."""
|
|
||||||
if request.param == "overrides":
|
|
||||||
return OVERRIDES_URL
|
|
||||||
return _schedule_url(await _create_child(reads.client, "Ada"))
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_without_credential_is_401(
|
|
||||||
reads: SimpleNamespace, read_url: str
|
|
||||||
) -> None:
|
|
||||||
response = await reads.client.get(read_url)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_refuses_panel_service_token(
|
|
||||||
reads: SimpleNamespace, read_url: str
|
|
||||||
) -> None:
|
|
||||||
response = await reads.client.get(
|
|
||||||
read_url, headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_refuses_non_admin_jwt(
|
|
||||||
reads: SimpleNamespace, read_url: str
|
|
||||||
) -> None:
|
|
||||||
non_admin = make_token(groups=("some-other-group",))
|
|
||||||
response = await reads.client.get(
|
|
||||||
read_url, headers={"Authorization": f"Bearer {non_admin}"}
|
|
||||||
)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
|
||||||
|
|
||||||
|
|
||||||
async def test_read_admin_jwt_is_200(
|
|
||||||
reads: SimpleNamespace, read_url: str
|
|
||||||
) -> None:
|
|
||||||
response = await reads.client.get(read_url, headers=_admin_headers())
|
|
||||||
assert response.status_code == 200
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
"""Admin-plane SSE tests: the admin transition stream (task 92f75680).
|
|
||||||
|
|
||||||
These tests exercise ``GET /api/v1/admin/events`` — the admin plane's
|
|
||||||
Server-Sent Events stream for the Admin PWA — against a REAL server:
|
|
||||||
|
|
||||||
- The stream inherits the admin router's ONE ``require_admin``
|
|
||||||
dependency: an absent credential is 401, the panel service token and
|
|
||||||
a non-admin JWT are 403, all before any stream is established.
|
|
||||||
- An admin JWT subscribes; each of the four documented transitions
|
|
||||||
(quest-completed, quest-uncompleted, quest-missed, child-day-complete)
|
|
||||||
arrives as an ``event: <type>`` / ``data: <json>`` frame carrying its
|
|
||||||
documented payload fields (design/ENTITIES-AND-SERVICES.md §3).
|
|
||||||
- The admin and panel streams are fed by the SAME in-process publisher:
|
|
||||||
one transition arrives, identically, on both.
|
|
||||||
|
|
||||||
Streaming approach: :mod:`tests.test_api_sse`'s live-uvicorn reader
|
|
||||||
(httpx's ASGITransport buffers SSE bodies), on the admin-enabled
|
|
||||||
:class:`tests.test_api_admin_uncomplete._AdminServer` whose JWKS cache
|
|
||||||
is stubbed, so a real admin JWT verifies with NO network access.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from api import routes_admin, routes_panel
|
|
||||||
from tests.admin_jwt_harness import make_token
|
|
||||||
from tests.test_api_admin_uncomplete import _AdminServer
|
|
||||||
from tests.test_api_panel import PANEL_TOKEN, _seed_household
|
|
||||||
from tests.test_api_sse import (
|
|
||||||
EVENT_CHILD_DAY_COMPLETE,
|
|
||||||
EVENT_QUEST_COMPLETED,
|
|
||||||
EVENT_QUEST_MISSED,
|
|
||||||
EVENT_QUEST_UNCOMPLETED,
|
|
||||||
READ_TIMEOUT,
|
|
||||||
_stream,
|
|
||||||
)
|
|
||||||
|
|
||||||
EVENTS_PATH = "/api/v1/admin/events"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def temp_db_path(tmp_path) -> str:
|
|
||||||
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
|
||||||
return str(tmp_path / "nestquest.db")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def admin_live(temp_db_path: str) -> SimpleNamespace:
|
|
||||||
"""A seeded household served by a live admin-enabled uvicorn app.
|
|
||||||
|
|
||||||
Both planes' clocks are pinned to 12:00 UTC (past Ada's 10:00 due,
|
|
||||||
before Bo's 17:00) BEFORE the server starts — the app is created
|
|
||||||
inside uvicorn's factory thread.
|
|
||||||
"""
|
|
||||||
today = datetime.datetime.now().astimezone().date()
|
|
||||||
pinned_now = datetime.datetime(
|
|
||||||
today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC")
|
|
||||||
)
|
|
||||||
seed = await _seed_household(temp_db_path, today, pinned_now)
|
|
||||||
original_panel = routes_panel._local_now
|
|
||||||
original_admin = routes_admin._local_now
|
|
||||||
routes_panel._local_now = lambda: pinned_now
|
|
||||||
routes_admin._local_now = lambda: pinned_now
|
|
||||||
try:
|
|
||||||
with _AdminServer(temp_db_path) as server:
|
|
||||||
client = httpx.AsyncClient(
|
|
||||||
base_url=server.url(""), timeout=READ_TIMEOUT
|
|
||||||
)
|
|
||||||
async with client:
|
|
||||||
yield SimpleNamespace(client=client, seed=seed, server=server)
|
|
||||||
finally:
|
|
||||||
routes_panel._local_now = original_panel
|
|
||||||
routes_admin._local_now = original_admin
|
|
||||||
|
|
||||||
|
|
||||||
async def _panel_complete(live: SimpleNamespace, instance_id: int, child_id: int):
|
|
||||||
"""Complete ``instance_id`` through the panel route (a real transition)."""
|
|
||||||
response = await live.client.post(
|
|
||||||
f"/api/v1/panel/instances/{instance_id}/complete",
|
|
||||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
|
||||||
json={"actor_child_id": child_id},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
# --- auth: the ONE require_admin dependency, no stream on refusal ----------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_events_refuses_absent_panel_and_non_admin(
|
|
||||||
admin_live: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""No credential is 401; the panel token and a non-admin JWT are 403.
|
|
||||||
|
|
||||||
Each refusal is a complete (non-streaming) JSON error response, so
|
|
||||||
no stream is established for any of them.
|
|
||||||
"""
|
|
||||||
client = admin_live.client
|
|
||||||
|
|
||||||
absent = await client.get(EVENTS_PATH)
|
|
||||||
assert absent.status_code == 401
|
|
||||||
assert not absent.headers["content-type"].startswith("text/event-stream")
|
|
||||||
|
|
||||||
panel = await client.get(
|
|
||||||
EVENTS_PATH, headers={"Authorization": f"Bearer {PANEL_TOKEN}"}
|
|
||||||
)
|
|
||||||
assert panel.status_code == 403
|
|
||||||
assert panel.json()["detail"] == (
|
|
||||||
"This credential type is not accepted for admin access"
|
|
||||||
)
|
|
||||||
|
|
||||||
non_admin = await client.get(
|
|
||||||
EVENTS_PATH,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {make_token(groups=('other-group',))}"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert non_admin.status_code == 403
|
|
||||||
assert non_admin.json()["detail"] == (
|
|
||||||
"Admin access requires membership in the nestquest-admins group"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_jwt_is_not_accepted_on_panel_events(
|
|
||||||
admin_live: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""The panel plane is unchanged: an admin JWT does not open it."""
|
|
||||||
response = await admin_live.client.get(
|
|
||||||
"/api/v1/panel/events",
|
|
||||||
headers={"Authorization": f"Bearer {make_token()}"},
|
|
||||||
)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_events_sets_no_cache(admin_live: SimpleNamespace) -> None:
|
|
||||||
"""The admin stream is ``text/event-stream`` with ``no-cache``."""
|
|
||||||
async with admin_live.client.stream(
|
|
||||||
"GET",
|
|
||||||
EVENTS_PATH,
|
|
||||||
headers={"Authorization": f"Bearer {make_token()}"},
|
|
||||||
) as response:
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.headers["content-type"].startswith("text/event-stream")
|
|
||||||
assert response.headers["cache-control"] == "no-cache"
|
|
||||||
|
|
||||||
|
|
||||||
# --- the four documented transitions on the admin stream -------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_stream_carries_completed_and_day_complete(
|
|
||||||
admin_live: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""A panel completion streams quest-completed then child-day-complete.
|
|
||||||
|
|
||||||
Ada's only quest today is Pack bag, so completing it also clears her
|
|
||||||
day; both frames arrive on the ADMIN stream with their documented
|
|
||||||
payload fields.
|
|
||||||
"""
|
|
||||||
seed = admin_live.seed
|
|
||||||
pack_bag = seed.pack_bag_instance
|
|
||||||
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
|
||||||
async with stream:
|
|
||||||
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
|
|
||||||
|
|
||||||
event_type, payload = await stream.next_event()
|
|
||||||
assert event_type == EVENT_QUEST_COMPLETED == "nestquest_quest_completed"
|
|
||||||
assert payload["child_id"] == seed.ada.id
|
|
||||||
assert payload["child_name"] == "Ada"
|
|
||||||
assert payload["instance_id"] == pack_bag.id
|
|
||||||
assert payload["quest_title"] == "Pack bag"
|
|
||||||
assert payload["window"] == "morning"
|
|
||||||
assert payload["due_date"] == pack_bag.due_date
|
|
||||||
assert payload["due_time"] == "10:00"
|
|
||||||
assert payload["occurred_at"]
|
|
||||||
assert payload["was_on_time"] is False # 10:00 due, 12:00 now
|
|
||||||
completed_at = payload["occurred_at"]
|
|
||||||
|
|
||||||
event_type, payload = await stream.next_event()
|
|
||||||
assert event_type == EVENT_CHILD_DAY_COMPLETE
|
|
||||||
assert event_type == "nestquest_child_day_complete"
|
|
||||||
assert payload["child_id"] == seed.ada.id
|
|
||||||
assert payload["child_name"] == "Ada"
|
|
||||||
assert payload["quests_due"] == 1
|
|
||||||
assert payload["quests_completed"] == 1
|
|
||||||
assert payload["occurred_at"] == completed_at
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_stream_carries_uncompleted(
|
|
||||||
admin_live: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""The admin uncomplete route's reversal streams quest-uncompleted."""
|
|
||||||
seed = admin_live.seed
|
|
||||||
brush = seed.brush_teeth_instance
|
|
||||||
admin_headers = {"Authorization": f"Bearer {make_token()}"}
|
|
||||||
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
|
||||||
async with stream:
|
|
||||||
response = await admin_live.client.post(
|
|
||||||
f"/api/v1/admin/instances/{brush.id}/uncomplete",
|
|
||||||
headers=admin_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["appended"] is True
|
|
||||||
|
|
||||||
event_type, payload = await stream.next_event()
|
|
||||||
assert event_type == EVENT_QUEST_UNCOMPLETED
|
|
||||||
assert event_type == "nestquest_quest_uncompleted"
|
|
||||||
assert payload["child_id"] == seed.bo.id
|
|
||||||
assert payload["child_name"] == "Bo"
|
|
||||||
assert payload["instance_id"] == brush.id
|
|
||||||
assert payload["quest_title"] == "Brush teeth"
|
|
||||||
assert payload["window"] == "morning"
|
|
||||||
assert payload["due_date"] == brush.due_date
|
|
||||||
assert payload["due_time"] == "17:00"
|
|
||||||
assert payload["occurred_at"]
|
|
||||||
assert "was_on_time" not in payload
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_stream_carries_missed(admin_live: SimpleNamespace) -> None:
|
|
||||||
"""The missed-sweep publisher's transition streams to the admin client.
|
|
||||||
|
|
||||||
Drives the API-side helper the sweep calls, on the SERVER's loop,
|
|
||||||
against the app's own database and its ONE publisher.
|
|
||||||
"""
|
|
||||||
from api import transitions
|
|
||||||
|
|
||||||
seed = admin_live.seed
|
|
||||||
stale = seed.stale_instance
|
|
||||||
app = admin_live.server.app
|
|
||||||
stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
|
||||||
async with stream:
|
|
||||||
admin_live.server.run_on_loop(
|
|
||||||
transitions.publish_quest_missed(
|
|
||||||
app.state.db.database, stale.id, app.state.publisher
|
|
||||||
)
|
|
||||||
)
|
|
||||||
event_type, payload = await stream.next_event()
|
|
||||||
assert event_type == EVENT_QUEST_MISSED == "nestquest_quest_missed"
|
|
||||||
assert payload["child_id"] == seed.ada.id
|
|
||||||
assert payload["child_name"] == "Ada"
|
|
||||||
assert payload["instance_id"] == stale.id
|
|
||||||
assert payload["quest_title"] == "Stale chore"
|
|
||||||
assert payload["window"] == "morning"
|
|
||||||
assert payload["due_date"] == stale.due_date
|
|
||||||
assert payload["due_time"] == "09:00"
|
|
||||||
assert payload["occurred_at"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- one publisher feeds both planes ---------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_admin_and_panel_streams_share_one_publisher(
|
|
||||||
admin_live: SimpleNamespace,
|
|
||||||
) -> None:
|
|
||||||
"""One transition arrives, identically, on the admin AND panel streams.
|
|
||||||
|
|
||||||
The route publishes once to ``app.state.publisher``; both planes'
|
|
||||||
subscribers receive the same frames (same ``occurred_at``), which a
|
|
||||||
second, admin-only publisher could never produce.
|
|
||||||
"""
|
|
||||||
seed = admin_live.seed
|
|
||||||
pack_bag = seed.pack_bag_instance
|
|
||||||
admin_stream = _stream(admin_live.server.url(EVENTS_PATH), make_token())
|
|
||||||
panel_stream = _stream(admin_live.server.url("/api/v1/panel/events"))
|
|
||||||
async with admin_stream, panel_stream:
|
|
||||||
await _panel_complete(admin_live, pack_bag.id, seed.ada.id)
|
|
||||||
|
|
||||||
admin_events = [
|
|
||||||
await admin_stream.next_event(),
|
|
||||||
await admin_stream.next_event(),
|
|
||||||
]
|
|
||||||
panel_events = [
|
|
||||||
await panel_stream.next_event(),
|
|
||||||
await panel_stream.next_event(),
|
|
||||||
]
|
|
||||||
assert [event_type for event_type, _ in admin_events] == [
|
|
||||||
EVENT_QUEST_COMPLETED,
|
|
||||||
EVENT_CHILD_DAY_COMPLETE,
|
|
||||||
]
|
|
||||||
assert admin_events == panel_events
|
|
||||||
+5
-10
@@ -158,8 +158,7 @@ class _EventStream:
|
|||||||
parsed frame with a timeout.
|
parsed frame with a timeout.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, token: str = PANEL_TOKEN) -> None:
|
def __init__(self) -> None:
|
||||||
self.token = token
|
|
||||||
self.queue: asyncio.Queue = asyncio.Queue()
|
self.queue: asyncio.Queue = asyncio.Queue()
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
self._client: httpx.AsyncClient | None = None
|
self._client: httpx.AsyncClient | None = None
|
||||||
@@ -171,7 +170,7 @@ class _EventStream:
|
|||||||
self._stream_cm = self._client.stream(
|
self._stream_cm = self._client.stream(
|
||||||
"GET",
|
"GET",
|
||||||
self.url,
|
self.url,
|
||||||
headers={"Authorization": f"Bearer {self.token}"},
|
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||||
)
|
)
|
||||||
self._response = await self._stream_cm.__aenter__()
|
self._response = await self._stream_cm.__aenter__()
|
||||||
assert self._response.status_code == 200
|
assert self._response.status_code == 200
|
||||||
@@ -264,13 +263,9 @@ async def seeded_panel(temp_db_path: str) -> SimpleNamespace:
|
|||||||
routes_panel._local_now = original
|
routes_panel._local_now = original
|
||||||
|
|
||||||
|
|
||||||
def _stream(url: str, token: str = PANEL_TOKEN) -> _EventStream:
|
def _stream(url: str) -> _EventStream:
|
||||||
"""A subscribed SSE reader for ``url``, sending ``token`` as Bearer.
|
"""A subscribed SSE reader for ``url``."""
|
||||||
|
return _EventStream()._set_url(url)
|
||||||
Defaults to the panel service token; the admin-plane stream tests
|
|
||||||
pass an admin JWT instead.
|
|
||||||
"""
|
|
||||||
return _EventStream(token)._set_url(url)
|
|
||||||
|
|
||||||
|
|
||||||
# --- auth and content type ----------------------------------------------
|
# --- auth and content type ----------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user