/** * Definitions ("Tasks") tab — list + edit sheet with icon picker * (design/ADMIN-SPEC.md §3.1–3.3). */ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; import { ApiForbiddenError } from "../api/client"; import { ApiRequestError, createDefinition, deleteDefinition, fetchChildren, fetchDefinitions, previewOccurrences, updateDefinition, type AdminChild, type DefinitionCreateBody, type DefinitionDeleteResult, type DefinitionEditBody, type DefinitionRule, type QuestDefinition, type WindowEntry, type WindowName, } from "../api/definitions"; import { MONTH_SHORT, WEEKDAY_DISPLAY_ORDER, WEEKDAY_SHORT, WINDOW_LABELS, WINDOW_ORDER, assigneeSummary, occurrenceLabel, recurrenceSummary, windowSummary, } from "./format"; import { CheckGlyph, ChevronDownGlyph, ChevronRightGlyph, PlusGlyph, TaskGlyph, } from "./glyphs"; import { DEFINITION_ICONS, DefinitionIcon, FA_DEFINITION_ICONS, emojiIconKey, emojiIconText, faIconKey, faIconName, lucideIconKey, normalizeDefinitionIconName, } from "./definitionIcons"; import { GlyphSvg } from "../icons/GlyphSvg"; 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", }; /** 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; /** * The stored icon key (`lucide:`, `fa:` or `emoji:`); a legacy value * outside the curated sets is kept until replaced. */ icon: string | null; /** The emoji field's text; typing in it sets `icon` to `emoji:`. */ emoji: 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: "", icon: null, emoji: "", assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [], repeats: "daily", interval: "1", weekdays: [(today.getDay() + 6) % 7], dayOfMonth: String(today.getDate()), month: String(today.getMonth() + 1), monthlyWeekday: null, windows: [], dueTimes: emptyDueTimes(), // Mirrors the API's create default: no instance on an away day. skipOnAway: true, startDate: localIsoDate(today), endDate: null, }; } function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft { const { rule } = definition; const today = new Date(); const active = new Set(activeChildren.map((child) => child.id)); const dueTimes = emptyDueTimes(); for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? ""; return { title: definition.title, icon: definition.icon, emoji: emojiIconText(definition.icon), // Inactive children cannot be submitted (the API rejects them with 422). assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)), repeats: REPEATS_BY_RULE[rule.rule_type], interval: String(rule.interval), weekdays: rule.rule_type === "weekly" || rule.rule_type === "custom_days" ? [...(rule.weekday_set ?? [])] : [(today.getDay() + 6) % 7], dayOfMonth: String(rule.day_of_month ?? today.getDate()), month: String(rule.month ?? today.getMonth() + 1), monthlyWeekday: rule.rule_type === "monthly_weekday" ? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 } : null, windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)), dueTimes, skipOnAway: definition.skip_on_away, startDate: rule.start_date, endDate: rule.end_date, }; } function parseWhole(value: string, min: number, max: number): number | null { if (!/^\d+$/.test(value.trim())) return null; const n = Number(value); return n >= min && n <= max ? n : null; } /** Build the request rule from the draft, or return a message naming what to fix. */ function buildRule(draft: Draft): DefinitionRule | string { const interval = parseWhole(draft.interval, 1, 999); if (interval === null) return "Interval must be a whole number of at least 1."; if (draft.startDate === "") return "Pick a start date."; const rule: DefinitionRule = { rule_type: "daily", interval, weekday_set: null, day_of_month: null, nth_weekday: null, nth_weekday_weekday: null, month: null, start_date: draft.startDate, end_date: draft.endDate, }; switch (draft.repeats) { case "daily": return rule; case "weekly": case "custom": if (draft.weekdays.length === 0) return "Pick at least one day."; return { ...rule, rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days", weekday_set: [...draft.weekdays].sort((a, b) => a - b), }; case "monthly": { if (draft.monthlyWeekday) { return { ...rule, rule_type: "monthly_weekday", nth_weekday: draft.monthlyWeekday.nth, nth_weekday_weekday: draft.monthlyWeekday.weekday, }; } const day = parseWhole(draft.dayOfMonth, 1, 31); if (day === null) return "Day of month must be 1 to 31."; return { ...rule, rule_type: "monthly_day", day_of_month: day }; } case "yearly": { const month = parseWhole(draft.month, 1, 12); const day = parseWhole(draft.dayOfMonth, 1, 31); if (month === null || day === null) return "Pick a month and a day (1 to 31)."; return { ...rule, rule_type: "yearly", month, day_of_month: day }; } } } /** * `keepsAssignees`: an edit whose active selection is unchanged sends no * assignee list, so an empty selection is fine when inactive children remain. */ function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string { const title = draft.title.trim(); if (!title) return "Give the task a title."; if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child."; if (draft.windows.length === 0) return "Pick at least one window."; if (draft.emoji && draft.icon === null) { return "Enter a single emoji (no spaces or < > &), or clear the emoji field."; } const rule = buildRule(draft); if (typeof rule === "string") return rule; const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map( (w) => [w, draft.dueTimes[w] || null], ); return { title, icon: draft.icon, rule, assignee_child_ids: [...draft.assigneeIds], windows, skip_on_away: draft.skipOnAway, }; } function saveErrorMessage(error: unknown): string { if (error instanceof ApiForbiddenError) return error.message; if (error instanceof ApiRequestError && error.detail) return error.detail; return "The task could not be saved. Check your connection and try again."; } function deleteErrorMessage(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 deleted. Check your connection and try again."; } function toggle(list: T[], value: T): T[] { return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; } function sameIds(a: number[], b: number[]): boolean { return a.length === b.length && a.every((id) => b.includes(id)); } const FOCUSABLE = 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; /* ── List ───────────────────────────────────────────────────────────── */ function FilterChips({ options, filter, onChange, }: { options: AdminChild[]; filter: number | "all"; onChange: (filter: number | "all") => void; }) { const chips: { key: number | "all"; label: string }[] = [ { key: "all", label: "All" }, ...options.map((child) => ({ key: child.id, label: child.display_name })), ]; return (
{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 ─────────────────────────────────────────────────────── */ 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({ 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 (

    {preview.message}

    ); } 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 (

    {text}

    ); } function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) { return (
    {label} {children}
    ); } function EditSheet({ target, activeChildren, onCancel, onSaved, onDeleted, }: { target: "new" | QuestDefinition; activeChildren: AdminChild[]; onCancel: () => void; onSaved: () => void; onDeleted: (result: DefinitionDeleteResult) => void; }) { const [draft, setDraft] = useState(() => target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren), ); // The active selection as loaded: while it is unchanged, the PATCH omits // assignee_child_ids, because the API replaces the whole set and inactive // children cannot be sent back. const [originalActiveIds] = useState(() => draft.assigneeIds); // The icon as loaded: while it is unchanged, the PATCH omits `icon` (absent // means unchanged), because a legacy value read back as `lucide:` would // fail the API's strict icon validation if sent back. const [originalIcon] = useState(() => draft.icon); const inactiveAssignees = target === "new" ? [] : target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id)); const [pickerOpen, setPickerOpen] = useState(false); const [saving, setSaving] = useState(false); const [confirming, setConfirming] = useState(false); const [confirmingDelete, setConfirmingDelete] = 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 sheetRef = useRef(null); const titleRef = useRef(null); const confirmRef = useRef(null); // Modal focus: move in on open, hand back to the trigger on close. useEffect(() => { const trigger = document.activeElement as HTMLElement | null; titleRef.current?.focus(); return () => { if (trigger && trigger.isConnected) trigger.focus(); }; }, []); useEffect(() => { if (confirming || confirmingDelete) confirmRef.current?.focus(); }, [confirming, confirmingDelete]); function onKeyDown(event: ReactKeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); if (!inFlight.current) onCancel(); return; } if (event.key !== "Tab" || !sheetRef.current) return; const focusable = Array.from(sheetRef.current.querySelectorAll(FOCUSABLE)); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; const current = document.activeElement; const outside = !focusable.includes(current as HTMLElement); if (event.shiftKey && (current === first || outside)) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && (current === last || outside)) { event.preventDefault(); first.focus(); } } const update = (patch: Partial) => setDraft((d) => ({ ...d, ...patch })); const selectedAssignees = activeChildren .filter((child) => draft.assigneeIds.includes(child.id)) .map((child) => ({ id: child.id, display_name: child.display_name })); const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w)); const previewRule = buildRule(draft); const previewKey = typeof previewRule === "string" ? null : JSON.stringify(previewRule); async function save(confirmed = false) { if (inFlight.current) return; const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds); const body = buildBody(draft, keepsAssignees); if (typeof body === "string") { setError(body); setConfirming(false); return; } if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) { setError(null); setConfirming(true); return; } setConfirming(false); inFlight.current = true; setSaving(true); setError(null); try { if (target === "new") { await createDefinition(body); } else { const edit: DefinitionEditBody = { ...body }; if (keepsAssignees) delete edit.assignee_child_ids; if (draft.icon === originalIcon) delete edit.icon; await updateDefinition(target.id, edit); } onSaved(); } catch (err) { setError(saveErrorMessage(err)); inFlight.current = false; setSaving(false); } } async function remove() { if (target === "new" || inFlight.current) return; setConfirmingDelete(false); inFlight.current = true; setSaving(true); setError(null); try { onDeleted(await deleteDefinition(target.id)); } catch (err) { setError(deleteErrorMessage(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); }} onDeleted={(result) => { setSheet(null); setState((current) => current.kind !== "ready" ? current : { ...current, definitions: result.definition ? current.definitions.map((d) => d.id === result.definition_id ? result.definition! : d, ) : current.definitions.filter((d) => d.id !== result.definition_id), }, ); }} /> ) : null}
    ); }