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

@@ -143,8 +143,13 @@ test('manages a school, shared memberships and shows through the real REST API',
await page.getByRole('button', { name: /Schedule groups/ }).click();
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
await expect(
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }),
page.locator('.scheduling-progress').filter({ hasText: 'Optimaal conceptrooster.' }),
).toBeVisible();
await expect(page.locator('.scheduling-snackbar')).toContainText('Zomershow');
await expect(page.locator('.scheduling-progress')).toContainText('Spreiding:');
await page.reload();
await expect(page.locator('.scheduling-progress')).toContainText('Optimaal conceptrooster.');
await expect(page.locator('.scheduling-snackbar')).toHaveCount(0);
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
const scheduleDialog = page.getByRole('dialog');
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();

View File

@@ -5,6 +5,10 @@ server {
index index.html;
location /api/ {
proxy_buffering off;
proxy_cache off;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 1860s;
proxy_send_timeout 1860s;
proxy_pass http://${BACKEND_UPSTREAM};

View File

@@ -11,7 +11,10 @@ export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(),
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })),
provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }),
),
{ provide: LOCALE_ID, useValue: 'nl-NL' },
],
};

View File

@@ -237,3 +237,30 @@ main {
margin: 0 20px;
}
}
.scheduling-snackbar {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
display: flex;
align-items: center;
gap: 1rem;
max-width: min(40rem, calc(100vw - 3rem));
padding: 1rem 1.5rem;
background: #193b33;
color: white;
border-radius: 0.75rem;
box-shadow: 0 0.25rem 1rem #0003;
}
.scheduling-snackbar a {
color: white;
text-decoration: underline;
}
.scheduling-snackbar button {
background: transparent;
color: white;
border: 0;
cursor: pointer;
font-size: 1.5rem;
}

View File

@@ -44,3 +44,19 @@
</footer>
</div>
</div>
@if (scheduling.notifications()[0]; as run) {
<aside class="scheduling-snackbar" role="status" aria-live="polite">
<span
><strong>{{ run.showName }}</strong
>: {{ run.message }}</span
>
<a
[routerLink]="['/schools', run.schoolId, 'shows']"
[fragment]="'show-' + run.showId"
(click)="scheduling.dismiss()"
>Bekijk show</a
>
<button type="button" aria-label="Melding sluiten" (click)="scheduling.dismiss()">×</button>
</aside>
}

View File

@@ -1,12 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { App } from './app';
import { SchedulingStatus } from './core/scheduling-status';
import { signal } from '@angular/core';
describe('Application shell', () => {
it('provides navigation and a keyboard skip link', async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideRouter([])],
providers: [
provideRouter([]),
{ provide: SchedulingStatus, useValue: { connect: vi.fn(), notifications: signal([]) } },
],
}).compileComponents();
const fixture = TestBed.createComponent(App);
await fixture.whenStable();

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import { SchedulingStatus } from './core/scheduling-status';
import { Component, inject, OnInit } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
@@ -7,4 +8,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
export class App implements OnInit {
readonly scheduling = inject(SchedulingStatus);
ngOnInit(): void {
this.scheduling.connect();
}
}

View File

