import { useCallback, useEffect, useMemo, useState } from 'react' import { Link } 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, isProjected } from '../../../lib/eventAuthoring.js' // Admin → Events → Calendar (EVENTS.md §I, Phase 4). // // **This screen is the deliverable.** What this feature replaces is a WordPress // calendar plugin with no series field, no recurrence and no results — so a // month grid that knows about arcs, repeats and local time is not decoration // here, it is the point. // // **Two kinds of entry, drawn differently on purpose.** A solid one is a *run*: // a real row with a status, a pinned version and a console, and somebody can // cancel it. A dashed one is a *projection*: arithmetic past the runner's // fourteen-day horizon, with no row behind it, nothing committed and nothing to // open. An operator who treats a forecast as a booking has been misled by the // UI, not by the server, so the difference is drawn rather than merely stated — // and the legend says which is which. // // **The grid's date axis is the READER's zone; each entry's time is the // EVENT's.** §E gives the timezone to the event because every listing this // replaces is written in the shard's local zone, but "what is happening this // month" is a question about the month the person reading is living in. So the // cell an event lands in is the reader's date, and the time beside it always // carries the event's own zone — `20:00 Europe/Berlin` misreads as nothing. const DAY_MS = 86_400_000 const STATUS_COLOR = { failed: '#d98b84', missed: '#d98b84', paused: '#d9c184', cancelled: 'var(--muted)', running: '#8fc79a', } /** The event's own wall clock, which is the only time worth showing beside it. */ function localTime(instant, timezone) { try { return new Intl.DateTimeFormat(undefined, { timeZone: timezone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).format(new Date(instant)) } catch { return new Date(instant).toISOString().slice(11, 16) } } /** The reader's own date key, which is what places an entry in a cell. */ const readerDayKey = (instant) => { const d = new Date(instant) return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}` } /** * The six-week grid a month view draws, Monday first. * * Always six weeks rather than however many the month needs: a grid that * changes height as you page through it is a grid whose rows move under the * cursor. */ function monthGrid(year, month) { const first = new Date(year, month, 1) const offset = (first.getDay() + 6) % 7 const start = new Date(year, month, 1 - offset) return Array.from({ length: 42 }, (_, i) => new Date(start.getTime() + i * DAY_MS)) } const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ] export default function EventsCalendar() { const { user } = useAuth() const today = useMemo(() => new Date(), []) const [year, setYear] = useState(today.getFullYear()) const [month, setMonth] = useState(today.getMonth()) const [view, setView] = useState('month') const [seriesId, setSeriesId] = useState('') const [series, setSeries] = useState([]) const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [managingSeries, setManagingSeries] = useState(false) const mayAuthor = user?.role === 'admin' || user?.role === 'editor' // The window is the grid's own span, not the month's: an entry in the leading // or trailing week of the grid belongs to a neighbouring month and still has // to be fetched, or the first row of every month renders empty. const grid = useMemo(() => monthGrid(year, month), [year, month]) const window = useMemo(() => { if (view === 'month') { return { from: grid[0], to: new Date(grid[41].getTime() + DAY_MS) } } // The list view answers a different question — "what is coming" — so it runs // forward from now rather than over a calendar month. const from = new Date() return { from, to: new Date(from.getTime() + 60 * DAY_MS) } }, [view, grid]) const load = useCallback(async () => { setLoading(true) setError(null) try { const [calendar, seriesList] = await Promise.all([ api.admin.eventCalendar({ from: window.from.toISOString(), to: window.to.toISOString(), seriesId: seriesId || undefined, }), api.admin.eventSeries(), ]) setData(calendar) setSeries(seriesList.series || []) } catch (err) { setError(err) } finally { setLoading(false) } }, [window.from, window.to, seriesId]) useEffect(() => { load() }, [load]) const byDay = useMemo(() => { const map = new Map() for (const entry of data?.entries || []) { const key = readerDayKey(entry.scheduledFor) if (!map.has(key)) map.set(key, []) map.get(key).push(entry) } return map }, [data]) const step = (delta) => { const next = new Date(year, month + delta, 1) setYear(next.getFullYear()) setMonth(next.getMonth()) } if (loading && !data) return if (error && !data) return const horizon = data?.horizon ? new Date(data.horizon) : null return ( <>
{/* The stepper belongs to the MONTH view only. The list answers "what is coming" and runs sixty days forward from now whatever month is selected -- so paging it would be three controls that visibly do nothing, which is the one thing this feature has refused since Phase 1. The heading says which question is being asked instead. */} {view === 'month' && ( <> {`${MONTH_NAMES[month]} ${year}`} )} {view === 'list' && ( The next 60 days )}
{mayAuthor && ( )} Events
{/* The legend is not optional. The whole screen rests on the reader knowing that a dashed entry is not a booking. */}

