Compare commits

...
Author SHA1 Message Date
nessarandClaude Opus 5.5 cee67ce3df refactor(calendar): drop the unused projection horizon
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m2s
CI / build (push) Successful in 1m27s
CI / policy (pull_request) Successful in 11s
CI / security (push) Successful in 1m12s
CI / validate (pull_request) Successful in 1m21s
CI / benchmark (push) Successful in 4m10s
CI / test (push) Successful in 4m19s
CI / security (pull_request) Successful in 1m3s
CI / test (pull_request) Successful in 3m23s
CI / images (push) Successful in 1m34s
CI / build (pull_request) Successful in 34s
CI / required (push) Successful in 3s
CI / benchmark (pull_request) Successful in 7m13s
CI / images (pull_request) Successful in 1m56s
CI / required (pull_request) Successful in 2s
No caller read CalendarProjection.horizon, and with fixed months it no
longer described the listed months.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:36:19 +02:00
nessarandClaude Opus 5.5 a472347a5e fix(calendar): keep declaration-entry months fixed while editing
Declaring a date at the edge of the planning period widened the
projection and inserted a disabled month above the grid. Declaration
entry now lists only the planning period's months, while selections
keep the connected-period widening.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:29:31 +02:00
nessarandClaude Opus 5.5 feb651148e fix(planning): react only to calculations started by the mounted calendar
Re-mounting the calendar no longer records a duration sample or
announces the previous result, explanation or rejected explanation.
Starting a calculation during a cancellation announces loading and
keeps focus where it is. Only rejected explanation deliveries are
announced, as before. Replacement tests now replace a real plan.

Refs #275, #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:22:38 +02:00
nessarandClaude Opus 5.5 43677ff434 refactor(planning): drop the unreachable late worker message guard
release() already clears the worker handlers, so the released checks
inside them could never run. The lifetime test now states what it
checks: no handler is left for late messages.

Refs #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:22:38 +02:00
nessarandClaude Opus 5.5 502d561f6f refactor(calendar): encapsulate calendar projection and selection ranges
CalendarService.project returns the covered months together with the
projected selections, so month enumeration and selected-day rendering
share one projection horizon. SelectedDates is built once from the
complete range list and groups connected periods internally, exposing
read-only selection, boundary and day-type queries.

In declaration entry, months reached by adjacent declared or
non-working days are now shown, matching the projected selections.

Refs #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:18:33 +02:00
nessarandClaude Opus 5.5 5cb0b93339 refactor(planning): give planning calculations one state owner
PlanningCalculationService now builds the submitted request, owns the
pending and accepted calculation state, and delivers explanations for
the accepted calculation only. The duration estimator reads the same
request. The calendar and leave-balance summary consume the accepted
outcome instead of coordinating writable state on the input service.

Refs #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +02:00
nessarandClaude Opus 5.5 b69b3c3690 refactor(planning): give planning workers one lifetime owner
Planning, counterfactual analysis and checkpoint explanation now share
one internal worker-job owner for creation, handler cleanup,
termination and supersession, and one request/response message
contract between the coordinator and the worker. Job-specific progress,
checkpoint recovery and completion rules stay explicit.

Refs #277

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +02:00
nessarandClaude Opus 5.5 7fc2b33587 refactor(planning): remove the unused remaining-balance mirror
The summary and details already read the displayed plan's remaining
balances, so the writable mirror, its legacy conversion and the VacDay
classification/reason fields it alone populated are removed.

Refs #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:05:51 +02:00
19 changed files with 1547 additions and 946 deletions

No files matched your search

