S12 - Async feedback for scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
95
agiledancers-fe/src/app/core/scheduling-status.spec.ts
Normal file
95
agiledancers-fe/src/app/core/scheduling-status.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
107
agiledancers-fe/src/app/core/scheduling-status.ts
Normal file
107
agiledancers-fe/src/app/core/scheduling-status.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user