The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
132 lines
5.1 KiB
JavaScript
132 lines
5.1 KiB
JavaScript
// The public event calendar (EVENTS.md §I, Phase 14a).
|
|
//
|
|
// **A list, not a month grid.** The admin calendar draws a grid because an
|
|
// operator's question is "what does this month look like" — coverage, clashes,
|
|
// the gap on the third weekend. A visitor's question is "what is on, and when is
|
|
// the next one", which a chronological list answers in one glance and a grid
|
|
// answers by making them count squares. Same data, different question.
|
|
//
|
|
// **A projection is drawn differently from a run, and the reason is the
|
|
// operator's reason one tier along.** Past the materialisation horizon there is
|
|
// no row: nothing is committed to, nothing can be cancelled, and a forecast
|
|
// rendered identically to a booking would be the page promising something the
|
|
// server has not. It is dashed and labelled "expected".
|
|
//
|
|
// The date heading is the READER's day and the time beside each entry is the
|
|
// EVENT's own zone. That split is §I's: the shard's evening is what "8pm" means
|
|
// to everyone reading it, but "this month" is the month the reader is living in.
|
|
|
|
import { Link } from 'react-router-dom'
|
|
import PublicLayout from '../../components/PublicLayout.jsx'
|
|
import PageHeader from '../../components/PageHeader.jsx'
|
|
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
|
|
import { useAsync } from '../../lib/useAsync.js'
|
|
import { api } from '../../api/client.js'
|
|
import { eventTime, readerDayLabel, statusWord } from '../../lib/eventCalendar.js'
|
|
|
|
export default function Events() {
|
|
const { loading, error, data } = useAsync(() => api.publicEvents())
|
|
const entries = data?.entries || []
|
|
|
|
// Grouped by the reader's own day, in order. The server already sorted by
|
|
// instant, so this preserves that order rather than re-sorting.
|
|
const days = []
|
|
for (const entry of entries) {
|
|
const label = readerDayLabel(entry.scheduledFor)
|
|
const last = days[days.length - 1]
|
|
if (last && last.label === label) last.entries.push(entry)
|
|
else days.push({ label, entries: [entry] })
|
|
}
|
|
|
|
return (
|
|
<PublicLayout section="website">
|
|
<div className="shell-mid page-body">
|
|
<PageHeader
|
|
eyebrow="What's on"
|
|
title="Events"
|
|
lead="Everything scheduled, live and recently finished. Times are shown in the shard's own timezone."
|
|
/>
|
|
<section style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load the calendar right now." />}
|
|
{!loading && !error && entries.length === 0 && (
|
|
<EmptyState>Nothing on the calendar just yet — check back soon.</EmptyState>
|
|
)}
|
|
{days.map((day) => (
|
|
<div key={day.label}>
|
|
<h2
|
|
className="sans"
|
|
style={{
|
|
margin: '0 0 12px',
|
|
fontSize: '0.74rem',
|
|
letterSpacing: '0.08em',
|
|
textTransform: 'uppercase',
|
|
color: 'var(--muted)',
|
|
}}
|
|
>
|
|
{day.label}
|
|
</h2>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{day.entries.map((entry) => (
|
|
<EventRow key={`${entry.slug}-${entry.scheduledFor}-${entry.kind}`} entry={entry} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</section>
|
|
</div>
|
|
</PublicLayout>
|
|
)
|
|
}
|
|
|
|
function EventRow({ entry }) {
|
|
const projected = entry.kind === 'projected'
|
|
const body = (
|
|
<div
|
|
className="panel"
|
|
style={{
|
|
padding: '16px 20px',
|
|
display: 'flex',
|
|
alignItems: 'baseline',
|
|
gap: 16,
|
|
flexWrap: 'wrap',
|
|
// The whole visual difference between a booking and a forecast, and it
|
|
// is deliberately not subtle.
|
|
borderStyle: projected ? 'dashed' : undefined,
|
|
opacity: projected ? 0.72 : 1,
|
|
}}
|
|
>
|
|
<span className="sans" style={{ fontWeight: 700, color: 'var(--accent)', minWidth: 96 }}>
|
|
{eventTime(entry.scheduledFor, entry.timezone)}
|
|
</span>
|
|
<span style={{ flex: 1, minWidth: 200 }}>
|
|
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
|
|
{entry.title}
|
|
</span>
|
|
{entry.seriesName && (
|
|
<span className="dim" style={{ marginLeft: 10, fontSize: '0.9rem' }}>
|
|
{entry.seriesName}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span
|
|
className="sans"
|
|
style={{
|
|
fontSize: '0.72rem',
|
|
letterSpacing: '0.06em',
|
|
textTransform: 'uppercase',
|
|
color: entry.live ? '#8fc79a' : 'var(--muted)',
|
|
fontWeight: entry.live ? 700 : 400,
|
|
}}
|
|
>
|
|
{projected ? 'Expected' : statusWord(entry.status, entry.scheduledFor)}
|
|
</span>
|
|
</div>
|
|
)
|
|
|
|
// A projection has no page of its own worth linking to any differently — the
|
|
// event page IS the definition's — so both link to the same place. It is the
|
|
// OCCURRENCE that does not exist yet, not the event.
|
|
return <Link to={`/site/events/${entry.slug}`} style={{ textDecoration: 'none' }}>{body}</Link>
|
|
}
|