import { DestroyRef, inject, Injectable, signal } from '@angular/core'; export type SchedulingState = | 'RUNNING' | 'INTERRUPTED' | 'OPTIMAL' | 'VALID' | 'STALE' | 'INVALID_INPUT' | 'INFEASIBLE' | 'NO_SOLUTION' | 'SOLVER_FAILED'; export type SchedulingPhase = 'PREPARING' | 'ATTENDANCE' | 'SPREAD' | 'VARIETY'; export interface SchedulingRun { runId: string; schoolId: number; showId: number; showName: string; startedAt: string; finishedAt: string | null; state: SchedulingState; progress: { phase: SchedulingPhase; feasible: boolean }; message: string; summary: { attendanceExcess: number; performanceSpread: number; varietyPenalty: number; } | null; } @Injectable({ providedIn: 'root' }) export class SchedulingStatus { private readonly destroyRef = inject(DestroyRef); readonly runs = signal([]); readonly connected = signal(false); readonly notifications = signal([]); readonly now = signal(Date.now()); private source?: EventSource; private reconnectTimer?: ReturnType; private initialized = false; private readonly seen = new Set(); connect(): void { if (this.source) return; this.open(); const timer = setInterval(() => this.now.set(Date.now()), 1000); this.destroyRef.onDestroy(() => { this.source?.close(); clearTimeout(this.reconnectTimer); clearInterval(timer); }); } private open(): void { const source = new EventSource('/api/scheduling/events'); this.source = source; source.onmessage = (event: MessageEvent) => { const eventData = JSON.parse(event.data) as { runs: SchedulingRun[] | null }; if (eventData.runs !== null) this.accept(eventData.runs); this.connected.set(true); }; source.onerror = () => { this.connected.set(false); // Retry also when a proxy error makes native EventSource reconnection stop permanently. source.close(); clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => this.open(), 3000); }; } private accept(runs: SchedulingRun[]): void { const activeRuns = new Set( this.runs() .filter((run) => run.state === 'RUNNING') .map((run) => run.runId), ); for (const run of runs) { if (run.state === 'RUNNING' || this.seen.has(run.runId)) continue; this.seen.add(run.runId); if (this.initialized || activeRuns.has(run.runId)) this.notifications.update((items) => [...items, run]); } this.initialized = true; this.runs.set(runs); } run(schoolId: number, showId: number): SchedulingRun | undefined { return this.runs().find((run) => run.schoolId === schoolId && run.showId === showId); } started(run: SchedulingRun): void { // The SSE completion can arrive before the POST response. Never overwrite it with RUNNING. if (this.runs().some((item) => item.runId === run.runId)) return; this.runs.update((items) => [...items.filter((item) => item.showId !== run.showId), run]); } elapsed(run: SchedulingRun): string { const seconds = Math.max( 0, Math.floor( ((run.finishedAt ? Date.parse(run.finishedAt) : this.now()) - Date.parse(run.startedAt)) / 1000, ), ); return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; } phase(run: SchedulingRun): string { return ( ( { PREPARING: 'Voorbereiden', ATTENDANCE: 'Aanwezigheid', SPREAD: 'Spreiding', VARIETY: 'Variatie', } satisfies Record )[run.progress.phase] ?? run.progress.phase ); } dismiss(): void { this.notifications.update((items) => items.slice(1)); } }