+2 -3
View File
@@ -73,9 +73,8 @@ range is explicitly indicative, not a confidence interval or a promise.
## What is timed
Manual requests capture `performance.now()` inside
`UserInputService.requestComputation`, before updating the calculation trigger.
The existing initial calculation falls back to the start of `startCalculation`.
Every request, manual or initial, captures `performance.now()` inside
`PlanningCalculationService.start`.
The endpoint is Angular's `afterNextRender` callback after the complete plan has
been projected into the calendar, its months and placements have been rendered,
and the loading overlay has been removed. This measures request scheduling,
@@ -184,14 +184,10 @@ export class CalendarMonth implements OnInit {
getDayTooltip(day: Date): string | null {
const previewed = this.declarationPreview?.dates.has(this.getDateKey(day));
const tooltips: string[] = this.selectedDates.datesSelected
.filter(
(dateSelected) =>
dateSelected.isSelected(day) &&
dateSelected.type &&
(!previewed || !['cp', 'rtt', 'other'].includes(dateSelected.type)),
)
.map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!));
const tooltips: string[] = this.selectedDates
.dayTypes(day)
.filter((type) => !previewed || !['cp', 'rtt', 'other'].includes(type))
.map((type) => this.translationService.getDayTypeLabel(type));
const declaredType = this.getDeclaredType(this.getDateKey(day));
if (declaredType)
tooltips.push(
@@ -9,6 +9,7 @@ import {
DayType,
SelectedDate,
SelectedDates,
calendarProjectionHorizon,
SelectableDayType,
} from '../../services/calendar-service';
import {
@@ -30,6 +31,7 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic
import { RuleService, RuleType } from '../../services/rule-service';
import { TranslationService } from '../../services/translation-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
@@ -124,17 +126,39 @@ const explanation: PlanningExplanation = {
})),
};
function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates {
const selected = new SelectedDates();
function projectionFor(
value: Parameters<typeof calendarProjectionHorizon>[0] & {
horizon: { start: string; end: string };
},
includeHoliday = false,
) {
const horizon = calendarProjectionHorizon(value)!;
const months: { monthIndex: number; year: number }[] = [];
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const cursor = new Date(`${horizon.start}T00:00:00`);
cursor.setDate(1);
while (cursor <= last) {
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
}
return { months, selections: selectedDatesForPlan(value, includeHoliday) };
}
function selectedDatesForPlan(
value: { placements: readonly PlanOutcome['placements'][number][] },
includeHoliday = false,
): SelectedDates {
const ranges: SelectedDate[] = [];
if (includeHoliday) {
const holiday = new Date('2024-05-08T00:00:00');
selected.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
ranges.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
}
for (const placement of value.placements) {
const date = new Date(`${placement.date}T00:00:00`);
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
ranges.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
}
return selected;
return new SelectedDates(ranges);
}
function placementForSelection(selection: CounterfactualSelection): LeavePlacement {
@@ -208,11 +232,9 @@ function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExpla
return {
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),
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
},
};
}
@@ -474,7 +496,9 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const before = {
balances: structuredClone(input.vacationNumberSignal()),
remaining: structuredClone(input.remainingVacationDaysSignal()),
displayed: structuredClone(
TestBed.inject(PlanningCalculationService).displayedPlan(),
),
declarations: structuredClone(input.declaredDaysSignal()),
rules: structuredClone(rules.rules),
};
@@ -506,9 +530,11 @@ describe('CalendarYear planning outcome boundary', () => {
];
expect(statistics()).toEqual(expectedStatistics);
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
expect(input.vacationNumberSignal()).toEqual(before.balances);
expect(input.remainingVacationDaysSignal()).toEqual(before.remaining);
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual(
before.displayed,
);
expect(input.declaredDaysSignal()).toEqual(before.declarations);
expect(rules.rules).toEqual(before.rules);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
@@ -525,7 +551,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
await fixture.whenStable();
expect(statistics()).toEqual(expectedStatistics);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
},
);
@@ -1047,7 +1073,6 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const input = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
@@ -1056,10 +1081,10 @@ describe('CalendarYear planning outcome boundary', () => {
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
input.requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
input.requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
const otherControl = document.createElement('button');
otherControl.textContent = 'Another control';
@@ -1071,7 +1096,7 @@ describe('CalendarYear planning outcome boundary', () => {
oldOutcome.resolve(plan);
await fixture.whenStable();
fixture.detectChanges();
expect(input.planningOutcomeSignal()).toBe(replacement);
expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement);
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
if (replacement.kind === 'Plan') {
expect(opener).not.toBeNull();
@@ -1747,7 +1772,7 @@ describe('CalendarYear planning outcome boundary', () => {
},
]);
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
const submitted = TestBed.inject(PlanningCalculationService).request()!;
calendar.createPlanningCalculation.mockReturnValue({
...calculation,
outcome: pending.promise,
@@ -1849,11 +1874,13 @@ describe('CalendarYear planning outcome boundary', () => {
}).compileComponents();
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-01', '2024-12-31');
const input = TestBed.inject(UserInputService);
const duration = TestBed.inject(PlanningDurationService);
calendar.createPlanningCalculation.mockImplementation(() => ({
...calculation,
snapshot: { ...calculation.snapshot, ...duration.request()! },
snapshot: {
...calculation.snapshot,
...TestBed.inject(PlanningCalculationService).request()!,
},
outcome:
calendar.createPlanningCalculation.mock.calls.length === 1
? old.promise
@@ -1863,7 +1890,7 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
now = 11_000;
settings.setPlanningDates('2024-01-01', '2026-12-31');
input.requestComputation();
TestBed.inject(PlanningCalculationService).start();
now = 12_000; // Rendering/scheduling after the request is part of the wait.
fixture.detectChanges();
const initial = duration.estimate();
@@ -1885,7 +1912,10 @@ describe('CalendarYear planning outcome boundary', () => {
const revisitedOutcome = deferred<PlanningOutcome>();
calendar.createPlanningCalculation.mockReturnValueOnce({
...calculation,
snapshot: { ...calculation.snapshot, ...duration.request()! },
snapshot: {
...calculation.snapshot,
...TestBed.inject(PlanningCalculationService).request()!,
},
outcome: revisitedOutcome.promise,
});
now = 631_000; // Returning to the calendar is a fresh initial calculation.
@@ -1947,7 +1977,7 @@ describe('CalendarYear planning outcome boundary', () => {
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();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
@@ -1958,6 +1988,146 @@ describe('CalendarYear planning outcome boundary', () => {
},
);
it('does not calibrate or announce a previous result when the planner is remounted', async () => {
const { calendar } = calendarFor(plan, explanation);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan, explanation))
.mockReturnValueOnce({
...calculationFor(plan, explanation),
outcome: new Promise(() => undefined),
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const record = vi.spyOn(TestBed.inject(PlanningDurationService), 'recordCompletion');
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
};
const first = TestBed.createComponent(CalendarYear);
await settle(first);
expect(record).toHaveBeenCalledTimes(1);
first.destroy();
const second = TestBed.createComponent(CalendarYear);
await settle(second);
expect(record).toHaveBeenCalledTimes(1);
expect(second.componentInstance['liveMessage']()).toBe(
second.componentInstance['tr']().loading,
);
});
it('does not announce a previous rejected explanation when the planner is remounted', async () => {
const { calendar } = calendarFor(plan, explanation);
const rejected = {
...calculationFor(plan, explanation),
explanation: Promise.reject(new Error('explanation exploded')),
};
rejected.explanation.catch(() => undefined);
calendar.createPlanningCalculation.mockReturnValueOnce(rejected).mockReturnValueOnce({
...calculationFor(plan, explanation),
outcome: new Promise(() => undefined),
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const settle = async (fixture: ComponentFixture<CalendarYear>) => {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
};
const first = TestBed.createComponent(CalendarYear);
await settle(first);
expect(first.componentInstance['liveMessage']()).toContain('explanation exploded');
first.destroy();
const second = TestBed.createComponent(CalendarYear);
await settle(second);
expect(second.componentInstance['liveMessage']()).toBe(
second.componentInstance['tr']().loading,
);
});
it('announces loading and keeps focus when a calculation starts during a cancellation', async () => {
const first = deferred<PlanningOutcome>();
const { calendar, calculation } = calendarFor(plan, undefined);
calendar.createPlanningCalculation
.mockReturnValueOnce({ ...calculation, outcome: first.promise })
.mockReturnValueOnce({ ...calculation, outcome: new Promise(() => undefined) });
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
const live = () => fixture.componentInstance['liveMessage']();
const tr = fixture.componentInstance['tr']();
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.cancel-calculation')!);
fixture.detectChanges();
expect(live()).toBe(tr.planning.cancelling);
const outside = document.createElement('button');
document.body.append(outside);
outside.focus();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
first.resolve(plan);
await fixture.whenStable();
fixture.detectChanges();
expect(document.activeElement).toBe(outside);
expect(live()).toBe(tr.loading);
outside.remove();
});
it.each([
['a delivered explanation error', false],
['a rejected explanation', true],
])('announces only %s when it rejected', async (_, rejected) => {
const reason = { code: 'explanation-error', message: 'Explanation boom' };
const { calendar } = calendarFor(plan, undefined);
calendar.createPlanningCalculation.mockReturnValue({
...calculationFor(plan, undefined),
explanation: rejected
? Promise.reject(new Error(reason.message))
: Promise.resolve(undefined),
explanationError: Promise.resolve(rejected ? undefined : reason),
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const service = TestBed.inject(PlanningCalculationService);
expect(service.explanationError()).toMatchObject(reason);
expect(service.explanationRejected()).toBe(rejected);
expect(fixture.componentInstance['liveMessage']().includes(reason.message)).toBe(rejected);
});
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -2051,7 +2221,7 @@ describe('CalendarYear planning outcome boundary', () => {
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
settings.setPlanningDates('2021-05-14', '2021-05-14');
TestBed.inject(UserInputService).requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
oldOutcome.resolve(plan);
@@ -2117,7 +2287,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(calculate).toHaveBeenCalledTimes(1);
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
input.requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
@@ -2156,6 +2326,9 @@ describe('CalendarYear planning outcome boundary', () => {
['PlanningIncomplete without incumbent', incomplete],
] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => {
const { calendar } = calendarFor(outcome);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan, explanation))
.mockReturnValueOnce(calculationFor(outcome));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2163,20 +2336,27 @@ describe('CalendarYear planning outcome boundary', () => {
{ 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();
const service = TestBed.inject(PlanningCalculationService);
const settle = async () => {
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
};
await settle();
const root = fixture.nativeElement as HTMLElement;
expect(service.displayedPlan()).toBeDefined();
expect(
Array.from(root.querySelectorAll('.plan-summary dd')).map((e) => e.textContent?.trim()),
).toEqual(['2', '4', '6']);
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
service.start();
await settle();
expect(service.displayedPlan()).toBeUndefined();
expect(root.querySelector('.plan-summary')).toBeNull();
expect(root.querySelectorAll('button.selected')).toHaveLength(0);
});
it('keeps and summarizes a returned result', async () => {
@@ -2188,14 +2368,14 @@ describe('CalendarYear planning outcome boundary', () => {
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const userInput = TestBed.inject(UserInputService);
userInput.remainingVacationDaysSignal.set([]);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
expect(
TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances,
).toEqual(plan.remainingBalances);
const summaryValues = Array.from(
fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf<HTMLElement>,
).map((element) => element.textContent?.trim());
@@ -2340,9 +2520,7 @@ describe('CalendarYear planning outcome boundary', () => {
placeType: 'cp',
},
];
calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
calendar.project.mockImplementation((value) => projectionFor(value, true));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -2547,7 +2725,7 @@ describe('CalendarYear planning outcome boundary', () => {
);
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
TestBed.inject(UserInputService).requestComputation();
TestBed.inject(PlanningCalculationService).start();
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -2952,14 +3130,12 @@ 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),
outcome: Promise.reject(new Error('planner exploded')),
})),
selectedDatesForPlan: vi.fn(() => new SelectedDates()),
project: vi.fn((value: Parameters<typeof projectionFor>[0]) => projectionFor(value)),
};
await TestBed.configureTestingModule({
imports: [CalendarYear],
@@ -2968,15 +3144,6 @@ describe('CalendarYear planning outcome boundary', () => {
{ 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();
@@ -2988,6 +3155,6 @@ describe('CalendarYear planning outcome boundary', () => {
if (outcome?.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('planner-error');
expect(fixture.componentInstance['isLoading']()).toBe(false);
expect(userInput.remainingVacationDaysSignal()).toEqual([]);
expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined();
});
});
+106 -267
View File
@@ -33,26 +33,17 @@ import {
import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month';
import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import {
CalendarService,
calendarProjectionHorizon,
SelectedDates,
SelectableDayType,
VacDay,
} from '../../services/calendar-service';
import { CalendarService, SelectedDates } from '../../services/calendar-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import {
CounterfactualAnalysis,
CounterfactualSelection,
LeaveType,
DeclaredNonWorkingDay,
PlanExplanation,
PlanOutcome,
PlanningCalculationContext,
PlanningExplanation,
PlanningObjective,
PlanningObjectiveVector,
PlanningOutcome,
@@ -89,6 +80,7 @@ export class CalendarYear {
private readonly calendarService = inject(CalendarService);
private readonly settings = inject(CalendarSettingsService);
private readonly duration = inject(PlanningDurationService);
private readonly calculations = inject(PlanningCalculationService);
private readonly injector = inject(Injector);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
@@ -101,13 +93,13 @@ export class CalendarYear {
private statisticsDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(false);
protected readonly isCancelling = signal(false);
protected readonly isLoading = this.calculations.isCalculating;
protected readonly isCancelling = this.calculations.isCancelling;
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
protected readonly loadingProgress = signal(0);
protected readonly planningOutcome = this.userInput.planningOutcomeSignal;
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
protected readonly explanationError = signal<PlanningReason | null>(null);
protected readonly loadingProgress = this.calculations.progress;
protected readonly planningOutcome = this.calculations.outcome;
protected readonly planningExplanation = this.calculations.explanation;
protected readonly explanationError = this.calculations.explanationError;
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
@@ -118,8 +110,7 @@ export class CalendarYear {
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(null);
protected readonly liveMessage = signal('');
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
protected readonly planningHorizon = this.calculations.displayedHorizon;
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
private readonly declarationDrag = signal<{
@@ -144,21 +135,21 @@ export class CalendarYear {
}
return { dates, type: this.userInput.declaredTypeSignal() };
});
private readonly resultDeclaredDays = signal<readonly DeclaredNonWorkingDay[]>([]);
protected readonly calendarHorizon = computed(() =>
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
);
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
: (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays());
: (this.calculations.accepted()?.declaredDays ?? []);
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
private calculationVersion = 0;
private counterfactualVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
private runningCalculation: PlanningCalculationContext | null = null;
private wasCancelling = false;
private readonly initialAccepted = this.calculations.accepted();
private readonly initialExplanation = this.calculations.explanation();
private readonly initialExplanationError = this.calculations.explanationError();
private displayDateLocale = '';
private displayDateFormatter?: Intl.DateTimeFormat;
@@ -166,7 +157,7 @@ export class CalendarYear {
return this.translationService.tr;
}
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
protected readonly displayedPlan = this.calculations.displayedPlan;
protected readonly weekendConnectedHolidays = computed(() => {
const isWeekend = (date: string) => {
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
@@ -277,20 +268,22 @@ export class CalendarYear {
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly calendarSelectedDates = computed(() => {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
return horizon
? this.calendarService.selectedDatesForPlan({
placements: [],
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
})
: new SelectedDates();
}
return this.selectedDates();
protected readonly calendarProjection = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return undefined;
if (this.declarationEntry())
return this.calendarService.project(
{ placements: [], horizon, declaredDays: this.userInput.declaredDaysSignal() },
{ fixedMonths: true },
);
const plan = this.displayedPlan();
return this.calendarService.project(
plan ? { ...plan, horizon: plan.horizon ?? horizon } : { placements: [], horizon },
);
});
protected readonly calendarSelectedDates = computed(
() => this.calendarProjection()?.selections ?? new SelectedDates(),
);
protected readonly currentPlacementDate = computed(() => {
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
@@ -300,26 +293,7 @@ export class CalendarYear {
this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined,
);
protected readonly months = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return [];
const plan = this.displayedPlan();
const projectionHorizon =
!this.declarationEntry() && plan
? (calendarProjectionHorizon(plan) ?? horizon)
: horizon;
const first = new Date(`${projectionHorizon.start}T00:00:00`);
const last = new Date(`${projectionHorizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
cursor.setMonth(cursor.getMonth() + 1);
}
return result;
});
protected readonly months = computed(() => this.calendarProjection()?.months ?? []);
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
@@ -342,188 +316,75 @@ export class CalendarYear {
});
});
effect(() => {
this.userInput.computeTriggerSignal();
untracked(() => this.startCalculation());
if (this.calculations.pendingId() !== null)
untracked(() => this.liveMessage.set(this.tr().loading));
});
}
private startCalculation(): void {
const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now();
this.userInput.computationRequestedAtMs.set(null);
const request = this.duration.request();
if (!request) return;
const { horizon, rules: currentRules } = request;
this.declarationEntry.set(false);
if (!this.planningHorizon()) this.planningHorizon.set(horizon);
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;
let completed = false;
try {
calculation = this.calendarService.createPlanningCalculation({
...request,
balances: request.balances.map((balance) => ({
...balance,
label: this.translationService.getDayTypeLabel(balance.type),
})),
labels: Object.fromEntries(
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.calculationVersion)
this.loadingProgress.set(this.stageProgress(stage));
},
effect(() => {
const pending = this.calculations.pendingId() !== null;
const cancelling = this.isCancelling();
untracked(() => {
if (this.wasCancelling && !pending) this.resultsTitle()?.nativeElement.focus();
this.wasCancelling = cancelling;
});
} catch (error) {
this.resetCalculationState(horizon);
this.planningHorizon.set(horizon);
this.failCalculation(error, version);
return;
}
this.runningCalculation = calculation;
void calculation.outcome
.then((outcome) => {
if (version !== this.calculationVersion) return;
this.resetCalculationState(horizon);
this.activeCalculation = calculation;
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
this.planningHorizon.set(horizon);
this.applyOutcome(outcome);
this.loadArtifacts(calculation);
completed = outcome.kind === 'Plan' && !this.isCancelling();
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
this.resetCalculationState(horizon);
this.activeCalculation = calculation;
this.userInput.planningSnapshotSignal.set(calculation.snapshot);
this.planningHorizon.set(horizon);
this.applyOutcome({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [
{
code: 'planner-error',
message:
error instanceof Error
? error.message
: 'The planner failed unexpectedly.',
},
],
});
this.loadArtifacts(calculation);
})
.finally(() => {
if (version !== this.calculationVersion) return;
this.runningCalculation = null;
this.loadingProgress.set(100);
this.isLoading.set(false);
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
this.isCancelling.set(false);
if (completed && !this.destroyRef.destroyed)
});
effect(() => {
const accepted = this.calculations.accepted();
if (!accepted || accepted === this.initialAccepted) return;
untracked(() => {
this.closeStatistics();
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
this.clearCounterfactual();
this.liveMessage.set(this.outcomeLabel(accepted.outcome));
if (accepted.successful && !this.destroyRef.destroyed)
afterNextRender(
() => {
if (version === this.calculationVersion && !this.destroyRef.destroyed)
if (
this.calculations.accepted() === accepted &&
!this.destroyRef.destroyed &&
accepted.snapshot
)
this.duration.recordCompletion(
calculation.snapshot,
performance.now() - requestedAt,
accepted.snapshot,
performance.now() - accepted.requestedAt,
);
},
{ injector: this.injector },
);
});
});
effect(() => {
const explanation = this.calculations.explanation();
if (explanation?.kind !== 'PlanExplanation' || explanation === this.initialExplanation)
return;
untracked(() => {
this.selectPlacement(0, false);
const outcome = this.planningOutcome();
this.liveMessage.set(
`${outcome ? this.outcomeLabel(outcome) : ''} ${this.placementAnnouncement()}`.trim(),
);
});
});
effect(() => {
const reason = this.explanationError();
if (
reason &&
reason !== this.initialExplanationError &&
this.calculations.explanationRejected()
)
untracked(() =>
this.liveMessage.set(
`${this.tr().planning.explanationError} ${reason.message}`,
),
);
});
this.calculations.start();
}
protected cancelCalculation(): void {
if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
this.isCancelling.set(true);
if (!this.isLoading() || this.isCancelling()) return;
this.calculations.cancel();
this.liveMessage.set(this.tr().planning.cancelling);
this.runningCalculation.cancel();
}
private failCalculation(error: unknown, version: number): void {
if (version !== this.calculationVersion) return;
const reason = {
code: 'planner-error',
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
};
this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] });
this.explanationError.set(reason);
this.loadingProgress.set(100);
this.isLoading.set(false);
}
private resetCalculationState(horizon: { start: string; end: string }): void {
this.closeStatistics();
this.activeCalculation = null;
this.userInput.planningSnapshotSignal.set(null);
this.planningOutcome.set(null);
this.planningExplanation.set(null);
this.explanationError.set(null);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false);
this.clearCounterfactual();
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
this.userInput.remainingVacationDaysSignal.set([]);
}
private loadArtifacts(calculation: PlanningCalculationContext): void {
void calculation.explanation
.then((explanation) => {
if (calculation !== this.activeCalculation) return;
this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation') {
this.selectPlacement(0, false);
this.liveMessage.set(
`${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
);
}
})
.catch((error: unknown) => {
if (calculation !== this.activeCalculation) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
void calculation.explanationError
.then((reason) => {
if (calculation === this.activeCalculation)
this.explanationError.set(reason ?? null);
})
.catch((error: unknown) => {
if (calculation !== this.activeCalculation) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
this.explanationError.set(reason);
this.liveMessage.set(`${this.tr().planning.explanationError} ${reason.message}`);
});
}
private applyOutcome(outcome: PlanningOutcome): void {
this.planningOutcome.set(outcome);
this.liveMessage.set(this.outcomeLabel(outcome));
const plan = this.displayedPlan();
this.resultDeclaredDays.set(
plan?.declaredDays ?? this.activeCalculation?.snapshot.declaredDays ?? [],
);
if (plan) {
this.planningHorizon.set(plan.horizon);
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
return;
}
this.selectedDates.set(
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
);
this.userInput.remainingVacationDaysSignal.set([]);
}
protected formatStatistic(value: number | null): string {
@@ -829,7 +690,7 @@ export class CalendarYear {
selection: CounterfactualSelection,
openDialog = false,
): Promise<void> {
const calculation = this.activeCalculation;
const calculation = this.calculations.accepted()?.calculation;
if (!calculation) return;
this.counterfactualSelection = selection;
const version = ++this.counterfactualVersion;
@@ -844,7 +705,10 @@ export class CalendarYear {
);
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
if (
version !== this.counterfactualVersion ||
calculation !== this.calculations.accepted()?.calculation
)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
@@ -855,7 +719,10 @@ export class CalendarYear {
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
if (
version !== this.counterfactualVersion ||
calculation !== this.calculations.accepted()?.calculation
)
return;
const message = error instanceof Error ? error.message : String(error);
this.counterfactualError.set(message);
@@ -920,10 +787,10 @@ export class CalendarYear {
}
protected ruleLabel(ruleId: string): string {
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId;
const rule = this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === ruleId);
return rule?.name ?? this.calculations.snapshot()?.labels[ruleId] ?? ruleId;
}
protected balanceLabel(explanation: PlacementExplanation): string {
@@ -955,9 +822,9 @@ export class CalendarYear {
protected objectiveCopy(id: string) {
if (id.startsWith('optimization:')) {
const ruleId = id.slice('optimization:'.length);
const rule = this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === ruleId,
);
const rule = this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === ruleId);
const copy = this.tr().planning.explanation.objectives['optimization'];
return {
...copy,
@@ -975,9 +842,9 @@ export class CalendarYear {
const copy = this.tr().planning.explanation.objectives[key];
const rule =
key === 'preference'
? this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === id.slice('preference:'.length),
)
? this.calculations
.snapshot()
?.rules.find((candidate) => candidate.id === id.slice('preference:'.length))
: undefined;
return (
copy && {
@@ -1062,32 +929,4 @@ export class CalendarYear {
: this.tr().planning.cancelled;
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
}
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:') || stage.startsWith('optimization:')) return 50;
if (stage === 'holiday-week-value') return 58;
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();
}
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,
}));
}
}
@@ -7,12 +7,19 @@ import { MatDialog } from '@angular/material/dialog';
import { MatDialogHarness } from '@angular/material/dialog/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing';
import { vi } from 'vitest';
import { vi, type MockInstance } from 'vitest';
import { UserInput } from './user-input';
import { UserInputService } from '../../services/user-input-service';
import { CalendarService } from '../../services/calendar-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { UserSettings } from '../user-settings/user-settings';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service';
import {
createPlanningCalculation,
type PlanningCalculationContext,
type PlanningOutcome,
PlanningRequest,
} from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
const presets = createOptimizationPresetRules();
@@ -21,6 +28,8 @@ describe('UserInput', () => {
let component: UserInput;
let fixture: ComponentFixture<UserInput>;
let userInputService: UserInputService;
let createCalculation: MockInstance<CalendarService['createPlanningCalculation']>;
let calculations: PlanningCalculationService;
beforeEach(async () => {
localStorage.clear();
@@ -45,6 +54,11 @@ describe('UserInput', () => {
fixture = TestBed.createComponent(UserInput);
component = fixture.componentInstance;
userInputService = TestBed.inject(UserInputService);
calculations = TestBed.inject(PlanningCalculationService);
// Unless a test supplies a result, a started calculation stays pending.
createCalculation = vi
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
.mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never);
fixture.detectChanges();
await fixture.whenStable();
}
@@ -91,16 +105,25 @@ describe('UserInput', () => {
async function calculate(request: PlanningRequest): Promise<void> {
const browserWindow = globalThis.window;
let calculation: ReturnType<typeof createPlanningCalculation>;
let calculation: PlanningCalculationContext;
try {
vi.stubGlobal('window', undefined);
calculation = createPlanningCalculation(request);
} finally {
vi.stubGlobal('window', browserWindow);
}
const outcome = await calculation.outcome;
userInputService.planningSnapshotSignal.set(calculation.snapshot);
userInputService.planningOutcomeSignal.set(outcome);
await show(calculation);
}
async function show(
calculation: PlanningCalculationContext,
outcome?: PlanningOutcome,
): Promise<void> {
const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation;
createCalculation.mockReturnValueOnce(shown);
calculations.start();
await shown.outcome;
await Promise.resolve();
fixture.detectChanges();
}
@@ -132,6 +155,33 @@ describe('UserInput', () => {
expect(card.textContent).toBe(explanation);
});
it('clears the remaining-days summary when a valid plan is replaced by a conflict', async () => {
await calculate({
horizon: { start: '2027-05-01', end: '2027-05-10' },
balances: [
{
id: 'other',
type: 'other',
quantity: 1,
availableFrom: '2027-05-10',
expiryDate: '2028-05-10',
},
],
rules: [],
});
const root = fixture.nativeElement as HTMLElement;
expect(calculations.displayedPlan()).toBeDefined();
expect(root.querySelector('.warning-card')).not.toBeNull();
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningConflict',
reasons: [],
conflictingRuleIds: ['rule'],
});
expect(calculations.displayedPlan()).toBeUndefined();
expect(root.querySelector('.warning-card')).toBeNull();
});
it('explains usable leave kept for later and incomplete balances without attempt controls', async () => {
TestBed.inject(TranslationService).setLanguage('fr');
await calculate({
@@ -155,19 +205,18 @@ describe('UserInput', () => {
expect(card.textContent).toContain('Aucune action nécessaire');
expect(card.textContent).not.toContain('Disponible seulement');
expect(root.querySelector('.remaining-history')).toBeNull();
const outcome = JSON.stringify(userInputService.planningOutcomeSignal());
const outcome = JSON.stringify(calculations.outcome());
expect(card.querySelector('details')).toBeNull();
expect(card.textContent).not.toContain('trace');
expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome);
expect(JSON.stringify(calculations.outcome())).toBe(outcome);
const plan = userInputService.displayedPlanSignal()!;
userInputService.planningOutcomeSignal.set({
const plan = calculations.displayedPlan()!;
await show(calculations.accepted()!.calculation!, {
kind: 'PlanningIncomplete',
incumbent: plan,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
fixture.detectChanges();
expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive');
expect(card.textContent).not.toContain('Conservé pour plus tard');
expect(root.querySelector('.remaining-history')).toBeNull();
@@ -185,7 +234,7 @@ describe('UserInput', () => {
TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03');
fixture.detectChanges();
expect(estimateText()).not.toBe(initial);
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
});
it('saves both calendar selections as inclusive planning dates without recalculating', async () => {
@@ -218,7 +267,7 @@ describe('UserInput', () => {
start: '2024-02-27',
lastPlanningDate: '2024-03-09',
});
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
});
it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => {
@@ -233,11 +282,11 @@ describe('UserInput', () => {
expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error');
calculate.click();
component.triggerComputation();
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
editInput('input[name="planningStart"]', '29/02/2024');
expect(calculate.disabled).toBe(false);
calculate.click();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(createCalculation).toHaveBeenCalledTimes(1);
});
it.each(['', '30/02/2024', 'not-a-date', '2'])(
@@ -282,7 +331,7 @@ describe('UserInput', () => {
expect(last.getAttribute('aria-invalid')).toBe('false');
expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error');
expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' });
expect(userInputService.computeTriggerSignal()).toBe(0);
expect(createCalculation).not.toHaveBeenCalled();
});
it('should initialize with vacation days from service', () => {
@@ -518,7 +567,7 @@ describe('UserInput', () => {
fixture.nativeElement.querySelector('.calculate-button').click();
await fixture.whenStable();
expect(userInputService.computeTriggerSignal()).toBe(1);
expect(createCalculation).toHaveBeenCalledTimes(1);
expect(userInputService.vacationNumberSignal()).toEqual(initialBalances);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
+9 -11
View File
@@ -15,6 +15,7 @@ import { VacDay } from '../../services/calendar-service';
import type { LeaveType } from '../../services/leave-planner.service';
import { TranslationService } from '../../services/translation-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningCalculationService } from '../../services/planning-calculation.service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import { addPlanningDays, isPlanningDate } from '../../services/planning-date';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
@@ -45,6 +46,7 @@ export class UserInput {
protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other'];
protected readonly calendarSettings = inject(CalendarSettingsService);
protected readonly duration = inject(PlanningDurationService);
private readonly calculations = inject(PlanningCalculationService);
protected readonly translationService = inject(TranslationService);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
protected readonly planningDates = computed(() => {
@@ -72,22 +74,21 @@ export class UserInput {
}
protected readonly remainingDays = computed(() =>
(this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce(
(this.calculations.displayedPlan()?.remainingBalances ?? []).reduce(
(total, balance) => total + balance.quantity,
0,
),
);
protected readonly hasAtRiskDays = computed(() =>
this.userInputService
.displayedPlanSignal()
this.calculations
.displayedPlan()
?.remainingBalances.some((balance) => balance.classification !== 'deferred'),
);
protected remainingDaysDetails = computed(() => {
const plan = this.userInputService.displayedPlanSignal();
const plan = this.calculations.displayedPlan();
if (!plan) return [];
const incomplete =
this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete';
const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete';
const copy = this.tr().userInput;
return plan.remainingBalances.map((balance) => {
const code =
@@ -147,10 +148,7 @@ export class UserInput {
}
private ruleName(id: string): string {
return (
this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id)
?.name ?? id
);
return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id;
}
protected hasRemainingDays = computed(() => this.remainingDays() > 0);
@@ -178,6 +176,6 @@ export class UserInput {
public triggerComputation() {
if (this.calendarSettings.planningHorizonError()) return;
this.userInputService.requestComputation();
this.calculations.start();
}
}
+134 -111
View File
@@ -46,21 +46,18 @@ describe('CalendarService', () => {
const previousTimeZone = process.env['TZ'];
process.env['TZ'] = 'Europe/Paris';
try {
const selected = service.selectedDatesForPlan({
const { selections: selected } = service.project({
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);
const day = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.isStart(day(periodStart))).toBe(true);
expect(selected.isEnd(day(periodStart))).toBe(false);
expect(selected.isStart(day(date))).toBe(false);
expect(selected.isEnd(day(date))).toBe(true);
expect(selected.isSelected(day(end))).toBe(false);
} finally {
if (previousTimeZone === undefined) delete process.env['TZ'];
else process.env['TZ'] = previousTimeZone;
@@ -70,7 +67,7 @@ describe('CalendarService', () => {
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({
const { selections: selected } = service.project({
horizon,
placements: [
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
@@ -107,21 +104,15 @@ describe('CalendarService', () => {
],
});
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
const selected = service.selectedDatesForPlan(outcome);
const selected = service.project(outcome).selections;
const date = (key: string) => new Date(`${key}T00:00:00`);
expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER);
expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND);
expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP);
expect(selected.isSelected(date('2024-06-05'))).toBe(false);
expect(
selected.datesSelected
.find(
(selection) =>
selection.type === null &&
selection.range.start?.getTime() === date('2024-05-31').getTime(),
)
?.getLengthInDays(),
).toBe(4);
expect(selected.isStart(date('2024-05-31'))).toBe(true);
expect(selected.isEnd(date('2024-06-03'))).toBe(true);
expect(selected.isEnd(date('2024-06-02'))).toBe(false);
expect(outcome.placements).toEqual([]);
expect(outcome.horizon).toEqual(horizon);
});
@@ -134,7 +125,7 @@ describe('CalendarService', () => {
];
const before = { horizon, declaredDays, placements: [] };
expect(calendarProjectionHorizon(before)).toEqual(horizon);
expect(service.selectedDatesForPlan(before).isSelected(new Date('2024-08-01'))).toBe(false);
expect(service.project(before).selections.isSelected(new Date('2024-08-01'))).toBe(false);
const after = {
...before,
placements: [
@@ -147,7 +138,7 @@ describe('CalendarService', () => {
},
],
};
const selected = service.selectedDatesForPlan(after);
const selected = service.project(after).selections;
expect(calendarProjectionHorizon(after)).toEqual({
start: horizon.start,
end: '2024-08-02',
@@ -201,52 +192,60 @@ describe('CalendarService', () => {
});
});
describe('getSelectedDatesForYear', () => {
it('should return selected dates for the year', () => {
const selectedDates = service.getSelectedDatesForYear();
expect(selectedDates).toBeDefined();
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
describe('project', () => {
const project = (horizon: { start: string; end: string }) =>
service.project({ placements: [], horizon });
const day = (key: string) => new Date(`${key}T00:00:00`);
it('projects weekends, holidays and today for the default planning horizon', () => {
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY);
const horizon = service.getDefaultPlanningHorizon();
expect(project(horizon).selections.getClasses(day(horizon.start))).toContain(
DayType.TODAY,
);
});
it('should include weekends', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasWeekend = selectedDates._datesSelected.some(
(date) => date.type === DayType.WEEKEND,
);
expect(hasWeekend).toBe(true);
it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 },
{ monthIndex: 11, year: 2024 },
{ monthIndex: 0, year: 2025 },
{ monthIndex: 1, year: 2025 },
]);
});
it('should include holidays', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasHoliday = selectedDates._datesSelected.some(
(date) => date.type === DayType.CLOSED_DAY,
);
expect(hasHoliday).toBe(true);
});
it('should include today', () => {
const selectedDates = service.getSelectedDatesForYear();
const hasToday = selectedDates._datesSelected.some(
(date) => date.type === DayType.TODAY,
);
expect(hasToday).toBe(true);
it('keeps fixed months when an edge declaration widens the projection', () => {
const plan = {
placements: [],
horizon: { start: '2024-07-01', end: '2025-01-04' },
declaredDays: [{ date: '2024-07-01', type: 'cp' as const }],
};
const months = (projection: ReturnType<typeof service.project>) =>
projection.months.map(({ monthIndex, year }) => `${year}-${monthIndex}`);
expect(months(service.project(plan))[0]).toBe('2024-5');
const fixed = service.project(plan, { fixedMonths: true });
expect(months(fixed)).toEqual([
'2024-6',
'2024-7',
'2024-8',
'2024-9',
'2024-10',
'2024-11',
'2025-0',
]);
expect(fixed.selections.dayTypes(day('2024-06-30'))).toContain(DayType.WEEKEND);
expect(fixed.selections.isStart(day('2024-07-01'))).toBe(false);
});
it('includes the final partial month of a half-open one-year horizon', () => {
const selectedDates = service.getSelectedDatesForHorizon({
start: '2024-01-15',
end: '2025-01-15',
});
expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain(
DayType.WEEKEND,
);
const { selections } = project({ start: '2024-01-15', end: '2025-01-15' });
expect(selections.dayTypes(day('2025-01-04'))).toContain(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',
});
const { selections } = project({ start: '2023-12-26', end: '2027-01-03' });
for (const date of [
'2023-12-25',
'2024-04-01',
@@ -254,20 +253,34 @@ describe('CalendarService', () => {
'2026-04-06',
'2027-01-01',
]) {
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
DayType.CLOSED_DAY,
);
expect(selections.dayTypes(day(date))).toContain(DayType.CLOSED_DAY);
}
expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND);
expect(selections.dayTypes(day('2027-01-02'))).toContain(DayType.WEEKEND);
});
});
describe('setWeekendSelection', () => {
it('should add weekend for Saturday', () => {
const selectedDates = new SelectedDates();
const saturday = new Date(2024, 0, 6); // A Saturday
service.setWeekendSelection(saturday, selectedDates);
expect(selectedDates._datesSelected.length).toBeGreaterThan(0);
it('does not mutate the submitted plan or its horizon', () => {
const plan = {
horizon: { start: '2024-04-02', end: '2024-04-03' },
placements: [
{
date: '2024-04-02',
balanceId: 'cp',
type: 'cp' as const,
ruleIds: [],
supporting: false,
},
],
declaredDays: [{ date: '2024-04-03', type: 'rtt' as const }],
};
const before = structuredClone(plan);
const frozen = JSON.parse(JSON.stringify(plan), (_, value) =>
typeof value === 'object' && value ? Object.freeze(value) : value,
);
const projection = service.project(frozen);
expect(projection.selections.dayTypes(new Date('2024-04-03T00:00:00'))).toContain(
DayType.RTT,
);
expect(frozen).toEqual(before);
});
});
});
@@ -298,13 +311,6 @@ describe('SelectedDate', () => {
expect(classes).toContain(DayType.CP);
});
it('should return tooltip for selected date', () => {
const date = new Date(2024, 0, 1);
const selectedDate = new SelectedDate(DayType.CP, new DateRange<Date>(date, date));
const tooltip = selectedDate.getTooltip(date);
expect(tooltip).toBeTruthy();
});
it('should calculate length in days', () => {
const startDate = new Date(2024, 0, 1);
const endDate = new Date(2024, 0, 3);
@@ -320,47 +326,64 @@ describe('SelectedDate', () => {
});
describe('SelectedDates', () => {
it('should push selected dates', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
expect(selectedDates._datesSelected.length).toBe(1);
});
const range = (type: DayType, start: Date, end = start) =>
new SelectedDate(type, new DateRange<Date>(start, end));
it('should check if date is selected', () => {
const selectedDates = new SelectedDates();
it('selects covered dates and reports their day types', () => {
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const selectedDates = new SelectedDates([range(DayType.CP, date)]);
expect(selectedDates.isSelected(date)).toBe(true);
expect(selectedDates.dayTypes(date)).toEqual([DayType.CP]);
expect(selectedDates.getClasses(date)).toContain(DayType.CP);
});
it('should group adjacent dates', () => {
const selectedDates = new SelectedDates();
const date1 = new Date(2024, 0, 1);
const date2 = new Date(2024, 0, 2);
selectedDates._datesSelected.push(
new SelectedDate(DayType.CP, new DateRange<Date>(date1, date1)),
);
selectedDates._datesSelected.push(
new SelectedDate(DayType.RTT, new DateRange<Date>(date2, date2)),
);
selectedDates.update();
expect(selectedDates.datesSelected.length).toBeGreaterThan(2);
it('treats adjacent ranges as one period with shared boundaries', () => {
const first = new Date(2024, 0, 1);
const second = new Date(2024, 0, 2);
const third = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.RTT, second),
range(DayType.OTHER, third),
]);
expect(selectedDates.isStart(first)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(false);
expect(selectedDates.isStart(second)).toBe(false);
expect(selectedDates.isEnd(second)).toBe(false);
expect(selectedDates.isEnd(third)).toBe(true);
expect(selectedDates.dayTypes(second)).toEqual([DayType.RTT]);
});
it('should return correct classes', () => {
const selectedDates = new SelectedDates();
it('keeps separated ranges as separate periods', () => {
const first = new Date(2024, 0, 1);
const later = new Date(2024, 0, 5);
const selectedDates = new SelectedDates([
range(DayType.CP, first),
range(DayType.CP, later),
]);
expect(selectedDates.isStart(later)).toBe(true);
expect(selectedDates.isEnd(first)).toBe(true);
});
it('merges overlapping ranges and keeps every overlapping day type', () => {
const start = new Date(2024, 0, 1);
const overlap = new Date(2024, 0, 2);
const end = new Date(2024, 0, 3);
const selectedDates = new SelectedDates([
range(DayType.WEEKEND, start, overlap),
range(DayType.CLOSED_DAY, overlap, end),
]);
expect(selectedDates.dayTypes(overlap)).toEqual([DayType.WEEKEND, DayType.CLOSED_DAY]);
expect(selectedDates.isStart(start)).toBe(true);
expect(selectedDates.isEnd(end)).toBe(true);
expect(selectedDates.isStart(overlap)).toBe(false);
});
it('does not select today', () => {
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const classes = selectedDates.getClasses(date);
expect(classes).toContain(DayType.CP);
});
it('should return tooltip', () => {
const selectedDates = new SelectedDates();
const date = new Date(2024, 0, 1);
selectedDates.push(new SelectedDate(DayType.CP, new DateRange<Date>(date, date)));
const tooltip = selectedDates.getTooltip(date);
expect(tooltip).toBeTruthy();
const selectedDates = new SelectedDates([range(DayType.TODAY, date)]);
expect(selectedDates.isSelected(date)).toBe(false);
expect(selectedDates.dayTypes(date)).toEqual([]);
expect(selectedDates.getClasses(date)).toContain(DayType.TODAY);
});
});
+105 -231
View File
@@ -10,9 +10,7 @@ import {
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
PlanningReason,
PlanningStateSnapshot,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
@@ -54,6 +52,11 @@ export function calendarProjectionHorizon(plan: CalendarPlan) {
return horizon;
}
export interface CalendarProjection {
months: { monthIndex: number; year: number }[];
selections: SelectedDates;
}
@Injectable({
providedIn: 'root',
})
@@ -68,112 +71,82 @@ export class CalendarService {
return { start, end: addPlanningYears(start, 1) };
}
selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
const projectionHorizon = calendarProjectionHorizon(plan);
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) =>
!projectionHorizon ||
(day.date >= projectionHorizon.start && day.date < projectionHorizon.end),
),
]) {
selected.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(
new Date(`${placement.date}T00:00:00`),
new Date(`${placement.date}T00:00:00`),
),
),
);
}
return selected;
}
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
const _holidays = this.getHolidays();
const selectedDates: SelectedDates = new SelectedDates();
// Logic to populate selectedDates from now until the next year
return selectedDates;
}
getSelectedDatesForYear(): SelectedDates {
return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon());
}
getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates {
const start = new Date(`${horizon.start}T00:00:00`);
/** `fixedMonths` lists only the plan horizon's months, so editing declarations never adds a month. */
project(
plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] },
{ fixedMonths = false } = {},
): CalendarProjection {
const horizon = calendarProjectionHorizon(plan)!;
const firstMonth = plan.horizon.start.slice(0, 7);
const lastMonth = addPlanningDays(plan.horizon.end, -1).slice(0, 7);
const first = 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(), last.getFullYear());
const monthInHorizon: Year = [];
const cursor = new Date(start);
cursor.setDate(1);
while (cursor <= last) {
monthInHorizon.push(
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
);
cursor.setMonth(cursor.getMonth() + 1);
}
const selectedDates: SelectedDates = new SelectedDates();
// Use DateCacheService to get today's date components
const holidays = new Set(
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
);
const {
date: todayDate,
month: todayMonth,
year: todayYear,
} = this.dateCache.getTodayComponents();
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
for (const weeksInAMonth of monthInHorizon) {
for (const daysInAWeek of weeksInAMonth) {
for (const day of daysInAWeek) {
if (day) {
this.setWeekendSelection(day, selectedDates);
if (holidayTimestamps.has(day.getTime())) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
}
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)),
);
}
const months: CalendarProjection['months'] = [];
const ranges: SelectedDate[] = [];
const cursor = new Date(first);
cursor.setDate(1);
while (cursor <= last) {
const month = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
if (!fixedMonths || (month >= firstMonth && month <= lastMonth))
months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
for (const week of CalendarService.weeksInMonth(
cursor.getMonth(),
cursor.getFullYear(),
)) {
for (const day of week) {
if (!day) continue;
if (day.getDay() === 6) {
const end = new Date(day);
end.setDate(end.getDate() + 1);
ranges.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(day, end)),
);
}
if (day.getDate() === 1 && day.getDay() === 0) {
const start = new Date(day);
start.setDate(start.getDate() - 1);
ranges.push(
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, day)),
);
}
if (holidays.has(day.getTime()))
ranges.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
}
}
cursor.setMonth(cursor.getMonth() + 1);
}
selectedDates.update();
return selectedDates;
}
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, 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>(start, date)),
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) => day.date >= horizon.start && day.date < horizon.end,
),
]) {
const day = new Date(`${placement.date}T00:00:00`);
ranges.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(day, day),
),
);
}
return { months, selections: new SelectedDates(ranges) };
}
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
@@ -255,8 +228,6 @@ export type VacDay = {
/** Legacy balances omit this value; the planner then uses the horizon start. */
availableDate?: Date;
expiryDate: Date;
classification?: RemainingClassification;
reason?: PlanningReason;
};
export type Week = (Date | null)[];
@@ -285,16 +256,6 @@ const dayTypeForLeaveType: Record<LeaveType, DayType> = {
other: DayType.OTHER,
};
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
[DayType.WEEKEND]: 'Weekend',
[DayType.CLOSED_DAY]: 'Jour Férié',
[DayType.RTT]: 'RTT',
[DayType.CP]: 'Congé Payé',
[DayType.OTHER]: 'Autre',
[DayType.TODAY]: "Aujourd'hui",
[SelectableDayType.ANY]: "N'importe",
};
export const monthNames: string[] = [
'Janvier',
'Février',
@@ -354,15 +315,6 @@ export class SelectedDate implements SelectedDateInterface {
return returnType;
}
getTooltip(date: Date): string | null | undefined {
if (this.isSelected(date)) {
if (this.type) {
return tooltipTypeMapping[this.type];
}
}
return null;
}
getLengthInDays(): number {
if (this.range.start && this.range.end) {
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
@@ -372,113 +324,60 @@ export class SelectedDate implements SelectedDateInterface {
}
export class SelectedDates implements SelectedDateInterface {
_datesSelected: SelectedDate[] = [];
datesSelected: SelectedDate[] = [];
private readonly ranges: readonly SelectedDate[];
// Ranges plus synthetic null-type groups for connected ranges, so connected periods share boundaries.
private readonly grouped: readonly SelectedDate[];
private minDate: Date | null = null;
private maxDate: Date | null = null;
update(): void {
this.datesSelected = this._datesSelected.slice();
this.grouping();
constructor(ranges: readonly SelectedDate[] = []) {
this.ranges = ranges.slice();
this.grouped = SelectedDates.group(this.ranges);
}
push(selectedDate: SelectedDate) {
this._datesSelected.push(selectedDate);
this.datesSelected = this._datesSelected.slice();
this.updateMinMaxDates();
this.grouping();
}
private updateMinMaxDates(): void {
if (this._datesSelected.length === 0) {
this.minDate = null;
this.maxDate = null;
return;
}
this.minDate = this._datesSelected.reduce((min, date) => {
if (!date.range.start) return min;
if (!min) return date.range.start;
return date.range.start < min ? date.range.start : min;
}, this._datesSelected[0].range.start);
this.maxDate = this._datesSelected.reduce((max, date) => {
if (!date.range.end) return max;
if (!max) return date.range.end;
return date.range.end > max ? date.range.end : max;
}, this._datesSelected[0].range.end);
}
// make a "mother group" with type = null when two or more groups are adjacent or overlapping; keep the children groups
grouping(): void {
if (this._datesSelected.length <= 1) {
return;
}
// Sort dates by start date
const sorted = [...this._datesSelected].sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
const bStart = b.range.start?.getTime() ?? 0;
return aStart - bStart;
});
// Group adjacent or overlapping dates
private static group(ranges: readonly SelectedDate[]): SelectedDate[] {
const result = ranges.slice();
if (ranges.length <= 1) return result;
const sorted = [...ranges].sort(
(a, b) => (a.range.start?.getTime() ?? 0) - (b.range.start?.getTime() ?? 0),
);
const groups: SelectedDate[][] = [];
let currentGroup: SelectedDate[] = [sorted[0]];
let currentGroupMaxEnd = sorted[0].range.end?.getTime() ?? 0;
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const currStart = current.range.start?.getTime() ?? 0;
const currEnd = current.range.end?.getTime() ?? 0;
// Check if current date is adjacent or overlapping with the maximum end of the current group
const nextDay = new Date(currentGroupMaxEnd);
let current: SelectedDate[] = [sorted[0]];
let maxEnd = sorted[0].range.end?.getTime() ?? 0;
for (const next of sorted.slice(1)) {
const nextDay = new Date(maxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if (currStart <= nextDay.getTime()) {
currentGroup.push(current);
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
} else {
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
currentGroup = [current];
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
if ((next.range.start?.getTime() ?? 0) > nextDay.getTime()) {
if (current.length > 1) groups.push(current);
current = [];
}
current.push(next);
maxEnd = Math.max(maxEnd, next.range.end?.getTime() ?? 0);
}
// Add the last group if it has more than one element
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
// Create mother groups and add to result
const result: SelectedDate[] = [...this._datesSelected];
if (current.length > 1) groups.push(current);
for (const group of groups) {
const groupStart = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const groupEnd = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
const start = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const end = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
result.push(
new SelectedDate(
null,
new DateRange<Date>(new Date(groupStart), new Date(groupEnd)),
),
new SelectedDate(null, new DateRange<Date>(new Date(start), new Date(end))),
);
}
return result;
}
this.datesSelected = result;
/** Day types covering the date, in insertion order (today is never a selection). */
dayTypes(date: Date): readonly DayType[] {
return this.ranges
.filter((selected) => selected.type && selected.isSelected(date))
.map((selected) => selected.type!);
}
isSelected(date: Date): boolean {
return this.datesSelected.some((selectedDate) => selectedDate.isSelected(date));
return this.grouped.some((selectedDate) => selectedDate.isSelected(date));
}
private getRangeBoundaryCounts(date: Date): { starts: number; ends: number } {
let starts = 0;
let ends = 0;
for (const selected of this.datesSelected) {
for (const selected of this.grouped) {
if (selected.range.start && date.getTime() === selected.range.start.getTime()) {
starts++;
}
@@ -501,27 +400,9 @@ export class SelectedDates implements SelectedDateInterface {
return ends >= starts;
}
/**
* Check whether a given date is selected and lies strictly inside a selected range
* (i.e., it is part of at least one selected range but is not the start or end of that range).
*
* This method returns true only if:
* - `this.isSelected(dateTest)` is true (the date is considered selected), and
* - there exists at least one element in `this.datesSelected` for which
* `dateSelected.isStart(dateTest)` and `dateSelected.isEnd(dateTest)` are both false.
*
* Notes:
* - The exact equality/comparison semantics are delegated to `isSelected`, `isStart`
* and `isEnd` implementations on the respective objects.
* - `dateTest` is expected to be a valid Date. Behavior for null/undefined/invalid Date
* values is not defined here.
*
* @private
* @param dateTest - The date to test.
* @returns True if the date is an interior day of at least one selected range; otherwise false.
*/
// True when the date lies strictly inside at least one selected range.
private existInARangeButNotABoundary(dateTest: Date): boolean {
return this.datesSelected.some(
return this.grouped.some(
(dateSelected) =>
dateSelected.isSelected(dateTest) &&
!dateSelected.isStart(dateTest) &&
@@ -530,13 +411,6 @@ export class SelectedDates implements SelectedDateInterface {
}
getClasses(date: Date): string {
return this.datesSelected.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
getTooltip(date: Date): string | null | undefined {
return this.datesSelected
.map((dateSelected) => dateSelected.getTooltip(date))
.filter((tooltip) => tooltip)
.join(' + ');
return this.grouped.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
}
@@ -0,0 +1,172 @@
import { vi } from 'vitest';
import {
createPlanningCalculation,
type LeavePlacement,
type PlanningCalculationContext,
} from './leave-planner.service';
const request = { horizon: { start: '2024-01-01', end: '2025-01-01' }, balances: [], rules: [] };
const plan = {
kind: 'Plan',
placements: [{ date: '2024-01-05', balanceId: 'cp' } as unknown as LeavePlacement],
completedStages: [],
};
const state = { marker: 'state' };
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
class FakeWorker {
static all: FakeWorker[] = [];
static constructThrows = false;
static postThrows = false;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
terminate = vi.fn();
postMessage = vi.fn(() => {
if (this.throwOnPost) throw new Error('post failed');
});
private throwOnPost = FakeWorker.postThrows;
constructor() {
if (FakeWorker.constructThrows) throw new Error('no worker');
FakeWorker.all.push(this);
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
}
interface Started {
worker?: FakeWorker;
succeeded: Promise<boolean>;
deliver: () => void;
late: () => void;
}
type Flags = { construct?: boolean; post?: boolean };
const arm = (flags: Flags) => {
FakeWorker.constructThrows = !!flags.construct;
FakeWorker.postThrows = !!flags.post;
};
const jobs: Record<string, (flags: Flags) => Promise<Started>> = {
plan: async (flags) => {
arm(flags);
const progress = vi.fn();
const calculation = createPlanningCalculation({ ...request, progress });
const worker = FakeWorker.all[0];
return {
worker,
succeeded: calculation.outcome.then((outcome) => outcome.kind === 'Plan'),
deliver: () => {
worker?.emit({ type: 'result', outcome: plan });
worker?.emit({ type: 'artifacts', artifacts: {} });
},
late: () => {
worker?.emit({ type: 'progress', stage: 'late' });
expect(progress).not.toHaveBeenCalled();
},
};
},
counterfactual: async (flags) => {
const calculation = createPlanningCalculation(request);
FakeWorker.all[0].emit({ type: 'result', outcome: plan });
FakeWorker.all[0].emit({ type: 'artifacts', artifacts: {} });
await calculation.timings;
arm(flags);
const analysis = (calculation as PlanningCalculationContext).analyzeCounterfactual(
plan.placements[0],
);
const succeeded = analysis.then(
(result) => result.kind !== 'CounterfactualStale',
() => false,
);
await flush();
const worker = FakeWorker.all[1];
return {
worker,
succeeded,
deliver: () =>
worker?.emit({ type: 'counterfactual-result', analysis: { results: [] } }),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
'explain-checkpoint': async (flags) => {
const calculation = createPlanningCalculation(request);
const first = FakeWorker.all[0];
first.emit({ type: 'checkpoint', plan, state });
arm(flags);
first.emit({ type: 'error', message: 'planner crashed' });
const worker = FakeWorker.all[1];
return {
worker,
succeeded: calculation.explanation.then((explanation) => explanation !== undefined),
deliver: () =>
worker?.emit({
type: 'explanation',
explanation: { kind: 'IncompleteExplanation' },
}),
late: () => worker?.emit({ type: 'error', message: 'late' }),
};
},
};
describe('worker job lifetime', () => {
beforeEach(() => {
FakeWorker.all = [];
arm({});
vi.stubGlobal('window', {});
vi.stubGlobal('Worker', FakeWorker);
});
afterEach(() => {
arm({});
vi.unstubAllGlobals();
});
describe.each(Object.keys(jobs))('%s job', (name) => {
const start = jobs[name];
const released = (worker: FakeWorker) => {
expect(worker.terminate).toHaveBeenCalledOnce();
expect(worker.onmessage).toBeNull();
expect(worker.onerror).toBeNull();
};
it('releases the worker once on success and leaves no handler for late messages', async () => {
const job = await start({});
job.deliver();
expect(await job.succeeded).toBe(true);
released(job.worker!);
job.late();
job.deliver();
released(job.worker!);
});
it('fails without a worker when construction throws', async () => {
const job = await start({ construct: true });
expect(job.worker).toBeUndefined();
expect(await job.succeeded).toBe(false);
});
it('releases the worker once when postMessage throws', async () => {
const job = await start({ post: true });
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once on a runtime error', async () => {
const job = await start({});
job.worker!.onerror!();
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
expect(await job.succeeded).toBe(false);
released(job.worker!);
});
it('releases the worker once when a newer calculation supersedes it', async () => {
const job = await start({});
createPlanningCalculation(request);
expect(await job.succeeded).toBe(false);
released(job.worker!);
job.deliver();
released(job.worker!);
});
});
});
+110 -125
View File
@@ -17,6 +17,7 @@ import type {
StaleCounterfactualAnalysis,
} from './leave-planner.core';
import { planningDateInParis } from './planning-date';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
export type {
IfThenPlanningRule,
@@ -278,20 +279,6 @@ function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | unde
return undefined;
}
type PlannerWorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'error'; code?: string; message: string };
interface PlanningRun {
outcome: Promise<PlanningOutcome>;
explanation: Promise<PlanningExplanation | undefined>;
@@ -320,6 +307,47 @@ function interruptedOutcome(
const latestWorkerCancels = new Set<() => void>();
let latestCounterfactualCancel: (() => void) | undefined;
interface WorkerJob {
post(message: WorkerRequest, onFailure: (error: unknown) => void): void;
release(): void;
}
// Single owner of a worker's lifetime: creation, handlers, cancellation registration and an idempotent release.
function startWorkerJob(
supersede: () => void,
onMessage: (message: WorkerResponse) => void,
onError: () => void,
): WorkerJob {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
let released = false;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (event.data) onMessage(event.data);
};
worker.onerror = () => {
onError();
};
latestWorkerCancels.add(supersede);
return {
post(message, onFailure) {
try {
worker.postMessage(message);
} catch (error) {
onFailure(error);
}
},
release() {
if (released) return;
released = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
latestWorkerCancels.delete(supersede);
},
};
}
function cancelLatestWorkers(): void {
for (const cancel of [...latestWorkerCancels]) cancel();
latestWorkerCancels.clear();
@@ -349,9 +377,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
});
let cancel: (() => void) | undefined;
try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
const job = startWorkerJob(
() => supersede(),
(message) => handleMessage(message),
() => fail('worker-error', 'Planning worker failed before returning explanation data.'),
);
let outcomeSettled = false;
let cancellationRequested = false;
let cancelledRace: PlanningIncompleteOutcome | undefined;
@@ -364,9 +394,7 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
let receivedFinalState: PlanningStateSnapshot | undefined;
let livenessWatchdog: ReturnType<typeof setTimeout>;
const terminate = () => {
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
job.release();
clearTimeout(livenessWatchdog);
};
const finishOutcome = (result: PlanningOutcome) => {
@@ -434,7 +462,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
resolveArtifacts(complete);
resolveTimings(complete.timings ?? {});
terminate();
latestWorkerCancels.delete(supersede);
};
const supersede = () => {
if (artifactsSettled) return;
@@ -456,7 +483,6 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
finalState: receivedFinalState ?? checkpointState,
});
};
latestWorkerCancels.add(supersede);
const fail = (code: string, message: string) => {
const reason = { code, message };
if (!outcomeSettled) finishOutcome(interruptedOutcome(checkpoint, code, message));
@@ -508,15 +534,11 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
cancel = () => {
if (outcomeSettled || cancellationRequested) return;
cancellationRequested = true;
try {
worker.postMessage({ type: 'cancel' });
} catch {
fail('worker-error', 'The cancellation request could not reach the worker.');
}
job.post({ type: 'cancel' }, () =>
fail('worker-error', 'The cancellation request could not reach the worker.'),
);
};
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const message = event.data;
if (!message) return;
const handleMessage = (message: WorkerResponse) => {
if (message.type === 'heartbeat') {
scheduleLivenessWatchdog();
return;
@@ -552,13 +574,9 @@ function planInWorker(snapshot: TransportSnapshot): PlanningRun {
if (message.type === 'error') fail(message.code ?? 'worker-error', message.message);
else fail('worker-error', 'Planning worker returned an unexpected response.');
};
worker.onerror = () =>
fail('worker-error', 'Planning worker failed before returning explanation data.');
try {
worker.postMessage({ type: 'plan', request: { ...snapshot, progress: undefined } });
} catch {
fail('worker-error', 'Planning input could not be sent to the isolated worker.');
}
job.post({ type: 'plan', request: { ...snapshot, progress: undefined } }, () =>
fail('worker-error', 'Planning input could not be sent to the isolated worker.'),
);
} catch {
const result = interruptedOutcome(
undefined,
@@ -677,62 +695,43 @@ function counterfactualInWorker(
): Promise<CounterfactualAnalysis> {
return new Promise((resolve, reject) => {
latestCounterfactualCancel?.();
let worker: Worker;
let job: WorkerJob;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
job.release();
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
callback();
};
let settled = false;
const cancel = () =>
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
job = startWorkerJob(
cancel,
(message) => {
if (message.type === 'counterfactual-result' && message.analysis)
finish(() => resolve(message.analysis));
else if (message.type === 'error')
finish(() =>
reject(
new Error(
message.message ??
`Counterfactual analysis failed (${message.code ?? 'worker-error'}).`,
),
),
);
},
() => finish(() => reject(new Error('Counterfactual worker failed.'))),
);
} catch (error) {
reject(error);
return;
}
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
if (cancel) latestWorkerCancels.delete(cancel);
if (latestCounterfactualCancel === cancel) latestCounterfactualCancel = undefined;
callback();
};
const cancel = () =>
finish(() => reject(new Error('Counterfactual analysis was superseded.')));
latestCounterfactualCancel = cancel;
latestWorkerCancels.add(cancel);
worker.onmessage = (
event: MessageEvent<{
type: string;
analysis?: CounterfactualAnalysis;
code?: string;
message?: string;
}>,
) => {
if (event.data?.type === 'counterfactual-result' && event.data.analysis)
finish(() => resolve(event.data.analysis!));
else if (event.data?.type === 'error')
finish(() =>
reject(
new Error(
event.data.message ??
`Counterfactual analysis failed (${event.data.code ?? 'worker-error'}).`,
),
),
);
};
worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.')));
try {
worker.postMessage({
type: 'counterfactual',
request: snapshot,
plan,
placement,
state,
});
} catch (error) {
finish(() => reject(error));
}
job.post({ type: 'counterfactual', request: snapshot, plan, placement, state }, (error) =>
finish(() => reject(error)),
);
});
}
@@ -743,53 +742,39 @@ function explainCheckpointInWorker(
interruption: PlanningReason,
): Promise<Pick<PlanningArtifacts, 'explanation' | 'explanationError' | 'finalState'>> {
return new Promise((resolve, reject) => {
let worker: Worker;
try {
worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
});
} catch (error) {
reject(error);
return;
}
let job: WorkerJob;
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
worker.onmessage = null;
worker.onerror = null;
worker.terminate();
if (cancel) latestWorkerCancels.delete(cancel);
job.release();
callback();
};
const cancel = () =>
finish(() => reject(new Error('Checkpoint explanation was superseded.')));
latestWorkerCancels.add(cancel);
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
const response = event.data;
if (response?.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response?.type === 'error') finish(() => reject(new Error(response.message)));
};
worker.onerror = () =>
finish(() => reject(new Error('Checkpoint explanation worker failed.')));
try {
worker.postMessage({
type: 'explain-checkpoint',
request: snapshot,
plan,
state,
interruption,
});
job = startWorkerJob(
() => finish(() => reject(new Error('Checkpoint explanation was superseded.'))),
(response) => {
if (response.type === 'explanation')
finish(() =>
resolve({
explanation: response.explanation,
explanationError: response.explanationError,
finalState: response.finalState,
}),
);
else if (response.type === 'error')
finish(() => reject(new Error(response.message)));
},
() => finish(() => reject(new Error('Checkpoint explanation worker failed.'))),
);
} catch (error) {
finish(() => reject(error));
reject(error);
return;
}
job.post(
{ type: 'explain-checkpoint', request: snapshot, plan, state, interruption },
(error) => finish(() => reject(error)),
);
});
}
@@ -0,0 +1,60 @@
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import type { PlanningArtifacts } from './leave-planner.core';
// Message contract shared by the planning coordinator and its worker; types only.
export type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
export type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
export type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; code?: string; message: string };
+2 -58
View File
@@ -1,15 +1,7 @@
/// <reference lib="webworker" />
import type {
CounterfactualAnalysis,
LeavePlacement,
PlanOutcome,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
PlanningStateSnapshot,
} from './leave-planner.service';
import type { PlanningOutcome } from './leave-planner.service';
import type { WorkerRequest, WorkerResponse } from './leave-planner.worker-messages';
import {
analyzeCounterfactualInternal,
calculatePlanningAsyncInternal,
@@ -17,54 +9,6 @@ import {
type PlanningArtifacts,
} from './leave-planner.core';
type WorkerPlanningRequest = {
calculationDate?: string;
declaredDays?: unknown;
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
deadlineMs?: unknown;
progress?: unknown;
};
type WorkerRequest =
| {
type: 'plan';
request: WorkerPlanningRequest;
}
| { type: 'cancel' }
| {
type: 'counterfactual';
request: PlanningSnapshot;
plan: PlanOutcome;
placement: LeavePlacement;
state?: PlanningStateSnapshot;
}
| {
type: 'explain-checkpoint';
request: PlanningSnapshot;
plan: PlanOutcome;
state: PlanningStateSnapshot;
interruption: PlanningReason;
};
type WorkerResponse =
| { type: 'heartbeat' }
| { type: 'progress'; stage: string }
| { type: 'checkpoint'; plan: PlanOutcome; state?: PlanningStateSnapshot }
| { type: 'result'; outcome: PlanningOutcome }
| { type: 'artifacts'; artifacts: PlanningArtifacts }
| {
type: 'explanation';
explanation?: PlanningExplanation;
explanationError?: PlanningReason;
finalState?: PlanningStateSnapshot;
}
| { type: 'counterfactual-result'; analysis: CounterfactualAnalysis }
| { type: 'error'; code?: string; message: string };
const worker = self as DedicatedWorkerGlobalScope;
let activeCalculation: { cancelled: boolean } | undefined;
@@ -0,0 +1,301 @@
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CalendarService } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import type {
PlanOutcome,
PlanningCalculationContext,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
} from './leave-planner.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { UserInputService } from './user-input-service';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function planFor(start: string, end: string): PlanOutcome {
return {
kind: 'Plan',
horizon: { start, end },
declaredDays: [],
placements: [],
timeOffPeriods: [],
preferences: [],
remainingBalances: [],
completedStages: [],
} as unknown as PlanOutcome;
}
const explained = { kind: 'PlanExplanation', placements: [] } as unknown as PlanningExplanation;
function fakeCalculation(id: string) {
const outcome = deferred<PlanningOutcome>();
const explanation = deferred<PlanningExplanation | undefined>();
const explanationError = deferred<PlanningReason | undefined>();
const cancel = vi.fn();
const context = {
id,
cancel,
snapshot: { rules: [], labels: {}, declaredDays: [] },
outcome: outcome.promise,
explanation: explanation.promise,
explanationError: explanationError.promise,
analyzeCounterfactual: vi.fn(),
} as unknown as PlanningCalculationContext;
return { context, outcome, explanation, explanationError, cancel };
}
type Fake = ReturnType<typeof fakeCalculation>;
const flush = () => new Promise<void>((resolve) => setTimeout(resolve));
describe('PlanningCalculationService', () => {
let create: ReturnType<typeof vi.fn>;
let service: PlanningCalculationService;
function queue(...fakes: Fake[]): void {
for (const fake of fakes) create.mockReturnValueOnce(fake.context);
}
beforeEach(() => {
localStorage.clear();
create = vi.fn();
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: { createPlanningCalculation: create } },
],
});
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31');
service = TestBed.inject(PlanningCalculationService);
});
afterEach(() => {
TestBed.resetTestingModule();
localStorage.clear();
});
it('does not calculate until started and ignores input edits afterwards', async () => {
const first = fakeCalculation('first');
queue(first);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-02', 'cp');
expect(create).not.toHaveBeenCalled();
expect(service.accepted()).toBeNull();
service.start();
expect(create).toHaveBeenCalledTimes(1);
expect(service.isCalculating()).toBe(true);
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
const accepted = service.accepted()!;
expect(accepted.successful).toBe(true);
expect(service.isCalculating()).toBe(false);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-03', 'cp');
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-01', '2024-03-01');
await flush();
expect(create).toHaveBeenCalledTimes(1);
expect(service.accepted()).toBe(accepted);
expect(service.displayedHorizon()).toEqual({ start: '2024-01-01', end: '2025-01-01' });
});
it('hands the submission translated labels and leaves declaration entry', () => {
queue(fakeCalculation('first'));
const input = TestBed.inject(UserInputService);
input.declarationEntrySignal.set(true);
service.start();
expect(input.declarationEntrySignal()).toBe(false);
const submission = create.mock.calls[0][0];
expect(submission.horizon).toEqual(service.request()!.horizon);
expect(submission.balances.every((b: { label: string }) => !!b.label)).toBe(true);
expect(submission.labels).toBeDefined();
});
it('reports progress of the latest calculation only', () => {
queue(fakeCalculation('old'), fakeCalculation('latest'));
service.start();
const oldProgress = create.mock.calls[0][0].progress;
service.start();
const latestProgress = create.mock.calls[1][0].progress;
oldProgress('obligations');
expect(service.progress()).toBe(0);
latestProgress('at-risk');
expect(service.progress()).toBe(35);
latestProgress('unknown');
expect(service.progress()).toBe(35);
});
it('keeps the previous result visible until a replacement is accepted and ignores stale outcomes', async () => {
const first = fakeCalculation('first');
const stale = fakeCalculation('stale');
const latest = fakeCalculation('latest');
queue(first, stale, latest);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
const previous = service.accepted()!;
service.start();
service.start();
expect(service.accepted()).toBe(previous);
expect(service.displayedPlan()).toBe(previous.outcome);
stale.outcome.resolve(planFor('2030-01-01', '2031-01-01'));
await flush();
expect(service.accepted()).toBe(previous);
expect(service.isCalculating()).toBe(true);
latest.outcome.resolve(planFor('2026-01-01', '2027-01-01'));
await flush();
expect(service.accepted()!.calculation).toBe(latest.context);
expect(service.displayedHorizon()).toEqual({ start: '2026-01-01', end: '2027-01-01' });
expect(service.isCalculating()).toBe(false);
});
it('makes the outcome usable before its explanation and ignores stale explanations', async () => {
const first = fakeCalculation('first');
const second = fakeCalculation('second');
queue(first, second);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanation()).toBeNull();
service.start();
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
first.explanation.resolve(explained);
await flush();
expect(service.explanation()).toBeNull();
second.explanation.resolve(undefined);
second.explanationError.resolve(undefined);
await flush();
expect(service.outcome()?.kind).toBe('Plan');
});
it('ignores an explanation that arrives after a replacement was started and accepted', async () => {
const first = fakeCalculation('first');
const second = fakeCalculation('second');
queue(first, second);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
service.start();
second.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
first.explanationError.resolve({ code: 'old', message: 'old' });
await flush();
expect(service.explanationError()).toBeNull();
});
it('keeps the outcome when the explanation fails', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
first.explanation.reject(new Error('no trace'));
first.explanationError.resolve({ code: 'worker-error', message: 'broken' });
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanationError()?.message).toBeTruthy();
expect(service.explanation()).toBeNull();
});
it('keeps the outcome when the explanation error promise rejects', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
first.explanationError.reject(new Error('lost'));
await flush();
expect(service.outcome()?.kind).toBe('Plan');
expect(service.explanationError()).toEqual({ code: 'explanation-error', message: 'lost' });
});
it('turns an outcome rejection into an accepted planner error', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
first.outcome.reject(new Error('worker died'));
await flush();
const accepted = service.accepted()!;
expect(accepted.successful).toBe(false);
expect(accepted.outcome).toEqual({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'planner-error', message: 'worker died' }],
});
expect(service.isCalculating()).toBe(false);
});
it('turns a construction failure into an accepted planner error with the requested horizon', () => {
create.mockImplementation(() => {
throw new Error('cannot start');
});
service.start();
const reason = { code: 'planner-error', message: 'cannot start' };
expect(service.outcome()).toEqual({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [reason],
});
expect(service.explanationError()).toEqual(reason);
expect(service.accepted()!.calculation).toBeNull();
expect(service.displayedHorizon()).toEqual(service.request()!.horizon);
expect(service.isCalculating()).toBe(false);
});
it('cancels cooperatively once, and not when nothing is running', async () => {
const first = fakeCalculation('first');
queue(first);
service.cancel();
expect(first.cancel).not.toHaveBeenCalled();
service.start();
service.cancel();
service.cancel();
expect(first.cancel).toHaveBeenCalledTimes(1);
expect(service.isCancelling()).toBe(true);
first.outcome.resolve(planFor('2024-01-01', '2025-01-01'));
await flush();
expect(service.isCancelling()).toBe(false);
expect(service.accepted()!.successful).toBe(false);
});
it('accepts a cancelled calculation without an incumbent as incomplete, without inventing a conflict', async () => {
const first = fakeCalculation('first');
queue(first);
service.start();
service.cancel();
first.outcome.resolve({
kind: 'PlanningIncomplete',
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
await flush();
expect(service.outcome()?.kind).toBe('PlanningIncomplete');
expect(service.displayedPlan()).toBeUndefined();
expect(service.accepted()!.successful).toBe(false);
});
it('shows the incumbent of a cancelled calculation', async () => {
const first = fakeCalculation('first');
queue(first);
const incumbent = planFor('2024-01-01', '2025-01-01') as PlanOutcome;
service.start();
service.cancel();
first.outcome.resolve({
kind: 'PlanningIncomplete',
incumbent,
completedStages: [],
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
});
await flush();
expect(service.displayedPlan()).toBe(incumbent);
expect(service.accepted()!.successful).toBe(false);
});
});
@@ -0,0 +1,233 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
import { CalendarService } from './calendar-service';
import { CalendarSettingsService } from './calendar-settings-service';
import type {
DeclaredNonWorkingDay,
PlanningCalculationContext,
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningSnapshot,
} from './leave-planner.service';
import { RuleService } from './rule-service';
import { TranslationService } from './translation-service';
import { UserInputService } from './user-input-service';
type Horizon = { start: string; end: string };
interface PendingCalculation {
id: number;
horizon: Horizon;
progress: number;
cancelling: boolean;
}
export interface AcceptedCalculation {
/** Null when the planner could not even be created. */
calculation: PlanningCalculationContext | null;
outcome: PlanningOutcome;
snapshot: PlanningSnapshot | null;
horizon: Horizon;
declaredDays: readonly DeclaredNonWorkingDay[];
requestedAt: number;
successful: boolean;
}
function stageProgress(stage: string, current: number): 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:') || stage.startsWith('optimization:')) return 50;
if (stage === 'holiday-week-value') return 58;
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 current;
}
function plannerError(error: unknown): PlanningReason {
return {
code: 'planner-error',
message: error instanceof Error ? error.message : 'The planner failed unexpectedly.',
};
}
function explanationFailure(error: unknown): PlanningReason {
return {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
};
}
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
@Injectable({ providedIn: 'root' })
export class PlanningCalculationService {
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calendar = inject(CalendarService);
private readonly translation = inject(TranslationService);
private version = 0;
private running: PlanningCalculationContext | null = null;
private readonly pendingState = signal<PendingCalculation | null>(null);
private readonly acceptedState = signal<AcceptedCalculation | null>(null);
private readonly explanationState = signal<PlanningExplanation | null>(null);
private readonly explanationErrorState = signal<PlanningReason | null>(null);
private readonly explanationRejectedState = signal(false);
readonly request = computed(() => {
const horizon = this.settings.planningHorizon();
if (!horizon) return null;
return {
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
balances: this.userInput.vacationNumberSignal().map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
availableFrom: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: this.rules.rules,
};
});
readonly accepted = this.acceptedState.asReadonly();
readonly explanation = this.explanationState.asReadonly();
readonly explanationError = this.explanationErrorState.asReadonly();
readonly explanationRejected = this.explanationRejectedState.asReadonly();
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
readonly isCalculating = computed(() => this.pendingState() !== null);
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
readonly displayedPlan = computed(() => {
const outcome = this.outcome();
return outcome?.kind === 'Plan'
? outcome
: outcome?.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
});
readonly displayedHorizon = computed(
() => this.acceptedState()?.horizon ?? this.pendingState()?.horizon ?? null,
);
start(): void {
const request = this.request();
if (!request) return;
const requestedAt = performance.now();
const version = ++this.version;
this.userInput.declarationEntrySignal.set(false);
this.pendingState.set({
id: version,
horizon: request.horizon,
progress: 0,
cancelling: false,
});
let calculation: PlanningCalculationContext;
try {
calculation = this.calendar.createPlanningCalculation({
...request,
balances: request.balances.map((balance) => ({
...balance,
label: this.translation.getDayTypeLabel(balance.type),
})),
labels: Object.fromEntries(
request.rules.map((rule) => [rule.id, rule.name ?? rule.id]),
),
progress: (stage) => {
if (version === this.version)
this.pendingState.update(
(pending) =>
pending && {
...pending,
progress: stageProgress(stage, pending.progress),
},
);
},
});
} catch (error) {
const reason = plannerError(error);
this.running = null;
this.accept(null, this.incomplete(reason), request.horizon, [], requestedAt);
this.explanationErrorState.set(reason);
return;
}
this.running = calculation;
const accept = (outcome: PlanningOutcome) => {
if (version !== this.version) return false;
this.running = null;
this.accept(calculation, outcome, request.horizon, undefined, requestedAt);
return true;
};
void calculation.outcome
.then((outcome) => accept(outcome))
.catch((error: unknown) => accept(this.incomplete(plannerError(error))))
.then((accepted) => {
if (accepted) this.loadArtifacts(calculation);
});
}
cancel(): void {
const pending = this.pendingState();
if (!pending || pending.cancelling || !this.running) return;
this.pendingState.set({ ...pending, cancelling: true });
this.running.cancel();
}
private incomplete(reason: PlanningReason): PlanningOutcome {
return { kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] };
}
private accept(
calculation: PlanningCalculationContext | null,
outcome: PlanningOutcome,
requestHorizon: Horizon,
declaredDays: readonly DeclaredNonWorkingDay[] | undefined,
requestedAt: number,
): void {
const plan = outcome.kind === 'Plan' ? outcome : undefined;
const shown =
plan ?? (outcome.kind === 'PlanningIncomplete' ? outcome.incumbent : undefined);
const cancelling = this.pendingState()?.cancelling ?? false;
this.explanationState.set(null);
this.explanationErrorState.set(null);
this.explanationRejectedState.set(false);
this.pendingState.set(null);
this.acceptedState.set({
calculation,
outcome,
snapshot: calculation?.snapshot ?? null,
horizon: shown?.horizon ?? requestHorizon,
declaredDays:
shown?.declaredDays ?? declaredDays ?? calculation?.snapshot.declaredDays ?? [],
requestedAt,
successful: plan !== undefined && !cancelling,
});
}
private loadArtifacts(calculation: PlanningCalculationContext): void {
const current = () => this.acceptedState()?.calculation === calculation;
const fail = (error: unknown) => {
if (!current()) return;
this.explanationRejectedState.set(true);
this.explanationErrorState.set(explanationFailure(error));
};
calculation.explanation
.then((explanation) => current() && this.explanationState.set(explanation ?? null))
.catch(fail);
calculation.explanationError
.then((reason) => current() && this.explanationErrorState.set(reason ?? null))
.catch(fail);
}
}
@@ -5,6 +5,7 @@ import { RuleService, RuleType } from './rule-service';
import { UserInputService } from './user-input-service';
import { createPlanningCalculation } from './leave-planner.service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { OPTIMIZATION_PRESETS } from './planning-objectives';
describe('Planning duration estimate', () => {
@@ -31,7 +32,8 @@ describe('Planning duration estimate', () => {
it('offers a coarse seconds range on the first launch without calculating', () => {
const duration = TestBed.inject(PlanningDurationService);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' });
expect(TestBed.inject(UserInputService).computeTriggerSignal()).toBe(0);
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
expect(TestBed.inject(RuleService).rules).toEqual([]);
});
@@ -47,7 +49,7 @@ describe('Planning duration estimate', () => {
});
const duration = TestBed.inject(PlanningDurationService);
expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true);
duration.recordCompletion(duration.request()!, 20_000);
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
@@ -81,19 +83,20 @@ describe('Planning duration estimate', () => {
expect(duration.estimate()).toEqual(moreLeave);
settings.setPlanningDates('2026-12-31', '2024-01-01');
expect(duration.estimate()).toBeNull();
expect(input.computeTriggerSignal()).toBe(0);
expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false);
expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull();
});
it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
const submitted = TestBed.inject(PlanningCalculationService).request()!;
duration.recordCompletion(submitted, 120_000);
expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' });
});
it('calibrates against the submitted snapshot even when the form has since changed', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
const submitted = TestBed.inject(PlanningCalculationService).request()!;
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31');
duration.recordCompletion(submitted, 20_000);
expect(duration.estimate()!.maximum).toBeGreaterThan(60);
@@ -114,14 +117,16 @@ describe('Planning duration estimate', () => {
placeType: SelectableDayType.CP,
});
const duration = TestBed.inject(PlanningDurationService);
const calculation = createPlanningCalculation(duration.request()!);
const calculation = createPlanningCalculation(
TestBed.inject(PlanningCalculationService).request()!,
);
duration.recordCompletion(calculation.snapshot, 20_000);
expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' });
});
it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => {
const duration = TestBed.inject(PlanningDurationService);
const submitted = duration.request()!;
const submitted = TestBed.inject(PlanningCalculationService).request()!;
for (const elapsed of [20_000, 25_000, 900_000])
duration.recordCompletion(submitted, elapsed);
expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' });
@@ -136,7 +141,7 @@ describe('Planning duration estimate', () => {
it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => {
const duration = TestBed.inject(PlanningDurationService);
const initial = duration.estimate();
duration.recordCompletion(duration.request()!, elapsed);
duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed);
expect(duration.estimate()).toEqual(initial);
});
});
+3 -26
View File
@@ -1,9 +1,7 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import dayjs from 'dayjs';
import { CalendarSettingsService } from './calendar-settings-service';
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
import { RuleService } from './rule-service';
import { UserInputService } from './user-input-service';
import { PlanningCalculationService } from './planning-calculation.service';
export interface PlanningDurationRange {
minimum: number;
@@ -54,32 +52,11 @@ function initialDurationMs(request: PlanningRequest): number {
@Injectable({ providedIn: 'root' })
export class PlanningDurationService {
private readonly settings = inject(CalendarSettingsService);
private readonly userInput = inject(UserInputService);
private readonly rules = inject(RuleService);
private readonly calculations = inject(PlanningCalculationService);
private readonly calibration = signal<readonly number[]>([]);
readonly request = computed(() => {
const horizon = this.settings.planningHorizon();
if (!horizon) return null;
return {
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
balances: this.userInput.vacationNumberSignal().map((balance) => ({
id: balance.id,
type: balance.type,
quantity: balance.numberOfDays,
availableFrom: balance.availableDate
? dayjs(balance.availableDate).format('YYYY-MM-DD')
: undefined,
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
})),
rules: this.rules.rules,
};
});
readonly estimate = computed<PlanningDurationRange | null>(() => {
const request = this.request();
const request = this.calculations.request();
if (!request) return null;
const factors = [...this.calibration()].sort((a, b) => a - b);
const middle = Math.floor(factors.length / 2);
+2 -2
View File
@@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { RuleService, RuleType } from './rule-service';
import { vi } from 'vitest';
import { CalendarSettingsService } from './calendar-settings-service';
import { PlanningDurationService } from './planning-duration.service';
import { PlanningCalculationService } from './planning-calculation.service';
import { UserInputService } from './user-input-service';
import { SelectableDayType } from './calendar-service';
import { createPlanningCalculation } from './leave-planner.service';
@@ -260,7 +260,7 @@ describe('saved rule migration', () => {
expiryDate: new Date('2024-01-05'),
},
]);
const request = TestBed.inject(PlanningDurationService).request()!;
const request = TestBed.inject(PlanningCalculationService).request()!;
expect(request.rules).toHaveLength(6);
expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1);
vi.stubGlobal('window', undefined);
@@ -46,7 +46,6 @@ describe('UserInputService', () => {
{ date: '2024-01-02', type: 'other' },
]);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
@@ -119,7 +118,6 @@ describe('UserInputService', () => {
]);
expect(save).toHaveBeenCalledTimes(3);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
});
it('restores an empty list when saved declarations are unreadable', () => {
+2 -21
View File
@@ -1,4 +1,4 @@
import { computed, Injectable, signal } from '@angular/core';
import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
import {
@@ -6,7 +6,7 @@ import {
isWorkingPlanningDate,
parseDeclaredDays,
} from './declared-non-working-days';
import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service';
import type { LeaveType } from './leave-planner.service';
@Injectable({
providedIn: 'root',
@@ -161,20 +161,6 @@ export class UserInputService {
readonly vacationNumberSignal = signal<VacDay[]>(
this.getSavedValues() ?? this.getInitialValues(),
);
readonly remainingVacationDaysSignal = signal<VacDay[]>([]);
readonly planningOutcomeSignal = signal<PlanningOutcome | null>(null);
readonly planningSnapshotSignal = signal<PlanningSnapshot | null>(null);
readonly displayedPlanSignal = computed(() => {
const outcome = this.planningOutcomeSignal();
return outcome?.kind === 'Plan'
? outcome
: outcome?.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
});
readonly computeTriggerSignal = signal(0);
readonly computationRequestedAtMs = signal<number | null>(null);
public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void {
this.vacationNumberSignal.update(updater);
try {
@@ -194,9 +180,4 @@ export class UserInputService {
console.warn('Failed to save leave balances to localStorage.', error);
}
}
public requestComputation(): void {
this.computationRequestedAtMs.set(performance.now());
this.computeTriggerSignal.update((count) => count + 1);
}
}