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