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
100 lines
4.0 KiB
JavaScript
100 lines
4.0 KiB
JavaScript
// 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'
|
|
}
|