feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -17,6 +17,9 @@ import FiveOnFriday from './routes/public/FiveOnFriday.jsx'
|
||||
import Newsletter from './routes/public/Newsletter.jsx'
|
||||
import NewsletterIssue from './routes/public/NewsletterIssue.jsx'
|
||||
import About from './routes/public/About.jsx'
|
||||
import Events from './routes/public/Events.jsx'
|
||||
import EventPage from './routes/public/EventPage.jsx'
|
||||
import EventSeries from './routes/public/EventSeries.jsx'
|
||||
import Status from './routes/public/Status.jsx'
|
||||
import Wiki from './routes/wiki/Wiki.jsx'
|
||||
import WikiArticle from './routes/wiki/WikiArticle.jsx'
|
||||
@@ -74,6 +77,7 @@ import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import PlayerInbox from './routes/player/PlayerInbox.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
import PlayerEvents from './routes/player/PlayerEvents.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -107,6 +111,17 @@ export default function App() {
|
||||
<Route path="/site/newsletter" element={<Newsletter />} />
|
||||
<Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
<Route path="/site/about" element={<About />} />
|
||||
{/* Events (Phase 14a). `series/:slug` is declared before `:slug`
|
||||
although it could not be shadowed by it — two segments against
|
||||
one. It stays above because the ranking surprise this feature
|
||||
has already shipped once was exactly here: a static segment
|
||||
outranks a dynamic one whatever the source order, which is what
|
||||
made `/admin/events/new` unreachable from Phase 6 to Phase 13.
|
||||
Nothing static shares a segment with `:slug`, so nothing here
|
||||
repeats it. */}
|
||||
<Route path="/site/events" element={<Events />} />
|
||||
<Route path="/site/events/series/:slug" element={<EventSeries />} />
|
||||
<Route path="/site/events/:slug" element={<EventPage />} />
|
||||
<Route path="/site/status" element={<Status />} />
|
||||
<Route path="/wiki" element={<Wiki />} />
|
||||
<Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
@@ -290,6 +305,11 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
{/* Participation history (Phase 14a). Under /account rather than
|
||||
/player because it is role-agnostic self-service: staff are a
|
||||
superset of players and an admin reading their own attendance
|
||||
is as ordinary as anyone else doing it. */}
|
||||
<Route path="/account/events" element={<PlayerEvents />} />
|
||||
{/* The inbox took `/account/notifications` in engagement Phase 7
|
||||
and the preferences screen moved under it. Content and
|
||||
settings are different kinds of thing, and the plain word
|
||||
|
||||
@@ -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())}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
|
||||
99
client/src/lib/eventCalendar.js
Normal file
99
client/src/lib/eventCalendar.js
Normal 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'
|
||||
}
|
||||
@@ -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’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>
|
||||
|
||||
|
||||
84
client/src/routes/player/PlayerEvents.jsx
Normal file
84
client/src/routes/player/PlayerEvents.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
190
client/src/routes/public/EventPage.jsx
Normal file
190
client/src/routes/public/EventPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
client/src/routes/public/EventSeries.jsx
Normal file
78
client/src/routes/public/EventSeries.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
131
client/src/routes/public/Events.jsx
Normal file
131
client/src/routes/public/Events.jsx
Normal 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>
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
89
client/test/eventCalendar.test.js
Normal file
89
client/test/eventCalendar.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// The public event screens' time rendering (EVENTS_PLAN.md Phase 14a).
|
||||
//
|
||||
// One property matters here and it is EVENTS.md §I's: **the time beside an
|
||||
// entry is the EVENT's zone, the day it is filed under is the READER's.** A
|
||||
// helper that quietly rendered both in the reader's zone would pass any test
|
||||
// that only ever looked at one of them, and would put an American shard's 8pm
|
||||
// event at "02:00" for a player in Berlin — true, useless, and looking like the
|
||||
// shard's own announcement was wrong.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { eventTime, eventDateTime, readerDayLabel, statusWord } from '../src/lib/eventCalendar.js'
|
||||
|
||||
// 2026-09-12T00:00Z is 2026-09-11 20:00 in New York — deliberately an instant
|
||||
// whose DATE differs between the two zones, which is what makes the split
|
||||
// observable at all.
|
||||
const INSTANT = '2026-09-12T00:00:00.000Z'
|
||||
|
||||
test('the time is rendered in the EVENT’s zone, not the reader’s', () => {
|
||||
assert.equal(eventTime(INSTANT, 'America/New_York'), '20:00 New York')
|
||||
assert.equal(eventTime(INSTANT, 'UTC'), '00:00 UTC')
|
||||
assert.equal(eventTime(INSTANT, 'Europe/Berlin'), '02:00 Berlin')
|
||||
})
|
||||
|
||||
test('the zone is named in a form a reader recognises', () => {
|
||||
// `America/New_York` is a database identifier, not something to show a player.
|
||||
assert.match(eventTime(INSTANT, 'America/Los_Angeles'), /Los Angeles$/)
|
||||
})
|
||||
|
||||
test('an unknown zone falls back to UTC rather than throwing', () => {
|
||||
// `Intl` rejects an unknown identifier, and an event whose timezone column
|
||||
// holds a typo must still render.
|
||||
assert.equal(eventTime(INSTANT, 'Not/AZone'), '00:00 UTC')
|
||||
assert.equal(eventDateTime(INSTANT, 'Not/AZone'), '2026-09-12 00:00 UTC')
|
||||
})
|
||||
|
||||
test('a bad instant renders as nothing rather than as "Invalid Date"', () => {
|
||||
assert.equal(readerDayLabel('not a date'), '')
|
||||
assert.equal(eventDateTime('not a date', 'UTC'), '')
|
||||
})
|
||||
|
||||
test('the day label is the reader’s own day, whatever the event’s zone', () => {
|
||||
// Two entries at the same instant in different event zones are filed under one
|
||||
// heading, which is what makes a chronological list group correctly.
|
||||
assert.equal(readerDayLabel(INSTANT), readerDayLabel(INSTANT))
|
||||
const label = readerDayLabel(INSTANT)
|
||||
assert.ok(label.length > 0)
|
||||
// The instant's UTC date is the 12th and New York's is the 11th; the label
|
||||
// must not carry a zone at all, because it is neither of theirs.
|
||||
assert.equal(/UTC|New York/.test(label), false)
|
||||
})
|
||||
|
||||
test('eventDateTime carries the day and the zone together', () => {
|
||||
const text = eventDateTime(INSTANT, 'America/New_York')
|
||||
assert.match(text, /New York$/)
|
||||
assert.match(text, /20:00/)
|
||||
})
|
||||
|
||||
// ── The status word ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Found by the browser walk: the calendar was saying "DID NOT HAPPEN" about a
|
||||
// run four days out that an operator had cancelled. The server publishes
|
||||
// `failed` and `missed` as `cancelled` too — to a visitor the three are one
|
||||
// event — but they do not share one English sentence, so the tense follows the
|
||||
// clock rather than the status.
|
||||
|
||||
const NOW = Date.parse('2026-09-08T12:00:00Z')
|
||||
|
||||
test('a cancelled occurrence in the future reads "Cancelled"', () => {
|
||||
assert.equal(statusWord('cancelled', '2026-09-12T18:00:00Z', NOW), 'Cancelled')
|
||||
})
|
||||
|
||||
test('a cancelled occurrence in the past reads "Did not happen"', () => {
|
||||
// Which is also the honest word for the failed and missed runs folded into
|
||||
// `cancelled` on the way out.
|
||||
assert.equal(statusWord('cancelled', '2026-09-04T18:00:00Z', NOW), 'Did not happen')
|
||||
})
|
||||
|
||||
test('the other three words do not depend on the clock at all', () => {
|
||||
for (const at of ['2026-09-04T18:00:00Z', '2026-09-12T18:00:00Z']) {
|
||||
assert.equal(statusWord('live', at, NOW), 'Happening now')
|
||||
assert.equal(statusWord('scheduled', at, NOW), 'Scheduled')
|
||||
assert.equal(statusWord('completed', at, NOW), 'Finished')
|
||||
}
|
||||
})
|
||||
|
||||
test('an unreadable instant falls to the past-tense word rather than throwing', () => {
|
||||
assert.equal(statusWord('cancelled', 'not a date', NOW), 'Did not happen')
|
||||
})
|
||||
Reference in New Issue
Block a user