All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
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<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 {
|
|
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<SchedulingPhase, string>
|
|
)[run.progress.phase] ?? run.progress.phase
|
|
);
|
|
}
|
|
|
|
dismiss(): void {
|
|
this.notifications.update((items) => items.slice(1));
|
|
}
|
|
}
|