feat(planner): implement cancellation for planning calculations

- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations.
- Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations.
- Add cancellation handling in the worker message event.
- Update planning-date.ts with new utility functions for date manipulation.
- Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation.
- Create documentation for the cancellation feature in planner-cancellation.md.
- Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts.

#57 #58
This commit is contained in:
nessar committed 2026-10-05 15:34:07 +02:00
1 parent 5c35a59805
commit 2edf2004f5
31 files changed
+2275 -304

No files matched your search

@@ -79,7 +79,7 @@ export class CalendarMonth implements OnInit {
getDateKey(day: Date): string {
return [
day.getFullYear(),
String(day.getFullYear()).padStart(4, '0'),
String(day.getMonth() + 1).padStart(2, '0'),
String(day.getDate()).padStart(2, '0'),
].join('-');
@@ -25,6 +25,8 @@ import {
PlanningTrace,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { DateRange } from '@angular/material/datepicker';
@@ -182,6 +184,7 @@ function calculationFor(
};
return {
id: 'test',
cancel: vi.fn(),
snapshot: {
horizon: plan.horizon,
balances: [],
@@ -217,6 +220,7 @@ function calendarFor(
calculation,
calendar: {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
createPlanningCalculation: vi.fn(() => calculation),
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
@@ -397,6 +401,265 @@ function jointTrace(): PlanningTrace {
}
describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it.each([false, true])(
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
async (withIncumbent) => {
const pending = deferred<PlanningOutcome>();
const delayedTrace = deferred<PlanningTrace | undefined>();
const cancelled: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
incumbent: withIncumbent ? plan : undefined,
completedStages: ['validation'],
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
};
const { calendar, calculation } = calendarFor(
cancelled,
undefined,
undefined,
delayedTrace.promise,
);
calendar.createPlanningCalculation
.mockReturnValueOnce({ ...calculation, outcome: pending.promise })
.mockReturnValueOnce(calculationFor(plan, explanation));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).language.set('fr');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const cancel = root.querySelector<HTMLButtonElement>('.cancel-calculation');
expect(cancel?.textContent).toContain('Annuler');
activateWithKeyboard(cancel!);
fixture.detectChanges();
expect(calculation.cancel).toHaveBeenCalledOnce();
expect(cancel?.disabled).toBe(true);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
pending.resolve(cancelled);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(root.querySelector('.planning-status')?.textContent).toContain(
withIncumbent ? 'Calcul annulé — plan partiel' : 'Calcul annulé',
);
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
expect(root.querySelector('.loading-overlay')).toBeNull();
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
TestBed.inject(UserInputService).requestComputation();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
delayedTrace.resolve({ ...jointTrace(), status: 'incomplete' });
await fixture.whenStable();
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
},
);
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculationFor(conflict),
);
TestBed.inject(CalendarSettingsService).setPlanningDates('0099-05-14', '0099-05-14');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(1);
expect(
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-14"]')?.disabled,
).toBe(false);
expect(
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-15"]')?.disabled,
).toBe(true);
});
it.each([
['invalid balances', invalid],
['conflict', conflict],
[
'cancellation without a plan',
{ ...incomplete, reasons: [{ code: 'cancelled', message: 'Cancelled.' }] },
],
['worker error', new Error('Worker stopped.')],
['startup error', new Error('Worker could not start.')],
] satisfies [string, PlanningOutcome | Error][])(
'uses the requested historical three-year calendar after %s',
async (label, outcome) => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calendar = TestBed.inject(CalendarService);
vi.spyOn(calendar, 'createPlanningCalculation').mockImplementation(() => {
if (label === 'startup error') throw outcome;
return outcome instanceof Error
? { ...calculationFor(incomplete), outcome: Promise.reject(outcome) }
: calculationFor(outcome);
});
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-03-15', '2024-02-14');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(36);
for (const date of ['2021-03-15', '2024-02-14'])
expect(
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
).toBe(false);
for (const date of ['2021-03-14', '2024-02-15'])
expect(
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
).toBe(true);
expect(
root
.querySelector('button[data-date="2023-04-10"]')
?.classList.contains('closed-day'),
).toBe(true);
expect(root.querySelector('.loading-overlay')).toBeNull();
},
);
it('ignores an older outcome, explanation and trace after a new horizon is submitted', async () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const oldOutcome = deferred<PlanningOutcome>();
const oldExplanation = deferred<PlanningExplanation | undefined>();
const oldTrace = deferred<PlanningTrace | undefined>();
const calendar = TestBed.inject(CalendarService);
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
.mockReturnValueOnce({
...calculationFor(plan),
outcome: oldOutcome.promise,
explanation: oldExplanation.promise,
trace: oldTrace.promise,
})
.mockReturnValueOnce(calculationFor(conflict));
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-01', '2024-12-31');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
settings.setPlanningDates('2021-05-14', '2021-05-14');
TestBed.inject(UserInputService).requestComputation();
fixture.detectChanges();
await fixture.whenStable();
oldOutcome.resolve(plan);
oldExplanation.resolve(explanation);
oldTrace.resolve(jointTrace());
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(calculate).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance['planningOutcome']()).toBe(conflict);
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
expect(fixture.componentInstance['planningTrace']()).toBeNull();
expect(fixture.componentInstance['planningHorizon']()).toEqual({
start: '2021-05-14',
end: '2021-05-15',
});
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
});
it('captures selected dates with balances and rules only on an explicit calculation request', async () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calendar = TestBed.inject(CalendarService);
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
.mockReturnValueOnce(calculationFor(plan, explanation))
.mockReturnValueOnce(calculationFor(conflict));
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-01', '2024-12-31');
const input = TestBed.inject(UserInputService);
const rules = TestBed.inject(RuleService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(calculate).toHaveBeenCalledTimes(1);
expect(calculate.mock.calls[0][0].horizon).toEqual(plan.horizon);
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
settings.setPlanningDates('2021-05-14', '2021-05-14');
input.updateBalances(() => [
{
id: 'historical',
type: SelectableDayType.CP,
numberOfDays: 1,
availableDate: new Date(2021, 4, 14),
expiryDate: new Date(2021, 4, 14),
},
]);
rules.addRule({
id: 'historical-rule',
name: 'Historical rule',
type: RuleType.TIME_WINDOW,
force: 'obligation',
enabled: true,
placeDays: 1,
placeType: SelectableDayType.CP,
startDate: new Date(2021, 4, 14),
endDate: new Date(2021, 4, 14),
});
fixture.detectChanges();
await fixture.whenStable();
expect(calculate).toHaveBeenCalledTimes(1);
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
input.requestComputation();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(calculate).toHaveBeenCalledTimes(2);
expect(calculate.mock.calls[1][0]).toMatchObject({
horizon: { start: '2021-05-14', end: '2021-05-15' },
balances: [
{
id: 'historical',
type: 'cp',
quantity: 1,
availableFrom: '2021-05-14',
expiryDate: '2021-05-14',
},
],
});
expect(calculate.mock.calls[1][0].rules).toContainEqual(
expect.objectContaining({ id: 'historical-rule' }),
);
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
expect(
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
'button[data-date="2021-05-14"]',
)?.disabled,
).toBe(false);
expect(
fixture.nativeElement
.querySelector('button[data-date="2021-05-13"]')
?.classList.contains('closed-day'),
).toBe(true);
});
it.each([
['InvalidInput', invalid],
['PlanningConflict', conflict],
@@ -1472,6 +1735,7 @@ describe('CalendarYear planning outcome boundary', () => {
it('turns planner rejection into an incomplete error and clears loading', async () => {
const calendar = {
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
createPlanningCalculation: vi.fn(() => ({
...calculationFor(plan),
@@ -14,14 +14,25 @@
></mat-spinner>
<div class="spinner-text">{{ loadingProgress() }}%</div>
</div>
<p role="status">{{ tr().loading }}</p>
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
<button
mat-stroked-button
type="button"
class="cancel-calculation"
[disabled]="isCancelling()"
(click)="cancelCalculation()"
>
{{ tr().planning.cancel }}
</button>
</div>
</div>
}
<header class="results-header">
<div class="results-heading">
<h2 id="planning-results-title" tabindex="-1">{{ tr().planning.title }}</h2>
<h2 #resultsTitle id="planning-results-title" tabindex="-1">
{{ tr().planning.title }}
</h2>
@if (planningOutcome()?.kind === 'InvalidInput') {
<p class="planning-status error" role="alert">
<mat-icon aria-hidden="true">error</mat-icon>
@@ -42,7 +53,7 @@
<mat-icon aria-hidden="true">{{
hasPlannerError() ? 'error' : 'warning'
}}</mat-icon>
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
{{ outcomeLabel(planningOutcome()!) }}
</p>
} @else if (planningOutcome()?.kind === 'Plan') {
<p class="planning-status success" role="status">
@@ -526,6 +526,9 @@ output {
justify-items: center;
gap: 1rem;
}
.cancel-calculation {
min-height: 44px;
}
.spinner-container {
position: relative;
}
+103 -74
View File
@@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import dayjs from 'dayjs';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
@@ -32,6 +33,7 @@ import {
import { RuleService } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import {
applyReplayDeltas,
CounterfactualAnalysis,
@@ -64,6 +66,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatButton,
MatCard,
CalendarMonth,
MatCardContent,
@@ -78,6 +81,7 @@ export class CalendarYear {
private readonly userInput = inject(UserInputService);
private readonly calendarService = inject(CalendarService);
private readonly ruleService = inject(RuleService);
private readonly calendarSettings = inject(CalendarSettingsService);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
private readonly destroyRef = inject(DestroyRef);
@@ -86,7 +90,9 @@ export class CalendarYear {
private comparisonDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(true);
protected readonly isLoading = signal(false);
protected readonly isCancelling = signal(false);
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
protected readonly loadingProgress = signal(0);
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
@@ -201,7 +207,7 @@ export class CalendarYear {
// Material returns local midnight; converting to UTC can change the calendar day.
const date = selected
? [
selected.getFullYear(),
String(selected.getFullYear()).padStart(4, '0'),
String(selected.getMonth() + 1).padStart(2, '0'),
String(selected.getDate()).padStart(2, '0'),
].join('-')
@@ -283,7 +289,7 @@ export class CalendarYear {
if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
const day = selected.range.start;
closedDayDates.add(
`${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
`${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
);
}
if (this.traceReplay() && trace) {
@@ -428,7 +434,8 @@ export class CalendarYear {
}
}
const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(first.getFullYear(), first.getMonth(), 1);
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
@@ -441,74 +448,87 @@ export class CalendarYear {
this.destroyRef.onDestroy(() => this.clearCounterfactual());
effect(() => {
this.userInput.computeTriggerSignal();
this.resetCalculationState();
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
const currentRules = untracked(() => this.ruleService.rules);
const horizon = this.calendarService.getDefaultPlanningHorizon();
this.planningHorizon.set(horizon);
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.loadingProgress.set(0);
this.liveMessage.set(this.tr().loading);
queueMicrotask(() => {
let calculation: PlanningCalculationContext;
try {
calculation = this.calendarService.createPlanningCalculation({
horizon,
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
label: this.translationService.getDayTypeLabel(balance.type),
availableFrom: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: currentRules,
labels: Object.fromEntries(
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
});
} catch (error) {
this.failCalculation(error, version);
return;
}
this.activeCalculation = calculation;
this.loadArtifacts(calculation, version);
void calculation.outcome
.then((outcome) => {
if (version === this.calculationVersion) this.applyOutcome(outcome);
})
.catch((error: unknown) => {
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) return;
this.loadingProgress.set(100);
this.isLoading.set(false);
});
});
untracked(() => this.startCalculation());
});
}
private startCalculation(): void {
const horizon = this.calendarSettings.planningHorizon();
if (!horizon) return;
this.planningHorizon.set(horizon);
this.resetCalculationState(horizon);
const vacationData = this.userInput.vacationNumberSignal();
const currentRules = this.ruleService.rules;
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.isCancelling.set(false);
this.loadingProgress.set(0);
this.liveMessage.set(this.tr().loading);
let calculation: PlanningCalculationContext;
try {
calculation = this.calendarService.createPlanningCalculation({
horizon,
balances: vacationData.map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
label: this.translationService.getDayTypeLabel(balance.type),
availableFrom: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: currentRules,
labels: Object.fromEntries(
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
});
} catch (error) {
this.failCalculation(error, version);
return;
}
this.activeCalculation = calculation;
this.loadArtifacts(calculation, version);
void calculation.outcome
.then((outcome) => {
if (version === this.calculationVersion) this.applyOutcome(outcome);
})
.catch((error: unknown) => {
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) return;
this.loadingProgress.set(100);
this.isLoading.set(false);
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
this.isCancelling.set(false);
});
}
protected cancelCalculation(): void {
if (!this.isLoading() || this.isCancelling() || !this.activeCalculation) return;
this.isCancelling.set(true);
this.liveMessage.set(this.tr().planning.cancelling);
this.activeCalculation.cancel();
}
private failCalculation(error: unknown, version: number): void {
if (version !== this.calculationVersion) return;
const reason = {
@@ -523,7 +543,7 @@ export class CalendarYear {
this.isLoading.set(false);
}
private resetCalculationState(): void {
private resetCalculationState(horizon: { start: string; end: string }): void {
this.activeCalculation = null;
this.planningOutcome.set(null);
this.planningExplanation.set(null);
@@ -542,7 +562,7 @@ export class CalendarYear {
this.traceReplay.set(false);
this.clearHistoricalEligibility();
this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -626,7 +646,9 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.selectedDates.set(
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
);
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -1114,7 +1136,10 @@ export class CalendarYear {
protected hasPlannerError(): boolean {
const outcome = this.planningOutcome();
return (
outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error'
outcome?.kind === 'PlanningIncomplete' &&
outcome.reasons.some((reason) =>
['planner-error', 'worker-error', 'worker-unavailable'].includes(reason.code),
)
);
}
@@ -1245,10 +1270,14 @@ export class CalendarYear {
return value.replaceAll('-', ' ').replaceAll(':', ': ');
}
private outcomeLabel(outcome: PlanningOutcome): string {
protected outcomeLabel(outcome: PlanningOutcome): string {
if (outcome.kind === 'Plan') return this.tr().planning.complete;
if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput;
if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict;
if (outcome.reasons.some((reason) => reason.code === 'user-cancelled'))
return outcome.incumbent
? this.tr().planning.cancelledPartial
: this.tr().planning.cancelled;
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
}
@@ -30,11 +30,18 @@
class="calculate-button"
color="primary"
type="button"
[disabled]="!!calendarSettings.planningHorizonError()"
[attr.aria-describedby]="
calendarSettings.planningHorizonError() ? 'invalid-planning-horizon' : null
"
(click)="triggerComputation()"
>
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
{{ tr().userInput.calculate }}
</button>
@if (calendarSettings.planningHorizonError()) {
<p id="invalid-planning-horizon" role="alert">{{ tr().settings.invalidHorizon }}</p>
}
@if (hasRemainingDays()) {
<mat-card class="warning-card">
@@ -10,6 +10,7 @@ import { vi } from 'vitest';
import { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
import { UserSettings } from '../user-settings/user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
describe('UserInput', () => {
let component: UserInput;
@@ -82,6 +83,26 @@ describe('UserInput', () => {
expect(component).toBeTruthy();
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-03-01', '2024-02-29');
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
expect(calculate.disabled).toBe(true);
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'planning dates in Settings',
);
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
settings.setPlanningDates('2024-02-29', '2024-02-29');
fixture.detectChanges();
expect(calculate.disabled).toBe(false);
calculate.click();
expect(userInputService.computeTriggerSignal()).toBe(1);
});
it('should initialize with vacation days from service', () => {
const vacationDays = userInputService.vacationNumberSignal();
expect(vacationDays).toBeDefined();
@@ -8,6 +8,7 @@ import { DayInput } from '../userInput/day-input/day-input';
import { MatButtonModule } from '@angular/material/button';
import { VacDay } from '../../services/calendar-service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
@Component({
selector: 'app-user-input',
@@ -26,6 +27,7 @@ import { TranslationService } from '../../services/translation-service';
})
export class UserInput {
private userInputService: UserInputService = inject(UserInputService);
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly translationService = inject(TranslationService);
get tr() {
@@ -99,6 +101,7 @@ export class UserInput {
}
public triggerComputation() {
if (this.calendarSettings.planningHorizonError()) return;
this.userInputService.requestComputation();
}
}
@@ -2,6 +2,53 @@
<mat-dialog-content>
<p>{{ tr().settings.description }}</p>
<div class="settings-controls">
<fieldset class="planning-dates">
<legend>{{ tr().settings.planningHorizon }}</legend>
<p id="planning-dates-help">{{ tr().settings.planningDatesHelp }}</p>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.planningStart }}</mat-label>
<input
matInput
type="date"
name="planningStart"
required
#planningStart
[value]="calendarSettings.planningDates().start"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
aria-describedby="planning-dates-help planning-dates-error"
(input)="
calendarSettings.setPlanningDates(
planningStart.value,
lastPlanningDate.value
)
"
/>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
<input
matInput
type="date"
name="lastPlanningDate"
required
#lastPlanningDate
[value]="calendarSettings.planningDates().lastPlanningDate"
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
aria-describedby="planning-dates-help planning-dates-error"
(input)="
calendarSettings.setPlanningDates(
planningStart.value,
lastPlanningDate.value
)
"
/>
</mat-form-field>
@if (calendarSettings.planningHorizonError(); as error) {
<p id="planning-dates-error" class="validation-error" role="alert">
{{ tr().settings[error] }}
</p>
}
</fieldset>
<mat-form-field appearance="outline">
<mat-label>{{ tr().settings.selectTheme }}</mat-label>
<mat-select
@@ -20,3 +20,26 @@ mat-dialog-content > p {
mat-form-field {
width: 100%;
}
.planning-dates {
min-width: 0;
margin: 0 0 1rem;
padding: 0;
border: 0;
legend {
font-weight: 600;
}
p {
margin: 0.5rem 0 1rem;
color: var(--ui-text-secondary);
}
.validation-error {
color: var(--ui-error-text);
background: var(--ui-error-bg);
padding: 0.75rem;
border-radius: 0.5rem;
}
}
@@ -1,22 +1,71 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { UserSettings } from './user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { TranslationService } from '../../services/translation-service';
describe('UserSettings', () => {
let component: UserSettings;
let fixture: ComponentFixture<UserSettings>;
beforeEach(async () => {
localStorage.clear();
await TestBed.configureTestingModule({
imports: [UserSettings],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-29', '2027-02-28');
fixture = TestBed.createComponent(UserSettings);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => localStorage.clear());
it.each(['', '2024-02-30', 'not-a-date'])(
'reports an invalid date entry without saving it: %s',
(value) => {
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
start.value = value;
start.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
'two valid calendar dates',
);
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
start: '2024-02-29',
lastPlanningDate: '2027-02-28',
});
},
);
it('edits inclusive dates with an inline validation indication while preserving the saved horizon', () => {
const root = fixture.nativeElement as HTMLElement;
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
expect(start.value).toBe('2024-02-29');
expect(last.value).toBe('2027-02-28');
expect(root.textContent).toContain('Last planning date (inclusive)');
last.value = '2024-02-28';
last.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')?.textContent).toContain('on or after');
expect(last.getAttribute('aria-invalid')).toBe('true');
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
last.value = '2024-02-29';
last.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(root.querySelector('[role="alert"]')).toBeNull();
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toEqual({
start: '2024-02-29',
end: '2024-03-01',
});
});
it('should create', () => {
expect(component).toBeTruthy();
});
@@ -9,6 +9,7 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { MatInputModule } from '@angular/material/input';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { ThemeService } from '../../services/theme-service';
import { TranslationService } from '../../services/translation-service';
@@ -24,6 +25,7 @@ import { TranslationService } from '../../services/translation-service';
MatFormFieldModule,
MatSelectModule,
MatSlideToggle,
MatInputModule,
],
templateUrl: './user-settings.html',
styleUrl: './user-settings.scss',
@@ -32,7 +34,8 @@ import { TranslationService } from '../../services/translation-service';
export class UserSettings {
protected readonly themeService = inject(ThemeService);
protected readonly translationService = inject(TranslationService);
protected readonly showWeekNumbers = inject(CalendarSettingsService).get('showWeekNumbers');
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly showWeekNumbers = this.calendarSettings.get('showWeekNumbers');
protected readonly tr = this.translationService.tr;
protected readonly themePreferences = ['system', 'light', 'dark'] as const;
}
+75
View File
@@ -31,6 +31,62 @@ describe('CalendarService', () => {
}
});
it.each([
['2026-03-28', '2026-03-30', '2026-03-31'],
['2026-10-24', '2026-10-26', '2026-10-27'],
])(
'groups and counts calendar dates across the clock change starting %s',
(periodStart, date, end) => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Europe/Paris';
try {
const selected = service.selectedDatesForPlan({
horizon: { start: date, end },
placements: [
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
],
});
const period = selected.datesSelected.find(
(item) =>
item.type === null &&
item.range.start?.getTime() ===
new Date(`${periodStart}T00:00:00`).getTime() &&
item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(),
);
expect(period).toBeDefined();
expect(period?.getLengthInDays()).toBe(3);
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
}
},
);
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
const horizon = { start: '2024-04-02', end: '2024-04-03' };
const selected = service.selectedDatesForPlan({
horizon,
placements: [
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
],
timeOffPeriods: [
{
start: '2024-03-30',
end: '2024-04-02',
duration: 4,
placementDates: ['2024-04-02'],
nonWorkingDates: ['2024-03-30', '2024-03-31', '2024-04-01'],
nonWorkingDayGain: 3,
},
],
});
expect(selected.getClasses(new Date('2024-03-30T00:00:00'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(new Date('2024-04-01T00:00:00'))).toContain(DayType.CLOSED_DAY);
expect(selected.getClasses(new Date('2024-04-02T00:00:00'))).toContain(DayType.CP);
expect(selected.getClasses(new Date('2024-04-03T00:00:00'))).not.toContain(DayType.CP);
expect(horizon).toEqual({ start: '2024-04-02', end: '2024-04-03' });
});
describe('getHolidays', () => {
it('should return an array of holidays', () => {
const holidays = service.getHolidays();
@@ -115,6 +171,25 @@ describe('CalendarService', () => {
DayType.WEEKEND,
);
});
it('projects holidays in every covered year and in both partial boundary months', () => {
const selected = service.getSelectedDatesForHorizon({
start: '2023-12-26',
end: '2027-01-03',
});
for (const date of [
'2023-12-25',
'2024-04-01',
'2025-04-21',
'2026-04-06',
'2027-01-01',
]) {
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
DayType.CLOSED_DAY,
);
}
expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND);
});
});
describe('setWeekendSelection', () => {
+51 -54
View File
@@ -13,7 +13,7 @@ import {
PlanningReason,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningYears, planningDateInParis } from './planning-date';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
@@ -39,10 +39,21 @@ export class CalendarService {
}
selectedDatesForPlan(
plan: Pick<PlanOutcome, 'placements'> & { horizon?: { start: string; end: string } },
plan: Pick<PlanOutcome, 'placements'> & {
horizon?: { start: string; end: string };
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
},
): SelectedDates {
const selected = plan.horizon
? this.getSelectedDatesForHorizon(plan.horizon)
const projectionHorizon = plan.horizon ? { ...plan.horizon } : undefined;
if (projectionHorizon) {
for (const period of plan.timeOffPeriods ?? []) {
if (period.start < projectionHorizon.start) projectionHorizon.start = period.start;
if (period.end >= projectionHorizon.end)
projectionHorizon.end = addPlanningDays(period.end, 1);
}
}
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of plan.placements) {
selected.push(
@@ -58,9 +69,7 @@ export class CalendarService {
return selected;
}
// Cache for holiday timestamps
private holidayTimestampsCache: Set<number> | null = null;
private holidayCacheYear: number | null = null;
private readonly holidaysByYear = new Map<number, number[]>();
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
@@ -78,9 +87,10 @@ export class CalendarService {
const start = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const holidays = this.getHolidays(start.getFullYear());
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
const monthInHorizon: Year = [];
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
const cursor = new Date(start);
cursor.setDate(1);
while (cursor <= last) {
monthInHorizon.push(
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
@@ -128,59 +138,47 @@ export class CalendarService {
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
if (date.getDay() === 6) {
const end = new Date(date);
end.setDate(end.getDate() + 1);
selectedDates._datesSelected.push(
new SelectedDate(
DayType.WEEKEND,
new DateRange<Date>(
date,
new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1),
),
),
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(date, end)),
);
}
if (date.getDate() === 1 && date.getDay() === 0) {
const start = new Date(date);
start.setDate(start.getDate() - 1);
selectedDates._datesSelected.push(
new SelectedDate(
DayType.WEEKEND,
new DateRange<Date>(
new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1),
date,
),
),
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, date)),
);
}
}
getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] {
// Cache holidays to avoid recalculation
if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) {
// Return timestamps directly for efficient lookup
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
const dates: Date[] = [];
for (let year = startYear; year <= lastYear; year++) {
let timestamps = this.holidaysByYear.get(year);
if (!timestamps) {
const calendar = new Holidays('FR', allTypesOptions);
timestamps = calendar
.getHolidays(year, lang)
.filter((holiday) => holiday.type === 'public')
.map((holiday) => {
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
return (
dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date)
).getTime();
});
this.holidaysByYear.set(year, timestamps);
}
dates.push(...timestamps.map((timestamp) => new Date(timestamp)));
}
// Logic to return holidays for the given year
const hd = new Holidays('FR', allTypesOptions);
const holidays = hd
.getHolidays(startYear, lang)
.filter((holiday) => holiday.type === 'public');
const holidaysPlusOne = hd
.getHolidays(startYear + 1, lang)
.filter((holiday) => holiday.type === 'public');
holidays.push(...holidaysPlusOne);
const holidaysDate: Date[] = holidays.map((holiday) => {
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
return dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date);
});
// Update cache
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
this.holidayCacheYear = startYear;
return holidaysDate;
return dates;
}
static daysInWeek(weekIndex: number, monthIndex: number, year: number): Week {
const firstDayOfMonth = new Date(year, monthIndex, 1);
const firstDayOfMonth = new Date(0);
firstDayOfMonth.setFullYear(year, monthIndex, 1);
firstDayOfMonth.setHours(0, 0, 0, 0);
const firstDayOfWeek = new Date(firstDayOfMonth);
let offset = firstDayOfMonth.getDay() - 1; // Adjust to make Monday the first day of the week
if (offset < 0) {
@@ -357,8 +355,7 @@ export class SelectedDate implements SelectedDateInterface {
getLengthInDays(): number {
if (this.range.start && this.range.end) {
const timeDiff = this.range.end.getTime() - this.range.start.getTime();
return Math.floor(timeDiff / (1000 * 3600 * 24)) + 1;
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
}
return 0;
}
@@ -409,8 +406,6 @@ export class SelectedDates implements SelectedDateInterface {
return;
}
const oneDayInMs = 24 * 60 * 60 * 1000;
// Sort dates by start date
const sorted = [...this._datesSelected].sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
@@ -429,7 +424,9 @@ export class SelectedDates implements SelectedDateInterface {
const currEnd = current.range.end?.getTime() ?? 0;
// Check if current date is adjacent or overlapping with the maximum end of the current group
if (currStart - currentGroupMaxEnd <= oneDayInMs) {
const nextDay = new Date(currentGroupMaxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if (currStart <= nextDay.getTime()) {
currentGroup.push(current);
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
} else {
@@ -1,21 +1,114 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CalendarSettingsService } from './calendar-settings-service';
import { vi } from 'vitest';
describe('CalendarSettingsService', () => {
let service: CalendarSettingsService;
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-04T22:30:00Z'));
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
service = TestBed.inject(CalendarSettingsService);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
localStorage.clear();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('defaults to the Paris date and displays the day before the anniversary as the last planning date', () => {
expect(service.planningDates()).toEqual({
start: '2026-10-05',
lastPlanningDate: '2027-10-04',
});
expect(service.planningHorizon()).toEqual({ start: '2026-10-05', end: '2027-10-05' });
expect(localStorage.getItem('calendarSettings.planningDates')).toBeNull();
});
it('saves and restores fixed historical dates before applying a later default', () => {
service.setPlanningDates('2024-02-29', '2026-02-28');
vi.setSystemTime(new Date('2029-08-01T12:00:00Z'));
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
expect(restored.planningDates()).toEqual({
start: '2024-02-29',
lastPlanningDate: '2026-02-28',
});
expect(restored.planningHorizon()).toEqual({ start: '2024-02-29', end: '2026-03-01' });
});
it('uses the existing February 29 anniversary convention for the unsaved default', () => {
vi.setSystemTime(new Date('2024-02-29T12:00:00Z'));
const leapDay = TestBed.runInInjectionContext(() => new CalendarSettingsService());
expect(leapDay.planningDates()).toEqual({
start: '2024-02-29',
lastPlanningDate: '2025-02-27',
});
expect(leapDay.planningHorizon()).toEqual({ start: '2024-02-29', end: '2025-02-28' });
});
it.each([
['2024-02-29', '2024-03-01'],
['2026-03-29', '2026-03-30'],
['2026-10-25', '2026-10-26'],
['2026-12-31', '2027-01-01'],
])(
'converts a single inclusive planning date %s across leap days, clock changes and years',
(date, end) => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Pacific/Auckland';
try {
service.setPlanningDates(date, date);
expect(service.planningHorizon()).toEqual({ start: date, end });
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
}
},
);
it.each([
['2024-02-30', '2024-03-01', 'invalidDate'],
['2024-02-29', '2024-13-01', 'invalidDate'],
['29/02/2024', '2024-03-01', 'invalidDate'],
['', '2024-03-01', 'invalidDate'],
['2024-03-02', '2024-03-01', 'horizonOrder'],
])(
'keeps invalid input visible without replacing the last valid saved dates (%s, %s)',
(start, last, error) => {
service.setPlanningDates('2024-02-29', '2027-02-28');
service.setPlanningDates(start, last);
expect(service.planningDates()).toEqual({ start, lastPlanningDate: last });
expect(service.planningHorizon()).toBeNull();
expect(service.planningHorizonError()).toBe(error);
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
expect(restored.planningHorizon()).toEqual({ start: '2024-02-29', end: '2027-03-01' });
},
);
it.each([
'{',
'null',
'{}',
'{"start":"2024-02-30","lastPlanningDate":"2024-03-01"}',
'{"start":"2024-03-02","lastPlanningDate":"2024-03-01"}',
])('uses an unsaved default when saved dates are unreadable: %s', (saved) => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
localStorage.setItem('calendarSettings.planningDates', saved);
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
expect(restored.planningHorizon()).toEqual({ start: '2026-10-05', end: '2027-10-05' });
expect(localStorage.getItem('calendarSettings.planningDates')).toBe(saved);
});
it('should initialize with default values', () => {
expect(service.get('showWeekNumbers')()).toBe(false);
});
+74 -1
View File
@@ -1,5 +1,22 @@
import { Injectable, WritableSignal, inject, Injector } from '@angular/core';
import { Injectable, WritableSignal, inject, Injector, signal, computed } from '@angular/core';
import { storedSignal } from '../shared/utils/stored-signal';
import {
addPlanningDays,
addPlanningYears,
isPlanningDate,
planningDateInParis,
} from './planning-date';
const PLANNING_DATES_KEY = 'calendarSettings.planningDates';
function planningDatesError(
start: unknown,
lastPlanningDate: unknown,
): 'invalidDate' | 'horizonOrder' | null {
if (!isPlanningDate(start) || !isPlanningDate(lastPlanningDate)) return 'invalidDate';
if (lastPlanningDate < start) return 'horizonOrder';
return isPlanningDate(addPlanningDays(lastPlanningDate, 1)) ? null : 'invalidDate';
}
interface SettingDefinition {
defaultValue: boolean;
@@ -11,6 +28,62 @@ interface SettingDefinition {
})
export class CalendarSettingsService {
private readonly injector = inject(Injector);
private readonly selectedPlanningDates = signal(this.restorePlanningDates());
readonly planningDates = this.selectedPlanningDates.asReadonly();
readonly planningHorizonError = computed(() => {
const { start, lastPlanningDate } = this.planningDates();
return planningDatesError(start, lastPlanningDate);
});
readonly planningHorizon = computed(() =>
this.planningHorizonError()
? null
: {
start: this.planningDates().start,
end: addPlanningDays(this.planningDates().lastPlanningDate, 1),
},
);
setPlanningDates(start: string, lastPlanningDate: string): void {
const dates = { start, lastPlanningDate };
this.selectedPlanningDates.set(dates);
if (this.planningHorizonError()) return;
try {
if (typeof localStorage !== 'undefined')
localStorage.setItem(PLANNING_DATES_KEY, JSON.stringify(dates));
} catch (error) {
console.warn('Failed to save planning dates to localStorage.', error);
}
}
private restorePlanningDates(): { start: string; lastPlanningDate: string } {
try {
const stored =
typeof localStorage === 'undefined'
? null
: localStorage.getItem(PLANNING_DATES_KEY);
if (stored !== null) {
const saved: unknown = JSON.parse(stored);
if (
typeof saved === 'object' &&
saved !== null &&
'start' in saved &&
'lastPlanningDate' in saved &&
isPlanningDate(saved.start) &&
isPlanningDate(saved.lastPlanningDate) &&
!planningDatesError(saved.start, saved.lastPlanningDate)
)
return { start: saved.start, lastPlanningDate: saved.lastPlanningDate };
}
} catch (error) {
console.warn('Failed to restore planning dates from localStorage.', error);
}
return this.defaultPlanningDates();
}
private defaultPlanningDates(): { start: string; lastPlanningDate: string } {
const start = planningDateInParis(new Date());
return { start, lastPlanningDate: addPlanningDays(addPlanningYears(start, 1), -1) };
}
private readonly definitions = new Map<string, SettingDefinition>([
[
@@ -0,0 +1,289 @@
import { vi } from 'vitest';
import {
createPlanningCalculation,
type PlanningIncompleteOutcome,
type PlanningTraceUpdate,
} from './leave-planner.service';
const request = {
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [],
rules: [],
};
describe('user cancellation', () => {
const workers: CancellableWorker[] = [];
const currentWorker = () => workers[workers.length - 1];
class CancellableWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
postMessage = vi.fn();
terminate = vi.fn();
constructor() {
workers.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
}
beforeEach(() => {
workers.length = 0;
vi.stubGlobal('Worker', CancellableWorker);
});
afterEach(() => vi.unstubAllGlobals());
it('supports cancellation through the public non-browser calculation context', async () => {
vi.stubGlobal('window', undefined);
const calculation = createPlanningCalculation(request);
calculation.cancel();
expect(await calculation.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
expect((await calculation.trace)?.status).toBe('incomplete');
});
it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => {
vi.stubGlobal('window', undefined);
const replaced = createPlanningCalculation(request);
const current = createPlanningCalculation(request);
expect(await replaced.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'stale-calculation' }],
});
expect(await replaced.explanation).toMatchObject({
kind: 'IncompleteExplanation',
reason: { code: 'stale-calculation' },
});
expect((await replaced.trace)?.error).toMatchObject({ code: 'stale-calculation' });
expect(await current.outcome).toMatchObject({ kind: 'Plan' });
await current.trace;
});
it('retains a plan at least as good as every valid candidate actually evaluated before cancellation', async () => {
const core = await import('./leave-planner.core');
let cancelled = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = await core.calculatePlanningAsyncInternal(
{
horizon: request.horizon,
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
rules: [
{
id: 'january',
type: 'time_window',
force: 'preference',
startDate: '2024-01-01',
endDate: '2024-01-31',
placeDays: 2,
placeType: 'cp',
},
],
progress: (stage: string) => {
if (stage === 'longest-work-sequence') cancelled = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => cancelled,
);
expect(outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (artifacts.explanation?.kind !== 'PlanExplanation' || !artifacts.trace)
throw new Error('Expected a retained plan explanation and its original trace.');
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
const evaluated = artifacts.trace.phases.flatMap((phase) =>
phase.decisions.flatMap((decision) =>
decision.candidates.flatMap((candidate) =>
candidate.comparison?.after.objectives[0].value === 0
? [candidate.comparison.after.objectives]
: [],
),
),
);
expect(evaluated.length).toBeGreaterThan(0);
for (const candidate of evaluated) {
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
const firstDifference = retained.findIndex(
(objective, index) => objective.value !== candidate[index].value,
);
if (firstDifference < 0) continue;
const chosen = retained[firstDifference];
const alternative = candidate[firstDifference];
expect(
chosen.direction === 'minimize'
? chosen.value < alternative.value
: chosen.value > alternative.value,
).toBe(true);
}
});
it('requests interruption before a valid plan without terminating trace delivery', async () => {
const calculation = createPlanningCalculation(request);
calculation.cancel();
calculation.cancel();
expect(currentWorker().postMessage).toHaveBeenLastCalledWith({ type: 'cancel' });
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
expect(currentWorker().terminate).not.toHaveBeenCalled();
const cancelled: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
completedStages: ['validation'],
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
};
currentWorker().emit({ type: 'result', outcome: cancelled });
currentWorker().emit({
type: 'result',
outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] },
});
expect(await calculation.outcome).toEqual(cancelled);
expect(currentWorker().terminate).not.toHaveBeenCalled();
currentWorker().emit({ type: 'artifacts', artifacts: {} });
await calculation.trace;
expect(currentWorker().terminate).toHaveBeenCalledOnce();
});
it('drains records queued before and after cancellation while keeping the partial plan on artifact error', async () => {
const core = await import('./leave-planner.core');
const updates: PlanningTraceUpdate[] = [];
let interrupted = false;
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const submitted = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
rules: [],
};
const cancelled = await core.calculatePlanningAsyncInternal(
{
...submitted,
traceProgress: (_: number, update: PlanningTraceUpdate) =>
updates.push(structuredClone(update)),
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interrupted = true;
},
},
undefined,
(value) => (artifacts = value),
undefined,
undefined,
() => interrupted,
);
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent || !artifacts.trace)
throw new Error('Expected a cancelled fixture with a valid plan and original trace.');
const calculation = createPlanningCalculation(submitted);
const operations = updates.flatMap(({ operations }) => operations);
const split = Math.floor(operations.length / 2);
currentWorker().emit({
type: 'trace-update',
totalMs: 0,
acknowledge: true,
update: {
...updates[0],
operations: operations.slice(0, split),
},
});
calculation.cancel();
currentWorker().emit({ type: 'result', outcome: cancelled });
currentWorker().emit({ type: 'result', outcome: cancelled.incumbent });
currentWorker().emit({
type: 'explanation',
explanationError: { code: 'explanation-error', message: 'Unavailable' },
});
currentWorker().emit({
type: 'trace-update',
totalMs: 0,
acknowledge: true,
update: {
operations: operations.slice(split),
},
});
const trace = artifacts.trace;
currentWorker().emit({
type: 'artifacts-start',
artifacts: {},
trace: {
status: trace.status,
initialState: trace.initialState,
completedStages: trace.completedStages,
nonWorkingDates: trace.nonWorkingDates,
error: trace.error,
},
});
currentWorker().emit({ type: 'artifacts-complete' });
expect(await calculation.outcome).toEqual(cancelled);
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
expect(await calculation.trace).toEqual(trace);
});
it('reports worker failure distinctly after a cancellation request', async () => {
const calculation = createPlanningCalculation(request);
calculation.cancel();
currentWorker().onerror?.();
expect(await calculation.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'worker-error' }],
});
expect((await calculation.trace)?.status).toBe('failed');
});
it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => {
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const plan = core.calculatePlanningInternal(
request,
undefined,
(value) => (artifacts = value),
);
expect(plan.kind).toBe('Plan');
const calculation = createPlanningCalculation(request);
calculation.cancel();
currentWorker().emit({ type: 'result', outcome: plan });
currentWorker().emit({ type: 'artifacts', artifacts });
expect(await calculation.outcome).toMatchObject({
kind: 'PlanningIncomplete',
incumbent: plan,
reasons: [{ code: 'user-cancelled' }],
});
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
incomplete: { reason: { code: 'user-cancelled' } },
});
expect((await calculation.trace)?.status).toBe('incomplete');
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
});
it('reports replacement distinctly and ignores late replies from the cancelled calculation', async () => {
const cancelled = createPlanningCalculation(request);
cancelled.cancel();
const previousWorker = currentWorker();
const lateReply = previousWorker.onmessage!;
const newer = createPlanningCalculation(request);
expect(previousWorker.terminate).toHaveBeenCalledOnce();
lateReply({
data: {
type: 'result',
outcome: { kind: 'PlanningConflict', reasons: [], conflictingRuleIds: [] },
},
} as MessageEvent);
expect(await cancelled.outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'stale-calculation' }],
});
const outcome: PlanningIncompleteOutcome = {
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
};
currentWorker().emit({ type: 'result', outcome });
currentWorker().emit({ type: 'artifacts', artifacts: {} });
expect(await newer.outcome).toEqual(outcome);
await newer.trace;
const sent = currentWorker().postMessage.mock.calls.length;
newer.cancel();
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
});
});
File diff suppressed because it is too large. Load diff
+126 -1
View File
@@ -46,6 +46,68 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
}
describe('planLeave', () => {
it.each([
['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'],
['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
['two years', '2024-01-01', '2026-01-01', '2025-12-24', '2025-12-24', '2025-12-25'],
['three years', '2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'],
])(
'plans a light %s horizon with dated provenance and French holidays',
async (_, start, end, placementDate, periodStart, periodEnd) => {
const result = isPlan(
await runInNonBrowser({
horizon: { start, end },
balances: [
{
id: 'expired',
type: 'rtt',
quantity: 2,
availableFrom: '2020-01-01',
expiryDate: '2020-12-31',
},
{
id: 'dated-cp',
type: 'cp',
quantity: 1,
availableFrom: placementDate,
expiryDate: placementDate,
},
],
rules: [
{
id: 'chosen-date',
type: 'time_window',
force: 'obligation',
startDate: placementDate,
endDate: placementDate,
placeDays: 1,
placeType: 'cp',
},
],
}),
);
expect(result.horizon).toEqual({ start, end });
expect(result.placements).toEqual([
{
date: placementDate,
balanceId: 'dated-cp',
type: 'cp',
ruleIds: ['chosen-date'],
supporting: false,
},
]);
expect(result.timeOffPeriods).toHaveLength(1);
expect(result.timeOffPeriods[0]).toMatchObject({ start: periodStart, end: periodEnd });
expect(result.remainingBalances.find((item) => item.id === 'expired')).toMatchObject({
quantity: 2,
type: 'rtt',
availableFrom: '2020-01-01',
expiryDate: '2020-12-31',
classification: 'unavoidable-expiry',
});
},
);
it('rejects malformed horizons before calculation', async () => {
const result = await planLeave({
horizon: { start: '2025-01-01', end: '2024-01-01' },
@@ -57,6 +119,18 @@ describe('planLeave', () => {
if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order');
});
it.each([
['2024-02-30', '2025-01-01', 'horizon-start'],
['2024-13-01', '2025-01-01', 'horizon-start'],
['2024-01-01', '2024-00-01', 'horizon-end'],
['not-a-date', '2025-01-01', 'horizon-start'],
])('reports an invalid horizon date without throwing: %s to %s', async (start, end, code) => {
const outcome = await planLeave({ horizon: { start, end }, balances: [], rules: [] });
expect(outcome.kind).toBe('InvalidInput');
if (outcome.kind === 'InvalidInput')
expect(outcome.reasons.map((item) => item.code)).toContain(code);
});
it.each([
[
'invalid balance quantity',
@@ -620,6 +694,7 @@ describe('planLeave', () => {
expect(result.timeOffPeriods[0].duration).toBe(9);
});
// Async calculation yields to the test timer; allow the planner's ten-second budget.
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
const result = isPlan(
await runInNonBrowser({
@@ -677,7 +752,7 @@ describe('planLeave', () => {
'2027-07-16',
]),
);
});
}, 15_000);
it('moves a satisfied time-window preference to complete a holiday week', async () => {
const result = isPlan(
@@ -1411,6 +1486,56 @@ describe('planLeave transport', () => {
});
describe('planning calculation context', () => {
it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const request = {
horizon: { start: '2024-01-01', end: '2027-01-01' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2026-12-24',
expiryDate: '2026-12-24',
},
],
rules: [
{
id: 'final-year',
type: 'time_window',
force: 'obligation',
startDate: '2026-12-24',
endDate: '2026-12-24',
placeDays: 1,
placeType: 'cp',
},
],
};
const calculation = createPlanningCalculation(request);
request.horizon.end = '2025-01-01';
request.balances[0].expiryDate = '2024-12-31';
expect(calculation.snapshot.horizon).toEqual({
start: '2024-01-01',
end: '2027-01-01',
});
expect(isPlan(await calculation.outcome).horizon).toEqual(calculation.snapshot.horizon);
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind === 'PlanExplanation')
expect(explanation.placements[0]).toMatchObject({
placement: { date: '2026-12-24', balanceId: 'cp', ruleIds: ['final-year'] },
balance: { availableFrom: '2026-12-24', expiryDate: '2026-12-24' },
});
expect((await calculation.trace)?.initialState.horizon).toEqual(
calculation.snapshot.horizon,
);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('replays full balance metadata when an existing balance changes', () => {
const state = {
balances: [
+110 -8
View File
@@ -434,6 +434,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
const timings = new Promise<PlanningCalculationTimings>((resolve) => {
resolveTimings = resolve;
});
let supersede: (() => void) | undefined;
let cancel: (() => void) | undefined;
try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
@@ -441,6 +442,8 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
});
let outcomeSettled = false;
let cancellationRequested = false;
let cancelledRace: PlanningIncompleteOutcome | undefined;
let explanationSettled = false;
let artifactsSettled = false;
let checkpoint: PlanOutcome | undefined;
@@ -483,17 +486,44 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
};
const finishOutcome = (result: PlanningOutcome) => {
if (!outcomeSettled) {
if (
cancellationRequested &&
(result.kind === 'Plan' || result.kind === 'PlanningConflict')
) {
cancelledRace = interruptedOutcome(
result.kind === 'Plan' ? result : checkpoint,
deadline,
'user-cancelled',
'The calculation was cancelled by the user.',
);
result = cancelledRace;
}
outcomeSettled = true;
clearTimeout(livenessWatchdog);
resolveOutcome(result);
}
};
const cancelledExplanation = (
result: PlanningExplanation | undefined,
): PlanningExplanation | undefined => {
if (!cancelledRace) return result;
const incomplete = {
kind: 'IncompleteExplanation' as const,
reason: cancelledRace.reasons[0],
completedStages: cancelledRace.completedStages,
uncompletedStages: [],
};
return cancelledRace.incumbent && result?.kind === 'PlanExplanation'
? { ...result, incomplete }
: incomplete;
};
const finishExplanation = (
result: PlanningExplanation | undefined,
error: PlanningReason | undefined,
finalState?: PlanningTraceState,
) => {
if (!explanationSettled) {
if (!error) result = cancelledExplanation(result);
explanationSettled = true;
receivedExplanation = result;
receivedExplanationError = error;
@@ -511,6 +541,16 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
explanationError: result.explanationError ?? receivedExplanationError,
finalState: result.finalState ?? receivedFinalState,
};
if (cancelledRace) {
if (!complete.explanationError)
complete.explanation = cancelledExplanation(complete.explanation);
if (complete.trace && complete.trace.status !== 'failed')
complete.trace = {
...complete.trace,
status: 'incomplete',
error: cancelledRace.reasons[0],
};
}
if (finishExplanationPromise)
finishExplanation(
complete.explanation,
@@ -529,7 +569,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
traceTransferMs,
});
terminate();
if (cancel) latestWorkerCancels.delete(cancel);
if (supersede) latestWorkerCancels.delete(supersede);
}
};
const partialArtifacts = (
@@ -601,7 +641,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
deltas.push(operation.delta);
}
};
cancel = () => {
supersede = () => {
if (artifactsSettled) return;
const hadOutcome = outcomeSettled;
const reason = {
@@ -628,7 +668,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
},
);
};
if (cancel) latestWorkerCancels.add(cancel);
if (supersede) latestWorkerCancels.add(supersede);
const finalizeFailure = (reason: { code: string; message: string }) => {
if (!explanationSettled && checkpoint && checkpointState) {
receivedFinalState ??= checkpointState;
@@ -757,6 +797,15 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
};
scheduleLivenessWatchdog();
cancel = () => {
if (outcomeSettled || cancellationRequested) return;
cancellationRequested = true;
try {
worker.postMessage({ type: 'cancel' });
} catch {
fail('worker-error', 'The cancellation request could not reach the worker.');
}
};
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
@@ -1489,6 +1538,7 @@ function explainCheckpointInWorker(
}
export interface PlanningCalculationContext {
readonly cancel: () => void;
readonly id: string;
readonly snapshot: PlanningSnapshot;
readonly outcome: Promise<PlanningOutcome>;
@@ -1579,6 +1629,31 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
};
}
} else {
let cancellationRequested = false;
let outcomeSettled = false;
let superseded = false;
const replacementReason: PlanningReason = {
code: 'stale-calculation',
message: 'Planning stopped because a newer calculation started.',
};
const explanationForRun = (value: PlanningExplanation | undefined) => {
if (!superseded) return value;
if (value?.kind === 'IncompleteExplanation')
return { ...value, reason: replacementReason };
if (value?.kind === 'PlanExplanation' && value.incomplete)
return {
...value,
incomplete: { ...value.incomplete, reason: replacementReason },
};
return value;
};
const supersede = () => {
if (!outcomeSettled) {
superseded = true;
cancellationRequested = true;
}
};
latestWorkerCancels.add(supersede);
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
@@ -1600,15 +1675,26 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
try {
const core = await import('./leave-planner.core');
let completedArtifacts: PlanningArtifacts = {};
const result = core.calculatePlanningInternal(
const result = await core.calculatePlanningAsyncInternal(
transport,
undefined,
(value) => (completedArtifacts = value),
undefined,
(value) => {
outcomeSettled = true;
resolve(
superseded && value.kind === 'PlanningIncomplete'
? {
...value,
reasons: [replacementReason],
}
: value,
);
},
(value, error) => {
resolveExplanation(value);
resolveExplanation(explanationForRun(value));
resolveExplanationError(error);
},
() => cancellationRequested,
);
if (result.kind === 'InvalidInput') {
resolveExplanation(undefined);
@@ -1616,7 +1702,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
}
resolve(result);
queueMicrotask(() => {
resolveArtifacts(completedArtifacts);
resolveArtifacts({
...completedArtifacts,
explanation: explanationForRun(completedArtifacts.explanation),
});
resolveTimings(completedArtifacts.timings ?? {});
});
} catch (error) {
@@ -1636,10 +1725,22 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
explanationError: reason,
});
resolveTimings({});
} finally {
outcomeSettled = true;
latestWorkerCancels.delete(supersede);
}
});
});
run = { outcome, explanation, explanationError, artifacts, timings };
run = {
outcome,
explanation,
explanationError,
artifacts,
timings,
cancel: () => {
if (!outcomeSettled) cancellationRequested = true;
},
};
}
}
const retainedArtifacts = run.artifacts.then((artifacts) => ({
@@ -1657,6 +1758,7 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
const context: PlanningCalculationContext = {
id,
snapshot,
cancel: () => run.cancel?.(),
outcome: run.outcome,
explanation: run.explanation,
explanationError: run.explanationError,
@@ -0,0 +1,235 @@
import { execFile } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { Worker as NodeWorker } from 'node:worker_threads';
import { promisify } from 'node:util';
import { vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
describe('real planning worker cancellation', () => {
let directory: string;
let bundle: string;
let afterCheckpoint: (() => void) | undefined;
const threads = new Set<NodeWorker>();
beforeAll(async () => {
directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-'));
bundle = join(directory, 'worker.mjs');
const entry = join(directory, 'entry.ts');
writeFileSync(
entry,
`import { parentPort } from 'node:worker_threads';
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
`,
);
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
});
beforeEach(() => {
afterCheckpoint = undefined;
class RealWorker {
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
private thread = new NodeWorker(bundle);
constructor() {
threads.add(this.thread);
this.thread.on('message', (data) => {
this.onmessage?.({ data } as MessageEvent);
if (data.type === 'checkpoint') afterCheckpoint?.();
});
this.thread.on('error', () => this.onerror?.());
}
postMessage(message: unknown) {
this.thread.postMessage(message);
}
terminate() {
void this.thread.terminate();
}
}
vi.stubGlobal('Worker', RealWorker);
});
afterEach(async () => {
await Promise.all([...threads].map((thread) => thread.terminate()));
threads.clear();
vi.unstubAllGlobals();
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it('stops during calendar preparation without a plan or an obligation conflict', async () => {
const stages: string[] = [];
let preparationUpdates = 0;
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [],
rules: [],
progress: (stage) => {
stages.push(stage);
if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeUndefined();
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
expect(stages).not.toContain('obligations');
expect((await calculation.trace)?.status).toBe('incomplete');
expect(await calculation.explanationError).toBeUndefined();
});
it('cancels before a displayed valid plan without reporting a queued conflict', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [],
rules: [
{
id: 'required',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-01',
endDate: '2024-12-31',
placeDays: 1,
placeType: 'cp',
},
],
progress: (stage) => {
if (stage === 'obligations') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome).toMatchObject({
kind: 'PlanningIncomplete',
reasons: [{ code: 'user-cancelled' }],
});
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeUndefined();
expect((await calculation.trace)?.status).toBe('incomplete');
});
it('stops an active obligation search and retains a jointly valid incumbent and its history', async () => {
let cancelledAt = 0;
afterCheckpoint = () => {
afterCheckpoint = undefined;
setTimeout(() => {
cancelledAt = performance.now();
calculation.cancel();
}, 10);
};
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 6, expiryDate: '2024-12-31' }],
rules: ['first', 'second'].map((id) => ({
id,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: '2024-01-01',
endDate: '2024-12-31',
placeDays: 3,
placeType: 'cp' as const,
})),
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(performance.now() - cancelledAt).toBeLessThan(2000);
expect(outcome.completedStages).not.toContain('obligations');
expect(outcome.incumbent).toBeDefined();
const incumbent = outcome.incumbent!;
for (const id of ['first', 'second'])
expect(
incumbent.placements.filter((placement) => placement.ruleIds.includes(id)),
).toHaveLength(3);
expect(
incumbent.remainingBalances.reduce((sum, balance) => sum + balance.quantity, 0) +
incumbent.placements.length,
).toBe(6);
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind === 'PlanExplanation') {
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
incumbent.placements,
);
expect(explanation.incomplete?.reason.code).toBe('user-cancelled');
}
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(
trace?.phases.find((phase) => phase.name === 'obligations')?.decisions.length,
).toBeGreaterThan(0);
expect(trace?.phases.find((phase) => phase.name === 'dates')?.status).toBe('not-run');
});
it('preserves balances and provenance after a valid checkpoint and drains every produced trace packet', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2025-01-01' },
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
rules: [
{
id: 'fixed',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-02',
endDate: '2024-01-02',
placeDays: 1,
placeType: 'cp',
},
],
progress: (stage) => {
if (stage === 'non-working-day-gain') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe('PlanningIncomplete');
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.completedStages).toContain('holiday-week-value');
expect(outcome.completedStages).not.toContain('non-working-day-gain');
const incumbent = outcome.incumbent!;
expect(incumbent.placements).toHaveLength(3);
expect(incumbent.placements).toContainEqual({
date: '2024-01-02',
type: 'cp',
balanceId: 'cp',
ruleIds: ['fixed'],
supporting: false,
});
expect(incumbent.remainingBalances).toEqual([]);
const explanation = await calculation.explanation;
expect(explanation?.kind).toBe('PlanExplanation');
if (explanation?.kind === 'PlanExplanation')
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
incumbent.placements,
);
const trace = await calculation.trace;
expect(trace?.status).toBe('incomplete');
expect(trace?.error?.code).toBe('user-cancelled');
expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe(
'interrupted',
);
const frozenOutcome = structuredClone(outcome);
calculation.cancel();
expect(await calculation.outcome).toEqual(frozenOutcome);
});
it('completes the same deterministic plan as the synchronous adapter when not cancelled', async () => {
const submitted = {
horizon: { start: '2024-01-01', end: '2024-02-01' },
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
rules: [],
};
const { calculatePlanningInternal } = await import('./leave-planner.core');
const expected = calculatePlanningInternal(submitted);
const calculation = createPlanningCalculation(submitted);
expect(await calculation.outcome).toEqual(expected);
expect((await calculation.trace)?.status).toBe('ready');
expect(await calculation.explanationError).toBeUndefined();
});
});
+14 -3
View File
@@ -15,7 +15,7 @@ import type {
} from './leave-planner.service';
import {
analyzeCounterfactualInternal,
calculatePlanningInternal,
calculatePlanningAsyncInternal,
explainIncompleteCheckpoint,
historicalCounterfactualReasons,
type CounterfactualBaselineOptions,
@@ -38,6 +38,7 @@ type WorkerRequest =
request: WorkerPlanningRequest;
}
| { type: 'trace-ack'; count: number }
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
@@ -120,6 +121,7 @@ interface TraceDelivery {
}
let activeTraceDelivery: TraceDelivery | undefined;
let activeCalculation: { cancelled: boolean } | undefined;
function traceStatusIndex(trace: PlanningTrace): {
statuses: Map<string, TraceCandidateStatus>;
@@ -272,8 +274,12 @@ function pumpTraceUpdates(delivery: TraceDelivery): void {
finishTraceDelivery(delivery);
}
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
try {
if (event.data?.type === 'cancel') {
if (activeCalculation) activeCalculation.cancelled = true;
return;
}
if (event.data?.type === 'trace-ack') {
const delivery = activeTraceDelivery;
if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return;
@@ -282,6 +288,8 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
return;
}
if (event.data?.type === 'plan') {
const calculation = { cancelled: false };
activeCalculation = calculation;
let artifacts: PlanningArtifacts = {};
let outcomePosted = false;
let outcome: PlanningOutcome;
@@ -294,7 +302,7 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
};
activeTraceDelivery = traceDelivery;
try {
outcome = calculatePlanningInternal(
outcome = await calculatePlanningAsyncInternal(
{
...event.data.request,
progress: (stage: string) =>
@@ -331,6 +339,7 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
explanationError,
finalState,
} satisfies WorkerResponse),
() => calculation.cancelled,
);
} catch (error) {
if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined;
@@ -341,6 +350,8 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
message: error instanceof Error ? error.message : String(error),
} satisfies WorkerResponse);
return;
} finally {
if (activeCalculation === calculation) activeCalculation = undefined;
}
if (!outcomePosted)
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
+13 -1
View File
@@ -6,7 +6,7 @@ export function planningDateInParis(value: Date): string {
day: '2-digit',
}).formatToParts(value);
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')}`;
return `${get('year').padStart(4, '0')}-${get('month')}-${get('day')}`;
}
export function addPlanningYears(key: string, years: number): string {
@@ -16,3 +16,15 @@ export function addPlanningYears(key: string, years: number): string {
if (date.getUTCMonth() !== month) date.setUTCDate(0);
return date.toISOString().slice(0, 10);
}
export function addPlanningDays(key: string, days: number): string {
const date = new Date(`${key}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
export function isPlanningDate(value: unknown): value is string {
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
+11
View File
@@ -57,6 +57,13 @@ export interface AppTranslations {
dark: string;
};
systemAppearance: string;
planningHorizon: string;
planningStart: string;
lastPlanningDate: string;
planningDatesHelp: string;
invalidDate: string;
horizonOrder: string;
invalidHorizon: string;
};
rules: {
title: string;
@@ -191,6 +198,10 @@ export interface AppTranslations {
invalidInput: string;
conflict: string;
incomplete: string;
cancel: string;
cancelling: string;
cancelled: string;
cancelledPartial: string;
error: string;
complete: string;
whyPlacement: string;