/** * System Clock / Heartbeat Service * * Broadcasts consistent time signals to all connected canvas clients. * Single server-authoritative source — clients subscribe via CanvasEventBus. * Clock events are ephemeral (WebSocket only, not persisted in CRDT). */ export interface ClockPayload { timestamp: number; isoString: string; hour: number; minute: number; second: number; dayOfWeek: number; tickCount: number; } export interface ClockConfig { enabled: boolean; /** Timezone for daily boundary (default: UTC) */ timezone: string; /** Tick interval in seconds (minimum 10, default 60) */ tickInterval: number; /** Which channels to emit */ channels: ("tick" | "minute:5" | "hourly" | "daily")[]; } const DEFAULT_CONFIG: ClockConfig = { enabled: true, timezone: "UTC", tickInterval: 60, channels: ["tick", "minute:5", "hourly", "daily"], }; export type ClockBroadcastFn = (channel: string, payload: ClockPayload) => void; export class SystemClock { #config: ClockConfig; #broadcast: ClockBroadcastFn; #timer: ReturnType | null = null; #tickCount = 0; #lastHour = -1; #lastDay = -1; constructor(broadcast: ClockBroadcastFn, config?: Partial) { this.#config = { ...DEFAULT_CONFIG, ...config }; // Enforce minimum tick interval if (this.#config.tickInterval < 10) this.#config.tickInterval = 10; this.#broadcast = broadcast; } /** Start the clock. Idempotent. */ start(): void { if (this.#timer || !this.#config.enabled) return; console.log( `[SystemClock] Started (interval: ${this.#config.tickInterval}s, channels: ${this.#config.channels.join(", ")})`, ); // Fire first tick immediately this.#tick(); this.#timer = setInterval( () => this.#tick(), this.#config.tickInterval * 1000, ); } /** Stop the clock. */ stop(): void { if (this.#timer) { clearInterval(this.#timer); this.#timer = null; console.log("[SystemClock] Stopped"); } } /** Check if the clock is running. */ get running(): boolean { return this.#timer !== null; } /** Update config and restart if running. */ updateConfig(config: Partial): void { const wasRunning = this.running; this.stop(); this.#config = { ...this.#config, ...config }; if (this.#config.tickInterval < 10) this.#config.tickInterval = 10; if (wasRunning && this.#config.enabled) this.start(); } #tick(): void { this.#tickCount++; const now = new Date(); const payload = this.#makePayload(now); // Primary tick if (this.#config.channels.includes("tick")) { this.#broadcast("clock:tick", payload); } // 5-minute tick if ( this.#config.channels.includes("minute:5") && this.#tickCount > 1 && now.getMinutes() % 5 === 0 && now.getSeconds() < this.#config.tickInterval ) { this.#broadcast("clock:minute:5", payload); } // Hourly tick const currentHour = now.getUTCHours(); if ( this.#config.channels.includes("hourly") && this.#lastHour !== -1 && currentHour !== this.#lastHour ) { this.#broadcast("clock:hourly", payload); } this.#lastHour = currentHour; // Daily tick const currentDay = now.getUTCDay(); if ( this.#config.channels.includes("daily") && this.#lastDay !== -1 && currentDay !== this.#lastDay ) { this.#broadcast("clock:daily", payload); } this.#lastDay = currentDay; } #makePayload(now: Date): ClockPayload { return { timestamp: now.getTime(), isoString: now.toISOString(), hour: now.getUTCHours(), minute: now.getUTCMinutes(), second: now.getUTCSeconds(), dayOfWeek: now.getUTCDay(), tickCount: this.#tickCount, }; } }