feat(planning): implement constraint-first leave planner
This commit is contained in:
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">
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user