diff --git a/client/src/App.jsx b/client/src/App.jsx
index 6a6bb69..aac4eee 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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() {
} />
} />
} />
+ {/* 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. */}
+ } />
+ } />
+ } />
} />
} />
} />
@@ -247,6 +262,15 @@ export default function App() {
two paths; `lib/notificationPaths.js` is the one mapping. */}
} />
} />
+ {/* 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. */}
+ } />
{/* Installed modules' admin pages, at /admin//…, 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() {
} />
} />
} />
+ {/* 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. */}
+ } />
{/* 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
diff --git a/client/src/api/client.js b/client/src/api/client.js
index e3d486a..92b4e1c 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -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())}`)
+ },
},
}
diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx
index 21eee19..df088e4 100644
--- a/client/src/components/SiteHeader.jsx
+++ b/client/src/components/SiteHeader.jsx
@@ -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' },
diff --git a/client/src/lib/eventAuthoring.js b/client/src/lib/eventAuthoring.js
index 8a089e1..87c9e32 100644
--- a/client/src/lib/eventAuthoring.js
+++ b/client/src/lib/eventAuthoring.js
@@ -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 },
},
}
diff --git a/client/src/lib/eventCalendar.js b/client/src/lib/eventCalendar.js
new file mode 100644
index 0000000..fafff21
--- /dev/null
+++ b/client/src/lib/eventCalendar.js
@@ -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'
+}
diff --git a/client/src/lib/notificationPaths.js b/client/src/lib/notificationPaths.js
index f571447..48e378d 100644
--- a/client/src/lib/notificationPaths.js
+++ b/client/src/lib/notificationPaths.js
@@ -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'
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 80c1db1..452611a 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -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',
}
diff --git a/client/src/routes/admin/views/EventEditor.jsx b/client/src/routes/admin/views/EventEditor.jsx
index 3ce4ea2..d19eac2 100644
--- a/client/src/routes/admin/views/EventEditor.jsx
+++ b/client/src/routes/admin/views/EventEditor.jsx
@@ -1164,11 +1164,24 @@ export default function EventEditor() {
Storyline