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