Files
website/client/src/routes/player/PlayerEvents.jsx
wtclaude 1667e636bd 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
2026-09-08 06:18:38 -05:00

85 lines
3.3 KiB
JavaScript

// 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>
)
}