Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf00b63215 | ||
|
|
5a28fddfe2 | ||
|
|
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
|
||||
@@ -5,12 +5,3 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
*.metadata.json
|
||||
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
|
||||
|
||||
-47
@@ -1,47 +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).
|
||||
FROM python:3.12-slim 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
|
||||
|
||||
# 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",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -2258,15 +2257,6 @@
|
||||
"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": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
|
||||
+1
-2
@@ -10,7 +10,6 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.47.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -25,4 +24,4 @@
|
||||
"vite": "^7.1.5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,17 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
|
||||
@@ -29,8 +29,9 @@ describe("NestQuest Admin shell", () => {
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "History" })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
||||
expect(screen.getByRole("tabpanel").textContent).toContain(
|
||||
"History placeholder",
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: "History" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
|
||||
+40
-43
@@ -1,9 +1,6 @@
|
||||
import { useState } from "react";
|
||||
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";
|
||||
|
||||
const TABS = [
|
||||
@@ -17,47 +14,47 @@ type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export default function App() {
|
||||
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" || active === "tasks";
|
||||
|
||||
return (
|
||||
<TransitionsProvider>
|
||||
<div className="admin-shell">
|
||||
{/* Every tab is a built screen that owns its header (ADMIN-SPEC §1); the
|
||||
generic shell header stays only as a visually hidden page heading. */}
|
||||
<header className="admin-header visually-hidden">
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Admin console</p>
|
||||
</header>
|
||||
<main
|
||||
className="admin-panel admin-panel--screen"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : active === "schedule" ? (
|
||||
<ScheduleTab />
|
||||
) : (
|
||||
<HistoryTab />
|
||||
)}
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab.id === active}
|
||||
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
||||
onClick={() => setActive(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</TransitionsProvider>
|
||||
<div className="admin-shell">
|
||||
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Admin console</p>
|
||||
</header>
|
||||
<main
|
||||
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : (
|
||||
<p className="admin-placeholder">
|
||||
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||
arrive in later tasks.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab.id === active}
|
||||
className={tab.id === active ? "admin-tab active" : "admin-tab"}
|
||||
onClick={() => setActive(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { storeTokens } from "./auth/oidc";
|
||||
import { config } from "./config";
|
||||
@@ -32,32 +32,6 @@ describe("AppRoutes auth gating", () => {
|
||||
).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 () => {
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
|
||||
@@ -71,9 +45,6 @@ describe("AppRoutes auth gating", () => {
|
||||
for (const label of ["Today", "Tasks", "Schedule", "History"]) {
|
||||
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 () => {
|
||||
@@ -87,97 +58,6 @@ describe("AppRoutes auth gating", () => {
|
||||
expect(screen.getByTestId("refusal-screen")).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", () => {
|
||||
|
||||
+8
-43
@@ -12,7 +12,7 @@ import {
|
||||
handleCallback,
|
||||
isAuthenticated,
|
||||
} from "./auth/oidc";
|
||||
import { ApiForbiddenError, ApiUnauthorizedError, apiFetch } from "./api/client";
|
||||
import { ApiForbiddenError, apiFetch } from "./api/client";
|
||||
import AdminShell from "./App";
|
||||
|
||||
type Screen =
|
||||
@@ -21,28 +21,18 @@ type Screen =
|
||||
| { kind: "config-error"; message: string }
|
||||
| { kind: "auth-error"; message: string }
|
||||
| { kind: "refusal"; message: string }
|
||||
| { kind: "probe-error"; message: string }
|
||||
| { kind: "app" };
|
||||
|
||||
function currentPath(): string {
|
||||
return window.location.pathname.replace(/\/+$/, "") || "/";
|
||||
}
|
||||
|
||||
const ADMIN_PROBE_PATH = "/api/v1/admin/ping";
|
||||
|
||||
async function probeApi(setScreen: (screen: Screen) => void) {
|
||||
// The admin plane's authorization probe: only a 2xx enters the shell, so a
|
||||
// non-admin (403) sees the explicit refusal and any other failure an error.
|
||||
// A cheap authenticated probe so a 403 surfaces as an explicit refusal
|
||||
// right after login instead of a silent empty shell.
|
||||
try {
|
||||
const response = await apiFetch(ADMIN_PROBE_PATH, {}, config);
|
||||
if (response.ok) {
|
||||
setScreen({ kind: "app" });
|
||||
return;
|
||||
}
|
||||
setScreen({
|
||||
kind: "probe-error",
|
||||
message: `The admin API answered ${response.status}. The admin console cannot load.`,
|
||||
});
|
||||
await apiFetch("/api/v1/children", {}, config);
|
||||
setScreen({ kind: "app" });
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
setScreen({ kind: "config-error", message: error.message });
|
||||
@@ -52,15 +42,9 @@ async function probeApi(setScreen: (screen: Screen) => void) {
|
||||
setScreen({ kind: "refusal", message: error.message });
|
||||
return;
|
||||
}
|
||||
if (error instanceof ApiUnauthorizedError) {
|
||||
// apiFetch has already started the login redirect.
|
||||
setScreen({ kind: "login" });
|
||||
return;
|
||||
}
|
||||
setScreen({
|
||||
kind: "probe-error",
|
||||
message: "The admin API could not be reached. Check your connection and try again.",
|
||||
});
|
||||
// Network or other failures still allow the shell to render; API screens
|
||||
// handle their own errors in later tasks.
|
||||
setScreen({ kind: "app" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,25 +170,6 @@ export default function AppRoutes() {
|
||||
</button>
|
||||
</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":
|
||||
return <AdminShell />;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ function readDetail(body: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJson<T>(response: Response): Promise<T> {
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
@@ -146,29 +146,3 @@ export async function updateDefinition(
|
||||
): 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]);
|
||||
}
|
||||
@@ -414,30 +414,6 @@
|
||||
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;
|
||||
@@ -574,12 +550,3 @@
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.defs-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.defs-preview--error {
|
||||
color: var(--nq-a-ink-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import { DEFINITION_ICONS, DefinitionIcon } from "./definitionIcons";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
@@ -98,27 +97,19 @@ interface Call {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const PREVIEW_PATH = "/api/v1/admin/quest-definitions/occurrences-preview";
|
||||
|
||||
/** Answers an occurrence-preview request; the default serves no dates. */
|
||||
type PreviewHandler = (body: { rule: DefinitionRule; count?: number }) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* A stub of the admin API: serves the list and children, records writes, and
|
||||
* lets a test queue a response for the next write. Occurrence previews are
|
||||
* reads (recorded, never counted as writes) answered by `setPreview`.
|
||||
* lets a test queue a response for the next write.
|
||||
*/
|
||||
function fakeApi(initial: QuestDefinition[]) {
|
||||
let definitions = [...initial];
|
||||
const calls: Call[] = [];
|
||||
let nextWrite: Response | null = null;
|
||||
let preview: PreviewHandler = async () => jsonResponse({ dates: [] });
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const path = new URL(String(input), "http://localhost").pathname;
|
||||
const method = init.method ?? "GET";
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
|
||||
calls.push({ method, path, body });
|
||||
if (method === "POST" && path === PREVIEW_PATH) return preview(body);
|
||||
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
|
||||
return jsonResponse({ definitions });
|
||||
}
|
||||
@@ -135,7 +126,7 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
id: 99,
|
||||
title: body.title,
|
||||
description: null,
|
||||
icon: body.icon ?? null,
|
||||
icon: null,
|
||||
is_active: true,
|
||||
skip_on_away: body.skip_on_away,
|
||||
rule: body.rule,
|
||||
@@ -159,7 +150,6 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
? {
|
||||
...d,
|
||||
title: body.title ?? d.title,
|
||||
icon: "icon" in body ? body.icon : d.icon,
|
||||
skip_on_away: body.skip_on_away ?? d.skip_on_away,
|
||||
rule: body.rule ?? d.rule,
|
||||
// Like the API: a sent list replaces the whole set; an absent one keeps it.
|
||||
@@ -180,11 +170,7 @@ function fakeApi(initial: QuestDefinition[]) {
|
||||
return {
|
||||
fetchMock,
|
||||
calls,
|
||||
writes: () => calls.filter((c) => c.method !== "GET" && c.path !== PREVIEW_PATH),
|
||||
previews: () => calls.filter((c) => c.path === PREVIEW_PATH),
|
||||
setPreview: (handler: PreviewHandler) => {
|
||||
preview = handler;
|
||||
},
|
||||
writes: () => calls.filter((c) => c.method !== "GET"),
|
||||
listFetches: () =>
|
||||
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
|
||||
.length,
|
||||
@@ -333,7 +319,6 @@ describe("DefinitionsTab", () => {
|
||||
path: "/api/v1/admin/quest-definitions",
|
||||
body: {
|
||||
title: "Feed the cat",
|
||||
icon: null,
|
||||
rule: {
|
||||
rule_type: "weekly",
|
||||
interval: 2,
|
||||
@@ -422,7 +407,6 @@ describe("DefinitionsTab", () => {
|
||||
path: "/api/v1/admin/quest-definitions/11",
|
||||
body: {
|
||||
title: "Take out the bins",
|
||||
icon: null,
|
||||
rule: {
|
||||
rule_type: "monthly_day",
|
||||
interval: 1,
|
||||
@@ -440,6 +424,9 @@ describe("DefinitionsTab", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
// The icon is never sent, so it is carried through unchanged.
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
|
||||
await screen.findByText("Take out the bins");
|
||||
expect(api.listFetches()).toBe(2);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
@@ -655,433 +642,3 @@ describe("DefinitionsTab", () => {
|
||||
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Occurrence preview ─────────────────────────────────────────────── */
|
||||
|
||||
/** Past the sheet's 300 ms preview debounce. */
|
||||
function settleDebounce(): Promise<void> {
|
||||
return act(() => new Promise<void>((resolve) => setTimeout(resolve, 400)));
|
||||
}
|
||||
|
||||
function previewText(): string | null {
|
||||
return screen.queryByTestId("occurrence-preview")?.textContent ?? null;
|
||||
}
|
||||
|
||||
/** BINS' rule (every 2 weeks on Mon, Thu) from 2026-10-05. */
|
||||
const BINS_DATES = ["2026-10-05", "2026-10-08", "2026-10-19", "2026-10-22", "2026-11-02"];
|
||||
/** The same rule on Tuesdays only. */
|
||||
const TUESDAY_DATES = ["2026-10-06", "2026-10-20", "2026-11-03", "2026-11-17", "2026-12-01"];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (response: Response) => void;
|
||||
const promise = new Promise<Response>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("DefinitionsTab occurrence preview", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("previews a weekly rule's next dates in plain language from the API", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(previewText()).toBe(
|
||||
"Next: Mon 5 Oct · Thu 8 Oct · Mon 19 Oct · Thu 22 Oct · Mon 2 Nov · …",
|
||||
),
|
||||
);
|
||||
expect(api.previews()).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/admin/quest-definitions/occurrences-preview",
|
||||
body: { rule: BINS.rule, count: 5 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("re-requests once per rule change, debounced, and not for non-rule edits", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async ({ rule }) =>
|
||||
jsonResponse({ dates: rule.weekday_set?.includes(0) ? BINS_DATES : TUESDAY_DATES }),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||
|
||||
// A title edit leaves the rule alone: no new request.
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Bins" },
|
||||
});
|
||||
// Three rule edits in quick succession collapse into one request.
|
||||
const days = within(s).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(previewText()).toBe(
|
||||
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
|
||||
),
|
||||
);
|
||||
await settleDebounce();
|
||||
expect(api.previews()).toHaveLength(2);
|
||||
expect(api.previews()[1].body).toEqual({
|
||||
rule: { ...BINS.rule, weekday_set: [1] },
|
||||
count: 5,
|
||||
});
|
||||
|
||||
// An interval change is a rule change too.
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
await waitFor(() => expect(api.previews()).toHaveLength(3));
|
||||
expect((api.previews()[2].body as { rule: DefinitionRule }).rule.interval).toBe(1);
|
||||
});
|
||||
|
||||
it("window and assignee edits never request, and a rule edit waits out the debounce", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ dates: BINS_DATES }));
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
// Windows and assignees are not part of the rule.
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
expect(previewText()).toContain("Mon 5 Oct");
|
||||
|
||||
// A rule edit is not requested until the 300 ms debounce elapses.
|
||||
const days = within(s).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(299);
|
||||
});
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(api.previews()).toHaveLength(2);
|
||||
expect((api.previews()[1].body as { rule: DefinitionRule }).rule.weekday_set).toEqual([
|
||||
0, 1, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
it("a rule edit hides the previous rule's dates until its own response arrives", async () => {
|
||||
await renderReady();
|
||||
const pending: ReturnType<typeof deferred>[] = [];
|
||||
api.setPreview(() => {
|
||||
const next = deferred();
|
||||
pending.push(next);
|
||||
return next.promise;
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
await waitFor(() => expect(pending).toHaveLength(1));
|
||||
await act(async () => {
|
||||
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toContain("Mon 5 Oct"));
|
||||
|
||||
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
// Within the debounce: the old dates are already gone.
|
||||
expect(previewText()).toBeNull();
|
||||
// Request in flight: still nothing shown for the new rule.
|
||||
await waitFor(() => expect(pending).toHaveLength(2));
|
||||
expect(previewText()).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
pending[1].resolve(jsonResponse({ dates: ["2026-10-08", "2026-10-22"] }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct · Thu 22 Oct"));
|
||||
});
|
||||
|
||||
it("a rule edit hides the previous rule's error until its own response arrives", async () => {
|
||||
await renderReady();
|
||||
const pending: ReturnType<typeof deferred>[] = [];
|
||||
api.setPreview(() => {
|
||||
const next = deferred();
|
||||
pending.push(next);
|
||||
return next.promise;
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
await waitFor(() => expect(pending).toHaveLength(1));
|
||||
await act(async () => {
|
||||
pending[0].resolve(jsonResponse({ detail: "rule rejected" }, 422));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
|
||||
|
||||
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
expect(previewText()).toBeNull();
|
||||
await waitFor(() => expect(pending).toHaveLength(2));
|
||||
expect(previewText()).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
pending[1].resolve(jsonResponse({ dates: ["2026-10-08"] }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toBe("Next: Thu 8 Oct"));
|
||||
});
|
||||
|
||||
it("a stale response resolving after a newer one does not overwrite it", async () => {
|
||||
await renderReady();
|
||||
const pending: ReturnType<typeof deferred>[] = [];
|
||||
api.setPreview(() => {
|
||||
const next = deferred();
|
||||
pending.push(next);
|
||||
return next.promise;
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
await waitFor(() => expect(pending).toHaveLength(1));
|
||||
|
||||
const days = within(sheet()).getByRole("group", { name: "Days" });
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Monday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Thursday" }));
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
await waitFor(() => expect(pending).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
pending[1].resolve(jsonResponse({ dates: TUESDAY_DATES }));
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
|
||||
await act(async () => {
|
||||
pending[0].resolve(jsonResponse({ dates: BINS_DATES }));
|
||||
});
|
||||
await settleDebounce();
|
||||
expect(previewText()).toBe(
|
||||
"Next: Tue 6 Oct · Tue 20 Oct · Tue 3 Nov · Tue 17 Nov · Tue 1 Dec · …",
|
||||
);
|
||||
});
|
||||
|
||||
it("a failed preview shows a non-blocking message and the sheet still saves", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ detail: "rule rejected" }, 422));
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
|
||||
await waitFor(() => expect(previewText()).toBe("Upcoming dates unavailable: rule rejected"));
|
||||
expect(screen.queryByTestId("sheet-error")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("PATCH");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("an incomplete draft does not call the preview endpoint", async () => {
|
||||
await renderReady();
|
||||
api.setPreview(async () => jsonResponse({ dates: TUESDAY_DATES }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||
target: { value: "weekly" },
|
||||
});
|
||||
// Clear the default weekday: a weekly rule with no days is incomplete.
|
||||
const days = within(s).getByRole("group", { name: "Days" });
|
||||
for (const day of within(days).getAllByRole("button")) {
|
||||
if (day.getAttribute("aria-pressed") === "true") fireEvent.click(day);
|
||||
}
|
||||
await settleDebounce();
|
||||
expect(api.previews()).toHaveLength(0);
|
||||
expect(previewText()).toBeNull();
|
||||
|
||||
// An unparseable interval is incomplete too.
|
||||
fireEvent.click(within(days).getByRole("button", { name: "Tuesday" }));
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "" },
|
||||
});
|
||||
await settleDebounce();
|
||||
expect(api.previews()).toHaveLength(0);
|
||||
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "2" },
|
||||
});
|
||||
await waitFor(() => expect(previewText()).toContain("Tue 6 Oct"));
|
||||
expect(api.previews()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DefinitionsTab icon picker", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** The Lucide name a row renders, or null for the generic fallback glyph. */
|
||||
function rowIcon(row: HTMLElement): string | null {
|
||||
const svg = row.querySelector(".defs-row-glyph svg");
|
||||
expect(svg).not.toBeNull();
|
||||
return svg!.getAttribute("data-icon");
|
||||
}
|
||||
|
||||
function iconRadio(s: HTMLElement, label: string): HTMLElement {
|
||||
return within(within(s).getByRole("radiogroup", { name: "Icon" })).getByRole("radio", {
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
|
||||
it("ships the curated subset, and every entry renders an SVG", () => {
|
||||
expect(DEFINITION_ICONS.map((entry) => entry.name)).toEqual([
|
||||
"bed",
|
||||
"bath",
|
||||
"shower-head",
|
||||
"toilet",
|
||||
"brush",
|
||||
"smile",
|
||||
"shirt",
|
||||
"washing-machine",
|
||||
"utensils",
|
||||
"cooking-pot",
|
||||
"apple",
|
||||
"brush-cleaning",
|
||||
"spray-can",
|
||||
"trash-2",
|
||||
"recycle",
|
||||
"sofa",
|
||||
"house",
|
||||
"backpack",
|
||||
"book-open",
|
||||
"pencil",
|
||||
"music",
|
||||
"dog",
|
||||
"cat",
|
||||
"fish",
|
||||
"sprout",
|
||||
"droplets",
|
||||
"bike",
|
||||
"volleyball",
|
||||
"gamepad-2",
|
||||
"star",
|
||||
"heart",
|
||||
"sun",
|
||||
"moon",
|
||||
"clock",
|
||||
"calendar",
|
||||
"list-checks",
|
||||
]);
|
||||
expect(DEFINITION_ICONS.length).toBeGreaterThanOrEqual(24);
|
||||
expect(DEFINITION_ICONS.length).toBeLessThanOrEqual(40);
|
||||
for (const { name } of DEFINITION_ICONS) {
|
||||
const { container, unmount } = render(<DefinitionIcon name={name} />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg, name).not.toBeNull();
|
||||
expect(svg!.getAttribute("data-icon")).toBe(name);
|
||||
expect(svg!.querySelectorAll("path, circle, rect, line, polyline, ellipse").length).toBeGreaterThan(0);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("a chosen icon is POSTed for a new task and renders on its row", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Make the bed" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
fireEvent.click(iconRadio(s, "Bed"));
|
||||
expect(iconRadio(s, "Bed").getAttribute("aria-checked")).toBe("true");
|
||||
expect(iconRadio(s, "Bed").className).toContain("defs-icon--on");
|
||||
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].method).toBe("POST");
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("bed");
|
||||
expect(rowIcon(await screen.findByTestId("definition-99"))).toBe("bed");
|
||||
});
|
||||
|
||||
it("a chosen icon is PATCHed for an existing task without breaking the assignee omit", async () => {
|
||||
await renderReady();
|
||||
expect(rowIcon(screen.getByTestId("definition-11"))).toBeNull();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
expect(iconRadio(s, "No icon").getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(iconRadio(s, "Bins"));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
const { method, path, body } = api.writes()[0];
|
||||
expect(method).toBe("PATCH");
|
||||
expect(path).toBe("/api/v1/admin/quest-definitions/11");
|
||||
expect((body as { icon: string }).icon).toBe("trash-2");
|
||||
// Inactive "Old" stays assigned: the unchanged selection is still omitted.
|
||||
expect(body).not.toHaveProperty("assignee_child_ids");
|
||||
await waitFor(() => expect(rowIcon(screen.getByTestId("definition-11"))).toBe("trash-2"));
|
||||
});
|
||||
|
||||
it("an unchanged icon is still sent on edit and shows as selected", async () => {
|
||||
await renderReady();
|
||||
expect(rowIcon(screen.getByTestId("definition-10"))).toBe("smile");
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
expect(iconRadio(s, "Smile").getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Brush teeth well" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("smile");
|
||||
});
|
||||
|
||||
it("an unknown legacy icon falls back to the generic glyph and is kept on save", async () => {
|
||||
const legacy: QuestDefinition = { ...BRUSH, id: 20, icon: "rocket-ship-9000" };
|
||||
await renderReady([legacy]);
|
||||
const row = screen.getByTestId("definition-20");
|
||||
expect(rowIcon(row)).toBeNull();
|
||||
|
||||
fireEvent.click(row);
|
||||
const s = sheet();
|
||||
const group = within(s).getByRole("radiogroup", { name: "Icon" });
|
||||
for (const radio of within(group).getAllByRole("radio")) {
|
||||
expect(radio.getAttribute("aria-checked")).toBe("false");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect((api.writes()[0].body as { icon: string }).icon).toBe("rocket-ship-9000");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(rowIcon(screen.getByTestId("definition-20"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Definitions ("Tasks") tab — list + edit sheet with icon picker
|
||||
* (design/ADMIN-SPEC.md §3.1–3.3).
|
||||
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
*
|
||||
* The icon picker (§3.3) and the occurrence preview are separate tasks: an
|
||||
* existing definition's icon is carried through unchanged by never sending it.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
@@ -15,7 +17,6 @@ import {
|
||||
createDefinition,
|
||||
fetchChildren,
|
||||
fetchDefinitions,
|
||||
previewOccurrences,
|
||||
updateDefinition,
|
||||
type AdminChild,
|
||||
type DefinitionCreateBody,
|
||||
@@ -31,7 +32,6 @@ import {
|
||||
WINDOW_LABELS,
|
||||
WINDOW_ORDER,
|
||||
assigneeSummary,
|
||||
occurrenceLabel,
|
||||
recurrenceSummary,
|
||||
windowSummary,
|
||||
} from "./format";
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
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. */
|
||||
@@ -67,18 +66,10 @@ const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
||||
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;
|
||||
@@ -118,7 +109,6 @@ function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
const today = new Date();
|
||||
return {
|
||||
title: "",
|
||||
icon: null,
|
||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||
repeats: "daily",
|
||||
interval: "1",
|
||||
@@ -143,7 +133,6 @@ function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): D
|
||||
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],
|
||||
@@ -236,7 +225,6 @@ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody |
|
||||
);
|
||||
return {
|
||||
title,
|
||||
icon: draft.icon,
|
||||
rule,
|
||||
assignee_child_ids: [...draft.assigneeIds],
|
||||
windows,
|
||||
@@ -323,7 +311,7 @@ function DefinitionRow({
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="defs-row-glyph">
|
||||
<DefinitionIcon name={definition.icon} />
|
||||
<TaskGlyph />
|
||||
</span>
|
||||
<span className="defs-row-text">
|
||||
<span className="defs-row-title">{definition.title}</span>
|
||||
@@ -339,77 +327,6 @@ function DefinitionRow({
|
||||
|
||||
/* ── 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}>
|
||||
@@ -494,8 +411,6 @@ function EditSheet({
|
||||
.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;
|
||||
@@ -562,35 +477,6 @@ function EditSheet({
|
||||
/>
|
||||
</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"
|
||||
@@ -736,7 +622,6 @@ function EditSheet({
|
||||
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
<OccurrencePreview ruleKey={previewKey} />
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -95,17 +95,3 @@ export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): stri
|
||||
.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,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>
|
||||
);
|
||||
}
|
||||
@@ -23,14 +23,9 @@
|
||||
--nq-a-danger: #dc2626;
|
||||
--nq-a-danger-strong: #b91c1c;
|
||||
--nq-a-danger-bg: #fee2e2;
|
||||
--nq-a-warning-ink: #92400e;
|
||||
--nq-a-warning-ink-2: #78350f;
|
||||
--nq-a-warning-bg: #fef3c7;
|
||||
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
|
||||
--nq-a-info-bg: #dbeafe;
|
||||
--nq-a-info-ink: #1e3a8a;
|
||||
--nq-a-info-icon: #1d4ed8;
|
||||
--nq-a-reversal: #7d28a0;
|
||||
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
.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 {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
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 type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||
import { TransitionsProvider } from "../api/useTransitions";
|
||||
import { frame, testStream, type TestStream } from "../api/testStream";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||
@@ -223,186 +221,3 @@ describe("TodayTab", () => {
|
||||
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).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchSnapshot,
|
||||
type AdminSnapshot,
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
type SnapshotInstance,
|
||||
} from "../api/snapshot";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import { ApiRequestError } from "../api/definitions";
|
||||
import { uncompleteInstance } from "../api/instances";
|
||||
import { useTransitions } from "../api/useTransitions";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
@@ -147,18 +144,11 @@ function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
|
||||
return [...overdue, ...completed];
|
||||
}
|
||||
|
||||
interface UndoControl {
|
||||
/** The instance whose Undo is in flight, or null. */
|
||||
pendingId: number | null;
|
||||
onUndo: (instanceId: number) => void;
|
||||
}
|
||||
|
||||
function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }) {
|
||||
function AttentionRow({ item }: { item: AttentionItem }) {
|
||||
const { instance, childName } = item;
|
||||
const overdue = isOverdueOpen(instance);
|
||||
const undoing = undo.pendingId === instance.id;
|
||||
// TODO(e57facc2): wire "Mark done". The admin plane has no complete route
|
||||
// yet, so that action is inert here.
|
||||
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
|
||||
// The admin plane has no complete route yet, so both actions are inert here.
|
||||
return (
|
||||
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
||||
<span
|
||||
@@ -183,21 +173,12 @@ function AttentionRow({ item, undo }: { item: AttentionItem; undo: UndoControl }
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{overdue ? (
|
||||
<button type="button" className="today-action today-action--done">
|
||||
Mark done
|
||||
</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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
|
||||
>
|
||||
{overdue ? "Mark done" : "Undo"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -216,25 +197,10 @@ function PermissionNote() {
|
||||
export default function TodayTab() {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
const silent = silentNext.current;
|
||||
silentNext.current = false;
|
||||
if (!silent) setState({ kind: "loading" });
|
||||
setState({ kind: "loading" });
|
||||
fetchSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (!cancelled) setState({ kind: "ready", snapshot });
|
||||
@@ -245,37 +211,13 @@ export default function TodayTab() {
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "Today's snapshot could not be loaded. Check your connection and try again.";
|
||||
// A failed refresh keeps the snapshot already on screen.
|
||||
setState((prev) => (silent && prev.kind === "ready" ? prev : { kind: "error", message }));
|
||||
setState({ kind: "error", message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [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 body;
|
||||
if (state.kind === "loading") {
|
||||
@@ -309,11 +251,7 @@ export default function TodayTab() {
|
||||
{attention.length > 0 ? (
|
||||
<ul className="today-card today-list" aria-label="Needs attention">
|
||||
{attention.map((item) => (
|
||||
<AttentionRow
|
||||
key={item.instance.id}
|
||||
item={item}
|
||||
undo={{ pendingId: undoingId, onUndo: (id) => void undo(id) }}
|
||||
/>
|
||||
<AttentionRow key={item.instance.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
@@ -332,14 +270,6 @@ export default function TodayTab() {
|
||||
data-testid="today-scroll"
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +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. Network separation,
|
||||
backup and restore are later Feature 17 tasks.
|
||||
@@ -1,15 +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).
|
||||
#
|
||||
# Compatible ranges mirroring the dev group's floors; the API is a
|
||||
# server, so these are the only packages its runtime needs.
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
pyjwt[crypto]>=2.9
|
||||
httpx>=0.28
|
||||
@@ -58,24 +58,6 @@ layer the HA services share, so the two planes cannot drift:
|
||||
(children are never hard-deleted; overrides are deletable by
|
||||
design) and regenerates that override's child. It answers
|
||||
``{"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
|
||||
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
|
||||
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,
|
||||
deliberately narrow):
|
||||
|
||||
@@ -290,16 +245,6 @@ an omitted field is never passed to
|
||||
:func:`nestquest_core.children.edit_child` and stays unchanged — while
|
||||
an explicit ``null`` IS passed and is rejected by the core rather than
|
||||
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
|
||||
|
||||
@@ -307,7 +252,6 @@ import datetime
|
||||
from typing import Annotated, Literal, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
StrictBool,
|
||||
@@ -336,7 +280,6 @@ from api.nestquest_core import (
|
||||
core_snapshot,
|
||||
core_sweep,
|
||||
)
|
||||
from api.sse import transition_event_stream
|
||||
|
||||
#: All admin-plane routes share this router; the ONE admin dependency
|
||||
#: (service-token refusal + JWT verification + nestquest-admins group
|
||||
@@ -509,45 +452,6 @@ class AdminRuleRequest(BaseModel):
|
||||
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):
|
||||
"""The body of ``POST /api/v1/admin/quest-definitions``.
|
||||
|
||||
@@ -669,50 +573,6 @@ class AdminPresenceOverrideResponse(BaseModel):
|
||||
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):
|
||||
"""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
|
||||
|
||||
|
||||
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:
|
||||
"""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)
|
||||
|
||||
|
||||
@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(
|
||||
"/quest-definitions/{definition_id}",
|
||||
summary="Edit a quest definition",
|
||||
@@ -1655,114 +1445,6 @@ async def admin_delete_presence_override(
|
||||
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(
|
||||
"/instances/{instance_id}/uncomplete",
|
||||
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"]
|
||||
|
||||
+30
-7
@@ -53,6 +53,9 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
import json
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -67,7 +70,6 @@ from api.nestquest_core import (
|
||||
core_snapshot,
|
||||
)
|
||||
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
|
||||
#: child-mismatch guard on the complete route.
|
||||
@@ -326,6 +328,23 @@ async def panel_complete_instance(
|
||||
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(
|
||||
"/events",
|
||||
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
|
||||
token check; this handler performs NO auth of its own).
|
||||
|
||||
Returns the shared :func:`api.sse.transition_event_stream` response
|
||||
off the app's single in-process publisher (``app.state.publisher``,
|
||||
installed in the lifespan). The stream ends when the client
|
||||
disconnects, at which point the subscription is torn down (its
|
||||
queue removed) with nothing leaked.
|
||||
Returns a ``text/event-stream`` response whose body is produced by
|
||||
:func:`_sse_stream` off the app's single in-process publisher
|
||||
(``app.state.publisher``, installed in the lifespan). The stream
|
||||
ends when the client disconnects, at which point the subscription
|
||||
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"]
|
||||
|
||||
-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]
|
||||
|
||||
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:
|
||||
"""Return one override by id, or None when it does not exist."""
|
||||
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
|
||||
|
||||
import datetime
|
||||
from typing import NamedTuple
|
||||
|
||||
from .const import DEFAULT_HORIZON_DAYS
|
||||
from .dao_instances import (
|
||||
CompletionEventsDao,
|
||||
QuestInstancesDao,
|
||||
_resolve_today,
|
||||
)
|
||||
from .dao_instances import QuestInstancesDao, _resolve_today
|
||||
from .dao_rules import (
|
||||
_validate_date,
|
||||
load_materialization_input,
|
||||
@@ -131,15 +126,8 @@ def _decode_rule(snapshot) -> ScheduleRule:
|
||||
def _build_engine(
|
||||
schedules_records,
|
||||
overrides_records,
|
||||
*,
|
||||
extra_overrides: tuple[PresenceOverride, ...] = (),
|
||||
) -> 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`).
|
||||
"""
|
||||
"""Convert raw presence records into an immutable PresenceEngine."""
|
||||
schedules: dict[int, PresenceSchedule] = {}
|
||||
for child_id, record in schedules_records.items():
|
||||
schedules[child_id] = PresenceSchedule.decode(
|
||||
@@ -157,75 +145,9 @@ def _build_engine(
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
for override in extra_overrides:
|
||||
overrides.setdefault(override.child_id, []).append(override)
|
||||
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(
|
||||
database: NestQuestDatabase,
|
||||
start_date: str,
|
||||
@@ -285,6 +207,13 @@ async def materialize(
|
||||
engine = _build_engine(schedules_records, overrides_records)
|
||||
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()
|
||||
instances = QuestInstancesDao(database)
|
||||
|
||||
@@ -299,20 +228,32 @@ async def materialize(
|
||||
start = max(start, today_date)
|
||||
|
||||
count = 0
|
||||
for generated in _generation_preview(
|
||||
snapshots, engine, start, end, child_filter=child_filter
|
||||
):
|
||||
written = await instances.upsert_if_valid(
|
||||
generated.definition_id,
|
||||
generated.child_id,
|
||||
generated.due_date,
|
||||
generated_at,
|
||||
window=generated.window,
|
||||
due_time=generated.due_time,
|
||||
today=today,
|
||||
)
|
||||
if written is not None:
|
||||
count += 1
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
@@ -438,93 +379,3 @@ async def regenerate_for_child(
|
||||
return await materialize(
|
||||
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
|
||||
distinguish "unknown id" from a rejected argument the same way the
|
||||
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
|
||||
|
||||
import datetime
|
||||
|
||||
from .dao_children import ChildrenDao
|
||||
from .dao_presence import (
|
||||
PresenceOverrideRecord,
|
||||
PresenceOverridesDao,
|
||||
PresenceSchedulesDao,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@@ -135,78 +130,6 @@ async def create_presence_override(
|
||||
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(
|
||||
database: NestQuestDatabase,
|
||||
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,46 +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
|
||||
|
||||
volumes:
|
||||
nestquest-db:
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
def __init__(self, token: str = PANEL_TOKEN) -> None:
|
||||
self.token = token
|
||||
def __init__(self) -> None:
|
||||
self.queue: asyncio.Queue = asyncio.Queue()
|
||||
self._task: asyncio.Task | None = None
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
@@ -171,7 +170,7 @@ class _EventStream:
|
||||
self._stream_cm = self._client.stream(
|
||||
"GET",
|
||||
self.url,
|
||||
headers={"Authorization": f"Bearer {self.token}"},
|
||||
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
||||
)
|
||||
self._response = await self._stream_cm.__aenter__()
|
||||
assert self._response.status_code == 200
|
||||
@@ -264,13 +263,9 @@ async def seeded_panel(temp_db_path: str) -> SimpleNamespace:
|
||||
routes_panel._local_now = original
|
||||
|
||||
|
||||
def _stream(url: str, token: str = PANEL_TOKEN) -> _EventStream:
|
||||
"""A subscribed SSE reader for ``url``, sending ``token`` as Bearer.
|
||||
|
||||
Defaults to the panel service token; the admin-plane stream tests
|
||||
pass an admin JWT instead.
|
||||
"""
|
||||
return _EventStream(token)._set_url(url)
|
||||
def _stream(url: str) -> _EventStream:
|
||||
"""A subscribed SSE reader for ``url``."""
|
||||
return _EventStream()._set_url(url)
|
||||
|
||||
|
||||
# --- auth and content type ----------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user