/** * 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, useRef, useState, type ReactNode } from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, createDefinition, fetchChildren, fetchDefinitions, updateDefinition, type AdminChild, type DefinitionCreateBody, type DefinitionRule, type QuestDefinition, type WindowEntry, type WindowName, } from "../api/definitions"; import { MONTH_SHORT, WEEKDAY_SHORT, WINDOW_LABELS, WINDOW_ORDER, assigneeSummary, recurrenceSummary, windowSummary, } from "./format"; import { CheckGlyph, ChevronDownGlyph, ChevronRightGlyph, PlusGlyph, TaskGlyph, } from "./glyphs"; import "./DefinitionsTab.css"; /** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */ export const SCROLL_BOTTOM_PADDING = "92px"; type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom"; const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [ { value: "daily", label: "Daily" }, { value: "weekly", label: "Weekly" }, { value: "monthly", label: "Monthly" }, { value: "yearly", label: "Yearly" }, { value: "custom", label: "Custom" }, ]; const REPEATS_BY_RULE: Record = { daily: "daily", weekly: "weekly", monthly_day: "monthly", monthly_weekday: "monthly", yearly: "yearly", custom_days: "custom", }; const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; interface Draft { title: string; assigneeIds: number[]; repeats: Repeats; interval: string; weekdays: number[]; dayOfMonth: string; month: string; /** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */ monthlyWeekday: { nth: number; weekday: number } | null; windows: WindowName[]; dueTimes: Record; skipOnAway: boolean; startDate: string; endDate: string | null; } type LoadState = | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] }; /** `null` = closed, `"new"` = creating, a definition = editing it. */ type SheetTarget = null | "new" | QuestDefinition; function pad2(n: number): string { return String(n).padStart(2, "0"); } function localIsoDate(date: Date): string { return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; } function emptyDueTimes(): Record { return { morning: "", afternoon: "", evening: "" }; } function newDraft(activeChildren: AdminChild[]): Draft { const today = new Date(); return { title: "", assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], repeats: "daily", interval: "1", weekdays: [(today.getDay() + 6) % 7], dayOfMonth: String(today.getDate()), month: String(today.getMonth() + 1), monthlyWeekday: null, windows: [], dueTimes: emptyDueTimes(), // Mirrors the API's create default: no instance on an away day. skipOnAway: true, startDate: localIsoDate(today), endDate: null, }; } function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft { const { rule } = definition; const today = new Date(); const active = new Set(activeChildren.map((child) => child.id)); const dueTimes = emptyDueTimes(); for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; return { title: definition.title, // Inactive children cannot be submitted (the API rejects them with 422). assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), repeats: REPEATS_BY_RULE[rule.rule_type], interval: String(rule.interval), weekdays: rule.rule_type === "weekly" || rule.rule_type === "custom_days" ? [...(rule.weekday_set ?? [])] : [(today.getDay() + 6) % 7], dayOfMonth: String(rule.day_of_month ?? today.getDate()), month: String(rule.month ?? today.getMonth() + 1), monthlyWeekday: rule.rule_type === "monthly_weekday" ? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 } : null, windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)), dueTimes, skipOnAway: definition.skip_on_away, startDate: rule.start_date, endDate: rule.end_date, }; } function parseWhole(value: string, min: number, max: number): number | null { if (!/^\d+$/.test(value.trim())) return null; const n = Number(value); return n >= min && n <= max ? n : null; } /** Build the request rule from the draft, or return a message naming what to fix. */ function buildRule(draft: Draft): DefinitionRule | string { const interval = parseWhole(draft.interval, 1, 999); if (interval === null) return "Interval must be a whole number of at least 1."; const rule: DefinitionRule = { rule_type: "daily", interval, weekday_set: null, day_of_month: null, nth_weekday: null, nth_weekday_weekday: null, month: null, start_date: draft.startDate, end_date: draft.endDate, }; switch (draft.repeats) { case "daily": return rule; case "weekly": case "custom": if (draft.weekdays.length === 0) return "Pick at least one day."; return { ...rule, rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days", weekday_set: [...draft.weekdays].sort((a, b) => a - b), }; case "monthly": { if (draft.monthlyWeekday) { return { ...rule, rule_type: "monthly_weekday", nth_weekday: draft.monthlyWeekday.nth, nth_weekday_weekday: draft.monthlyWeekday.weekday, }; } const day = parseWhole(draft.dayOfMonth, 1, 31); if (day === null) return "Day of month must be 1 to 31."; return { ...rule, rule_type: "monthly_day", day_of_month: day }; } case "yearly": { const month = parseWhole(draft.month, 1, 12); const day = parseWhole(draft.dayOfMonth, 1, 31); if (month === null || day === null) return "Pick a month and a day (1 to 31)."; return { ...rule, rule_type: "yearly", month, day_of_month: day }; } } } function buildBody(draft: Draft): DefinitionCreateBody | string { const title = draft.title.trim(); if (!title) return "Give the task a title."; if (draft.assigneeIds.length === 0) return "Assign at least one child."; if (draft.windows.length === 0) return "Pick at least one window."; const rule = buildRule(draft); if (typeof rule === "string") return rule; const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( (w) => [w, draft.dueTimes[w] || null], ); return { title, rule, assignee_child_ids: [...draft.assigneeIds], windows, skip_on_away: draft.skipOnAway, }; } function saveErrorMessage(error: unknown): string { if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiRequestError && error.detail) return error.detail; return "The task could not be saved. Check your connection and try again."; } function toggle(list: T[], value: T): T[] { return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; } /* ── List ───────────────────────────────────────────────────────────── */ function FilterChips({ options, filter, onChange, }: { options: AdminChild[]; filter: number | "all"; onChange: (filter: number | "all") => void; }) { const chips: { key: number | "all"; label: string }[] = [ { key: "all", label: "All" }, ...options.map((child) => ({ key: child.id, label: child.display_name })), ]; return (
{chips.map((chip) => ( ))}
); } function DefinitionRow({ definition, activeChildren, editing, onOpen, }: { definition: QuestDefinition; activeChildren: AdminChild[]; editing: boolean; onOpen: () => void; }) { let meta = [ assigneeSummary(definition.assignees, activeChildren), recurrenceSummary(definition.rule), windowSummary(definition.windows), ].join(" · "); if (!definition.is_active) meta += " · inactive"; let className = "defs-row"; if (!definition.is_active) className += " defs-row--inactive"; if (editing) className += " defs-row--editing"; return (
  • ); } /* ── Edit sheet ─────────────────────────────────────────────────────── */ function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { return (
    {label} {children}
    ); } function EditSheet({ target, activeChildren, onCancel, onSaved, }: { target: "new" | QuestDefinition; activeChildren: AdminChild[]; onCancel: () => void; onSaved: () => void; }) { const [draft, setDraft] = useState(() => target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren), ); const [pickerOpen, setPickerOpen] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); // A ref, not state: two taps in one frame must not both see `saving === false`. const inFlight = useRef(false); const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); const selectedAssignees = activeChildren .filter((child) => draft.assigneeIds.includes(child.id)) .map((child) => ({ id: child.id, display_name: child.display_name })); const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w)); async function save() { if (inFlight.current) return; const body = buildBody(draft); if (typeof body === "string") { setError(body); return; } inFlight.current = true; setSaving(true); setError(null); try { if (target === "new") await createDefinition(body); else await updateDefinition(target.id, body); onSaved(); } catch (err) { setError(saveErrorMessage(err)); inFlight.current = false; setSaving(false); } } return (
    { if (!inFlight.current) onCancel(); }} />
    ); } /* ── Screen ─────────────────────────────────────────────────────────── */ export default function DefinitionsTab() { const [state, setState] = useState({ kind: "loading" }); const [attempt, setAttempt] = useState(0); const [filter, setFilter] = useState("all"); const [sheet, setSheet] = useState(null); useEffect(() => { let cancelled = false; // Keep the list on screen during a post-save re-fetch; spin only on first load. setState((current) => (current.kind === "ready" ? current : { kind: "loading" })); Promise.all([fetchDefinitions(), fetchChildren()]) .then(([definitions, children]) => { if (!cancelled) setState({ kind: "ready", definitions, children }); }) .catch((error: unknown) => { if (cancelled) return; const message = error instanceof ApiForbiddenError ? error.message : "Tasks could not be loaded. Check your connection and try again."; setState({ kind: "error", message }); }); return () => { cancelled = true; }; }, [attempt]); const activeChildren = state.kind === "ready" ? [...state.children] .filter((child) => child.is_active) .sort((a, b) => a.sort_order - b.sort_order) : []; let sub = ""; let body; if (state.kind === "loading") { body = (
    Loading tasks…
    ); } else if (state.kind === "error") { body = (

    {state.message}

    ); } else { const { definitions } = state; sub = `${definitions.length} definitions · ${activeChildren.length} children`; const shown = filter === "all" ? definitions : definitions.filter((d) => d.assignees.some((a) => a.id === filter)); const editingId = sheet !== null && sheet !== "new" ? sheet.id : null; body = ( <> {shown.length > 0 ? (
      {shown.map((definition) => ( setSheet(definition)} /> ))}
    ) : (
    No tasks yet.
    )} ); } return (

    Tasks

    {sub}

    {body}
    {sheet !== null && state.kind === "ready" ? ( setSheet(null)} onSaved={() => { setSheet(null); setAttempt((n) => n + 1); }} /> ) : null}
    ); }