6.6 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||
|---|---|---|---|---|---|---|---|---|---|
| guide | Event Window | How the event window state machine is computed and surfaced via REST + SSE. |
|
2026-07-23T03:09:00Z |
Configuration
Two setting rows control the window:
| Setting key | Default (seeded) | Description |
|---|---|---|
eventStartUtc |
now + 60s |
ISO 8601 instant the event becomes live. |
eventEndUtc |
now + 7d |
ISO 8601 instant the event ends. |
Both are seeded by SeedSystemData1700000000100 and editable through
SettingsService.
State machine
EventStatusService.getState(now)
(backend/src/common/services/event-status.service.ts) returns one of
four branches:
| Condition | state |
secondsToStart |
secondsToEnd |
|---|---|---|---|
eventStartUtc / eventEndUtc missing or unparseable |
unconfigured |
null |
null |
now < eventStartUtc |
countdown |
floor((start - now)/s) |
null |
start <= now <= end |
running |
null |
floor((end - now)/s) |
now > eventEndUtc |
stopped |
null |
null |
The legacy EventStatusService.getStatus(now) shape
({status: 'Stopped'|'Running', countdownMs, startUtc, endUtc, serverNowUtc})
is preserved for backward compatibility. Stopped is overloaded to
mean "still counting down" (countdownMs = eventStartUtc - now) when
state === 'countdown', and "ended" (countdownMs = 0) when
state === 'stopped' / unconfigured.
Endpoints
| Endpoint | Auth | Shape |
|---|---|---|
GET /api/v1/event/status |
@Public() |
Legacy {status, countdownMs, startUtc, endUtc, serverNowUtc}. |
GET /api/v1/event/stream |
@Public() |
SSE that emits the legacy payload on a 1-second tick + on hub pushes. |
GET /api/v1/settings/event |
@Public() |
Raw {eventStartUtc, eventEndUtc} (null when unset). |
GET /api/v1/events/status |
Authenticated (JWT) | SSE that emits the full EventStatePayload on connect + on a 60-second tick + on hub pushes; consecutive identical payloads suppressed via distinctUntilChanged(JSON.stringify). |
See System Endpoints for the full request/response shape.
Frontend wiring
The authenticated shell subscribes to /api/v1/events/status from
HomeComponent.ngOnInit() through AuthenticatedEventSourceService.open() and
EventStatusStore.start(...)
(frontend/src/app/features/home/home.component.ts,
frontend/src/app/core/services/authenticated-event-source.service.ts,
frontend/src/app/core/services/event-status.store.ts). The transport uses a
fetch stream rather than the browser EventSource API because it must attach the
JWT Authorization header. It sends Accept: text/event-stream, includes
credentials, parses streamed SSE records, buffers early frames, and aborts the
request on close().
The store:
- Calls
applyServerStatus(payload)on everymessageframe (parses JSON, setsstate,serverNowUtc,eventStartUtc,eventEndUtc, and stores the clock-skew anchorDate.now() - serverNow). - Runs a local 1-second tick so the
secondsToStart/secondsToEndcomputed signals update between SSE pushes. - Exposes
countdownText(computed):""forunconfigured,"Event ended"forstopped, andformatDdHhMm(seconds)forcountdown/running. The format helper lives in the pure moduleevent-status.pure.ts(DD:HH:mm:ss, zero-padded). The seconds digit is driven by the local 1-second tick (and the SSE re-push every 60 seconds) so the final minute of the countdown visibly elapses second-by-second on the shell header.
The header (ShellHeaderComponent) renders an LED with the
led-{state} class so the colour reflects the current state (the
exact theme colours are wired in styles.css against the existing
--color-success / --color-warning / --color-danger tokens;
unconfigured uses --color-secondary). See the
Event LED colors
table in the Authenticated Shell guide for the full mapping.
HomeComponent.ngOnDestroy() (and the DestroyRef hook in the store
itself) close the SSE source and clear the tick interval, so leaving
the shell (e.g. by logging out and landing on /login) tears the
stream down cleanly.
Auto-reload at the transition boundary
ChallengesPage registers a one-shot handler with
EventStatusStore.subscribeReloadAtCountdownZero(() => window.location.reload())
in its constructor; the returned disposer is wired into its
DestroyRef so the subscription is cleared when the page is destroyed.
The subscription is page-owned and independent of the transport
lifecycle (start()/stop() only manage the SSE source + tick +
watcher; they never clear the reload handler). The store owns a 1 s
watcher (checkZero) that fires the handler exactly once when the
local-clock countdown reaches zero (countdown → running) or the
running countdown reaches zero (running → stopped). The latch is
re-armed on every state transition (countdown→running and
running→stopped), and a strict-equal follow-up SSE frame is collapsed
so a duplicate zero-countdown payload cannot disarm the latch. After
stop() (end-of-life teardown) the store is fully reset; a fresh
handler subscription re-installs the watcher.
Push path
- The admin cron / settings update writes to
setting.eventStartUtc/setting.eventEndUtc. SseHubService.publish('event', payload)fans the new payload out to every subscriber of/api/v1/event/streamand (via theflat()mapping)/api/v1/events/status.- Each connected client receives the new state immediately; otherwise it picks up the next 60-second tick.
The hub is in-process; multi-replica deployments need a shared pub/sub to fan out across pods (not in scope for this repo).