Compare commits

..
Author SHA1 Message Date
renovate Bot 036b41057d chore(deps): update https://gitea.com/actions/upload-artifact action to v7
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m27s
CI / test (push) Successful in 5m11s
CI / benchmark (push) Successful in 7m15s
CI / build (push) Successful in 1m34s
CI / security (push) Successful in 1m42s
CI / images (push) Successful in 1m46s
CI / required (push) Successful in 1s
CI / policy (pull_request) Failing after 8s
CI / build (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 55s
CI / images (pull_request) Canceled after 0s
CI / required (pull_request) Canceled after 0s
CI / validate (pull_request) Canceled after 5m31s
CI / benchmark (pull_request) Canceled after 4m25s
CI / test (pull_request) Canceled after 5m3s
2026-10-17 05:05:19 +00:00
20 changed files with 955 additions and 1556 deletions

No files matched your search

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