Task 4ae0c879: Presence override with live consequence count (#207)

This commit was merged in pull request #207.
This commit is contained in:
2026-09-23 23:16:04 -04:00
parent 7b5177d055
commit cc970bb646
8 changed files with 1325 additions and 16 deletions
+133 -16
View File
@@ -4,10 +4,11 @@
* 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 {
deletePresenceOverride,
fetchPresenceOverrides,
fetchPresenceSchedule,
savePresenceSchedule,
@@ -37,6 +38,7 @@ import {
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. */
@@ -64,6 +66,12 @@ type LoadState =
| { 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" }
@@ -85,10 +93,19 @@ async function loadSchedule(): Promise<Loaded> {
};
}
function saveErrorMessage(error: unknown): string {
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 "The pattern could not be saved. Check your connection and try again.";
return fallback;
}
function ScreenHeader({ sub }: { sub: string }) {
@@ -185,7 +202,18 @@ function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onTo
);
}
function OverrideRow({ override, childName }: { override: PresenceOverride; childName: string }) {
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">
@@ -198,10 +226,36 @@ function OverrideRow({ override, childName }: { override: PresenceOverride; chil
</div>
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
</div>
{/* TODO(4ae0c879): the override editor wires delete; inert until then. */}
<button type="button" className="schedule-icon-action" aria-label="Delete override">
<Trash2Glyph />
</button>
{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>
);
}
@@ -218,6 +272,23 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
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;
@@ -230,17 +301,30 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
})
.catch((error: unknown) => {
if (cancelled) return;
const message =
error instanceof ApiForbiddenError
? error.message
: "The schedule could not be loaded. Check your connection and try again.";
setState({ kind: "error", message });
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") {
@@ -285,6 +369,22 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
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;
@@ -407,20 +507,37 @@ export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProp
<div className="schedule-section-head">
<h3 className="schedule-section-label">Overrides</h3>
{/* TODO(4ae0c879): the override editor wires Add; inert until then. */}
<button type="button" className="schedule-text-action">
<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} />
<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}
</>
);
}