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
85 lines
3.3 KiB
JavaScript
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>
|
|
)
|
|
}
|