@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { SchedulingRun, SchedulingStatus } from './scheduling-status';
class FakeEventSource {
static current: FakeEventSource;
readonly close = vi.fn();
onmessage?: (event: { data: string }) => void;
onerror?: () => void;
constructor(readonly url: string) {
FakeEventSource.current = this;
}
send(runs: SchedulingRun[] | null): void {
this.onmessage?.({ data: JSON.stringify({ runs }) });
}
}
function run(state: string, runId = 'run-1'): SchedulingRun {
return {
runId,
schoolId: 1,
showId: 2,
showName: 'Zomershow',
startedAt: '2026-11-03T12:00:00Z',
finishedAt: state === 'RUNNING' ? null : '2026-11-03T12:01:02Z',
state,
progress: { phase: 'VARIETY', feasible: true },
message: 'Afgerond',
summary: null,
};
}
describe('Scheduling status over SSE', () => {
let status: SchedulingStatus;
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('EventSource', FakeEventSource);
TestBed.configureTestingModule({});
status = TestBed.inject(SchedulingStatus);
status.connect();
});
afterEach(() => {
TestBed.resetTestingModule();
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('recovers an existing outcome without replaying old notifications', () => {
expect(FakeEventSource.current.url).toBe('/api/scheduling/events');
FakeEventSource.current.send([run('OPTIMAL')]);
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
expect(status.notifications()).toEqual([]);
expect(status.elapsed(run('OPTIMAL'))).toBe('1m 2s');
expect(status.phase(run('OPTIMAL'))).toBe('Variatie');
});
it('announces a completion missed during disconnection exactly once', () => {
FakeEventSource.current.send([run('RUNNING')]);
FakeEventSource.current.onerror?.();
expect(status.connected()).toBe(false);
const disconnected = FakeEventSource.current;
vi.advanceTimersByTime(3000);
expect(FakeEventSource.current).not.toBe(disconnected);
expect(disconnected.close).toHaveBeenCalledOnce();
expect(status.run(1, 2)?.state).toBe('RUNNING');
FakeEventSource.current.send([run('VALID')]);
expect(status.connected()).toBe(true);
expect(status.notifications()).toHaveLength(1);
status.dismiss();
FakeEventSource.current.send([run('VALID')]);
expect(status.notifications()).toEqual([]);
});
it('does not treat heartbeats or phase changes as completions', () => {
FakeEventSource.current.send(null);
FakeEventSource.current.send([run('OPTIMAL')]);
expect(status.notifications()).toEqual([]);
FakeEventSource.current.send([run('RUNNING', 'run-2')]);
FakeEventSource.current.send(null);
expect(status.run(1, 2)?.runId).toBe('run-2');
expect(status.notifications()).toEqual([]);
FakeEventSource.current.send([run('SOLVER_FAILED', 'run-2')]);
expect(status.notifications()).toHaveLength(1);
});
it('retains a fast completion when the start response arrives after SSE', () => {
FakeEventSource.current.send([]);
FakeEventSource.current.send([run('OPTIMAL')]);
status.started(run('RUNNING'));
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
status.connect();
const source = FakeEventSource.current;
TestBed.resetTestingModule();
expect(source.close).toHaveBeenCalledOnce();
});
});

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

View File

@@ -73,11 +73,11 @@ describe('SchoolApi contract', () => {
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
expect(failure).toHaveBeenCalledOnce();
});
it('schedules synchronously with room for the 30-minute solver limit', () => {
it('starts a background calculation without a solver-length HTTP timeout', () => {
api.schedule(7, 3).subscribe();
const request = http.expectOne('/api/dance-schools/7/shows/3/schedule');
expect(request.request.method).toBe('POST');
expect(request.request.timeout).toBe(1860000);
expect(request.request.timeout).toBeUndefined();
request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' });
});
});

View File

