148 lines
3.6 KiB
TypeScript
148 lines
3.6 KiB
TypeScript
/**
|
|
* 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<typeof setInterval> | null = null;
|
|
#tickCount = 0;
|
|
#lastHour = -1;
|
|
#lastDay = -1;
|
|
|
|
constructor(broadcast: ClockBroadcastFn, config?: Partial<ClockConfig>) {
|
|
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<ClockConfig>): 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,
|
|
};
|
|
}
|
|
}
|