feat(planning): implement constraint-first leave planner

This commit is contained in:
nessar committed 2026-08-28 22:44:14 +02:00
1 parent ba8dbe92d9
commit 7ceaf1a57a
32 files changed
+4842 -880

No files matched your search

@@ -0,0 +1,146 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CalendarYear } from './calendar-year';
import { CalendarService, SelectedDates, SelectableDayType } from '../../services/calendar-service';
import {
PlanOutcome,
PlanningConflictOutcome,
PlanningIncompleteOutcome,
InvalidInputOutcome,
PlanningOutcome,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
const invalid: InvalidInputOutcome = {
kind: 'InvalidInput',
reasons: [],
};
const conflict: PlanningConflictOutcome = {
kind: 'PlanningConflict',
reasons: [],
conflictingRuleIds: ['rule'],
};
const incomplete: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [],
};
const plan: PlanOutcome = {
kind: 'Plan',
horizon: { start: '2024-01-01', end: '2025-01-01' },
placements: [],
timeOffPeriods: [],
preferences: [],
remainingBalances: [],
metrics: {
unplacedAtRisk: 0,
nonWorkingDayGain: 0,
longestWorkSequence: 0,
shortSequenceDeficit: 0,
supportingPlacements: 0,
preferenceSatisfaction: [],
},
completedStages: ['dates'],
lastCompletedStage: 'dates',
};
describe('CalendarYear planning outcome boundary', () => {
it.each([
['InvalidInput', invalid],
['PlanningConflict', conflict],
['PlanningIncomplete without incumbent', incomplete],
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => outcome),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([
{
id: 'stale',
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date('2025-01-01'),
},
]);
const fixture: ComponentFixture<CalendarYear> = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
it('keeps a returned incumbent/result visible only when an outcome supplies one', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => plan),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
planLeave: vi.fn(async () => {
throw new Error('planner exploded');
}),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([
{
id: 'stale',
type: SelectableDayType.CP,
numberOfDays: 1,
expiryDate: new Date('2025-01-01'),
},
]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const outcome = fixture.componentInstance['planningOutcome']();
expect(outcome?.kind).toBe('PlanningIncomplete');
if (outcome?.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('planner-error');
expect(fixture.componentInstance['isLoading']()).toBe(false);
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
});
});
@@ -10,6 +10,17 @@
</div>
</div>
}
@if (planningOutcome()?.kind === 'InvalidInput') {
<p class="planning-status" role="alert">{{ tr().planning.invalidInput }}</p>
} @else if (planningOutcome()?.kind === 'PlanningConflict') {
<p class="planning-status" role="alert">{{ tr().planning.conflict }}</p>
} @else if (planningOutcome()?.kind === 'PlanningIncomplete') {
<p class="planning-status" role="status">
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
</p>
} @else if (planningOutcome()?.kind === 'Plan') {
<p class="planning-status" role="status">{{ tr().planning.complete }}</p>
}
@for (month of months; track $index) {
<mat-card class="card">
<mat-card-content>
@@ -7,6 +7,8 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { DateCacheService } from '../../services/date-cache.service';
import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service';
import { SelectableDayType, VacDay } from '../../services/calendar-service';
@Component({
selector: 'app-calendar-year',
@@ -28,6 +30,30 @@ export class CalendarYear {
protected isLoading = signal(true);
protected loadingProgress = signal(0);
protected planningOutcome = signal<PlanningOutcome | null>(null);
private calculationVersion = 0;
protected hasPlannerError(): boolean {
const outcome = this.planningOutcome();
return (
outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error'
);
}
private stageProgress(stage: string): number {
if (stage === 'complete') return 100;
if (stage === 'validation') return 5;
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:')) return 50;
if (stage === 'non-working-day-gain') return 65;
if (stage === 'longest-work-sequence') return 72;
if (stage === 'short-sequence-deficit') return 79;
if (stage === 'supporting-placements') return 86;
if (stage === 'expiry') return 93;
if (stage === 'dates') return 98;
return this.loadingProgress();
}
// Use DateCacheService instead of creating Date objects
private get currentMonth() {
@@ -43,37 +69,90 @@ export class CalendarYear {
this.userInput.computeTriggerSignal();
this.selectedDates = this.calendarService.getSelectedDatesForYear();
this.planningOutcome.set(null);
this.userInput.remainingVacationDaysSignal.set([]);
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const currentRules = untracked(() => this.ruleService.rules);
console.log('Optimizing vacations with data:', vacationData);
console.log('Using rules:', currentRules);
this.isLoading.set(true);
this.loadingProgress.set(0);
const version = ++this.calculationVersion;
// Defer execution to allow UI to update
queueMicrotask(() => {
void (async () => {
if (this.selectedDates) {
// Create a deep copy to avoid mutating the original signal data
const vacationDaysCopy = vacationData.map((vd) => ({ ...vd }));
const remainingDays = await this.selectedDates.optimizeVacations(
vacationDaysCopy,
currentRules,
this.calendarService,
(progress: number) => {
this.loadingProgress.set(progress);
try {
const outcome = await this.calendarService.planLeave({
horizon: this.calendarService.getDefaultPlanningHorizon(),
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
availableFrom: balance.availableDate,
expiryDate: balance.expiryDate,
})),
rules: currentRules,
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
);
// Update the remaining vacation days signal
this.userInput.remainingVacationDaysSignal.set(remainingDays);
});
if (version !== this.calculationVersion) return;
this.applyOutcome(outcome);
} catch (error) {
if (version !== this.calculationVersion) return;
this.applyOutcome({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [
{
code: 'planner-error',
message:
error instanceof Error
? error.message
: 'The planner failed unexpectedly.',
},
],
});
} finally {
if (version === this.calculationVersion) {
this.loadingProgress.set(100);
this.isLoading.set(false);
}
}
this.isLoading.set(false);
})();
});
});
}
private applyOutcome(outcome: PlanningOutcome): void {
this.planningOutcome.set(outcome);
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
if (plan) {
this.selectedDates = this.calendarService.selectedDatesForPlan(plan);
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates = this.calendarService.getSelectedDatesForYear();
this.userInput.remainingVacationDaysSignal.set([]);
}
private toLegacyBalances(plan: PlanOutcome): VacDay[] {
return plan.remainingBalances.map((balance) => ({
id: balance.id,
type: balance.type as SelectableDayType,
numberOfDays: balance.quantity,
availableDate: new Date(`${balance.availableFrom}T00:00:00`),
expiryDate: new Date(`${balance.expiryDate}T00:00:00`),
classification: balance.classification,
reason: balance.reason,
}));
}
protected selectedDates: SelectedDates = new SelectedDates();
getYear(monthIndex: number): number {
@@ -37,12 +37,17 @@
>
</mat-slide-toggle>
<div class="rule-actions">
<button mat-icon-button (click)="startEdit(rule, $event)">
<button
mat-icon-button
[aria-label]="tr().rules.editRule"
(click)="startEdit(rule, $event)"
>
<mat-icon>edit</mat-icon>
</button>
<button
mat-icon-button
color="warn"
[aria-label]="tr().rules.deleteRule"
(click)="deleteRule(rule.id, $event)"
>
<mat-icon>delete</mat-icon>
@@ -59,6 +64,16 @@
<mat-label>{{ tr().rules.ruleName }}</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.force }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).force">
@for (option of forceOptions; track option.value) {
<mat-option [value]="option.value">{{
option.label()
}}</mat-option>
}
</mat-select>
</mat-form-field>
@if ($any(editBuffer).type === RuleType.IF_THEN) {
<div class="editor-row">
@@ -210,6 +225,14 @@
<mat-label>{{ tr().rules.ruleName }}</mat-label>
<input matInput [(ngModel)]="$any(editBuffer).name" required />
</mat-form-field>
<mat-form-field class="full-width">
<mat-label>{{ tr().rules.force }}</mat-label>
<mat-select [(ngModel)]="$any(editBuffer).force">
@for (option of forceOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label() }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (newRuleType() === RuleType.IF_THEN) {
<div class="editor-row">
+11 -4
View File
@@ -101,6 +101,10 @@ export class RulesPanel {
protected readonly rewardDayTypes = Object.values(SelectableDayType).filter(
(t) => t !== SelectableDayType.ANY,
);
protected readonly forceOptions = [
{ value: 'obligation' as const, label: () => this.tr().rules.obligation },
{ value: 'preference' as const, label: () => this.tr().rules.preference },
];
protected get daysOfWeek() {
const tr = this.tr();
@@ -186,6 +190,7 @@ export class RulesPanel {
this.editBuffer = {
name: this.tr().rules.defaultNewRuleName,
type: RuleType.IF_THEN,
force: 'preference',
dayOfWeek: 6,
placeDays: 2,
placeType: SelectableDayType.CP,
@@ -197,6 +202,7 @@ export class RulesPanel {
this.editBuffer = {
name: this.tr().rules.defaultNewPeriodName,
type: RuleType.TIME_WINDOW,
force: 'preference',
startDate: new Date(),
endDate: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
placeDays: 5,
@@ -212,6 +218,7 @@ export class RulesPanel {
public getRuleSummary(rule: VacationRule): string {
const tr = this.tr();
const forceLabel = rule.force === 'obligation' ? tr.rules.obligation : tr.rules.preference;
if (rule.type === RuleType.IF_THEN) {
const ifThen = rule as IfThenRule;
const dayLabel = this.daysOfWeek.find((d) => d.value === ifThen.dayOfWeek)?.label ?? '';
@@ -223,13 +230,13 @@ export class RulesPanel {
.toLowerCase();
const rewardTypeLabel = this.getDayTypeLabel(ifThen.rewardType);
return tr.rules.ifThenTemplate
return `[${forceLabel}] ${tr.rules.ifThenTemplate
.replace('{day}', dayLabel)
.replace('{placeDays}', String(ifThen.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{placement}', placementLabel)
.replace('{rewardDays}', String(ifThen.rewardDays))
.replace('{rewardType}', rewardTypeLabel);
.replace('{rewardType}', rewardTypeLabel)}`;
} else {
const timeWindow = rule as TimeWindowRule;
const placeTypeLabel = this.getDayTypeLabel(timeWindow.placeType);
@@ -240,11 +247,11 @@ export class RulesPanel {
this.translationService.locale(),
);
return tr.rules.timeWindowTemplate
return `[${forceLabel}] ${tr.rules.timeWindowTemplate
.replace('{placeDays}', String(timeWindow.placeDays))
.replace('{placeType}', placeTypeLabel)
.replace('{startDate}', startDateStr)
.replace('{endDate}', endDateStr);
.replace('{endDate}', endDateStr)}`;
}
}
}
+20 -1
View File
@@ -54,10 +54,29 @@ export class UserInput {
},
);
const expiresLabel = this.tr().userInput.expires;
return `${day.numberOfDays} ${typeName} (${expiresLabel} ${expiryDate})`;
const classification = day.classification
? this.getClassificationLabel(day.classification)
: '';
const reason = day.classification ? this.getReasonLabel(day.classification) : '';
const explanation = classification ? `${classification}: ${reason}; ` : '';
return `${day.numberOfDays} ${typeName} (${explanation}${expiresLabel} ${expiryDate})`;
});
});
private getClassificationLabel(classification: NonNullable<VacDay['classification']>): string {
const labels = this.tr().userInput.remainingClassification;
if (classification === 'unavoidable-expiry') return labels.unavoidableExpiry;
if (classification === 'at-risk') return labels.atRisk;
return labels.deferred;
}
private getReasonLabel(classification: NonNullable<VacDay['classification']>): string {
const labels = this.tr().userInput.remainingReason;
if (classification === 'unavoidable-expiry') return labels.unavoidableExpiry;
if (classification === 'at-risk') return labels.atRisk;
return labels.deferred;
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
protected get daysOff() {
@@ -19,6 +19,17 @@
}
</mat-select>
</mat-form-field>
<mat-form-field class="dateInput">
<mat-label>{{ tr().userInput.availableDate }}</mat-label>
<input
matInput
[matDatepicker]="availablePicker"
[value]="day().availableDate"
(dateChange)="addAvailabilityEvent($event)"
/>
<mat-datepicker-toggle matIconSuffix [for]="availablePicker"></mat-datepicker-toggle>
<mat-datepicker #availablePicker></mat-datepicker>
</mat-form-field>
<mat-form-field class="dateInput">
<mat-label>{{ tr().userInput.chooseDate }}</mat-label>
<input
@@ -31,7 +42,7 @@
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
</mat-form-field>
<button matFab aria-label="Delete entry" (click)="deleteEntry.emit()">
<button matFab [aria-label]="tr().userInput.deleteEntry" (click)="deleteEntry.emit()">
<mat-icon>close</mat-icon>
</button>
</mat-card>
@@ -28,4 +28,15 @@ describe('DayInput', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('emits an availability-date change without mutating the input value', () => {
const emitted: VacDay[] = [];
component.dayChange.subscribe((value) => emitted.push(value));
const availableDate = new Date('2025-02-03T00:00:00');
component.addAvailabilityEvent({ value: availableDate } as never);
expect(emitted[0].availableDate).toBe(availableDate);
expect(mockVacDay.availableDate).toBeUndefined();
});
});
@@ -122,6 +122,11 @@ export class DayInput {
}
addEvent(event: MatDatepickerInputEvent<Date>) {
this.dayChange.emit({ ...this.day(), expiryDate: event.value! });
if (event.value) this.dayChange.emit({ ...this.day(), expiryDate: event.value });
}
addAvailabilityEvent(event: MatDatepickerInputEvent<Date>) {
const value = event.value;
if (value) this.dayChange.emit({ ...this.day(), availableDate: value });
}
}