Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee67ce3df | ||
|
|
a472347a5e | ||
|
|
feb651148e | ||
|
|
43677ff434 | ||
|
|
502d561f6f | ||
|
|
5cb0b93339 | ||
|
|
b69b3c3690 | ||
|
|
7fc2b33587 |
No files matched your search
@@ -65,7 +65,7 @@ jobs:
|
||||
run: bunx vitest run --coverage --maxWorkers=1 --exclude='src/app/benchmark/**'
|
||||
- name: Coverage report
|
||||
if: ${{ always() }}
|
||||
uses: https://gitea.com/actions/upload-artifact@v7
|
||||
uses: https://gitea.com/actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
- run: npm install --global bun@1.4.2
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun run build -- --configuration production
|
||||
- uses: https://gitea.com/actions/upload-artifact@v7
|
||||
- uses: https://gitea.com/actions/upload-artifact@v3
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
run: trivy fs --db-repository public.ecr.aws/aquasecurity/trivy-db:2 --format sarif --output trivy-results.sarif --skip-dirs node_modules,dist,coverage .
|
||||
- name: Security reports
|
||||
if: ${{ always() }}
|
||||
uses: https://gitea.com/actions/upload-artifact@v7
|
||||
uses: https://gitea.com/actions/upload-artifact@v3
|
||||
with:
|
||||
name: security
|
||||
path: '*-results.sarif'
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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!);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user