feat(events): the public calendar, event pages and participation history (Phase 14a) #196

Merged
whitlocktech merged 2 commits from feature/events-p14a-public-surface into edge 2026-09-08 17:04:55 +00:00
39 changed files with 3964 additions and 45 deletions
Showing only changes of commit 1667e636bd - Show all commits

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 EVENT’s zone, not the reader’s', () => {
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 reader’s own day, whatever the event’s 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')
})

View File

@@ -2598,6 +2598,25 @@ CREATE TABLE IF NOT EXISTS event_run_resources (
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_at DATETIME NULL;
ALTER TABLE event_versions ADD COLUMN IF NOT EXISTS verified_by INT NULL;
-- Whether this definition appears on the PUBLIC calendar (Phase 14a).
--
-- **Not a second answer to the question `state` answers**, which is the trap the
-- `findSchedulable` comment in eventDefinitions.db.js warns about: `state` says
-- whether an event is SCHEDULABLE, and this says whether it is ANNOUNCED. The
-- two came apart the moment there was a public surface at all, because
-- publishing is what makes a definition runnable -- so without this column a
-- surprise invasion would have to be advertised a fortnight in advance in order
-- to be allowed to happen.
--
-- Default 1, so every definition that exists keeps the behaviour it had while
-- the only reader was staff, and unlisting is the deliberate act.
--
-- It hides the definition, its runs and its projections from the public
-- surfaces and from a participant's own history. It hides nothing from staff:
-- the admin calendar is the operational view, and an event nobody outside can
-- see is still an event the team is running.
ALTER TABLE event_definitions ADD COLUMN IF NOT EXISTS listed TINYINT(1) NOT NULL DEFAULT 1;
-- ── Integrations: participants, results and the run's announcements
-- (EVENTS.md §D/§J — Phase 10) ─────────────────────────────────────────────

View File

@@ -11,7 +11,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 1,
"version": 2,
"variables": [
{
"name": "runId",
@@ -54,6 +54,13 @@
"required": true,
"example": 4,
"description": "How many phases the pinned version has in total."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
@@ -66,7 +73,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 1,
"version": 2,
"variables": [
{
"name": "runId",
@@ -88,6 +95,13 @@
"required": false,
"example": "The shard is down for an emergency patch.",
"description": "What the staff member gave as the reason, when they gave one."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
@@ -100,7 +114,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 1,
"version": 2,
"variables": [
{
"name": "runId",
@@ -136,6 +150,13 @@
"required": true,
"example": 95,
"description": "How long the run took, start to end, in whole minutes."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
@@ -148,7 +169,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 1,
"version": 2,
"variables": [
{
"name": "runId",
@@ -163,6 +184,13 @@
"required": true,
"example": "The Yew Invasion",
"description": "The event title."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
@@ -223,7 +251,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 1,
"version": 2,
"variables": [
{
"name": "runId",
@@ -273,6 +301,13 @@
"required": false,
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
"description": "The start time written out in the shard-local zone, for a mail to read."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},
@@ -285,7 +320,7 @@
"subjectKey": "runId",
"audience": "subscribers",
"ceiling": "authenticated",
"version": 1,
"version": 2,
"variables": [
{
"name": "runId",
@@ -335,6 +370,13 @@
"required": false,
"example": "Saturday 12 September at 8:00 pm (America/New_York)",
"description": "The start time written out in the shard-local zone, for a mail to read."
},
{
"name": "eventUrl",
"type": "url",
"required": false,
"example": "/site/events/the-yew-invasion?run=3692",
"description": "The public page for this occurrence."
}
]
},

View File

@@ -2267,6 +2267,15 @@
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/events/history",
"handlers": 1,
"gates": [
"noindex",
"requireAuth"
]
},
{
"method": "GET",
"path": "/api/v1/player/teams",
@@ -2444,6 +2453,30 @@
"handlers": 1,
"gates": []
},
{
"method": "GET",
"path": "/api/v1/public/events",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/events/:slug",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/events/series/:slug",
"handlers": 2,
"gates": [
"siteMode"
]
},
{
"method": "GET",
"path": "/api/v1/public/modules",

View File

@@ -933,6 +933,10 @@
"method": "GET",
"path": "/api/v1/player/appeals/eligible"
},
{
"method": "GET",
"path": "/api/v1/player/events/history"
},
{
"method": "GET",
"path": "/api/v1/player/teams"
@@ -1005,6 +1009,18 @@
"method": "POST",
"path": "/api/v1/public/engagement/unsubscribe/:token"
},
{
"method": "GET",
"path": "/api/v1/public/events"
},
{
"method": "GET",
"path": "/api/v1/public/events/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/events/series/:slug"
},
{
"method": "GET",
"path": "/api/v1/public/modules"

View File

@@ -185,15 +185,22 @@ const TRIGGERS = [
// runs of the same definition the ids differ, so a weekly event is not
// throttled by last week's.
//
// **None of the six public ones declares a `url` variable, deliberately.**
// There is no public event page until Phase 14 — `App.jsx` mounts nothing
// under `/site/events` — and `news.post` has already paid for this mistake
// once: its `postUrl` example named `/news/<slug>`, a path that does not
// exist, and the template editor previewed a link that was dead in every mail
// it sent. A variable added in Phase 14 alongside the page it points at is a
// version bump; a variable shipped now is a 404 in an operator's first
// announcement. `run.failed` is the exception because its destination exists
// today: `/admin/events/runs/:runId` is a real route and an admin can read it.
// **All six public ones now declare `eventUrl`, and Phase 14a is what made
// that legal.** Until it there was no public event page at all — `App.jsx`
// mounted nothing under `/site/events` — and `news.post` had already paid for
// that mistake once: its `postUrl` example named `/news/<slug>`, a path that
// did not exist, so the template editor previewed a link that was dead in
// every mail it sent. The variable arrived with the page it points at, which
// is what makes this a version bump (1 -> 2) rather than a correction.
//
// **It carries `?run=`, and the query string is the whole reason it is a run
// url and not an event url.** The page lives at the DEFINITION's slug, so a
// weekly event has one stable address — but every one of these triggers is
// about one OCCURRENCE, and a mail about last Friday's invasion whose link
// opened next Friday's would answer a different question from the one the
// reader clicked. `run.failed` keeps its own `runUrl` into the admin console
// and gains nothing here: an admin reading about broken machinery wants the
// console, not the storyline.
{
id: 'event.run.scheduled',
label: 'Event — scheduled',
@@ -202,7 +209,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -225,6 +232,12 @@ const TRIGGERS = [
{ name: 'startsAtLabel', type: 'string', required: false,
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
description: 'The start time written out in the shard-local zone, for a mail to read.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
@@ -235,7 +248,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -258,6 +271,12 @@ const TRIGGERS = [
{ name: 'startsAtLabel', type: 'string', required: false,
example: 'Saturday 12 September at 8:00 pm (America/New_York)',
description: 'The start time written out in the shard-local zone, for a mail to read.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
@@ -268,7 +287,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -282,6 +301,12 @@ const TRIGGERS = [
description: 'Which phase this is, counting from 1.' },
{ name: 'phaseCount', type: 'int', required: true, example: 4,
description: 'How many phases the pinned version has in total.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
@@ -292,12 +317,18 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion',
description: 'The event title.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
@@ -308,7 +339,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -324,6 +355,12 @@ const TRIGGERS = [
description: 'How many participants the run recorded. Zero when nothing collected any.' },
{ name: 'durationMinutes', type: 'int', required: true, example: 95,
description: 'How long the run took, start to end, in whole minutes.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
@@ -334,7 +371,7 @@ const TRIGGERS = [
subjectKey: 'runId',
audience: 'subscribers',
ceiling: 'authenticated',
version: 1,
version: 2,
variables: [
{ name: 'runId', type: 'string', required: true, example: '3692',
description: 'The run this is about. Also the cooldown subject.' },
@@ -345,6 +382,12 @@ const TRIGGERS = [
// string is for the run console and would read as gibberish in a mail.
{ name: 'reason', type: 'string', required: false, example: 'The shard is down for an emergency patch.',
description: 'What the staff member gave as the reason, when they gave one.' },
// The public page for THIS occurrence (Phase 14a). Relative, like
// `postUrl` and `runUrl`: the seam resolves it against the site's own
// base, and an absolute one baked in here would be wrong on every
// deployment but the first.
{ name: 'eventUrl', type: 'url', required: false, example: '/site/events/the-yew-invasion?run=3692',
description: 'The public page for this occurrence.' },
],
},
{
@@ -369,8 +412,9 @@ const TRIGGERS = [
description: 'The phase it failed in, when it had entered one.' },
{ name: 'error', type: 'string', required: false, example: 'sidecar responded 503',
description: 'The run’s last error, verbatim from the run row.' },
// The one url variable in this file's Phase 10 block, and the reason is
// that this route exists TODAY. See the note above the six.
// The admin console, not the public page — and this trigger gains no
// `eventUrl` at all. An admin reading that the machinery broke wants the
// steps and the errors, not the storyline. See the note above the six.
{ name: 'runUrl', type: 'url', required: true, example: '/admin/events/runs/3692',
description: 'Site-relative path to the run console.' },
],

View File

@@ -11,6 +11,25 @@
//
// `api` is the coarse contract version (bumped only on a breaking re-shape, which
// would be a v2 mount); `server` is the informational package version.
//
// ── `capabilities` (Phase 14a) ──
//
// Opaque strings naming what CORE serves beyond the surface every backend has —
// the same idea as a module's `capabilities` on GET /public/modules, and
// deliberately the same word, so a client feature-detects one way rather than
// two. They are a different LIST because core is not a module: publishing core
// as a pseudo-module would leave a client unable to tell "this backend has
// events" from "a module called core happens to be installed", which is exactly
// the distinction the loader exists to make.
//
// The value is in what is ABSENT. A backend released before Events answers this
// object with no `capabilities` key at all, so a client can tell an older site
// from one that simply has nothing on its calendar — which it could not do by
// probing /public/events, where "not built" and "temporarily down" look alike.
//
// Static, because these are compiled-in features rather than installed ones:
// a core that has these routes always has them. An unknown string is to be
// treated as absent, exactly as MODULE_API.md §2.1 says of a module's.
const pkg = require('../../package.json')
@@ -18,4 +37,6 @@ module.exports = {
service: 'runic-gateway', // stable backend identifier for first-run detection
api: 'v1', // API contract version (matches the /api/v1 mount)
server: pkg.version || '0.0.0', // server package version (informational)
// What core serves beyond the baseline. See the note above.
capabilities: ['events'],
}

View File

@@ -332,23 +332,29 @@ const SEEDS = [
// single absent variable renders nothing, in both parts). A standalone event
// has no `seriesName` and its line disappears rather than reading "Part of .".
//
// **No `{{actionUrl}}` and no button, deliberately.** There is no public event
// page until Phase 14, so the six public triggers declare no `url` variable at
// all (see `coreTriggers.js`), and a button here would render as an inert grey
// label in every mail — worse than none, because it advertises a link the
// reader cannot follow. Phase 14 adds the variable and the block together.
// **The button arrived with the page it points at** (Phase 14a). Until then
// there was no public event page, the six public triggers declared no url
// variable, and a button here would have rendered as an inert grey label in
// every mail — worse than no button, because it advertises a link the reader
// cannot follow. `eventUrl` is optional and `email.button` drops itself when
// its url interpolates to nothing, so an event that is not public still mails
// correctly: the block disappears rather than degrading.
{
key: 'notify.event-started',
name: 'Event starting',
channel: 'email',
protected: false,
seedVersion: 1,
// Bumped with the button. A deployment whose operator has not customized
// this template gets the new one; one that has is left alone and reported as
// stale, which is the whole mechanism.
seedVersion: 2,
subject: '{{title}} is starting',
variables: [
{ name: 'title', type: 'string', required: true, example: 'The Yew Invasion' },
{ name: 'summary', type: 'string', required: false, example: 'Orcish warbands are massing north of Yew.' },
{ name: 'seriesName', type: 'string', required: false, example: 'The Yew Campaign' },
{ name: 'startsAtLabel', type: 'string', required: false, example: 'Saturday 12 September at 8:00 pm (America/New_York)' },
{ name: 'eventUrl', type: 'string', required: false, example: '/site/events/the-yew-invasion?run=3692' },
{ name: 'unsubscribeUrl', type: 'string', required: false, example: 'https://example.com/unsubscribe/abc123' },
],
blocks: [
@@ -356,6 +362,7 @@ const SEEDS = [
text('summary', '{{summary}}'),
text('when', '{{startsAtLabel}}', { muted: true }),
text('series', '{{seriesName}}', { muted: true }),
button('open', 'Read more', '{{eventUrl}}', 'Read more about it here:'),
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these emails, use this link:'),
],

View File

@@ -92,10 +92,32 @@ async function baseFor(run) {
summary: definition.summary || undefined,
seriesName: definition.series_name || undefined,
timezone: run.timezone || definition.timezone || undefined,
eventUrl: eventUrl(definition, run),
definition,
}
}
/**
* The public page for one occurrence (Phase 14a).
*
* **Site-relative, and it carries the run.** The page lives at the definition's
* slug — one stable address for a weekly event, which is what makes a link in
* Discord survive a retitle — so the occurrence has to be in the query string or
* a mail about last Friday's invasion would open next Friday's.
*
* **`undefined` when the event is not public**, rather than a path that answers
* 404. An unlisted or not-yet-`ready` definition has no page, and `eventUrl` is
* declared optional precisely so its block can disappear from a template instead
* of rendering a dead button. That is `news.post`'s lesson applied before it
* costs anything: a link nobody can follow is worse than no link, because it
* advertises one.
*/
function eventUrl(definition, run) {
if (!definition.slug) return undefined
if (definition.state !== 'ready' || !definition.listed) return undefined
return `/site/events/${encodeURIComponent(definition.slug)}?run=${run.id}`
}
/**
* Fire one lifecycle trigger.
*

View File

@@ -11,6 +11,9 @@ const hydrate = (row) =>
row && {
...row,
spec: parseJson(row.spec, null),
// TINYINT(1) arrives as 0/1. Every reader of this column asks a yes/no
// question, and the public model's filters compare against a boolean.
listed: Boolean(row.listed),
}
// `current_version` is joined rather than stored: the list screen shows "v3" and
@@ -60,8 +63,8 @@ const insert = async (d) => {
const result = await query(
`INSERT INTO event_definitions
(title, slug, summary, body, image_url, owner_module, series_id, series_order,
concurrency_key, grace_seconds, timezone, spec, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
concurrency_key, grace_seconds, timezone, listed, spec, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
d.title,
d.slug,
@@ -74,6 +77,7 @@ const insert = async (d) => {
d.concurrency_key,
d.grace_seconds,
d.timezone,
d.listed ? 1 : 0,
JSON.stringify(d.spec),
d.created_by,
d.created_by,
@@ -87,7 +91,7 @@ const update = (id, d) =>
`UPDATE event_definitions
SET title = ?, slug = ?, summary = ?, body = ?, image_url = ?, series_id = ?,
series_order = ?, concurrency_key = ?, grace_seconds = ?, timezone = ?,
spec = ?, updated_by = ?
listed = ?, spec = ?, updated_by = ?
WHERE id = ?`,
[
d.title,
@@ -100,6 +104,7 @@ const update = (id, d) =>
d.concurrency_key,
d.grace_seconds,
d.timezone,
d.listed ? 1 : 0,
JSON.stringify(d.spec),
d.updated_by,
id,
@@ -140,20 +145,51 @@ const markReady = (id, versionId, userId) =>
* its arc exactly as a materialised run is, and a second query to learn the name
* of a row this one already reached would be two round trips for a join.
*/
const findSchedulable = async () => {
const findSchedulable = async ({ listedOnly = false } = {}) => {
const rows = await query(
`SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
d.current_version_id, d.series_id, v.spec AS version_spec,
`SELECT d.id, d.title, d.slug, d.summary, d.image_url, d.timezone, d.grace_seconds,
d.concurrency_key, d.current_version_id, d.series_id, v.spec AS version_spec,
s.name AS series_name, s.slug AS series_slug
FROM event_definitions d
JOIN event_versions v ON v.id = d.current_version_id
LEFT JOIN event_series s ON s.id = d.series_id
WHERE d.state = 'ready'
WHERE d.state = 'ready'${listedOnly ? ' AND d.listed = 1' : ''}
ORDER BY d.id`,
)
return rows.map((row) => ({ ...row, version_spec: parseJson(row.version_spec, null) }))
}
/**
* One definition by slug, for the PUBLIC event page (Phase 14a).
*
* `ready` and `listed` are both in the WHERE rather than checked by the caller,
* so an unlisted event answers exactly as a nonexistent one does — a 404 that
* cannot be told from "no such slug". A caller that filtered afterwards would
* be one forgotten early-return away from publishing a draft.
*
* An ARCHIVED definition is deliberately absent too. Archiving is what delete
* means on the admin screen, and a page that kept answering afterwards would
* make the only delete this feature has do nothing an operator could see.
*/
const getPublicBySlug = async (slug) => {
const [row] = await query(
`${SELECT_LIST} WHERE d.slug = ? AND d.state = 'ready' AND d.listed = 1`,
[slug],
)
return hydrate(row)
}
/** Every listed, ready definition in one series, in the arc's own order. */
const listPublicBySeries = async (seriesId) => {
const rows = await query(
`${SELECT_LIST}
WHERE d.series_id = ? AND d.state = 'ready' AND d.listed = 1
ORDER BY d.series_order, d.id`,
[seriesId],
)
return rows.map(hydrate)
}
/**
* Archive. Never a hard delete while runs reference it (§ API surface) — and the
* schema would refuse one anyway, because `event_runs.version_id` RESTRICTs.
@@ -166,6 +202,8 @@ module.exports = {
list,
getById,
getBySlug,
getPublicBySlug,
listPublicBySeries,
slugTaken,
findSchedulable,
insert,

View File

@@ -124,6 +124,15 @@ async function validate(input, { existing = null } = {}) {
const seriesOrder = Number(seriesOrderRaw)
if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer')
// Whether this event is announced on the public calendar (Phase 14a). It is
// NOT whether it may run: `state` answers that, and the two are separate
// precisely because publishing is what makes a definition runnable — an
// unlisted event still schedules, still runs and is still on the admin
// calendar. A missing key means "leave it as it was", and a NEW definition
// defaults to listed, which is the column's own default and the ordinary
// case; unlisting is the deliberate act.
const listed = body.listed === undefined ? (existing ? Boolean(existing.listed) : true) : Boolean(body.listed)
// ── the spec ──
const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec
const known = existing?.spec ? spec.actionIdsIn(existing.spec) : []
@@ -159,6 +168,7 @@ async function validate(input, { existing = null } = {}) {
concurrency_key: concurrencyKey,
grace_seconds: graceSeconds,
timezone,
listed,
spec: checked.spec,
},
}

View File

@@ -0,0 +1,409 @@
// ── The public event surface ───────────────────────────────────────────────
//
// EVENTS.md § API surface, and Phase 14a of EVENTS_PLAN.md: the calendar an
// anonymous visitor reads, one event's page, and an arc.
//
// **This file is a projection, and the projection is the security boundary.**
// Every other reader of these tables is staff, and every field they are shown is
// one somebody with a role was allowed to see. What comes out of here is read by
// nobody at all, so the rule is the opposite of the admin shapes': nothing is
// spread, and a field reaches a public entry because a line below put it there.
// The day somebody adds a column to `event_runs` — a claim token, an operator's
// note, a last error — a `{ ...run }` anywhere here would publish it, silently,
// in the release after the one anybody reviewed.
//
// Three things are absent from every shape below, and each is a decision:
//
// • **The spec.** Phases, steps, actions and their params are the plan for
// changing a live world. A visitor is told what is happening and when, and
// the LABEL of the phase while it is happening; the steps are the operator's.
// • **Health, cleanup, claims and errors.** A degraded run is a fact about the
// deployment's plumbing. "The event is running" is the fact about the event.
// • **`member_key`.** It is the game's own identifier for a character, it is
// module-opaque, and core cannot say what it discloses — so it stays unsent
// even on a results table where every other column is published.
//
// **What makes something public is `listed` AND `ready` AND not a rehearsal**,
// and all three live in SQL (`eventDefinitions.db.getPublicBySlug`, and
// `publicOnly` on `eventRuns.db.listInWindow`). Filtering in JavaScript after
// the read would work exactly as well, right up until the first caller that
// forgot to.
const definitionsDb = require('./eventDefinitions.db')
const runsDb = require('./eventRuns.db')
const seriesDb = require('./eventSeries.db')
const versionsDb = require('./eventVersions.db')
const participantsDb = require('./eventRunParticipants.db')
const calendarModel = require('./eventCalendar.model')
const recurrence = require('../../events/recurrence')
// The public calendar's window when a caller names neither end: now through a
// month out. A visitor arriving at /site/events wants "what is on", and a client
// that had to compute a window before it could ask anything would make every
// deep link carry two ISO instants.
const DEFAULT_WINDOW_DAYS = 31
// How many past occurrences an event page carries. It shows what is next and
// what happened recently; the whole history of a three-year-old weekly event is
// a different screen and nobody has asked for one.
const PAST_RUNS = 10
const RESULTS_LIMIT = 100
// The status words a visitor is told. `paused` maps to `live` deliberately: an
// operator holding a run for two minutes while they deal with something is not a
// state a public page should render, and a page that said "paused" would invite
// a question whose answer is internal.
const PUBLIC_STATUS = {
scheduled: 'scheduled',
starting: 'live',
running: 'live',
paused: 'live',
ending: 'live',
completed: 'completed',
cancelled: 'cancelled',
failed: 'cancelled',
missed: 'cancelled',
}
/**
* The public status word for a run.
*
* **`failed` and `missed` are published as `cancelled`**, which is the mapping
* worth defending. To a visitor the three are one event: it was on the calendar
* and it did not happen. The difference between them is entirely about the
* deployment — `failed` names broken machinery, `missed` names a process that
* was down when the schedule came round — so publishing either word would tell a
* stranger something true about the server and nothing about the event.
*/
const publicStatus = (status) => PUBLIC_STATUS[status] || 'scheduled'
/** Is this a run a visitor should be shown as happening now? */
const isLive = (status) => publicStatus(status) === 'live'
/**
* The label of the phase a run is in, resolved from the PINNED version's spec.
*
* A phase id is a slug an author typed and the label is what they meant it to
* read as, so a page rendering the id would show `phase-2` to the public. A
* phase the spec does not name answers null and the page shows nothing, which is
* the right answer for a version edited since: the run pinned the old spec and
* the old spec is what it is executing.
*/
function phaseLabel(spec, phaseId) {
if (!phaseId || !spec || !Array.isArray(spec.phases)) return null
const phase = spec.phases.find((p) => p && p.id === phaseId)
return (phase && (phase.label || phase.id)) || null
}
/** One calendar entry, from a materialised run. */
const publicRunEntry = (run) => ({
kind: 'run',
title: run.definition_title,
slug: run.definition_slug,
seriesName: run.series_name || null,
seriesSlug: run.series_slug || null,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
status: publicStatus(run.status),
live: isLive(run.status),
})
/**
* One calendar entry, from a projection.
*
* A projection is arithmetic past the materialisation horizon (§I), and the
* public entry keeps the distinction for the visitor's version of the operator's
* reason: a forecast three weeks out is a plan rather than a booking, and a page
* drawing the two identically would promise something nothing has committed to.
* `adjusted` rides along because a DST-shifted occurrence is worth explaining
* before it happens rather than after.
*/
const publicProjectedEntry = (definition, occurrence) => ({
kind: 'projected',
title: definition.title,
slug: definition.slug,
seriesName: definition.series_name || null,
seriesSlug: definition.series_slug || null,
scheduledFor: occurrence.at,
timezone: definition.timezone,
status: 'scheduled',
live: false,
adjusted: occurrence.adjusted,
shiftMinutes: occurrence.shiftMinutes,
})
/**
* The public calendar for a window.
*
* **The run half is read here rather than borrowed from `eventCalendar.model`**,
* and the reason is the file header's: that model answers with `status`,
* `health`, `version` and `waitingSteps` on every entry, so reusing it would
* mean building the public answer by DELETING fields from an operator's, which
* is the direction that fails silently. The arithmetic IS shared —
* `occurrencesBetween` is the same function the runner calls, so a forecast
* still cannot disagree with what later appears — and so are the window bound
* and the entry cap, which are a defence against an expensive query on the one
* surface that has no login in front of it.
*/
async function calendar({ from, to, seriesId = null, now = new Date() } = {}) {
const start = from ? new Date(from) : new Date(now)
const end = to ? new Date(to) : new Date(start.getTime() + DEFAULT_WINDOW_DAYS * recurrence.DAY_MS)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return { ok: false, status: 400, errors: ['from and to must be dates'] }
}
if (end <= start) {
return { ok: false, status: 400, errors: ['to must be after from'] }
}
if (end - start > calendarModel.MAX_WINDOW_DAYS * recurrence.DAY_MS) {
return {
ok: false,
status: 400,
errors: [`the window may span at most ${calendarModel.MAX_WINDOW_DAYS} days`],
}
}
const runs = await runsDb.listInWindow({ from: start, to: end, seriesId, publicOnly: true })
const entries = runs.map(publicRunEntry)
// Every instant a run already occupies, so the fortnight inside the horizon is
// not drawn twice — and so a CANCELLED occurrence is not re-forecast as though
// it were still coming. The same key the admin calendar uses, for the same
// reason: projections are per definition at the empty scope.
const taken = new Set(
runs
.filter((r) => !r.scope)
.map((r) => `${r.definition_id}@${new Date(r.scheduled_for).getTime()}`),
)
const definitions = await definitionsDb.findSchedulable({ listedOnly: true })
for (const definition of definitions) {
if (seriesId && Number(definition.series_id) !== Number(seriesId)) continue
const schedule = definition.version_spec?.schedule
if (!schedule || schedule.kind === 'manual') continue
let occurrences = []
try {
occurrences = recurrence.occurrencesBetween(schedule, definition.timezone || 'UTC', start, end)
} catch {
// A version whose schedule the recurrence engine will not read is one the
// runner will not expand either. The calendar then shows that definition's
// materialised runs and no forecast, rather than failing the whole page.
continue
}
for (const occurrence of occurrences) {
if (taken.has(`${definition.id}@${occurrence.at.getTime()}`)) continue
entries.push(publicProjectedEntry(definition, occurrence))
}
}
entries.sort((a, b) => new Date(a.scheduledFor) - new Date(b.scheduledFor))
return {
ok: true,
status: 200,
window: { from: start, to: end },
entries: entries.slice(0, calendarModel.MAX_ENTRIES),
truncated: entries.length > calendarModel.MAX_ENTRIES,
}
}
/** One participant, as a results table publishes them. */
const publicParticipant = (p) => ({
// NOT `memberKey` — see the file header. A display name is whatever the module
// chose to put in `meta`, because core has no name for a character and must
// not invent one from the key.
name: (p.meta && (p.meta.name || p.meta.displayName)) || null,
score: p.score,
rank: p.rank_at,
meta: p.meta || null,
})
/** One occurrence, as an event page lists it. */
const publicOccurrence = (run, spec) => ({
runId: run.id,
scheduledFor: run.scheduled_for,
timezone: run.timezone,
startedAt: run.started_at,
endedAt: run.ended_at,
status: publicStatus(run.status),
live: isLive(run.status),
scope: run.scope || null,
phase: isLive(run.status) ? phaseLabel(spec, run.current_phase) : null,
resultsPublishedAt: run.results_published_at || null,
})
/**
* One event's public page: the storyline, its arc, its occurrences, and a
* results table when there is one to show.
*
* **`runId` selects WHICH occurrence the results are about, and it is optional
* for a reason that exists only because of the announcements.** The page lives
* at the definition's slug, so a weekly event has one address and a visitor
* arriving at it should be shown what is next. But an `event.run.completed` mail
* is about ONE occurrence, and a link in it that opened next Friday's would
* answer a different question from the one the reader clicked. So the trigger's
* `eventUrl` carries `?run=`, and this is what resolves it.
*
* **A `runId` that does not belong to this definition is ignored rather than
* refused.** It names some other event's run, or none; the honest answer to
* "show me this event" is still this event, and a 404 for the whole page would
* turn a stale link in a months-old mail into a dead end rather than a page
* about the thing the mail was about.
*/
async function event(slug, { runId = null } = {}) {
const definition = await definitionsDb.getPublicBySlug(String(slug || ''))
if (!definition) return { ok: false, status: 404, errors: ['Not found'] }
const series = definition.series_id ? await seriesDb.getById(definition.series_id) : null
const runs = await runsDb.listPublicForDefinition(definition.id, PAST_RUNS + 20)
const now = Date.now()
const live = runs.filter((r) => isLive(r.status))
// **Split on the instant, not on the status**, and the difference is visible
// in both directions. A `missed` run is in the past whatever its status says,
// and so is a `scheduled` one whose moment went by while the runner had not
// reached it — but a run an operator CANCELLED next Friday is still next
// Friday, and filing it under "previously" tells a visitor it already
// happened, which is the one thing that is certainly untrue about it. That a
// cancelled occurrence still appears under what is coming is the point:
// "next Friday is off" is exactly what somebody checking the calendar came to
// find out.
const upcoming = runs
.filter((r) => !live.includes(r) && new Date(r.scheduled_for).getTime() >= now)
.sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for))
const past = runs.filter((r) => !live.includes(r) && !upcoming.includes(r)).slice(0, PAST_RUNS)
// `next` is narrower than `upcoming[0]`, deliberately: the headline answers
// "when is the next one", and a cancelled occurrence is not one. An event
// whose only future occurrence has been called off has no `next` and says so,
// while the cancellation itself is still listed below.
const next = upcoming.find((r) => r.status === 'scheduled') || null
// Which occurrence the results table is about. An explicit `run` wins; then a
// live one, because that is what the visitor is looking at; then the most
// recent that actually published results, because a page with a table on it is
// more use than one with an empty heading.
const named = runId ? runs.find((r) => String(r.id) === String(runId)) : null
const resultsRun = named || live[0] || past.find((r) => r.results_published_at) || null
let participants = []
if (resultsRun && resultsRun.results_published_at) {
participants = (await participantsDb.listForRun(resultsRun.id, RESULTS_LIMIT)).map(
publicParticipant,
)
}
// Phase labels come from the version the run is EXECUTING rather than from the
// definition's working draft, which an author may be halfway through editing.
// One extra read, and only when there is a run to label at all.
const specRun = named || live[0] || null
const spec = specRun ? (await versionsDb.getById(specRun.version_id))?.spec || null : null
return {
ok: true,
status: 200,
event: {
title: definition.title,
slug: definition.slug,
summary: definition.summary,
body: definition.body,
imageUrl: definition.image_url,
timezone: definition.timezone,
series: series ? { name: series.name, slug: series.slug } : null,
live: live.length > 0,
current: live[0] ? publicOccurrence(live[0], spec) : null,
next: next ? publicOccurrence(next, spec) : null,
upcoming: upcoming.map((r) => publicOccurrence(r, spec)),
past: past.map((r) => publicOccurrence(r, spec)),
results:
resultsRun && resultsRun.results_published_at
? {
runId: resultsRun.id,
scheduledFor: resultsRun.scheduled_for,
publishedAt: resultsRun.results_published_at,
participants,
}
: null,
},
}
}
/**
* One arc: the series, and the listed events in it in the order an editor
* dragged them into.
*
* **A series with no listed events is a 404 rather than an empty page.** The arc
* is a label on its definitions and nothing else, so a page for an empty one
* would publish the single fact that an operator has named something they have
* not announced.
*/
async function series(slug) {
const row = await seriesDb.getBySlug(String(slug || ''))
if (!row) return { ok: false, status: 404, errors: ['Not found'] }
const definitions = await definitionsDb.listPublicBySeries(row.id)
if (!definitions.length) return { ok: false, status: 404, errors: ['Not found'] }
return {
ok: true,
status: 200,
series: {
name: row.name,
slug: row.slug,
description: row.description,
events: definitions.map((d) => ({
title: d.title,
slug: d.slug,
summary: d.summary,
imageUrl: d.image_url,
})),
},
}
}
/**
* One account's participation history.
*
* Self-scoped by the caller's own id and nothing else. There is no route on
* which one account reads another's, and deliberately no id parameter that could
* later grow into one.
*/
async function history(userId, { limit = 50, before = null } = {}) {
const rows = await participantsDb.listForUser(userId, { limit, before })
return {
ok: true,
status: 200,
entries: rows.map((r) => ({
id: r.id,
runId: r.run_id,
title: r.definition_title,
slug: r.definition_slug,
seriesName: r.series_name || null,
seriesSlug: r.series_slug || null,
scheduledFor: r.scheduled_for,
startedAt: r.started_at,
endedAt: r.ended_at,
timezone: r.timezone,
status: publicStatus(r.status),
joinedAt: r.joined_at,
score: r.score,
// Null until `core.results.publish` ran. The screen says so rather than
// inventing a position nobody computed.
rank: r.rank_at,
resultsPublishedAt: r.results_published_at || null,
meta: r.meta || null,
})),
}
}
module.exports = {
calendar,
event,
series,
history,
publicStatus,
phaseLabel,
DEFAULT_WINDOW_DAYS,
PAST_RUNS,
}

View File

@@ -75,6 +75,52 @@ async function listForRun(runId, limit = 500) {
return rows.map(hydrate)
}
/**
* One account's participation history, most recent event first (Phase 14a).
*
* **Joined all the way out to the definition, and the join is the access
* control.** A rehearsal is excluded by §D's own rule, and an unlisted
* definition is excluded because unlisting is what an operator does to an event
* they are not announcing — a history that named it would announce it to
* everyone who attended, which is everyone who could tell anybody.
*
* `member_key` is NOT selected. It is the game's identifier for a character and
* the caller is a player reading their own page; the run, the date, the score
* and the rank are what a history is, and the key adds a module-opaque string
* nothing on the page can render.
*
* `rank_at` is null until results are published, and that is a real state the
* screen shows rather than an error — a run whose participants are collected
* and unranked is exactly what Phase 10 made visible on the admin side.
*/
async function listForUser(userId, { limit = 50, before = null } = {}) {
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
const args = [userId]
// A keyset cursor on the participation row rather than an offset: the list
// gains a row every time the reader attends something, and an offset page two
// would skip whatever arrived in between.
const cursor = before ? ' AND p.id < ?' : ''
if (before) args.push(before)
const rows = await query(
`SELECT p.id, p.run_id, p.score, p.rank_at, p.joined_at, p.meta,
r.scheduled_for, r.started_at, r.ended_at, r.status, r.scope,
r.timezone, r.results_published_at,
d.title AS definition_title, d.slug AS definition_slug,
s.name AS series_name, s.slug AS series_slug
FROM event_run_participants p
JOIN event_runs r ON r.id = p.run_id
JOIN event_definitions d ON d.id = r.definition_id
LEFT JOIN event_series s ON s.id = d.series_id
WHERE p.user_id = ?${cursor}
AND r.rehearsal = 0
AND d.listed = 1
ORDER BY p.id DESC
LIMIT ${n}`,
args,
)
return rows.map(hydrate)
}
/** How many the run has. Its own query because the trigger payload needs only this. */
async function countForRun(runId) {
const rows = await query('SELECT COUNT(*) AS n FROM event_run_participants WHERE run_id = ?', [runId])
@@ -116,4 +162,4 @@ async function rankRun(runId) {
return Number(result.affectedRows || 0)
}
module.exports = { record, listForRun, countForRun, rankRun }
module.exports = { record, listForRun, listForUser, countForRun, rankRun }

View File

@@ -116,13 +116,30 @@ const materialise = async (run) => {
* a run records the zone it was COMPUTED in and a definition's zone can be
* edited afterwards.
*/
const listInWindow = async ({ from, to, status = null, scope = null, seriesId = null, limit = 500 } = {}) => {
const listInWindow = async ({
from,
to,
status = null,
scope = null,
seriesId = null,
limit = 500,
publicOnly = false,
} = {}) => {
const where = ['r.scheduled_for >= ?', 'r.scheduled_for < ?']
const args = [from, to]
if (status) {
where.push('r.status = ?')
args.push(status)
}
// The public calendar's two exclusions, in SQL rather than in the model that
// maps the rows. A rehearsal "is excluded from the public calendar and from
// participation history" by §D's own column comment, and an unlisted
// definition is one an operator chose not to announce. Both belong in the
// query because a filter applied after the read is a filter somebody can
// forget in the next caller.
if (publicOnly) {
where.push('r.rehearsal = 0', 'd.listed = 1', "d.state <> 'archived'")
}
if (scope !== null && scope !== undefined) {
where.push('r.scope = ?')
args.push(scope)
@@ -497,6 +514,33 @@ const reclaimStale = async (now) => {
return Number(result?.affectedRows || 0)
}
/**
* One definition's public occurrences, newest first (Phase 14a).
*
* Rehearsals are excluded here rather than by the caller, for `listInWindow`'s
* reason. The definition's own `listed`/`state` are NOT re-checked: the only
* caller has already resolved the definition through `getPublicBySlug`, and a
* second copy of that rule is a second thing to keep in step with the first.
*
* `scheduled` runs come back too — an upcoming occurrence is exactly what a
* visitor came to the page for — and the caller splits past from future on the
* instant rather than on the status, because a `missed` run is in the past
* whatever its status says.
*/
const listPublicForDefinition = async (definitionId, limit = 50) => {
const n = Math.min(Math.max(Number(limit) || 50, 1), 200)
const rows = await query(
`SELECT r.*, v.version AS version_number
FROM event_runs r
JOIN event_versions v ON v.id = r.version_id
WHERE r.definition_id = ? AND r.rehearsal = 0
ORDER BY r.scheduled_for DESC, r.id DESC
LIMIT ${n}`,
[definitionId],
)
return rows.map(hydrate)
}
/** Terminal runs that ended before `before` — what the log retention sweep walks. */
const terminalBefore = async (before, limit = 500) => {
const n = Math.min(Math.max(Number(limit) || 500, 1), 5000)
@@ -516,6 +560,7 @@ module.exports = {
getById,
materialise,
listInWindow,
listPublicForDefinition,
repinScheduled,
listScheduledFor,
findOccurrence,

View File

@@ -29,6 +29,11 @@ const getById = async (id) => {
return row || null
}
const getBySlug = async (slug) => {
const [row] = await query(`${SELECT_LIST} WHERE s.slug = ?`, [slug])
return row || null
}
const exists = async (id) => {
const [row] = await query('SELECT id FROM event_series WHERE id = ?', [id])
return Boolean(row)
@@ -70,4 +75,4 @@ const update = (id, s) =>
*/
const remove = (id) => query('DELETE FROM event_series WHERE id = ?', [id])
module.exports = { list, getById, exists, slugTaken, insert, update, remove }
module.exports = { list, getById, getBySlug, exists, slugTaken, insert, update, remove }

View File

@@ -69,6 +69,10 @@ const shapeDefinition = (d) => ({
concurrencyKey: d.concurrency_key,
graceSeconds: d.grace_seconds,
timezone: d.timezone,
// Whether the public calendar announces it (Phase 14a). Not whether it may
// run — an unlisted event schedules and runs exactly as a listed one does,
// and is on THIS screen either way.
listed: Boolean(d.listed),
spec: d.spec,
createdAt: d.created_at,
updatedAt: d.updated_at,

View File

@@ -0,0 +1,29 @@
// Player · Events — the one handler behind /player/events/history (Phase 14a).
//
// Self-scoped on `req.user.id` and on nothing the caller sent. The model does
// the same joins the public surface does — rehearsals and unlisted events are
// absent — so a participant cannot learn from their own history that an
// unannounced event exists.
const events = require('../../../model/events/eventPublic.model')
const log = require('../../../utils/logger')('player:events')
async function getHistory(req, res) {
try {
// A non-integer cursor is dropped rather than bound. `Number('abc')` is NaN,
// and NaN reaching a placeholder is a driver-level failure — a 500 for what
// is a malformed query string, and the honest answer to one is the first
// page.
const cursor = Number(req.query.before)
const result = await events.history(req.user.id, {
limit: req.query.limit ? Number(req.query.limit) : undefined,
before: Number.isInteger(cursor) && cursor > 0 ? cursor : null,
})
return res.json(result)
} catch (err) {
log.error('participation history failed', { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { getHistory }

View File

@@ -0,0 +1,39 @@
// Player · Events — this account's participation history (EVENTS.md § API
// surface, Phase 14a). Mounted at /api/v1/player/events by player/index.js.
//
// The group gate is `requireAuth` and it is the whole gate: this is role-agnostic
// self-service, like the rest of /player. Staff are a superset of players (see
// player/index.js), and an admin reading their own attendance is exactly as
// ordinary as a player doing it.
//
// **No backtick in a `#swagger.parameters` annotation.** Unlike `#swagger.summary`
// and `#swagger.description`, which are plain strings, a parameters annotation is
// parsed as an object literal — a backtick inside its quoted `description` is
// rewritten as a quote, and swagger-autogen then DROPS the whole annotation with a
// syntax error rather than failing the build.
//
// **There is no id parameter, deliberately.** The history is `req.user.id`'s and
// nothing else's; a route that took a user id would be one middleware mistake
// away from publishing who attended what, which is a question about people
// rather than about events.
const express = require('express')
const ctrl = require('./events.controller')
const eventsRouter = express.Router()
eventsRouter.get(
'/history',
// #swagger.tags = ['Player · Events']
// #swagger.summary = 'This account’s event participation'
// #swagger.description = 'The events this account took part in, most recent first — the run, when it was, the score a module reported, and the rank once results were published. `rank` is null until then, which is a real state rather than an error. Rehearsals and unlisted events are absent, the same rule the public calendar follows. Keyset paging: pass the last entry’s `id` as `before`.'
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, max 200 (default 50).' }
// #swagger.parameters['before'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Cursor: the id of the last entry on the previous page.' }
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Participation history', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerEventHistory" } } } } */
/* #swagger.responses[401] = { description: 'Not signed in', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
ctrl.getHistory,
)
module.exports = eventsRouter

View File

@@ -27,6 +27,7 @@ const noindex = require('../../../middleware/noindex')
const appealsRouter = require('./appeals.router')
const teamsRouter = require('./teams.router')
const teamForumRouter = require('./teamForum.router')
const eventsRouter = require('./events.router')
const playerRouter = express.Router()
@@ -39,6 +40,9 @@ const playerRouter = express.Router()
playerRouter.use(noindex, requireAuth)
playerRouter.use('/appeals', appealsRouter)
// This account's own event participation (Phase 14a). Self-scoped on
// req.user.id, like everything else in this group.
playerRouter.use('/events', eventsRouter)
playerRouter.use('/teams', teamsRouter)
// Same prefix, second router. The forum and the leader-exercised grant flow are a
// different capability from "the caller's own Teams", and splitting them keeps

View File

@@ -0,0 +1,68 @@
// Public · Events — the anonymous event surface (EVENTS.md § API surface).
//
// Phase 14a. Three reads and no writes: the calendar, one event, one arc.
//
// **Every one of them is a thin pass-through to `eventPublic.model`, and that is
// deliberate.** The projection — which fields exist at all on a public entry — is
// the security boundary, and it belongs in one file rather than in three
// controllers that would each have to remember it. What is left here is the
// HTTP: parse the query, map the model's `status` onto a response code, and turn
// a thrown read into a 500 rather than a stack trace.
//
// **A 404 here means "no such public event"** and cannot be told from "no such
// slug at all". A draft, an archived definition and an unlisted one answer
// identically, which is the whole point: an operator who has not announced
// something has not announced its existence either.
const events = require('../../../model/events/eventPublic.model')
const log = require('../../../utils/logger')('public:events')
const fail = (res, err, what) => {
log.error(`${what} failed`, { message: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
const answer = (res, result) =>
result.ok
? res.json(result)
: res.status(result.status || 400).json({ message: result.errors?.[0] || 'Bad Request', errors: result.errors })
async function getCalendar(req, res) {
try {
const seriesId = req.query.seriesId ? Number(req.query.seriesId) : null
if (req.query.seriesId && !Number.isInteger(seriesId)) {
return res.status(400).json({ message: 'seriesId must be an integer' })
}
const result = await events.calendar({
from: req.query.from || null,
to: req.query.to || null,
seriesId,
})
return answer(res, result)
} catch (err) {
return fail(res, err, 'public calendar')
}
}
async function getEvent(req, res) {
try {
// `run` is optional and un-validated beyond being carried through as a
// string: the model matches it against this definition's own runs and
// ignores anything else, so a garbage value renders the page rather than an
// error. See the model's note on why it is not refused.
const result = await events.event(req.params.slug, { runId: req.query.run || null })
return answer(res, result)
} catch (err) {
return fail(res, err, 'public event')
}
}
async function getSeries(req, res) {
try {
return answer(res, await events.series(req.params.slug))
} catch (err) {
return fail(res, err, 'public series')
}
}
module.exports = { getCalendar, getEvent, getSeries }

View File

@@ -0,0 +1,60 @@
// Public · Events — mounted at /api/v1/public/events by public/index.js.
//
// No group gate: this is the anonymous surface, and `siteMode` is applied per
// route as it is everywhere else in this tier — during maintenance only an admin
// with a valid session sees content.
//
// Declaration order: '/' is literal and precedes ':slug', and 'series/:slug' is
// declared BEFORE ':slug' although it could not be shadowed by it (two segments
// against one). It stays above so the relationship is visible to whoever adds
// the next route here — and because the one route bug this feature has already
// shipped was exactly a static/dynamic ranking surprise, one tier up in React
// Router (see App.jsx's note above `events/:id`).
const express = require('express')
const ctrl = require('./events.controller')
const siteMode = require('../../../middleware/siteMode')
const eventsRouter = express.Router()
eventsRouter.get(
'/',
// #swagger.tags = ['Public · Events']
// #swagger.summary = 'The public event calendar'
// #swagger.description = 'Upcoming, live and recent events in a window, ascending by instant. An entry is one of two things and says which: a `run` is a materialised occurrence, and a `projected` entry is arithmetic past the materialisation horizon — a forecast, with nothing committed to it, which a client should draw as such. Instants are UTC and each entry carries the EVENT\'s own timezone, because a shard-local 8pm means the shard\'s evening to everyone reading it; the reader\'s own zone places the entry in a month grid. Rehearsals and unlisted events are absent. Defaults to now through 31 days out; the window may span at most 92 days.'
// #swagger.parameters['from'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window start (ISO). Defaults to now.' }
// #swagger.parameters['to'] = { in: 'query', required: false, schema: { type: 'string', format: 'date-time' }, description: 'Window end (ISO). Defaults to 31 days after the start.' }
// #swagger.parameters['seriesId'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Restrict to one arc.' }
/* #swagger.responses[200] = { description: 'The calendar', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventCalendar" } } } } */
/* #swagger.responses[400] = { description: 'Bad window', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getCalendar,
)
eventsRouter.get(
'/series/:slug',
// #swagger.tags = ['Public · Events']
// #swagger.summary = 'One arc'
// #swagger.description = 'A series and the listed events in it, in the order an editor arranged them. A series with no listed events answers 404 rather than an empty page: the arc is a label on its definitions, so a page for an empty one would publish the fact that an operator has named something they have not announced.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The series slug.' }
/* #swagger.responses[200] = { description: 'The arc', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEventSeries" } } } } */
/* #swagger.responses[404] = { description: 'No such arc, or nothing in it is listed', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getSeries,
)
eventsRouter.get(
'/:slug',
// #swagger.tags = ['Public · Events']
// #swagger.summary = 'One event'
// #swagger.description = 'The storyline, the arc it belongs to, what is live, what is next, what happened recently, and a results table once one has been published. A draft, an archived definition and an unlisted one all answer 404, indistinguishable from a slug that never existed. The plan behind the event — phases, steps, actions and their params — is never published; a live run carries the LABEL of the phase it is in and nothing more.'
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'The event slug.' }
// #swagger.parameters['run'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Which occurrence the results are about — what an announcement\'s link carries, so a mail about last Friday does not open next Friday\'s. A run that does not belong to this event is ignored rather than refused.' }
/* #swagger.responses[200] = { description: 'The event', content: { "application/json": { schema: { $ref: "#/components/schemas/PublicEvent" } } } } */
/* #swagger.responses[404] = { description: 'No such public event', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
siteMode,
ctrl.getEvent,
)
module.exports = eventsRouter

View File

@@ -22,6 +22,7 @@ const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const modulesRouter = require('./modules.router')
const teamsRouter = require('./teams.router')
const eventsRouter = require('./events.router')
const engagementRouter = require('./engagement.router')
const siteRouter = require('./site.router')
@@ -42,6 +43,10 @@ publicRouter.use('/modules', modulesRouter)
// is what populates it (TEAMS.md §10.3). Site-mode gated per route, like the
// content above it.
publicRouter.use('/teams', teamsRouter)
// Events. A core prefix like /teams: the calendar, the event page and the arc
// are core's surface even when every step an event dispatches belongs to a
// module. Site-mode gated per route, like the content above it.
publicRouter.use('/events', eventsRouter)
// The unauthenticated half of the engagement system: today exactly the
// unsubscribe pair. Its own prefix rather than a Teams sub-path, because what a
// token names is a channel and a scope and a scope is not always a Team

File diff suppressed because it is too large Load Diff

View File

@@ -991,6 +991,13 @@ const doc = {
service: { type: 'string', example: 'runic-gateway', description: 'Stable backend identifier for first-run recognition.' },
api: { type: 'string', example: 'v1', description: 'API contract version (matches the /api/v1 mount).' },
server: { type: 'string', example: '1.0.0', description: 'Server package version (informational).' },
capabilities: {
type: 'array',
items: { type: 'string' },
example: ['events'],
description:
'Opaque strings naming what CORE serves beyond the baseline every backend has — the same idea as a module’s `capabilities` on /public/modules, and a separate list because core is not a module. A backend released before a capability existed omits the key entirely, which is how a client tells an older site from one that simply has nothing to show. Treat an unknown string as absent.',
},
},
},
PublicModules: {
@@ -1234,6 +1241,190 @@ const doc = {
},
},
},
PublicEventEntry: {
type: 'object',
description:
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
properties: {
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
title: { type: 'string', example: 'The Yew Invasion' },
slug: { type: 'string', example: 'the-yew-invasion' },
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
seriesSlug: { type: 'string', nullable: true, example: 'the-yew-campaign' },
scheduledFor: { type: 'string', format: 'date-time', description: 'The instant, UTC.' },
timezone: {
type: 'string',
example: 'America/New_York',
description:
'The EVENT’s zone, not the reader’s. A shard-local 8pm means the shard’s evening to everyone reading it, so the time is rendered in this zone while the reader’s own zone places the entry in a month grid.',
},
status: {
type: 'string',
enum: ['scheduled', 'live', 'completed', 'cancelled'],
description:
'The public status word. `failed` and `missed` are both published as `cancelled`: to a visitor they are one event — it was on the calendar and it did not happen — while the difference between them is about the deployment rather than about the event.',
},
live: { type: 'boolean', example: false },
adjusted: {
type: 'boolean',
description: 'Projections only: this instant is not the wall clock the schedule names, because a DST change moved it.',
},
shiftMinutes: { type: 'integer', description: 'Projections only: by how much.' },
},
},
PublicEventCalendar: {
type: 'object',
properties: {
ok: { type: 'boolean', example: true },
window: {
type: 'object',
properties: {
from: { type: 'string', format: 'date-time' },
to: { type: 'string', format: 'date-time' },
},
},
entries: { type: 'array', items: { $ref: '#/components/schemas/PublicEventEntry' } },
truncated: { type: 'boolean', description: 'The window held more entries than the cap.', example: false },
},
},
PublicEventOccurrence: {
type: 'object',
description:
'One occurrence of an event, as a public page lists it. Health, cleanup state, claims and errors are never published — a degraded run is a fact about the deployment’s plumbing, while "the event is running" is the fact about the event.',
properties: {
runId: { type: 'integer', example: 3692 },
scheduledFor: { type: 'string', format: 'date-time' },
timezone: { type: 'string', example: 'America/New_York' },
startedAt: { type: 'string', format: 'date-time', nullable: true },
endedAt: { type: 'string', format: 'date-time', nullable: true },
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
live: { type: 'boolean' },
scope: {
type: 'string',
nullable: true,
description: 'Module-opaque — the shard or server this occurrence ran on, on a deployment that uses them.',
},
phase: {
type: 'string',
nullable: true,
example: 'The assault',
description:
'The LABEL of the phase a live run is in, resolved from the version the run pinned. Null unless it is live. The plan behind the event — phases, steps, actions and their params — is never published.',
},
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
PublicEventParticipant: {
type: 'object',
description:
'A results row. The member key is the game’s own identifier for a character and is module-opaque, so core cannot say what publishing one would disclose — it is not published. A display name is whatever the module chose to put in `meta`.',
properties: {
name: { type: 'string', nullable: true, example: 'Aldric' },
score: { type: 'number', example: 1420 },
rank: { type: 'integer', nullable: true, example: 3, description: 'Null until results were published.' },
meta: { type: 'object', nullable: true, description: 'Module-opaque: whatever it wanted shown beside a name.' },
},
},
PublicEvent: {
type: 'object',
properties: {
ok: { type: 'boolean', example: true },
event: {
type: 'object',
properties: {
title: { type: 'string', example: 'The Yew Invasion' },
slug: { type: 'string', example: 'the-yew-invasion' },
summary: { type: 'string', nullable: true },
body: { type: 'string', nullable: true, description: 'The storyline. Sanitized HTML, the treatment a wiki page gets.' },
imageUrl: { type: 'string', nullable: true },
timezone: { type: 'string', example: 'America/New_York' },
series: {
type: 'object',
nullable: true,
properties: { name: { type: 'string' }, slug: { type: 'string' } },
},
live: { type: 'boolean' },
current: { $ref: '#/components/schemas/PublicEventOccurrence' },
next: { $ref: '#/components/schemas/PublicEventOccurrence' },
upcoming: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
past: { type: 'array', items: { $ref: '#/components/schemas/PublicEventOccurrence' } },
results: {
type: 'object',
nullable: true,
description:
'Present only once an occurrence has published results. Which occurrence follows `?run=`, then a live one, then the most recent that published any.',
properties: {
runId: { type: 'integer' },
scheduledFor: { type: 'string', format: 'date-time' },
publishedAt: { type: 'string', format: 'date-time' },
participants: { type: 'array', items: { $ref: '#/components/schemas/PublicEventParticipant' } },
},
},
},
},
},
},
PublicEventSeries: {
type: 'object',
properties: {
ok: { type: 'boolean', example: true },
series: {
type: 'object',
properties: {
name: { type: 'string', example: 'The Yew Campaign' },
slug: { type: 'string', example: 'the-yew-campaign' },
description: { type: 'string', nullable: true },
events: {
type: 'array',
description: 'The listed events in the arc, in the order an editor arranged them.',
items: {
type: 'object',
properties: {
title: { type: 'string' },
slug: { type: 'string' },
summary: { type: 'string', nullable: true },
imageUrl: { type: 'string', nullable: true },
},
},
},
},
},
},
},
PlayerEventHistory: {
type: 'object',
properties: {
ok: { type: 'boolean', example: true },
entries: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer', description: 'The participation row. Pass the last one as `before` to page.' },
runId: { type: 'integer' },
title: { type: 'string' },
slug: { type: 'string' },
seriesName: { type: 'string', nullable: true },
seriesSlug: { type: 'string', nullable: true },
scheduledFor: { type: 'string', format: 'date-time' },
startedAt: { type: 'string', format: 'date-time', nullable: true },
endedAt: { type: 'string', format: 'date-time', nullable: true },
timezone: { type: 'string' },
status: { type: 'string', enum: ['scheduled', 'live', 'completed', 'cancelled'] },
joinedAt: { type: 'string', format: 'date-time' },
score: { type: 'number' },
rank: {
type: 'integer',
nullable: true,
description: 'Null until results were published — a real state rather than an error.',
},
resultsPublishedAt: { type: 'string', format: 'date-time', nullable: true },
meta: { type: 'object', nullable: true },
},
},
},
},
},
PublicTeamMember: {
type: 'object',
description:

View File

@@ -283,21 +283,40 @@ test('six are ceilinged authenticated; run.failed is admin on both halves', () =
}
})
test('no public event trigger declares a url — there is no page for one to point at yet', () => {
// `news.post` shipped an example naming `/news/<slug>`, a path that does not
// exist, and the template editor previewed a link that was dead in every mail
// it sent. Phase 14 adds the variable alongside the page.
test('every public event trigger points at the page Phase 14a built, and run.failed at the console', () => {
// The inverse of what this asserted from Phase 10 until Phase 14a, and the
// inversion is the point: `news.post` shipped an example naming `/news/<slug>`,
// a path that did not exist, so the template editor previewed a link that was
// dead in every mail it sent. The variable was withheld until there was a page,
// and it arrived with it.
for (const t of eventTriggers()) {
const urls = t.variables.filter((v) => v.type === 'url')
if (t.id === 'event.run.failed') {
// No `eventUrl` here, deliberately: an admin reading that the machinery
// broke wants the steps and the errors, not the storyline.
assert.deepEqual(urls.map((v) => v.name), ['runUrl'])
assert.match(urls[0].example, /^\/admin\/events\/runs\//)
} else {
assert.deepEqual(urls, [], `${t.id} must declare no url until Phase 14`)
assert.deepEqual(urls.map((v) => v.name), ['eventUrl'], `${t.id}`)
// The example has to carry `?run=`, because that is what makes a link in a
// mail about last Friday open last Friday rather than next Friday.
assert.match(urls[0].example, /^\/site\/events\/[^?]+\?run=/, `${t.id}`)
// Optional, so `email.button` drops itself rather than rendering an inert
// grey label when the event is not public and there is no page.
assert.equal(urls[0].required, false, `${t.id}`)
}
}
})
test('the six public triggers are at version 2 — the url variable is a declaration change', () => {
// A variable added to a declaration is a version bump, not a correction: a rule
// written against version 1 was written against a payload with no link in it.
// `run.failed` gained nothing and stays where it was.
for (const t of eventTriggers()) {
assert.equal(t.version, t.id === 'event.run.failed' ? 1 : 2, `${t.id}`)
}
})
test('every declared variable carries an example, which is what the editor previews with', () => {
for (const t of eventTriggers()) {
for (const v of t.variables) {

View File

@@ -0,0 +1,417 @@
// ── The public event surface (EVENTS_PLAN.md Phase 14a) ────────────────────
//
// The phase's shipped claim: **a visitor with no account sees the calendar, one
// event's page and an arc, and sees nothing an operator did not announce.**
//
// What is worth testing here is almost entirely the second half. The reads
// themselves are joins; the decisions are about what is absent:
//
// • a rehearsal, an unlisted definition and a draft are absent from every
// surface, and absent the same way — a 404 that cannot be told from a slug
// that never existed
// • the plan behind an event (phases, steps, actions, params) is never
// published; a live run carries the LABEL of its phase and nothing else
// • `failed` and `missed` are published as `cancelled`, because the difference
// between them is about the deployment rather than about the event
// • `member_key` never leaves the server, even on a results table
// • a participant's own history obeys the same two exclusions as the calendar,
// so attending an unannounced event does not disclose that it exists
//
// Stubbed at the `.db` layer, the shape `eventSchedule.test.js` uses.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const publicModel = require('../src/model/events/eventPublic.model')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const runsDb = require('../src/model/events/eventRuns.db')
const seriesDb = require('../src/model/events/eventSeries.db')
const versionsDb = require('../src/model/events/eventVersions.db')
const participantsDb = require('../src/model/events/eventRunParticipants.db')
const db = require('../src/utils/db')
after(() => db.close())
const NOW = new Date('2026-09-01T12:00:00Z')
const SPEC = {
schedule: { kind: 'manual' },
phases: [
{ id: 'muster', label: 'The muster', steps: [{ id: 's1', action: 'core.announce' }] },
{ id: 'assault', label: 'The assault', steps: [] },
],
}
const originals = {}
for (const [name, mod] of [
['definitionsDb', definitionsDb],
['runsDb', runsDb],
['seriesDb', seriesDb],
['versionsDb', versionsDb],
['participantsDb', participantsDb],
]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
}
let store
const definition = (over = {}) => ({
id: 1,
title: 'The Yew Invasion',
slug: 'the-yew-invasion',
summary: 'Orcish warbands are massing north of Yew.',
body: '<p>They came at dusk.</p>',
image_url: null,
state: 'ready',
listed: true,
timezone: 'America/New_York',
series_id: null,
series_name: null,
series_slug: null,
current_version_id: 100,
spec: SPEC,
...over,
})
const run = (over = {}) => ({
id: 3692,
definition_id: 1,
version_id: 100,
scope: '',
status: 'completed',
// Every one of these is a field the public shapes must NOT carry. They are on
// the fixture on purpose: a `{ ...run }` anywhere in the model would publish
// them, and the assertions below are what would catch it.
health: 'degraded',
cleanup_status: 'incomplete',
claimed_by: 'worker-3',
claim_expires_at: new Date(),
last_error: 'sidecar responded 503',
current_phase: 'assault',
scheduled_for: new Date('2026-08-29T00:00:00Z'),
started_at: new Date('2026-08-29T00:00:05Z'),
ended_at: new Date('2026-08-29T01:30:00Z'),
timezone: 'America/New_York',
rehearsal: false,
results_published_at: new Date('2026-08-29T02:00:00Z'),
...over,
})
function installStubs() {
definitionsDb.getPublicBySlug = async (slug) => {
const d = store.definitions.find((x) => x.slug === slug)
return d && d.state === 'ready' && d.listed ? d : undefined
}
definitionsDb.listPublicBySeries = async (seriesId) =>
store.definitions.filter((d) => d.series_id === seriesId && d.state === 'ready' && d.listed)
definitionsDb.findSchedulable = async ({ listedOnly = false } = {}) =>
store.definitions
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
.map((d) => ({ ...d, version_spec: d.spec }))
runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
store.runs.filter((r) => {
const at = new Date(r.scheduled_for)
if (at < from || at >= to) return false
if (!publicOnly) return true
const d = store.definitions.find((x) => x.id === r.definition_id)
return !r.rehearsal && d && d.listed && d.state !== 'archived'
})
runsDb.listPublicForDefinition = async (id) =>
store.runs
.filter((r) => r.definition_id === id && !r.rehearsal)
.sort((a, b) => new Date(b.scheduled_for) - new Date(a.scheduled_for))
seriesDb.getById = async (id) => store.series.find((s) => s.id === id) || null
seriesDb.getBySlug = async (slug) => store.series.find((s) => s.slug === slug) || null
versionsDb.getById = async (id) => (id === 100 ? { id, spec: SPEC } : null)
participantsDb.listForRun = async () => store.participants
participantsDb.listForUser = async () => store.history
}
beforeEach(() => {
const d = definition()
store = {
definitions: [d],
// The joined shape `listInWindow` answers with.
runs: [{ ...run(), definition_title: d.title, definition_slug: d.slug }],
series: [],
participants: [],
history: [],
}
installStubs()
})
afterEach(restoreOriginals)
// ── The calendar ───────────────────────────────────────────────────────────
test('a calendar entry carries no operational field at all', async () => {
const result = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
assert.equal(result.ok, true)
const [entry] = result.entries
assert.equal(entry.title, 'The Yew Invasion')
// The whole security property of this file, asserted positively: the entry has
// exactly these keys and gaining one is a deliberate act.
assert.deepEqual(Object.keys(entry).sort(), [
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
])
})
test('the calendar defaults to a month from now when no window is given', async () => {
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
assert.equal(new Date(result.window.from).getTime(), NOW.getTime())
const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000
assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS)
})
test('a window wider than the cap is refused rather than served slowly', async () => {
const result = await publicModel.calendar({ from: '2026-01-01', to: '2026-12-31', now: NOW })
assert.equal(result.ok, false)
assert.equal(result.status, 400)
})
test('a projection is not emitted for an instant a run already occupies', async () => {
// The definition recurs weekly on the Saturday its one run already sits on.
store.definitions[0].spec = {
...SPEC,
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
}
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-08-31', now: NOW })
const at = result.entries.filter(
(e) => new Date(e.scheduledFor).getTime() === new Date('2026-08-29T00:00:00Z').getTime(),
)
assert.equal(at.length, 1)
assert.equal(at[0].kind, 'run')
})
// ── What the public never sees ─────────────────────────────────────────────
test('an unlisted event is absent from the calendar and 404s on its own page', async () => {
store.definitions[0].listed = false
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
assert.deepEqual(cal.entries, [])
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.ok, false)
assert.equal(page.status, 404)
})
test('a draft answers exactly as an unlisted one does — indistinguishable from no such slug', async () => {
store.definitions[0].state = 'draft'
const draft = await publicModel.event('the-yew-invasion')
const missing = await publicModel.event('no-such-event')
assert.deepEqual(draft, missing)
})
test('a rehearsal is absent from the calendar and from an event page', async () => {
store.runs[0].rehearsal = true
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
assert.deepEqual(cal.entries, [])
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.ok, true)
assert.deepEqual(page.event.past, [])
})
test('an occurrence publishes no health, no cleanup state, no claim and no error', async () => {
store.runs[0].status = 'running'
const page = await publicModel.event('the-yew-invasion')
const occurrence = page.event.current
for (const leaked of ['health', 'cleanupStatus', 'claimedBy', 'claimExpiresAt', 'lastError', 'versionId']) {
assert.equal(occurrence[leaked], undefined, `${leaked} must not be published`)
}
assert.equal(JSON.stringify(page).includes('sidecar responded 503'), false)
})
test('a live run carries its phase LABEL, and never the spec behind it', async () => {
store.runs[0].status = 'running'
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.current.phase, 'The assault')
// The step ids in the fixture spec are the tell: if the spec were published
// anywhere in this answer, this would find it.
assert.equal(JSON.stringify(page).includes('core.announce'), false)
})
test('a phase the pinned version does not name renders nothing rather than an id', async () => {
store.runs[0].status = 'running'
store.runs[0].current_phase = 'a-phase-since-renamed'
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.current.phase, null)
})
test('failed and missed are both published as cancelled', async () => {
for (const status of ['failed', 'missed']) {
store.runs[0].status = status
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.past[0].status, 'cancelled', status)
}
})
test('paused is published as live — an operator holding a run is not a public state', async () => {
store.runs[0].status = 'paused'
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.current.status, 'live')
assert.equal(page.event.live, true)
})
// ── Which side of now an occurrence falls on ───────────────────────────────
//
// Both of these were found by the browser walk, and both are the same mistake:
// the split reading a STATUS where it should read a clock. Dates here are
// relative to the real clock, because `event()` asks `Date.now()` — a run
// "next Friday" has to still be next Friday when this runs.
const inDays = (n) => new Date(Date.now() + n * 86_400_000)
test('a cancelled occurrence in the FUTURE is what is coming, not what happened', async () => {
// It was announced and it has been called off, and "next Friday is off" is
// exactly what somebody checking the calendar came to find out. Filing it
// under "previously" tells them it already happened, which is the one thing
// certainly untrue about it.
store.runs = [
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
{ ...run({ id: 2, status: 'scheduled', scheduled_for: inDays(11), ended_at: null, results_published_at: null }) },
]
const page = await publicModel.event('the-yew-invasion')
assert.deepEqual(page.event.upcoming.map((o) => o.runId), [1, 2])
assert.deepEqual(page.event.past, [])
})
test('`next` skips a cancelled occurrence even though it is listed as coming', async () => {
// The headline answers "when is the next one", and a cancelled occurrence is
// not one. An event whose only future occurrence was called off has no `next`
// and says so, while the cancellation is still listed below.
store.runs = [
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
]
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.next, null)
assert.equal(page.event.upcoming.length, 1)
})
test('a scheduled occurrence whose moment has gone by is in the past', async () => {
// The other direction of the same rule: the runner had not reached it, so its
// status still says `scheduled` while the evening it named is over.
store.runs = [
{ ...run({ id: 1, status: 'scheduled', scheduled_for: inDays(-3), ended_at: null, results_published_at: null }) },
]
const page = await publicModel.event('the-yew-invasion')
assert.deepEqual(page.event.upcoming, [])
assert.deepEqual(page.event.past.map((o) => o.runId), [1])
})
// ── Results ────────────────────────────────────────────────────────────────
test('a results row publishes the score and the rank and never the member key', async () => {
store.participants = [
{ id: 1, member_key: 'serial:0x40001234', user_id: 7, score: 1420, rank_at: 1, meta: { name: 'Aldric' } },
]
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.results.participants[0].name, 'Aldric')
assert.equal(page.event.results.participants[0].rank, 1)
assert.equal(JSON.stringify(page).includes('0x40001234'), false)
assert.equal(JSON.stringify(page).includes('user_id'), false)
})
test('an unpublished results table is absent rather than empty', async () => {
store.runs[0].results_published_at = null
store.participants = [{ id: 1, member_key: 'k', score: 10, rank_at: null, meta: null }]
const page = await publicModel.event('the-yew-invasion')
assert.equal(page.event.results, null)
})
test('?run= selects which occurrence the results are about', async () => {
const older = {
...run({ id: 3600, scheduled_for: new Date('2026-08-22T00:00:00Z') }),
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
}
store.runs.push(older)
const page = await publicModel.event('the-yew-invasion', { runId: '3600' })
assert.equal(page.event.results.runId, 3600)
})
test('a run id belonging to no occurrence of this event renders the page anyway', async () => {
// A stale link in a months-old mail should land on the event it was about, not
// on a dead end.
const page = await publicModel.event('the-yew-invasion', { runId: '999999' })
assert.equal(page.ok, true)
assert.equal(page.event.slug, 'the-yew-invasion')
})
// ── The arc ────────────────────────────────────────────────────────────────
test('a series with nothing listed in it is a 404, not an empty page', async () => {
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: null }]
store.definitions[0].series_id = 5
store.definitions[0].listed = false
const arc = await publicModel.series('the-yew-campaign')
assert.equal(arc.ok, false)
assert.equal(arc.status, 404)
})
test('an arc lists its listed events and nothing about their plans', async () => {
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: 'An arc.' }]
store.definitions[0].series_id = 5
const arc = await publicModel.series('the-yew-campaign')
assert.equal(arc.ok, true)
assert.deepEqual(Object.keys(arc.series.events[0]).sort(), ['imageUrl', 'slug', 'summary', 'title'])
})
// ── Participation history ──────────────────────────────────────────────────
test('history publishes the rank as null until results were published', async () => {
store.history = [
{
id: 9,
run_id: 3692,
score: 1420,
rank_at: null,
joined_at: new Date(),
meta: null,
scheduled_for: new Date('2026-08-29T00:00:00Z'),
started_at: null,
ended_at: null,
status: 'completed',
scope: '',
timezone: 'UTC',
results_published_at: null,
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
series_name: null,
series_slug: null,
},
]
const result = await publicModel.history(7)
assert.equal(result.entries[0].rank, null)
assert.equal(result.entries[0].resultsPublishedAt, null)
assert.equal(result.entries[0].score, 1420)
})
test('history publishes no member key', async () => {
store.history = [
{
id: 9,
run_id: 3692,
member_key: 'serial:0x40001234',
score: 1,
rank_at: 1,
joined_at: new Date(),
meta: null,
scheduled_for: new Date(),
status: 'completed',
timezone: 'UTC',
definition_title: 't',
definition_slug: 's',
},
]
const result = await publicModel.history(7)
assert.equal(JSON.stringify(result).includes('0x40001234'), false)
})