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
This commit is contained in:
2026-09-08 06:18:38 -05:00
parent 6e6c24065c
commit 1667e636bd
39 changed files with 3964 additions and 45 deletions

View File

@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Events from './routes/public/Events.jsx'
import EventPage from './routes/public/EventPage.jsx'
import EventSeries from './routes/public/EventSeries.jsx'
import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -74,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
import PlayerEvents from './routes/player/PlayerEvents.jsx'
export default function App() {
return (
@@ -107,6 +111,17 @@ export default function App() {
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
although it could not be shadowed by it — two segments against
one. It stays above because the ranking surprise this feature
has already shipped once was exactly here: a static segment
outranks a dynamic one whatever the source order, which is what
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
Nothing static shares a segment with `:slug`, so nothing here
repeats it. */}
<Route path="/site/events" element={<Events />} />
<Route path="/site/events/series/:slug" element={<EventSeries />} />
<Route path="/site/events/:slug" element={<EventPage />} />
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
@@ -290,6 +305,11 @@ export default function App() {
<Route path="/player" element={<PlayerIndex />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Participation history (Phase 14a). Under /account rather than
/player because it is role-agnostic self-service: staff are a
superset of players and an admin reading their own attendance
is as ordinary as anyone else doing it. */}
<Route path="/account/events" element={<PlayerEvents />} />
{/* The inbox took `/account/notifications` in engagement Phase 7
and the preferences screen moved under it. Content and
settings are different kinds of thing, and the plain word

View File

@@ -256,6 +256,24 @@ export const api = {
// their mail, not signed in. Always resolves 200 whatever the token was.
unsubscribeTeam: (token) =>
req(`/public/teams/unsubscribe/${encodeURIComponent(token)}`, { method: 'POST' }),
// ----- Events (EVENTS.md § API surface, Phase 14a) -----
//
// The anonymous surface. `from`/`to` are optional — the server defaults to now
// through a month out, so the calendar's first render need not compute a window
// before it can ask for anything.
publicEvents: ({ from, to, seriesId } = {}) => {
const qs = new URLSearchParams()
if (from) qs.set('from', from)
if (to) qs.set('to', to)
if (seriesId) qs.set('seriesId', String(seriesId))
return req(`/public/events${withQs(qs.toString())}`)
},
// `run` is what an announcement's link carries, so a mail about last Friday's
// occurrence opens last Friday's results rather than next Friday's.
publicEvent: (slug, run = null) =>
req(`/public/events/${encodeURIComponent(slug)}${run ? `?run=${encodeURIComponent(run)}` : ''}`),
publicEventSeries: (slug) => req(`/public/events/series/${encodeURIComponent(slug)}`),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
@@ -708,6 +726,18 @@ export const api = {
getEligibleAppeals: () => req('/player/appeals/eligible'),
submitAppeal: (data) => req('/player/appeals', { method: 'POST', body: data }),
withdrawAppeal: (id) => req(`/player/appeals/${id}/withdraw`, { method: 'POST' }),
// ----- event participation (Phase 14a) -----
//
// Self-scoped on the session and nothing else — there is no id to pass.
// `before` is a keyset cursor (the last entry's `id`), not an offset: the
// list gains a row every time the reader attends something.
eventHistory: ({ limit, before } = {}) => {
const qs = new URLSearchParams()
if (limit) qs.set('limit', String(limit))
if (before) qs.set('before', String(before))
return req(`/player/events/history${withQs(qs.toString())}`)
},
},
}

View File

@@ -29,6 +29,7 @@ import { useFeatureGate } from '../modules/features.jsx'
export const NAV = [
{ label: 'Home', to: '/', end: true },
{ label: 'News', to: '/site/news' },
{ label: 'Events', to: '/site/events' },
{ label: 'Screenshots', to: '/site/screenshots' },
{ label: 'Five on Friday', to: '/site/five-on-friday' },
{ label: 'Newsletter', to: '/site/newsletter' },

View File

@@ -232,6 +232,14 @@ export function formFromDefinition(event) {
concurrencyKey: event?.concurrencyKey || '',
graceSeconds: event?.graceSeconds ?? 900,
timezone: event?.timezone || 'UTC',
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
// Whether the public calendar announces it (Phase 14a). `?? true` rather
// than `|| true`: a definition an operator has deliberately unlisted sends
// `false`, and `||` would quietly re-list it on the next save.
listed: event?.listed ?? true,
...scheduleFormFrom(spec.schedule),
phases: (spec.phases || []).map((p) => ({
key: p.key || '',
@@ -351,6 +359,8 @@ export function payloadFromForm(form, { triggersById = new Map() } = {}) {
concurrencyKey: form.concurrencyKey || null,
graceSeconds: Number(form.graceSeconds),
timezone: form.timezone,
listed: Boolean(form.listed),
listed: Boolean(form.listed),
spec: { schedule: scheduleFromForm(form), phases },
},
}

View File

@@ -0,0 +1,99 @@
// Rendering an event's instant, shared by the public event screens.
//
// **The split these two functions make is EVENTS.md §I's, and it is the one
// thing about event times that is easy to get wrong.** The server returns UTC
// instants and never guesses the reader's zone. The client places them:
//
// • the DAY an entry is filed under is the reader's own — "what is on this
// month" is a question about the month the person reading is living in;
// • the TIME beside it is always the EVENT's zone, carried on the entry —
// because every listing this feature replaces is written in the shard's
// local zone, and "8pm" means the shard's evening to everyone reading it.
//
// Rendering the time in the reader's zone instead would be defensible and is
// wrong here: a player in Berlin told an American shard's event is at "02:00"
// has been told something true and useless, and told it in a way that makes the
// shard's own announcement look like a mistake.
/** The event's own wall clock, with the zone named so it misreads as nothing. */
export function eventTime(instant, timezone) {
try {
const time = new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(new Date(instant))
return `${time} ${shortZone(timezone)}`
} catch {
// An unknown IANA name throws rather than falling back, and an event whose
// timezone column holds a typo must still render. UTC off the instant is the
// honest answer when the zone cannot be honoured.
return `${new Date(instant).toISOString().slice(11, 16)} UTC`
}
}
/** The zone as a reader recognises it: `America/New_York` → `New York`. */
function shortZone(timezone) {
if (!timezone) return 'UTC'
const tail = String(timezone).split('/').pop()
return tail.replace(/_/g, ' ')
}
/** The reader's own day, for the heading an entry is filed under. */
export function readerDayLabel(instant) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
return new Intl.DateTimeFormat(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: d.getFullYear() === new Date().getFullYear() ? undefined : 'numeric',
}).format(d)
}
/** The event's own day and time together, for a page that shows one occurrence. */
export function eventDateTime(instant, timezone) {
const d = new Date(instant)
if (Number.isNaN(d.getTime())) return ''
try {
return `${new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
weekday: 'long',
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(d)} ${shortZone(timezone)}`
} catch {
return `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`
}
}
// The word beside an entry, for the four public statuses.
//
// **`cancelled` needs the instant, and that is the whole reason this is a
// function rather than a lookup table.** The server publishes `failed` and
// `missed` as `cancelled` too — to a visitor those three are one event, and the
// difference between them is about the deployment — but the three do not share
// one English sentence. "Did not happen" is right for a past occurrence and a
// plain falsehood for a future one, and a run four days out that an operator has
// called off is exactly the common case: the calendar was saying *did not
// happen* about next Friday.
//
// So the tense follows the clock, not the status. A future call-off reads
// **Cancelled**; a past one reads **Did not happen**, which is also the honest
// word for the failed and missed runs folded in with it.
const WORDS = {
live: 'Happening now',
scheduled: 'Scheduled',
completed: 'Finished',
}
export function statusWord(status, scheduledFor, now = Date.now()) {
if (WORDS[status]) return WORDS[status]
if (status !== 'cancelled') return status
const at = new Date(scheduledFor).getTime()
return Number.isNaN(at) || at <= now ? 'Did not happen' : 'Cancelled'
}

View File

@@ -1164,11 +1164,24 @@ export default function EventEditor() {
<span className="field-label">Storyline</span>
<textarea className="input" rows={4} value={form.body} onChange={(e) => set({ body: e.target.value })} />
</label>
{/* Announcement, not permission. Publishing is what makes an event
RUNNABLE, so without this switch a surprise invasion would have to be
advertised a fortnight in advance in order to be allowed to happen. */}
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<input
type="checkbox"
checked={Boolean(form.listed)}
onChange={(e) => set({ listed: e.target.checked })}
/>
<span className="field-label" style={{ margin: 0 }}>Show on the public calendar</span>
</label>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
The grace window is how late this event may still start: past it an occurrence becomes
<em> missed</em> rather than beginning hours after it was announced. Two runs sharing a
concurrency key never overlap <code>{'{placeholders}'}</code> are filled from the run&rsquo;s own
params.
params. An event that is <em>not</em> shown publicly still schedules, still runs and is
still on this calendar it is simply not announced, and its lifecycle mails carry no
link because there is no page to link to.
</p>
</div>

View File

@@ -0,0 +1,84 @@
// This account's event participation (EVENTS.md §J, Phase 14a).
//
// **The screen's one real design decision is what an unranked row says.** A run
// whose participants were collected but whose results have not been published
// has a score and no rank, and that is a real state rather than an error — it is
// the same state the admin run console has shown since Phase 10. Rendering "—"
// with nothing explaining it would read as a bug; the row says "not published",
// which is a fact about the event rather than about the reader.
//
// The list is keyset-paged on the participation row's own id, not offset-paged:
// it gains a row every time the reader attends something.
import { useCallback, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { eventDateTime } from '../../lib/eventCalendar.js'
const PAGE = 25
export default function PlayerEvents() {
const [pages, setPages] = useState([])
const [more, setMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const load = useCallback(async () => {
const result = await api.player.eventHistory({ limit: PAGE })
setPages([result.entries || []])
setMore((result.entries || []).length === PAGE)
return result
}, [])
const { loading, error } = useAsync(load)
const entries = pages.flat()
const loadMore = async () => {
const last = entries[entries.length - 1]
if (!last) return
setLoadingMore(true)
try {
const result = await api.player.eventHistory({ limit: PAGE, before: last.id })
setPages((p) => [...p, result.entries || []])
setMore((result.entries || []).length === PAGE)
} finally {
setLoadingMore(false)
}
}
if (error) return <ErrorState message="Could not load your event history." />
if (loading) return <Loading />
if (entries.length === 0) {
return <EmptyState>You have not taken part in an event yet.</EmptyState>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{entries.map((e) => (
<div key={e.id} className="panel" style={{ padding: '16px 20px', display: 'flex', gap: 18, flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: 240 }}>
<Link to={`/site/events/${e.slug}?run=${e.runId}`} style={{ fontSize: '1.05rem' }}>
{e.title}
</Link>
<div className="dim sans" style={{ fontSize: '0.82rem', marginTop: 4 }}>
{eventDateTime(e.scheduledFor, e.timezone)}
{e.seriesName && ` · ${e.seriesName}`}
</div>
</span>
<span style={{ textAlign: 'right', minWidth: 140 }}>
<div className="sans" style={{ color: 'var(--head)' }}>
{e.rank != null ? `Rank ${e.rank}` : <span className="dim">Results not published</span>}
</div>
<div className="dim sans" style={{ fontSize: '0.82rem' }}>Score {e.score}</div>
</span>
</div>
))}
{more && (
<button className="btn" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading…' : 'Show more'}
</button>
)}
</div>
)
}

View File

@@ -39,6 +39,11 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// The settings row's own icon: a bell would make the two rows read as the same
// destination twice, which is exactly the confusion the split was meant to end.
// Participation history (Phase 14a). A calendar rather than a trophy: the row
// is every event this account attended, ranked or not, and most of them will
// never have a result published against them at all.
const IconCalendar = () => <Icon><rect x="3" y="5" width="18" height="16" rx="2" /><path d="M3 10h18M8 3v4M16 3v4" /></Icon>
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
@@ -51,6 +56,7 @@ const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" />
// UO module registers it again at `/player/uo/characters`, in this position,
// with `order: 0`.
export const NAV = [
{ to: '/account/events', label: 'Events', icon: IconCalendar },
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },

View File

@@ -0,0 +1,190 @@
// 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>
)
}

View File

@@ -0,0 +1,78 @@
// One arc (EVENTS.md §I, Phase 14a).
//
// **The arc is the thing the tooling this replaces could not express at all.**
// A WordPress calendar plugin has no series field, so "Royal Spy Mission → Risky
// Partner → Message From the Void" existed only in a GM's head and in whatever
// the forum post said. This page is that continuity, in the order an editor
// arranged it — which is why the events are numbered rather than dated: an arc
// has an order, and its parts may be months apart or run out of sequence.
import { useParams, Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
export default function EventSeries() {
const { slug } = useParams()
const { loading, error, data } = useAsync(() => api.publicEventSeries(slug), [slug])
// Error first, then loading — the order Phase 13 had to fix on the admin
// editor, where a failed load sat behind a spinner that never stopped.
if (error) {
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<ErrorState message="That series 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 series = data.series
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader eyebrow="Series" title={series.name} lead={series.description || ''} />
<ol style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: 14 }}>
{series.events.map((e, i) => (
<li key={e.slug}>
<Link to={`/site/events/${e.slug}`} style={{ textDecoration: 'none' }}>
<div className="panel" style={{ padding: '18px 22px', display: 'flex', gap: 18 }}>
<span
className="display"
style={{ color: 'var(--accent)', fontSize: '1.4rem', minWidth: 36, textAlign: 'right' }}
>
{i + 1}
</span>
<span>
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
{e.title}
</span>
{e.summary && <p style={{ margin: '6px 0 0', color: 'var(--text)' }}>{e.summary}</p>}
</span>
</div>
</Link>
</li>
))}
</ol>
<p className="sans" style={{ marginTop: 24 }}>
<Link to="/site/events">Back to the calendar</Link>
</p>
</div>
</PublicLayout>
)
}

View File

@@ -0,0 +1,131 @@
// 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>
}

View File

@@ -252,3 +252,40 @@ test('a Team slug is URL-encoded on every forum path', async () => {
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
})
// ── Public events (Phase 14a) ───────────────────────────────────────────
//
// The one shape worth pinning is `?run=`: it is what an announcement's link
// carries, and a client that dropped it would make a mail about last Friday's
// occurrence open next Friday's.
test('the public calendar asks for no window at all by default', async () => {
willReply({ body: { entries: [] } })
await api.publicEvents()
// The server defaults to now through a month out, so the first render need
// not compute two ISO instants before it can ask for anything.
assert.equal(calls[0].url, '/api/v1/public/events')
})
test('an event page carries the run when one was named, and not when it was not', async () => {
willReply({ body: { ok: true } })
await api.publicEvent('the-yew-invasion')
assert.equal(calls[0].url, '/api/v1/public/events/the-yew-invasion')
willReply({ body: { ok: true } })
await api.publicEvent('the-yew-invasion', 3692)
assert.equal(calls[1].url, '/api/v1/public/events/the-yew-invasion?run=3692')
})
test('an event slug is URL-encoded on every public path', async () => {
willReply({ body: { ok: true } })
await api.publicEventSeries('a b/c')
assert.equal(calls[0].url, '/api/v1/public/events/series/a%20b%2Fc')
})
test('participation history takes a keyset cursor, never an offset', async () => {
willReply({ body: { entries: [] } })
await api.player.eventHistory({ limit: 25, before: 900 })
assert.equal(calls[0].url, '/api/v1/player/events/history?limit=25&before=900')
})

View File

@@ -252,6 +252,29 @@ test('the form round-trips a definition without losing a step', () => {
assert.equal(built.payload.concurrencyKey, 'invasion:{region}')
})
test('`listed` round-trips, and an unlisted event is not quietly re-listed', () => {
// The trap this guards is `||` where `??` is meant. A definition an operator
// deliberately unlisted sends `listed: false`, and `event?.listed || true`
// would put it back on the public calendar on the author's next save — a
// surprise event announced by a typo fix.
const unlisted = payloadFromForm(
formFromDefinition({ title: 'Invasion', listed: false, spec: { schedule: { kind: 'manual' }, phases: [] } }),
)
assert.equal(unlisted.payload.listed, false)
const listed = payloadFromForm(
formFromDefinition({ title: 'Invasion', listed: true, spec: { schedule: { kind: 'manual' }, phases: [] } }),
)
assert.equal(listed.payload.listed, true)
})
test('a new definition defaults to listed', () => {
// The column's own default, and the ordinary case: unlisting is the
// deliberate act, not listing.
const fresh = payloadFromForm(formFromDefinition({ spec: { schedule: { kind: 'manual' }, phases: [] } }))
assert.equal(fresh.payload.listed, true)
})
test('an unchosen onFailure is omitted rather than invented', () => {
// The server defaults it from the action's risk class, which is the whole
// reason `risk` is required at registration. A form that posted a value would

View File

@@ -0,0 +1,89 @@
// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
//
// One property matters here and it is EVENTS.md §I's: **the time beside an
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
// helper that quietly rendered both in the reader's zone would pass any test
// that only ever looked at one of them, and would put an American shard's 8pm
// event at "02:00" for a player in Berlin — true, useless, and looking like the
// shard's own announcement was wrong.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
// whose DATE differs between the two zones, which is what makes the split
// observable at all.
const INSTANT = '2026-09-12T00:00:00.000Z'
test('the time is rendered in the EVENTs zone, not the readers', () => {
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
})
test('the zone is named in a form a reader recognises', () => {
// `America/New_York` is a database identifier, not something to show a player.
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
})
test('an unknown zone falls back to UTC rather than throwing', () => {
// `Intl` rejects an unknown identifier, and an event whose timezone column
// holds a typo must still render.
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
})
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
assert.equal(readerDayLabel('not a date'), '')
assert.equal(eventDateTime('not a date', 'UTC'), '')
})
test('the day label is the readers own day, whatever the events zone', () => {
// Two entries at the same instant in different event zones are filed under one
// heading, which is what makes a chronological list group correctly.
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
const label = readerDayLabel(INSTANT)
assert.ok(label.length > 0)
// The instant's UTC date is the 12th and New York's is the 11th; the label
// must not carry a zone at all, because it is neither of theirs.
assert.equal(/UTC|New York/.test(label), false)
})
test('eventDateTime carries the day and the zone together', () => {
const text = eventDateTime(INSTANT, 'America/New_York')
assert.match(text, /New York$/)
assert.match(text, /20:00/)
})
// ── The status word ────────────────────────────────────────────────────────
//
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
// run four days out that an operator had cancelled. The server publishes
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
// event — but they do not share one English sentence, so the tense follows the
// clock rather than the status.
const NOW = Date.parse('2026-09-08T12:00:00Z')
test('a cancelled occurrence in the future reads "Cancelled"', () => {
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
})
test('a cancelled occurrence in the past reads "Did not happen"', () => {
// Which is also the honest word for the failed and missed runs folded into
// `cancelled` on the way out.
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
})
test('the other three words do not depend on the clock at all', () => {
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
assert.equal(statusWord('live', at, NOW), 'Happening now')
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
assert.equal(statusWord('completed', at, NOW), 'Finished')
}
})
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
})