S12 - Async feedback for scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s

This commit is contained in:
2026-11-03 23:18:44 +01:00
parent f1785ed174
commit c241392132
34 changed files with 1310 additions and 79 deletions

View File

@@ -0,0 +1,107 @@
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
export interface SchedulingRun {
runId: string;
schoolId: number;
showId: number;
showName: string;
startedAt: string;
finishedAt: string | null;
state: string;
progress: { phase: string; 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<SchedulingRun[]>([]);
readonly connected = signal(false);
readonly notifications = signal<SchedulingRun[]>([]);
readonly now = signal(Date.now());
private source?: EventSource;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private initialized = false;
private readonly seen = new Set<string>();
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<string>) => {
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 {
for (const run of runs) {
if (run.state === 'RUNNING' || this.seen.has(run.runId)) continue;
this.seen.add(run.runId);
if (this.initialized) 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',
} as Record<string, string>
)[run.progress.phase] ?? run.progress.phase
);
}
dismiss(): void {
this.notifications.update((items) => items.slice(1));
}
}