Files
website/client/src/routes/admin/views/EventsCalendar.jsx
wtclaude 6e73660b52
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s
feat(events): schedule, recurrence and the calendar (Phase 4)
The four closed recurrence shapes computed in the definition's own IANA zone,
a fourteen-day materialisation horizon with projections beyond it, series as a
managed thing, and the admin calendar that replaces the plugin this feature
exists to replace. An event now happens on its own.

No schema change: Phase 1 built every column this needed.

- events/recurrence.js is the ONE place an occurrence is computed, so the
  runner's expansion and the calendar's forecast cannot disagree. No date
  library added — Node ships the tzdata one would vendor, behind Intl.
- The runner's materialise leg is now two halves: expand, then sweep. The
  window starts at `now - grace`, so an occurrence nobody could have seen is
  never invented retroactively; the horizon is what makes the missed sweep
  mean anything for a recurrence.
- Publishing is the schedule switch and archiving turns it off, and publishing
  re-pins every occurrence that has not started.
- A projection is never drawn over an instant a run occupies, so a cancelled
  occurrence does not reappear as a forecast.

54 new tests, incl. the DST fixture set the plan asked for and three new
statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail
(pre-existing CRLF). Walked end to end on the local review stack.

Docs: RunicGateway/docs#PENDING

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 16:10:16 -05:00

