diff --git a/admin/src/schedule/OverrideEditor.test.tsx b/admin/src/schedule/OverrideEditor.test.tsx
index 0dc4176..0a2c4bb 100644
--- a/admin/src/schedule/OverrideEditor.test.tsx
+++ b/admin/src/schedule/OverrideEditor.test.tsx
@@ -1,5 +1,6 @@
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";
@@ -93,6 +94,10 @@ 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;
}
@@ -162,10 +167,9 @@ describe("OverrideEditor", () => {
expect(consequenceBodies()).toEqual([
{ child_id: 1, start_date: TODAY, end_date: TODAY, is_present: false },
]);
- const warning = screen.getByTestId("override-consequence");
- expect(warning.textContent).toContain("Declan");
- expect(warning.textContent).toContain("Sep 23");
- expect(warning.textContent).toContain("Completed days are untouched.");
+ 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 });
@@ -177,7 +181,9 @@ describe("OverrideEditor", () => {
end_date: "2026-09-27",
is_present: false,
});
- expect(warning.textContent).toContain("Sep 23 – 27");
+ 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" }));
@@ -188,12 +194,16 @@ describe("OverrideEditor", () => {
end_date: "2026-09-27",
is_present: false,
});
- expect(warning.textContent).toContain("Maeve");
+ 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(warning.textContent).toContain("Completed days are untouched.");
+ expect(warningBody()).toBe(
+ "Maeve is home Sep 23 – 27, so no tasks are removed. Completed days are untouched.",
+ );
await pastDebounce();
expect(consequenceBodies()).toHaveLength(3);
});
@@ -208,8 +218,8 @@ describe("OverrideEditor", () => {
await openEditor();
setDate("Through", "2026-09-25");
await waitFor(() => expect(headline()).toBe("This removes 3 upcoming tasks"));
- expect(screen.getByTestId("override-consequence").textContent).toContain(
- "Completed days are untouched.",
+ expect(warningBody()).toBe(
+ "Declan's open tasks Sep 23 – 25 are removed while away. Completed days are untouched.",
);
});
@@ -261,6 +271,19 @@ describe("OverrideEditor", () => {
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");
@@ -383,6 +406,120 @@ describe("OverrideEditor", () => {
});
});
+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(
Beats the custody pattern for these dates
diff --git a/admin/src/schedule/ScheduleTab.tsx b/admin/src/schedule/ScheduleTab.tsx index f367d63..6bd5eb4 100644 --- a/admin/src/schedule/ScheduleTab.tsx +++ b/admin/src/schedule/ScheduleTab.tsx @@ -4,7 +4,7 @@ * Every day's cycle position comes from the anchor date (./cycle.ts), never * from ISO week numbers or week parity (D-004). */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions"; import { @@ -274,6 +274,15 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp const [save, setSave] = useState