@@ -1,3 +1,4 @@
import { SchedulingRun } from './scheduling-status';
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs';
@@ -47,12 +48,8 @@ export class SchoolApi {
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
return this.save(`${this.base}/${schoolId}/shows`, body, id);
}
schedule(schoolId: number, showId: number): Observable<{ optimal: boolean; message: string }> {
return this.http.post<{ optimal: boolean; message: string }>(
`${this.base}/${schoolId}/shows/${showId}/schedule`,
null,
{ timeout: 1860000 },
);
schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
return this.http.post<SchedulingRun>(`${this.base}/${schoolId}/shows/${showId}/schedule`, null);
}
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {

View File

@@ -344,7 +344,7 @@
</div>
<div class="show-list">
@for (show of shows(); track show.id) {
<article class="show-card">
<article class="show-card" [id]="'show-' + show.id">
<div class="date-tile">
<strong>{{ firstDate(show) | date: 'dd' }}</strong
><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span>
@@ -372,6 +372,48 @@
: 'Geen geldige planning. Plan de groepen opnieuw in.'
}}
</p>
@if (schedulingStatus.run(context.school.id, show.id); as run) {
<div class="scheduling-progress" role="status">
@if (!schedulingStatus.connected()) {
<p>
Verbinding verbroken. Status mogelijk verouderd; de berekening loopt door.
</p>
}
@if (run.state === 'RUNNING') {
<p>
Bezig met inplannen · {{ schedulingStatus.phase(run) }} ·
{{ schedulingStatus.elapsed(run) }}
</p>
<p>
{{
run.progress.feasible
? 'Geldige oplossing gevonden; optimalisatie loopt door.'
: 'Nog geen geldige oplossing gevonden.'
}}
</p>
} @else {
<p>{{ run.message }}</p>
<p>
Afgerond: {{ run.finishedAt | date: 'dd-MM-yyyy HH:mm:ss' }} · Duur:
{{ schedulingStatus.elapsed(run) }}
</p>
@if (run.summary; as summary) {
<p>
Aanwezigheid: {{ summary.attendanceExcess }} extra dagdelen boven voorkeur
(showcasedansers, exclusief coaches).
</p>
<p>
Spreiding: {{ summary.performanceSpread }} optredens verschil tussen drukste
en rustigste dagdeel.
</p>
<p>
Variatie: {{ summary.varietyPenalty }} strafpunten voor opeenvolgende
groepen (lager is beter).
</p>
}
}
</div>
}
<button
type="button"
class="button primary"

View File

@@ -1,5 +1,5 @@
import { SchoolTransfer } from '../shared/school-transfer';
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
@@ -14,6 +14,7 @@ import {
Show,
} from '../core/api-models';
import { apiError } from '../core/api-error';
import { SchedulingStatus } from '../core/scheduling-status';
import { SchoolApi } from '../core/school-api';
import { EntityEditor } from '../shared/entity-editor';
import { ConfirmDialog } from '../shared/confirm-dialog';
@@ -57,7 +58,30 @@ export class SchoolPage implements OnInit {
readonly scheduleEditing = signal<Show | null>(null);
readonly members = signal<DanceGroup | null>(null);
readonly deleting = signal<DeleteTarget | null>(null);
readonly scheduling = signal<number[]>([]);
readonly schedulingStatus = inject(SchedulingStatus);
private readonly starting = signal<number[]>([]);
readonly scheduling = computed(() => [
...this.starting(),
...this.schedulingStatus
.runs()
.filter((run) => run.schoolId === this.data()?.school.id && run.state === 'RUNNING')
.map((run) => run.showId),
]);
private readonly completed = new Set<string>();
constructor() {
effect(() => {
const schoolId = this.data()?.school.id;
let changed = false;
for (const run of this.schedulingStatus.runs()) {
if (run.schoolId !== schoolId || run.state === 'RUNNING' || this.completed.has(run.runId))
continue;
this.completed.add(run.runId);
changed = true;
}
if (changed) void this.reload();
});
}
readonly deleteBusy = signal(false);
readonly deleteError = signal('');
readonly search = signal('');
@@ -151,20 +175,15 @@ export class SchoolPage implements OnInit {
)
)
return;
this.scheduling.update((ids) => [...ids, show.id]);
this.starting.update((ids) => [...ids, show.id]);
this.error.set('');
try {
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
this.notice.set(
result.optimal
? 'Optimaal conceptrooster.'
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
);
await this.reload();
this.schedulingStatus.started(result);
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.scheduling.update((ids) => ids.filter((id) => id !== show.id));
this.starting.update((ids) => ids.filter((id) => id !== show.id));
}
}
async reload(): Promise<void> {