Scheduled run is a real occurrence with a console — it can be opened, paused and cancelled.{' '} Forecast is what the recurrence works out to beyond the {data?.horizonDays ?? 14}-day horizon: nothing is committed yet and there is nothing to open. {horizon && ` Everything up to ${horizon.toLocaleDateString()} is real.`}

{managingSeries && } {data?.truncated && (

This window has more than the calendar will draw. Narrow it by series, or page to a shorter span.

)} {view === 'month' ? (
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
{d}
))} {grid.map((day) => { const entries = byDay.get(readerDayKey(day)) || [] const outside = day.getMonth() !== month const isToday = readerDayKey(day) === readerDayKey(today) return (
{day.getDate()}
{entries.map((entry) => )}
) })}
) : (
{(data?.entries || []).length === 0 ? (

Nothing is scheduled in the next sixty days.{' '} {mayAuthor && Author an event}

) : ( {(data?.entries || []).map((entry) => ( ))}
When Event Series Status
{new Date(entry.scheduledFor).toLocaleDateString()}{' '} {localTime(entry.scheduledFor, entry.timezone)} {entry.timezone} {entry.runId ? ( {entry.title} ) : ( {entry.title} )} {entry.adjusted === 'gap' && ( {' '}(clocks change) )} {entry.seriesName || '—'} {isProjected(entry) ? Forecast : runStatusWord(entry.status)} {entry.waitingSteps > 0 && ( · waiting on a person )}
)}
)} ) } // A projection has no run id, so the definition and the instant are its // identity — the same pair the server dedupes projections against. const entryKey = (entry) => entry.runId ? `run-${entry.runId}` : `proj-${entry.definitionId}-${entry.scheduledFor}` const chip = { display: 'inline-block', padding: '0 5px', borderRadius: 3, borderWidth: 1, border: '1px solid var(--muted)', fontSize: '0.72rem', } /** * The arcs, managed where they are used. * * A series is a label, not authored content — nothing pins one and no run * references one — so this is a small inline panel rather than a screen of its * own, and it lives on the calendar because the calendar is what makes an arc * visible in the first place. §I: *"Royal Spy Mission → Risky Partner → Message * From the Void" is continuity that exists nowhere in the tooling this replaces.* * * The delete is a real delete, and it says what it will detach before it * happens: `series_id` is ON DELETE SET NULL, so the definitions survive without * an arc and re-attaching one is a dropdown in the editor. Nothing is destroyed, * which is why this is the one delete in this feature that is not an archive. */ function SeriesManager({ series, onChanged }) { const [name, setName] = useState('') const [busy, setBusy] = useState(false) const [problem, setProblem] = useState(null) const run = async (fn) => { setBusy(true) setProblem(null) try { await fn() await onChanged() } catch (err) { setProblem(err?.body?.errors?.join('; ') || err?.message || 'That did not work') } finally { setBusy(false) } } return (

Series

An arc several events form together. The order here is where a series sits among the others; where an event sits within its arc is that event’s own order, in the editor.

{problem && (

{problem}

)} {series.map((s) => (
{ const next = e.target.value.trim() if (next && next !== s.name) { run(() => api.admin.updateEventSeries(s.id, { name: next, description: s.description, ordering: s.ordering })) } }} /> { const next = Number(e.target.value) if (Number.isInteger(next) && next !== s.ordering) { run(() => api.admin.updateEventSeries(s.id, { name: s.name, description: s.description, ordering: next })) } }} /> {s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
))}
setName(e.target.value)} />
) } function EntryChip({ entry }) { const projected = isProjected(entry) const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}` return ( {localTime(entry.scheduledFor, entry.timezone)} {entry.title} {entry.waitingSteps > 0 && ●} ) }