458 lines
19 KiB
JavaScript

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 <Loading />
if (error && !data) return <ErrorState error={error} onRetry={load} />
const horizon = data?.horizon ? new Date(data.horizon) : null
return (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{/*
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' && (
<>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(-1)}>
&larr;
</button>
<strong className="sans" style={{ fontSize: '0.95rem', minWidth: 150, textAlign: 'center' }}>
{`${MONTH_NAMES[month]} ${year}`}
</strong>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }} onClick={() => step(1)}>
&rarr;
</button>
<button type="button" className="pill" style={{ fontSize: '0.72rem' }}
disabled={year === today.getFullYear() && month === today.getMonth()}
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()) }}>
Today
</button>
</>
)}
{view === 'list' && (
<strong className="sans" style={{ fontSize: '0.95rem' }}>The next 60 days</strong>
)}
</div>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', alignItems: 'center' }}>
<select className="select" value={seriesId} onChange={(e) => setSeriesId(e.target.value)}
style={{ minWidth: 170 }}>
<option value="">Every series</option>
{series.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<button type="button" className="pill" aria-pressed={view === 'month'}
style={{ fontSize: '0.72rem', opacity: view === 'month' ? 1 : 0.5 }}
onClick={() => setView('month')}>
Month
</button>
<button type="button" className="pill" aria-pressed={view === 'list'}
style={{ fontSize: '0.72rem', opacity: view === 'list' ? 1 : 0.5 }}
onClick={() => setView('list')}>
List
</button>
{mayAuthor && (
<button type="button" className="pill" aria-pressed={managingSeries}
style={{ fontSize: '0.72rem', opacity: managingSeries ? 1 : 0.6 }}
onClick={() => setManagingSeries((v) => !v)}>
Series
</button>
)}
<Link to="/admin/events" className="pill" style={{ fontSize: '0.72rem' }}>Events</Link>
</div>
</div>
{/* The legend is not optional. The whole screen rests on the reader
knowing that a dashed entry is not a booking. */}
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
<span style={{ ...chip, borderStyle: 'solid' }}>Scheduled run</span> is a real occurrence with
a console — it can be opened, paused and cancelled.{' '}
<span style={{ ...chip, borderStyle: 'dashed', opacity: 0.7 }}>Forecast</span> 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.`}
</p>
{managingSeries && <SeriesManager series={series} onChanged={load} />}
{data?.truncated && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d9c184' }}>
This window has more than the calendar will draw. Narrow it by series, or page to a
shorter span.
</p>
)}
{view === 'month' ? (
<div className="panel-flat" style={{ padding: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 4 }}>
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((d) => (
<div key={d} className="sans dim" style={{ fontSize: '0.72rem', textAlign: 'center', padding: '2px 0' }}>
{d}
</div>
))}
{grid.map((day) => {
const entries = byDay.get(readerDayKey(day)) || []
const outside = day.getMonth() !== month
const isToday = readerDayKey(day) === readerDayKey(today)
return (
<div key={day.toISOString()}
style={{
minHeight: 84,
padding: 4,
borderRadius: 4,
border: isToday ? '1px solid var(--accent, #8fc79a)' : '1px solid transparent',
background: outside ? 'transparent' : 'rgba(255,255,255,0.03)',
opacity: outside ? 0.4 : 1,
}}>
<div className="sans dim" style={{ fontSize: '0.7rem', marginBottom: 3 }}>
{day.getDate()}
</div>
{entries.map((entry) => <EntryChip key={entryKey(entry)} entry={entry} />)}
</div>
)
})}
</div>
</div>
) : (
<div className="panel-flat" style={{ padding: 4 }}>
{(data?.entries || []).length === 0 ? (
<p className="sans dim" style={{ padding: 14, margin: 0, fontSize: '0.84rem' }}>
Nothing is scheduled in the next sixty days.{' '}
{mayAuthor && <Link to="/admin/events/new">Author an event</Link>}
</p>
) : (
<table className="table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Series</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{(data?.entries || []).map((entry) => (
<tr key={entryKey(entry)} style={{ opacity: isProjected(entry) ? 0.7 : 1 }}>
<td className="sans" style={{ fontSize: '0.82rem', whiteSpace: 'nowrap' }}>
{new Date(entry.scheduledFor).toLocaleDateString()}{' '}
<span className="dim">
{localTime(entry.scheduledFor, entry.timezone)} {entry.timezone}
</span>
</td>
<td className="sans" style={{ fontSize: '0.84rem' }}>
{entry.runId ? (
<Link to={`/admin/events/runs/${entry.runId}`}>{entry.title}</Link>
) : (
<Link to={`/admin/events/${entry.definitionId}`}>{entry.title}</Link>
)}
{entry.adjusted === 'gap' && (
<span className="dim" title="Daylight saving skips the time this was authored at, so it moves forward to the next one that exists">
{' '}(clocks change)
</span>
)}
</td>
<td className="sans dim" style={{ fontSize: '0.8rem' }}>{entry.seriesName || '—'}</td>
<td className="sans" style={{ fontSize: '0.8rem', color: STATUS_COLOR[entry.status] }}>
{isProjected(entry) ? <span className="dim">Forecast</span> : runStatusWord(entry.status)}
{entry.waitingSteps > 0 && (
<span style={{ color: '#d9c184' }}> · waiting on a person</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</>
)
}
// 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 (
<div className="panel-flat" style={{ padding: 14, marginBottom: 12 }}>
<h3 className="sans" style={{ margin: '0 0 4px', fontSize: '0.92rem' }}>Series</h3>
<p className="sans dim" style={{ margin: '0 0 10px', fontSize: '0.78rem' }}>
An arc several events form together. The order here is where a series sits among the
others; where an event sits <em>within</em> its arc is that event&rsquo;s own order, in the
editor.
</p>
{problem && (
<p className="sans" style={{ fontSize: '0.8rem', color: '#d98b84' }}>{problem}</p>
)}
{series.map((s) => (
<div key={s.id} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input className="input" defaultValue={s.name} disabled={busy} style={{ flex: 1 }}
onBlur={(e) => {
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 }))
}
}} />
<input className="input" type="number" defaultValue={s.ordering} disabled={busy}
style={{ width: 72 }} aria-label={`Order of ${s.name}`}
onBlur={(e) => {
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 }))
}
}} />
<span className="sans dim" style={{ fontSize: '0.76rem', minWidth: 70 }}>
{s.definitionCount} event{s.definitionCount === 1 ? '' : 's'}
</span>
<button type="button" className="pill" disabled={busy} style={{ fontSize: '0.7rem' }}
onClick={() => {
// The count is in the question, because the consequence of this
// delete is entirely about the rows it does not delete.
const ask = s.definitionCount
? `Delete "${s.name}"? ${s.definitionCount} event(s) will keep their content and lose this series.`
: `Delete "${s.name}"?`
// eslint-disable-next-line no-alert
if (window.confirm(ask)) run(() => api.admin.deleteEventSeries(s.id))
}}>
Delete
</button>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
<input className="input" value={name} placeholder="New series name" disabled={busy}
style={{ flex: 1 }} onChange={(e) => setName(e.target.value)} />
<button type="button" className="pill" disabled={busy || !name.trim()} style={{ fontSize: '0.72rem' }}
onClick={() => run(async () => {
await api.admin.createEventSeries({ name: name.trim() })
setName('')
})}>
Add
</button>
</div>
</div>
)
}
function EntryChip({ entry }) {
const projected = isProjected(entry)
const to = entry.runId ? `/admin/events/runs/${entry.runId}` : `/admin/events/${entry.definitionId}`
return (
<Link
to={to}
className="sans"
title={`${entry.title} — ${localTime(entry.scheduledFor, entry.timezone)} ${entry.timezone}${projected ? ' (forecast)' : ` — ${runStatusWord(entry.status)}`}`}
style={{
display: 'block',
fontSize: '0.7rem',
padding: '1px 4px',
marginBottom: 2,
borderRadius: 3,
borderLeft: `2px ${projected ? 'dashed' : 'solid'} ${STATUS_COLOR[entry.status] || 'var(--accent, #8fc79a)'}`,
background: projected ? 'transparent' : 'rgba(255,255,255,0.05)',
opacity: projected ? 0.7 : 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textDecoration: 'none',
}}>
<span className="dim">{localTime(entry.scheduledFor, entry.timezone)}</span> {entry.title}
{entry.waitingSteps > 0 && <span style={{ color: '#d9c184' }}> ●</span>}
</Link>
)
}