S18 Added OR Tools rules for scheduling show
All checks were successful
Build, test and publish / verify (push) Successful in 6m12s

This commit is contained in:
2026-11-03 11:03:47 +01:00
parent c965f9b41d
commit a7f7730ea0
48 changed files with 2262 additions and 256 deletions

View File

@@ -0,0 +1,70 @@
import { Component, inject, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { SchoolData } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
@Component({
selector: 'app-coach-editor',
imports: [FormsModule],
template: `
<details>
<summary>Coaches koppelen aan dansers</summary>
<p>
Coaches mogen in alle dagdelen dansen. Voeg ze als danser toe aan elke groep waarin ze
meedansen. Ook voor coaches blijven twee andere optredens tussen hun dansen verplicht.
</p>
@if (error()) {
<p role="alert" class="alert error">{{ error() }}</p>
}
<fieldset [disabled]="busy()">
@for (coach of data().coaches; track coach.id) {
<label
>{{ coach.name }}
<select [ngModel]="coach.dancerId" (ngModelChange)="link($event, coach.id)">
<option [ngValue]="null">Geen danser gekoppeld</option>
@for (dancer of data().dancers; track dancer.id) {
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
}
</select>
</label>
}
<label
>Danser als coach registreren
<select [ngModel]="null" (ngModelChange)="link($event)">
<option [ngValue]="null">Kies een danser</option>
@for (dancer of data().dancers; track dancer.id) {
<option [ngValue]="dancer.id">{{ dancer.name }}</option>
}
</select>
</label>
</fieldset>
</details>
`,
})
export class CoachEditor {
readonly data = input.required<SchoolData>();
readonly changed = output<void>();
readonly busy = signal(false);
readonly error = signal('');
private readonly api = inject(SchoolApi);
async link(dancerId: number | null, coachId?: number): Promise<void> {
if (this.busy() || (dancerId === null && coachId === undefined)) return;
this.busy.set(true);
this.error.set('');
try {
await firstValueFrom<unknown>(
dancerId === null
? this.api.unlinkCoach(this.data().school.id, coachId!)
: this.api.linkCoach(this.data().school.id, dancerId, coachId),
);
this.changed.emit();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}

View File

@@ -70,6 +70,9 @@ describe('EntityEditor', () => {
const show: Show = {
id: 3,
name: 'Summer',
preferredDayParts: 2,
finalized: false,
violations: [],
dayParts: [
{ date: '2027-06-01', type: 'AFTERNOON', danceGroupIds: [4] },
{ date: '2027-06-02', type: 'AFTERNOON', danceGroupIds: [4] },
@@ -77,7 +80,7 @@ describe('EntityEditor', () => {
appearances: { REGULAR: 2, SELECTION: 2, PRE_SCHOOLER: 1, SHOW_CASE: 2, PRE_EDUCATION: 2 },
};
it('initializes defaults for every type and rejects duplicate dated dayparts and fractional counts', () => {
it('defaults x to two and rejects duplicate dated dayparts and fractional x', () => {
const component = editor({ kind: 'show' });
component.name = 'Show';
expect(component.validDetails()).toBe(true);
@@ -87,31 +90,25 @@ describe('EntityEditor', () => {
component.dayParts[1].date = '2027-06-02';
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.appearances).toEqual({
REGULAR: 2,
SELECTION: 2,
PRE_SCHOOLER: 1,
SHOW_CASE: 3,
PRE_EDUCATION: 3,
});
expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(true);
component.dayParts[2].date = '2027-06-02';
expect(component.validDetails()).toBe(false);
component.dayParts[2].date = '2027-06-03';
component.appearances.PRE_SCHOOLER = 1.5;
component.preferredDayParts = 1.5;
expect(component.validDetails()).toBe(false);
});
it('preserves saved counts and requires explicit correction when reducing to one daypart', () => {
it('retains x when dayparts are removed so it can be explicitly corrected', () => {
const component = editor({ kind: 'show', value: show });
component.addPart();
component.dayParts[2].date = '2027-06-03';
expect(component.appearances.SHOW_CASE).toBe(2);
expect(component.preferredDayParts).toBe(2);
component.removePart(2);
component.removePart(1);
expect(component.appearances.REGULAR).toBe(2);
expect(component.validDetails()).toBe(false);
component.setSingleCounts();
expect(component.preferredDayParts).toBe(2);
expect(component.validDetails()).toBe(true);
component.preferredDayParts = 1;
expect(component.validDetails()).toBe(true);
});
@@ -120,11 +117,11 @@ describe('EntityEditor', () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
try {
const component = editor({ kind: 'show', value: show });
component.appearances.REGULAR = 1;
component.preferredDayParts = 1;
await component.save();
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('Summer'));
expect(api.saveShow).not.toHaveBeenCalled();
component.appearances.REGULAR = 2;
component.preferredDayParts = 2;
confirm.mockClear();
component.name = 'Renamed';
await component.save();
@@ -147,7 +144,7 @@ describe('EntityEditor', () => {
}
});
it('displays one disabled appearance field for every type with one daypart', async () => {
it('displays one editable x field with a default of one for a one-daypart show', async () => {
// jsdom does not implement native dialog opening.
const descriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, 'showModal');
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
@@ -163,8 +160,8 @@ describe('EntityEditor', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const inputs = Array.from(element.querySelectorAll<HTMLInputElement>('input[type="number"]'));
expect(inputs).toHaveLength(5);
expect(inputs.every((input) => input.disabled && input.value === '1')).toBe(true);
expect(inputs).toHaveLength(1);
expect(inputs.every((input) => !input.disabled && input.value === '1')).toBe(true);
} finally {
if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, 'showModal', descriptor);
else Reflect.deleteProperty(HTMLDialogElement.prototype, 'showModal');

View File

@@ -130,33 +130,22 @@ import { DialogFrame } from './dialog-frame';
</button>
</fieldset>
@if (dayParts.length) {
<fieldset>
<legend>Optredens per groepstype</legend>
@for (option of types; track option.value) {
<label
>{{ option.label
}}<input
type="number"
[name]="'count' + option.value"
[ngModel]="dayParts.length === 1 ? 1 : appearances[option.value]"
(ngModelChange)="appearances[option.value] = $event"
required
min="1"
[max]="dayParts.length"
step="1"
[disabled]="dayParts.length === 1"
/></label>
}
<p class="form-hint">
Bestaande aantallen blijven behouden bij wijzigingen. Corrigeer te hoge aantallen
vóór opslaan.
</p>
@if (dayParts.length === 1 && !validCounts()) {
<button type="button" class="button secondary" (click)="setSingleCounts()">
Alle aantallen op één zetten
</button>
}
</fieldset>
<label
>Gewenst aantal dagdelen (x)
<input
type="number"
name="preferredDayParts"
[(ngModel)]="preferredDayParts"
min="1"
max="6"
step="1"
required
/>
</label>
<p class="form-hint">
Gewone groepen dansen in precies x dagdelen. Kleuters eenmaal; showcases ieder
dagdeel. Is x groter dan het aantal dagdelen, corrigeer x vóór inplannen.
</p>
} @else {
<p class="form-hint">Zonder dagdelen blijft deze show ongepland.</p>
}
@@ -196,8 +185,7 @@ export class EntityEditor implements OnInit {
readonly today = new Date().toLocaleDateString('en-CA');
private readonly api = inject(SchoolApi);
dayParts: DayPart[] = [];
appearances: Partial<Record<GroupType, number>> = {};
private countsInitialized = false;
preferredDayParts = 2;
name = '';
date = '';
type: GroupType = 'REGULAR';
@@ -223,44 +211,23 @@ export class EntityEditor implements OnInit {
if (target.kind === 'dancer') this.date = target.value.birthDate;
if (target.kind === 'show') {
this.dayParts = target.value.dayParts.map((p) => ({ ...p }));
this.appearances = { ...target.value.appearances };
this.countsInitialized = Object.keys(this.appearances).length > 0;
this.preferredDayParts = target.value.preferredDayParts;
}
}
}
addPart(): void {
if (this.dayParts.length >= 6) return;
const previous = this.dayParts.length;
this.dayParts.push({ date: '', type: 'AFTERNOON', danceGroupIds: [] });
if (!this.countsInitialized) {
for (const { value } of this.types) {
const defaultCount = (n: number) =>
value === 'PRE_SCHOOLER'
? 1
: value === 'SHOW_CASE' || value === 'PRE_EDUCATION'
? n
: Math.min(2, n);
if (
this.appearances[value] === undefined ||
this.appearances[value] === defaultCount(previous)
)
this.appearances[value] = defaultCount(this.dayParts.length);
}
}
if (!this.target().value) this.preferredDayParts = Math.min(2, this.dayParts.length);
}
removePart(index: number): void {
this.dayParts.splice(index, 1);
if (!this.countsInitialized && !this.dayParts.length) this.appearances = {};
}
setSingleCounts(): void {
for (const option of this.types) this.appearances[option.value] = 1;
}
validCounts(): boolean {
return this.types.every(
(o) =>
Number.isInteger(this.appearances[o.value]) &&
this.appearances[o.value]! >= 1 &&
this.appearances[o.value]! <= this.dayParts.length,
return (
Number.isInteger(this.preferredDayParts) &&
this.preferredDayParts >= 1 &&
this.preferredDayParts <= 6
);
}
toggleStyle(style: GroupStyle, checked: boolean): void {
@@ -300,7 +267,7 @@ export class EntityEditor implements OnInit {
current.value &&
(JSON.stringify(current.value.dayParts.map((p) => ({ date: p.date, type: p.type }))) !==
JSON.stringify(this.dayParts.map((p) => ({ date: p.date, type: p.type }))) ||
JSON.stringify(current.value.appearances) !== JSON.stringify(this.appearances))
current.value.preferredDayParts !== this.preferredDayParts)
)
affected = [current.value];
const names = affected
@@ -349,7 +316,7 @@ export class EntityEditor implements OnInit {
{
name: this.name.trim(),
dayParts: this.dayParts.map((p) => ({ date: p.date, type: p.type })),
appearances: this.appearances,
preferredDayParts: this.preferredDayParts,
},
id,
);

View File

@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
import { Show, DanceGroup } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { ScheduleEditor } from './schedule-editor';
const groups: DanceGroup[] = [1, 2, 3].map((id) => ({
id,
category: `Group ${id}`,
type: 'REGULAR',
startAge: 0,
endAge: 99,
styles: [],
dancerIds: [id],
}));
const show: Show = {
id: 4,
name: 'Show',
preferredDayParts: 1,
finalized: true,
violations: [],
appearances: {},
dayParts: [
{ date: '2027-01-01', type: 'MORNING', danceGroupIds: [1, 2] },
{ date: '2027-01-02', type: 'MORNING', danceGroupIds: [3] },
],
};
describe('ScheduleEditor', () => {
const api = { saveAssignments: vi.fn(), finalize: vi.fn() };
beforeEach(() => {
vi.resetAllMocks();
TestBed.configureTestingModule({ providers: [{ provide: SchoolApi, useValue: api }] });
});
function editor() {
const fixture = TestBed.createComponent(ScheduleEditor);
fixture.componentRef.setInput('show', show);
fixture.componentRef.setInput('groups', groups);
fixture.componentRef.setInput('schoolId', 7);
fixture.componentInstance.ngOnInit();
return fixture.componentInstance;
}
it('edits order and dayparts without changing the saved input', () => {
const component = editor();
component.reorder(0, 1, -1);
component.move(0, 0, 1);
component.add(0, 3);
component.remove(1, 0);
expect(component.assignments).toEqual([[1, 3], [2]]);
expect(show.dayParts[0].danceGroupIds).toEqual([1, 2]);
expect(component.dirty()).toBe(true);
});
it('saves invalid drafts and displays server violations without finalizing', async () => {
const component = editor();
component.remove(0, 0);
const draft = { ...show, finalized: false, violations: ['Group 1 requires 1 dayparts.'] };
api.saveAssignments.mockReturnValue(of(draft));
await component.save();
expect(api.saveAssignments).toHaveBeenCalledWith(7, 4, [[2], [3]]);
expect(component.current()).toEqual(draft);
expect(component.dirty()).toBe(false);
expect(api.finalize).not.toHaveBeenCalled();
});
it('keeps unsaved edits when saving fails', async () => {
const component = editor();
component.remove(0, 0);
api.saveAssignments.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 })));
await component.save();
expect(component.assignments).toEqual([[2], [3]]);
expect(component.dirty()).toBe(true);
expect(component.error()).toContain('niet bereikbaar');
expect(component.busy()).toBe(false);
});
it('finalizes through the server and reports a stale validation failure', async () => {
const component = editor();
api.finalize.mockReturnValue(of(show));
await component.finalize();
expect(component.current()?.finalized).toBe(true);
api.finalize.mockReturnValue(
throwError(
() => new HttpErrorResponse({ status: 400, error: { message: 'Changing gap violated' } }),
),
);
await component.finalize();
expect(component.error()).toContain('Changing gap violated');
});
});

View File

@@ -0,0 +1,178 @@
import { Component, inject, input, OnInit, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { DanceGroup, Show } from '../core/api-models';
import { SchoolApi } from '../core/school-api';
import { apiError } from '../core/api-error';
import { DialogFrame } from './dialog-frame';
@Component({
selector: 'app-schedule-editor',
imports: [FormsModule, DialogFrame],
template: `
<app-dialog-frame
[title]="show().name + ' — indeling'"
[busy]="busy()"
(dismissed)="closed.emit()"
>
<p>
{{ !dirty() && current()?.finalized ? 'Definitief' : 'Concept' }} · x =
{{ show().preferredDayParts }}
</p>
<p>
Tussen twee dansen van dezelfde danser zijn twee andere optredens nodig. Je kunt een
onvolledige indeling als concept opslaan.
</p>
@if (error()) {
<p role="alert" class="alert error">{{ error() }}</p>
}
@if (dirty()) {
<p role="status">
Niet-opgeslagen wijzigingen. Sla het concept op om alle regels te controleren.
</p>
}
@for (violation of current()?.violations ?? []; track $index) {
<p class="alert error">{{ violation }}</p>
}
<fieldset [disabled]="busy()">
@for (part of show().dayParts; track $index; let p = $index) {
<fieldset>
<legend>
{{ part.date }} · {{ part.type }} ({{ assignments[p].length }} optredens)
</legend>
<ol>
@for (id of assignments[p]; track $index; let i = $index) {
<li>
{{ name(id) }}
<button
type="button"
[disabled]="i === 0"
(click)="reorder(p, i, -1)"
[attr.aria-label]="name(id) + ' omhoog'"
>
↑
</button>
<button
type="button"
[disabled]="i === assignments[p].length - 1"
(click)="reorder(p, i, 1)"
[attr.aria-label]="name(id) + ' omlaag'"
>
↓
</button>
<label
>Verplaatsen naar
<select [ngModel]="p" (ngModelChange)="move(p, i, +$event)">
@for (target of show().dayParts; track $index; let t = $index) {
<option [ngValue]="t">{{ target.date }} · {{ target.type }}</option>
}
</select>
</label>
<button type="button" (click)="remove(p, i)">Optreden verwijderen</button>
</li>
}
</ol>
<label
>Groep toevoegen
<select [ngModel]="null" (ngModelChange)="add(p, +$event)">
<option [ngValue]="null">Kies een groep</option>
@for (group of groups(); track group.id) {
@if (group.dancerIds.length && !assignments[p].includes(group.id)) {
<option [ngValue]="group.id">{{ group.category }}</option>
}
}
</select>
</label>
</fieldset>
}
</fieldset>
<div class="dialog-actions">
<button type="button" class="button secondary" [disabled]="busy()" (click)="closed.emit()">
Gereed
</button>
<button
type="button"
class="button primary"
[disabled]="busy() || !dirty()"
(click)="save()"
>
Concept opslaan
</button>
<button
type="button"
class="button primary"
[disabled]="busy() || dirty() || current()?.finalized || !!current()?.violations?.length"
(click)="finalize()"
>
Definitief maken
</button>
</div>
</app-dialog-frame>
`,
})
export class ScheduleEditor implements OnInit {
readonly show = input.required<Show>();
readonly groups = input.required<DanceGroup[]>();
readonly schoolId = input.required<number>();
readonly closed = output<void>();
readonly saved = output<void>();
readonly busy = signal(false);
readonly dirty = signal(false);
readonly error = signal('');
readonly current = signal<Show | null>(null);
private readonly api = inject(SchoolApi);
assignments: number[][] = [];
ngOnInit(): void {
this.current.set(this.show());
this.assignments = this.show().dayParts.map((p) => [...p.danceGroupIds]);
}
name(id: number): string {
return this.groups().find((g) => g.id === id)?.category ?? `Groep ${id}`;
}
reorder(p: number, i: number, delta: number): void {
const list = this.assignments[p];
[list[i], list[i + delta]] = [list[i + delta], list[i]];
this.dirty.set(true);
}
move(p: number, i: number, target: number): void {
if (target === p || this.assignments[target].includes(this.assignments[p][i])) return;
this.assignments[target].push(this.assignments[p].splice(i, 1)[0]);
this.dirty.set(true);
}
add(p: number, id: number): void {
if (!id || this.assignments[p].includes(id)) return;
this.assignments[p].push(id);
this.dirty.set(true);
}
remove(p: number, i: number): void {
this.assignments[p].splice(i, 1);
this.dirty.set(true);
}
async save(): Promise<void> {
await this.perform(false);
}
async finalize(): Promise<void> {
await this.perform(true);
}
private async perform(finalize: boolean): Promise<void> {
if (this.busy()) return;
this.busy.set(true);
this.error.set('');
try {
const result = await firstValueFrom(
finalize
? this.api.finalize(this.schoolId(), this.show().id)
: this.api.saveAssignments(this.schoolId(), this.show().id, this.assignments),
);
this.current.set(result);
this.assignments = result.dayParts.map((p) => [...p.danceGroupIds]);
this.dirty.set(false);
this.saved.emit();
} catch (error: unknown) {
this.error.set(apiError(error));
} finally {
this.busy.set(false);
}
}
}