Files
agiledancers/agiledancers-fe/src/app/core/scheduling-status.ts
Wanja Krah 694bf3fa86
All checks were successful
Build, test and publish / verify (push) Successful in 7m31s
S16 postgresql opslag added
2026-11-04 17:10:25 +01:00

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));
}
}