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
41 changed files with 4030 additions and 45 deletions

View File

@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
import Newsletter from './routes/public/Newsletter.jsx'
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
import About from './routes/public/About.jsx'
import Events from './routes/public/Events.jsx'
import EventPage from './routes/public/EventPage.jsx'
import EventSeries from './routes/public/EventSeries.jsx'
import Status from './routes/public/Status.jsx'
import Wiki from './routes/wiki/Wiki.jsx'
import WikiArticle from './routes/wiki/WikiArticle.jsx'
@@ -74,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
import PlayerInbox from './routes/player/PlayerInbox.jsx'
import Unsubscribe from './routes/player/Unsubscribe.jsx'
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
import PlayerEvents from './routes/player/PlayerEvents.jsx'
export default function App() {
return (
@@ -107,6 +111,17 @@ export default function App() {
<Route path="/site/newsletter" element={<Newsletter />} />
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
<Route path="/site/about" element={<About />} />
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
although it could not be shadowed by it — two segments against
one. It stays above because the ranking surprise this feature
has already shipped once was exactly here: a static segment
outranks a dynamic one whatever the source order, which is what
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
Nothing static shares a segment with `:slug`, so nothing here
repeats it. */}
<Route path="/site/events" element={<Events />} />
<Route path="/site/events/series/:slug" element={<EventSeries />} />
<Route path="/site/events/:slug" element={<EventPage />} />
<Route path="/site/status" element={<Status />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
@@ -247,6 +262,15 @@ export default function App() {
two paths; `lib/notificationPaths.js` is the one mapping. */}
<Route path="notifications" element={<PlayerInbox />} />
<Route path="notifications/settings" element={<PlayerNotifications />} />
{/* And participation history, for the same reason and by the same
arrangement (Phase 14a): `/player/events/history` is behind
requireAuth alone, so a staff member has one — but
`RequirePlayer` sends them out of `/account`. Declared BEFORE
`events/:id`, though it need not be: a static segment outranks
a dynamic one whatever the order, which is the rule that made
`events/new` unreachable for seven phases. Written in the order
it resolves. */}
<Route path="events/mine" element={<PlayerEvents />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
@@ -290,6 +314,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

@@ -19,3 +19,17 @@ export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/a
/** The per-channel preferences screen. */
export const notificationSettingsPath = (user) =>
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
/**
* This account's own event participation (events Phase 14a).
*
* The third screen to need this mapping, and it needed it for exactly the reason
* the two above did: `GET /player/events/history` is behind `requireAuth` alone,
* self-scoped on `req.user.id` — a staff member has a participation history like
* anyone else, and the group's own header says staff are a superset of players.
* The WEB is what disagrees, because `RequirePlayer` sends them to the login
* page. Found the same way the notifications pair was: signed in as an admin,
* the screen simply redirected.
*/
export const eventHistoryPath = (user) =>
isStaff(user) ? '/admin/events/mine' : '/account/events'

View File

@@ -142,6 +142,12 @@ export const NAV = [
// governs: what a deployment permits at all is configuration, not a read,
// and the server gates both the GET and the PUT on `admin`.
{ to: '/admin/events/actions', label: 'Actions', icon: IconGear, roles: ['admin'] },
// Phase 14a, and the one row here that is not about running the
// deployment: it is this staff member's OWN attendance, the same screen
// and the same route a player reads at /account/events. It has no `roles`
// because it needs none — every account has a participation history, and
// the server scopes it to the caller.
{ to: '/admin/events/mine', label: 'My participation', icon: IconCalendar },
],
},
{
@@ -229,6 +235,7 @@ const TITLES = {
'/admin/events': 'Events',
'/admin/events/calendar': 'Event calendar',
'/admin/events/actions': 'Event actions',
'/admin/events/mine': 'My participation',
'/admin/events/new': 'New event',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
// The public event calendar (EVENTS.md §I, Phase 14a).
//
// **A list, not a month grid.** The admin calendar draws a grid because an
// operator's question is "what does this month look like" — coverage, clashes,
// the gap on the third weekend. A visitor's question is "what is on, and when is
// the next one", which a chronological list answers in one glance and a grid
// answers by making them count squares. Same data, different question.
//
// **A projection is drawn differently from a run, and the reason is the
// operator's reason one tier along.** Past the materialisation horizon there is
// no row: nothing is committed to, nothing can be cancelled, and a forecast
// rendered identically to a booking would be the page promising something the
// server has not. It is dashed and labelled "expected".
//
// The date heading is the READER's day and the time beside each entry is the
// EVENT's own zone. That split is §I's: the shard's evening is what "8pm" means
// to everyone reading it, but "this month" is the month the reader is living in.
import { Link } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx'
import { useAsync } from '../../lib/useAsync.js'
import { api } from '../../api/client.js'
import { eventTime, readerDayLabel, statusWord } from '../../lib/eventCalendar.js'
export default function Events() {
const { loading, error, data } = useAsync(() => api.publicEvents())
const entries = data?.entries || []
// Grouped by the reader's own day, in order. The server already sorted by
// instant, so this preserves that order rather than re-sorting.
const days = []
for (const entry of entries) {
const label = readerDayLabel(entry.scheduledFor)
const last = days[days.length - 1]
if (last && last.label === label) last.entries.push(entry)
else days.push({ label, entries: [entry] })
}
return (
<PublicLayout section="website">
<div className="shell-mid page-body">
<PageHeader
eyebrow="What's on"
title="Events"
lead="Everything scheduled, live and recently finished. Times are shown in the shard's own timezone."
/>
<section style={{ display: 'flex', flexDirection: 'column', gap: 28 }}>
{loading && <Loading />}
{error && <ErrorState message="Could not load the calendar right now." />}
{!loading && !error && entries.length === 0 && (
<EmptyState>Nothing on the calendar just yet check back soon.</EmptyState>
)}
{days.map((day) => (
<div key={day.label}>
<h2
className="sans"
style={{
margin: '0 0 12px',
fontSize: '0.74rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: 'var(--muted)',
}}
>
{day.label}
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{day.entries.map((entry) => (
<EventRow key={`${entry.slug}-${entry.scheduledFor}-${entry.kind}`} entry={entry} />
))}
</div>
</div>
))}
</section>
</div>
</PublicLayout>
)
}
function EventRow({ entry }) {
const projected = entry.kind === 'projected'
const body = (
<div
className="panel"
style={{
padding: '16px 20px',
display: 'flex',
alignItems: 'baseline',
gap: 16,
flexWrap: 'wrap',
// The whole visual difference between a booking and a forecast, and it
// is deliberately not subtle.
borderStyle: projected ? 'dashed' : undefined,
opacity: projected ? 0.72 : 1,
}}
>
<span className="sans" style={{ fontWeight: 700, color: 'var(--accent)', minWidth: 96 }}>
{eventTime(entry.scheduledFor, entry.timezone)}
</span>
<span style={{ flex: 1, minWidth: 200 }}>
<span className="display" style={{ fontSize: '1.15rem', color: 'var(--head)' }}>
{entry.title}
</span>
{entry.seriesName && (
<span className="dim" style={{ marginLeft: 10, fontSize: '0.9rem' }}>
{entry.seriesName}
</span>
)}
</span>
<span
className="sans"
style={{
fontSize: '0.72rem',
letterSpacing: '0.06em',
textTransform: 'uppercase',
color: entry.live ? '#8fc79a' : 'var(--muted)',
fontWeight: entry.live ? 700 : 400,
}}
>
{projected ? 'Expected' : statusWord(entry.status, entry.scheduledFor)}
</span>
</div>
)
// A projection has no page of its own worth linking to any differently — the
// event page IS the definition's — so both link to the same place. It is the
// OCCURRENCE that does not exist yet, not the event.
return <Link to={`/site/events/${entry.slug}`} style={{ textDecoration: 'none' }}>{body}</Link>
}

View File

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

View File

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

View File

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

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 runs 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 accounts 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 entrys `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 modules `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 EVENTs zone, not the readers. A shard-local 8pm means the shards evening to everyone reading it, so the time is rendered in this zone while the readers 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 deployments 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 games 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

@@ -45,9 +45,16 @@ after(() => db.close())
const DEFINITION = {
id: 3,
title: 'The Yew Invasion',
slug: 'the-yew-invasion',
summary: 'Orcish warbands are massing north of Yew.',
series_name: 'The Yew Campaign',
timezone: 'America/New_York',
// Both are load-bearing for `eventUrl` (Phase 14a): an event with no public
// page gets no link. They were absent from this fixture, which meant the url
// was undefined in every test here and the new code was exercised by none of
// them.
state: 'ready',
listed: true,
}
const RUN = {
@@ -201,6 +208,12 @@ test('run.cancelled carries the operator\'s reason, and omits it when none was g
assert.equal(only().envelope.data.reason, undefined)
})
// `run.failed` alone gets no public page, and the DECLARATION is what enforces
// that rather than anything here: `baseFor` assembles `eventUrl` for every
// trigger and the seam drops the keys a trigger does not declare. The test above
// that asserts run.failed's url variables are exactly `['runUrl']` is therefore
// the one that proves it — an assertion on this envelope would be reading the
// wrong layer, because the filtering has not happened yet at this point.
test('run.failed links the run console — the one destination that exists today', async () => {
await announce.runFailed(RUN, 'sidecar responded 503')
const { data } = only().envelope
@@ -209,6 +222,29 @@ test('run.failed links the run console — the one destination that exists today
assert.equal(data.runUrl, '/admin/events/runs/3692')
})
test('every public emit carries the page for THIS occurrence', async () => {
await announce.runStarted(RUN)
// The slug is the definition's and the run is in the query string. Without
// `?run=` a mail about last Friday's occurrence would open next Friday's.
assert.equal(only().envelope.data.eventUrl, '/site/events/the-yew-invasion?run=3692')
})
test('an UNLISTED event announces with no link rather than a link that 404s', async () => {
// `eventUrl` is declared optional exactly so `email.button` can drop itself.
// A path here would render as a dead button in every mail — worse than none,
// because it advertises a link the reader cannot follow. `news.post` paid for
// that once already.
definitionsDb.getById = async () => ({ ...DEFINITION, listed: false })
await announce.runStarted(RUN)
assert.equal(only().envelope.data.eventUrl, undefined)
})
test('a definition that is not yet `ready` has no page either', async () => {
definitionsDb.getById = async () => ({ ...DEFINITION, state: 'draft' })
await announce.runStarted(RUN)
assert.equal(only().envelope.data.eventUrl, undefined)
})
test('run.failed falls back to the run\'s own last error', async () => {
await announce.runFailed({ ...RUN, last_error: 'the pinned version has no phases' }, null)
assert.equal(only().envelope.data.error, 'the pinned version has no phases')
@@ -283,21 +319,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)
})