import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { Loading, ErrorState } from '../../../components/PageState.jsx' import { useAuth } from '../../../contexts/AuthContext.jsx' import { api } from '../../../api/client.js' import { runStatusWord, isTerminalRun } from '../../../lib/eventAuthoring.js' // Admin → Events (EVENTS.md §I, Phase 3). // // Two tables on one screen: the definitions an operator authors, and the runs // those definitions have produced. They are together rather than on two nav rows // because the question this screen exists to answer is one question — "what is // scheduled, and what is happening right now" — and the second half of it is the // one somebody opens at 8pm on a Friday. // // **The waiting badge is the whole reason the run table is here rather than // buried a click away.** A run parked on a GM cue looks perfectly healthy: it is // `running`, nothing has failed, and it will stay that way for ever because it // is waiting for a person who does not know they are being waited for. The count // comes from the run row itself (`waitingSteps`), so a run needs nobody to open // it before it can say so. // // **The calendar is a separate screen, not a third table here.** It answers // "when", this one answers "what" — and Phase 4, which built it, also made a // definition able to carry a recurrence, so the two questions stopped having the // same answer the moment an occurrence could exist before anybody pressed Start. const STATE_WORD = { draft: 'Draft', ready: 'Ready', archived: 'Archived' } const STATUS_COLOR = { failed: '#d98b84', missed: '#d98b84', paused: '#d9c184', cancelled: 'var(--muted)', running: '#8fc79a', } const HEALTH_COLOR = { degraded: '#d9c184', stalled: '#d98b84' } const when = (value) => (value ? new Date(value).toLocaleString() : '—') export default function EventsAdmin() { const { user } = useAuth() const navigate = useNavigate() const [events, setEvents] = useState([]) const [runs, setRuns] = useState([]) const [state, setState] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [notice, setNotice] = useState(null) const isAdmin = user?.role === 'admin' const mayAuthor = isAdmin || user?.role === 'editor' const load = useCallback(async (nextState) => { const [defs, runList] = await Promise.all([ api.admin.listEvents(nextState || undefined), api.admin.listEventRuns({ limit: 50 }), ]) setEvents(defs.events || []) setRuns(runList.runs || []) }, []) useEffect(() => { let alive = true ;(async () => { setLoading(true) try { await load(state) if (alive) setError(null) } catch (err) { if (alive) setError(err.message) } finally { if (alive) setLoading(false) } })() return () => { alive = false } }, [load, state]) // "Start now" is an occurrence whose instant is the present, not a separate // concept — the same route a scheduled occurrence will use in Phase 4. Admin // only, deliberately (§N2): starting commits the deployment to everything the // definition contains, unattended. const startNow = async (event) => { setBusy(true) setNotice(null) try { const result = await api.admin.startEventRun(event.id, {}) navigate(`/admin/events/runs/${result.run.id}`) } catch (err) { setNotice(err.message) } finally { setBusy(false) } } if (loading && !events.length && !runs.length) return if (error) return const live = runs.filter((r) => !isTerminalRun(r.status)) const waiting = live.filter((r) => r.waitingSteps > 0) return (

Scheduled, bounded, audited changes to the live world. A definition is authored as a draft, published as an immutable version, and every occurrence of it runs against the version it pinned. A definition can repeat — once, weekly, or on the nth weekday of the month, in its own timezone — and the calendar is where those occurrences are read.

Calendar {mayAuthor && ( New event )}
{notice && (

{notice}

)} {waiting.length > 0 && (

{waiting.length === 1 ? 'One run is' : `${waiting.length} runs are`} waiting on a person.{' '} A cue holds its phase until somebody confirms it was done in-client — nothing else will move it.

{waiting.map((r) => ( {r.definitionTitle} · {r.waitingSteps} waiting ))}
)}

Definitions

{events.length === 0 ? (

{state ? 'Nothing matches that filter.' : 'No events have been authored yet.'}

) : (
{events.map((e) => ( ))}
Event State Version Series Updated
{e.title}
{e.slug}
{STATE_WORD[e.state] || e.state} {e.currentVersion ? `v${e.currentVersion}` : unpublished} {e.seriesName || —} {when(e.updatedAt)} {/* Start is admin only and the button follows the route: an editor sees the definition and cannot commit the deployment to running it. */} {isAdmin && e.state === 'ready' && ( )}
)}

Recent runs {live.length > 0 && · {live.length} in flight}

{runs.length === 0 ? (

Nothing has run yet.

) : (
{runs.map((r) => ( ))}
Occurrence Event Status Phase Health
{when(r.scheduledFor)} {r.rehearsal && · rehearsal} {r.definitionTitle} v{r.version} {runStatusWord(r.status)} {r.currentPhase || —} {r.health === 'ok' ? ok : r.health} {r.waitingSteps > 0 && ( waiting on {r.waitingSteps === 1 ? 'a person' : `${r.waitingSteps} people`} )}
)}
) }