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>
458 lines
19 KiB
JavaScript
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)}>
|
|
←
|
|
</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)}>
|
|
→
|
|
</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’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>
|
|
)
|
|
}
|