Files
website/client/src/routes/public/EventPage.jsx
wtclaude 1667e636bd feat(events): the public calendar, event pages and participation history (Phase 14a)
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
2026-09-08 06:18:38 -05:00

191 lines
7.5 KiB
JavaScript

// One event's public page (EVENTS.md § API surface, Phase 14a).
//
// The storyline, its arc, what is live, what is next, what happened recently,
// and a results table once an occurrence has published one.
//
// **`?run=` is read from the URL and passed straight through**, because that is
// what an announcement's link carries. The page lives at the definition's slug —
// one stable address, so a link posted in Discord survives a retitle — and the
// occurrence has to be in the query string or a mail about last Friday's
// invasion would open next Friday's.
//
// **The error is checked before the form.** Phase 13 found the inverse of this
// on the admin editor: `if (loading || !form) return <Loading/>` above the error
// branch left a failed load spinning for ever with nothing on screen naming the
// problem. Order matters, and the order is error first.
import { useParams, useSearchParams, 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 { eventDateTime, statusWord } from '../../lib/eventCalendar.js'
export default function EventPage() {
const { slug } = useParams()
const [params] = useSearchParams()
const run = params.get('run')
const { loading, error, data } = useAsync(() => api.publicEvent(slug, run), [slug, run])
if (error) {
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<ErrorState message="That event could not be found." />
<p style={{ marginTop: 16 }}>
<Link to="/site/events">Back to the calendar</Link>
</p>
</div>
</PublicLayout>
)
}
if (loading || !data) {
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<Loading />
</div>
</PublicLayout>
)
}
const event = data.event
const headline = event.current || event.next
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader
eyebrow={event.series ? event.series.name : 'Event'}
title={event.title}
lead={event.summary || ''}
/>
{event.series && (
<p className="sans" style={{ marginTop: -12 }}>
<Link to={`/site/events/series/${event.series.slug}`}>Part of {event.series.name}</Link>
</p>
)}
{/* The one fact a visitor came for, before the storyline rather than
after it: whether it is happening now, and if not, when it next is. */}
<div
className="panel"
style={{
padding: '18px 22px',
marginBottom: 24,
borderColor: event.live ? '#8fc79a' : undefined,
}}
>
{event.live ? (
<>
<div
className="sans"
style={{ color: '#8fc79a', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}
>
Happening now
</div>
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
{/* The phase LABEL, and only while it is live. The plan behind
the event is never published. */}
{event.current.phase || 'Under way'}
</div>
</>
) : event.next ? (
<>
<div className="sans dim" style={{ letterSpacing: '0.06em', textTransform: 'uppercase', fontSize: '0.74rem' }}>
Next
</div>
<div style={{ marginTop: 6, color: 'var(--head)', fontSize: '1.1rem' }}>
{eventDateTime(event.next.scheduledFor, event.next.timezone)}
</div>
</>
) : (
<div className="dim">Nothing scheduled at the moment.</div>
)}
</div>
{event.body && (
<article
className="panel"
style={{ padding: 28, marginBottom: 24 }}
// Sanitized on write, the treatment a wiki page and a forum post get.
dangerouslySetInnerHTML={{ __html: event.body }}
/>
)}
{event.results && (
<section style={{ marginBottom: 24 }}>
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
Results
</h2>
<p className="dim sans" style={{ marginTop: -6, fontSize: '0.85rem' }}>
{eventDateTime(event.results.scheduledFor, event.timezone)}
</p>
{event.results.participants.length === 0 ? (
<EmptyState>Results were published with nobody recorded.</EmptyState>
) : (
<table className="table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 60 }}>#</th>
<th>Who</th>
<th style={{ width: 120, textAlign: 'right' }}>Score</th>
</tr>
</thead>
<tbody>
{event.results.participants.map((p, i) => (
<tr key={`${p.name || 'anon'}-${i}`}>
<td>{p.rank ?? '—'}</td>
{/* A module supplies a display name in `meta` or it does
not; the member key is never published, so there is
genuinely nothing else to render. */}
<td>{p.name || <span className="dim">Unnamed</span>}</td>
<td style={{ textAlign: 'right' }}>{p.score}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)}
<Occurrences title="Coming up" list={event.upcoming} slug={event.slug} timezone={event.timezone} />
<Occurrences title="Previously" list={event.past} slug={event.slug} timezone={event.timezone} past />
{!headline && event.past.length === 0 && (
<EmptyState>This event has not been scheduled yet.</EmptyState>
)}
</div>
</PublicLayout>
)
}
function Occurrences({ title, list, slug, timezone, past = false }) {
if (!list || list.length === 0) return null
return (
<section style={{ marginBottom: 24 }}>
<h2 className="display" style={{ fontSize: '1.3rem', color: 'var(--head)' }}>
{title}
</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{list.map((o) => (
<li key={o.runId} className="panel" style={{ padding: '12px 18px', display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: 220 }}>{eventDateTime(o.scheduledFor, o.timezone || timezone)}</span>
<span className="dim sans" style={{ fontSize: '0.78rem' }}>{statusWord(o.status, o.scheduledFor)}</span>
{/* Only a past occurrence gets its own link, and only when it has
results: on any other, `?run=` would change nothing a reader
could see. */}
{past && o.resultsPublishedAt && (
<Link className="sans" style={{ fontSize: '0.78rem' }} to={`/site/events/${slug}?run=${o.runId}`}>
Results
</Link>
)}
</li>
))}
</ul>
</section>
)
}