diff --git a/docs/planning-duration.md b/docs/planning-duration.md index 698bedb..6271a2d 100644 --- a/docs/planning-duration.md +++ b/docs/planning-duration.md @@ -73,9 +73,8 @@ range is explicitly indicative, not a confidence interval or a promise. ## What is timed -Manual requests capture `performance.now()` inside -`UserInputService.requestComputation`, before updating the calculation trigger. -The existing initial calculation falls back to the start of `startCalculation`. +Every request, manual or initial, captures `performance.now()` inside +`PlanningCalculationService.start`. The endpoint is Angular's `afterNextRender` callback after the complete plan has been projected into the calendar, its months and placements have been rendered, and the loading overlay has been removed. This measures request scheduling, diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index c7a4c99..a6a47ac 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -30,6 +30,7 @@ import { CalendarSettingsService } from '../../services/calendar-settings-servic import { RuleService, RuleType } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { PlanningDurationService } from '../../services/planning-duration.service'; +import { PlanningCalculationService } from '../../services/planning-calculation.service'; import { DateRange } from '@angular/material/datepicker'; const invalid: InvalidInputOutcome = { @@ -474,7 +475,9 @@ describe('CalendarYear planning outcome boundary', () => { fixture.detectChanges(); const before = { balances: structuredClone(input.vacationNumberSignal()), - displayed: structuredClone(input.displayedPlanSignal()), + displayed: structuredClone( + TestBed.inject(PlanningCalculationService).displayedPlan(), + ), declarations: structuredClone(input.declaredDaysSignal()), rules: structuredClone(rules.rules), }; @@ -506,9 +509,11 @@ describe('CalendarYear planning outcome boundary', () => { ]; expect(statistics()).toEqual(expectedStatistics); expect(dialog.textContent?.includes('Partial plan')).toBe(partial); - expect(input.planningOutcomeSignal()).toBe(outcome); + expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome); expect(input.vacationNumberSignal()).toEqual(before.balances); - expect(input.displayedPlanSignal()).toEqual(before.displayed); + expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toEqual( + before.displayed, + ); expect(input.declaredDaysSignal()).toEqual(before.declarations); expect(rules.rules).toEqual(before.rules); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); @@ -525,7 +530,7 @@ describe('CalendarYear planning outcome boundary', () => { fixture.detectChanges(); await fixture.whenStable(); expect(statistics()).toEqual(expectedStatistics); - expect(input.planningOutcomeSignal()).toBe(outcome); + expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(outcome); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); }, ); @@ -1047,7 +1052,6 @@ describe('CalendarYear planning outcome boundary', () => { { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); - const input = TestBed.inject(UserInputService); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); @@ -1056,10 +1060,10 @@ describe('CalendarYear planning outcome boundary', () => { activateWithKeyboard(root.querySelector('.statistics-button')!); await fixture.whenStable(); expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2'); - input.requestComputation(); + TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2'); - input.requestComputation(); + TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); const otherControl = document.createElement('button'); otherControl.textContent = 'Another control'; @@ -1071,7 +1075,7 @@ describe('CalendarYear planning outcome boundary', () => { oldOutcome.resolve(plan); await fixture.whenStable(); fixture.detectChanges(); - expect(input.planningOutcomeSignal()).toBe(replacement); + expect(TestBed.inject(PlanningCalculationService).outcome()).toBe(replacement); const opener = root.querySelector('.statistics-button'); if (replacement.kind === 'Plan') { expect(opener).not.toBeNull(); @@ -1747,7 +1751,7 @@ describe('CalendarYear planning outcome boundary', () => { }, ]); const duration = TestBed.inject(PlanningDurationService); - const submitted = duration.request()!; + const submitted = TestBed.inject(PlanningCalculationService).request()!; calendar.createPlanningCalculation.mockReturnValue({ ...calculation, outcome: pending.promise, @@ -1849,11 +1853,13 @@ describe('CalendarYear planning outcome boundary', () => { }).compileComponents(); const settings = TestBed.inject(CalendarSettingsService); settings.setPlanningDates('2024-01-01', '2024-12-31'); - const input = TestBed.inject(UserInputService); const duration = TestBed.inject(PlanningDurationService); calendar.createPlanningCalculation.mockImplementation(() => ({ ...calculation, - snapshot: { ...calculation.snapshot, ...duration.request()! }, + snapshot: { + ...calculation.snapshot, + ...TestBed.inject(PlanningCalculationService).request()!, + }, outcome: calendar.createPlanningCalculation.mock.calls.length === 1 ? old.promise @@ -1863,7 +1869,7 @@ describe('CalendarYear planning outcome boundary', () => { fixture.detectChanges(); now = 11_000; settings.setPlanningDates('2024-01-01', '2026-12-31'); - input.requestComputation(); + TestBed.inject(PlanningCalculationService).start(); now = 12_000; // Rendering/scheduling after the request is part of the wait. fixture.detectChanges(); const initial = duration.estimate(); @@ -1885,7 +1891,10 @@ describe('CalendarYear planning outcome boundary', () => { const revisitedOutcome = deferred(); calendar.createPlanningCalculation.mockReturnValueOnce({ ...calculation, - snapshot: { ...calculation.snapshot, ...duration.request()! }, + snapshot: { + ...calculation.snapshot, + ...TestBed.inject(PlanningCalculationService).request()!, + }, outcome: revisitedOutcome.promise, }); now = 631_000; // Returning to the calendar is a fresh initial calculation. @@ -1947,7 +1956,7 @@ describe('CalendarYear planning outcome boundary', () => { expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0); expect(root.querySelector('.loading-overlay')).toBeNull(); expect(document.activeElement).toBe(root.querySelector('#planning-results-title')); - TestBed.inject(UserInputService).requestComputation(); + TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -2051,7 +2060,7 @@ describe('CalendarYear planning outcome boundary', () => { const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); settings.setPlanningDates('2021-05-14', '2021-05-14'); - TestBed.inject(UserInputService).requestComputation(); + TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); oldOutcome.resolve(plan); @@ -2117,7 +2126,7 @@ describe('CalendarYear planning outcome boundary', () => { expect(calculate).toHaveBeenCalledTimes(1); expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull(); expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12); - input.requestComputation(); + TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -2163,12 +2172,11 @@ describe('CalendarYear planning outcome boundary', () => { { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); - const userInput = TestBed.inject(UserInputService); const fixture: ComponentFixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); - expect(userInput.displayedPlanSignal()).toBeUndefined(); + expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined(); }); it('keeps and summarizes a returned result', async () => { @@ -2180,13 +2188,14 @@ describe('CalendarYear planning outcome boundary', () => { { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); - const userInput = TestBed.inject(UserInputService); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); - expect(userInput.displayedPlanSignal()?.remainingBalances).toEqual(plan.remainingBalances); + expect( + TestBed.inject(PlanningCalculationService).displayedPlan()?.remainingBalances, + ).toEqual(plan.remainingBalances); const summaryValues = Array.from( fixture.nativeElement.querySelectorAll('.plan-summary dd') as NodeListOf, ).map((element) => element.textContent?.trim()); @@ -2538,7 +2547,7 @@ describe('CalendarYear planning outcome boundary', () => { ); expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1); - TestBed.inject(UserInputService).requestComputation(); + TestBed.inject(PlanningCalculationService).start(); fixture.detectChanges(); await fixture.whenStable(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -2959,7 +2968,6 @@ describe('CalendarYear planning outcome boundary', () => { { provide: CalendarService, useValue: calendar }, ], }).compileComponents(); - const userInput = TestBed.inject(UserInputService); const fixture = TestBed.createComponent(CalendarYear); fixture.detectChanges(); await fixture.whenStable(); @@ -2971,6 +2979,6 @@ describe('CalendarYear planning outcome boundary', () => { if (outcome?.kind === 'PlanningIncomplete') expect(outcome.reasons[0].code).toBe('planner-error'); expect(fixture.componentInstance['isLoading']()).toBe(false); - expect(userInput.displayedPlanSignal()).toBeUndefined(); + expect(TestBed.inject(PlanningCalculationService).displayedPlan()).toBeUndefined(); }); }); diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 7ae5169..1a0fd9a 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -42,14 +42,12 @@ import { TranslationService } from '../../services/translation-service'; import { UserInputService } from '../../services/user-input-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { PlanningDurationService } from '../../services/planning-duration.service'; +import { PlanningCalculationService } from '../../services/planning-calculation.service'; import { CounterfactualAnalysis, CounterfactualSelection, LeaveType, - DeclaredNonWorkingDay, PlanExplanation, - PlanningCalculationContext, - PlanningExplanation, PlanningObjective, PlanningObjectiveVector, PlanningOutcome, @@ -86,6 +84,7 @@ export class CalendarYear { private readonly calendarService = inject(CalendarService); private readonly settings = inject(CalendarSettingsService); private readonly duration = inject(PlanningDurationService); + private readonly calculations = inject(PlanningCalculationService); private readonly injector = inject(Injector); private readonly dialog = inject(MatDialog); private readonly dateAdapter = inject>(DateAdapter); @@ -98,13 +97,13 @@ export class CalendarYear { private statisticsDialog: MatDialogRef | null = null; protected readonly translationService = inject(TranslationService); - protected readonly isLoading = signal(false); - protected readonly isCancelling = signal(false); + protected readonly isLoading = this.calculations.isCalculating; + protected readonly isCancelling = this.calculations.isCancelling; private readonly resultsTitle = viewChild>('resultsTitle'); - protected readonly loadingProgress = signal(0); - protected readonly planningOutcome = this.userInput.planningOutcomeSignal; - protected readonly planningExplanation = signal(null); - protected readonly explanationError = signal(null); + protected readonly loadingProgress = this.calculations.progress; + protected readonly planningOutcome = this.calculations.outcome; + protected readonly planningExplanation = this.calculations.explanation; + protected readonly explanationError = this.calculations.explanationError; protected readonly selectedPlacementIndex = signal(0); protected readonly planDetailsOpen = signal(false); private readonly planDetails = viewChild>('planDetails'); @@ -115,7 +114,7 @@ export class CalendarYear { protected readonly invalidComparisonDate = signal(false); protected readonly comparisonDateInput = signal(null); protected readonly liveMessage = signal(''); - protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null); + protected readonly planningHorizon = this.calculations.displayedHorizon; protected readonly selectedDates = signal(new SelectedDates()); protected readonly declarationEntry = this.userInput.declarationEntrySignal; private readonly monthGrid = viewChild>('monthGrid'); @@ -141,21 +140,18 @@ export class CalendarYear { } return { dates, type: this.userInput.declaredTypeSignal() }; }); - private readonly resultDeclaredDays = signal([]); protected readonly calendarHorizon = computed(() => this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(), ); protected readonly calendarDeclaredDays = computed>>(() => { const days = this.declarationEntry() ? this.userInput.declaredDaysSignal() - : (this.displayedPlan()?.declaredDays ?? this.resultDeclaredDays()); + : (this.calculations.accepted()?.declaredDays ?? []); return Object.fromEntries(days.map(({ date, type }) => [date, type])); }); - private calculationVersion = 0; private counterfactualVersion = 0; - private activeCalculation: PlanningCalculationContext | null = null; - private runningCalculation: PlanningCalculationContext | null = null; + private wasCancelling = false; private displayDateLocale = ''; private displayDateFormatter?: Intl.DateTimeFormat; @@ -163,7 +159,7 @@ export class CalendarYear { return this.translationService.tr; } - protected readonly displayedPlan = this.userInput.displayedPlanSignal; + protected readonly displayedPlan = this.calculations.displayedPlan; protected readonly weekendConnectedHolidays = computed(() => { const isWeekend = (date: string) => { const day = new Date(`${date}T00:00:00Z`).getUTCDay(); @@ -339,185 +335,74 @@ export class CalendarYear { }); }); effect(() => { - this.userInput.computeTriggerSignal(); - untracked(() => this.startCalculation()); + if (this.isLoading()) untracked(() => this.liveMessage.set(this.tr().loading)); }); - } - - private startCalculation(): void { - const requestedAt = this.userInput.computationRequestedAtMs() ?? performance.now(); - this.userInput.computationRequestedAtMs.set(null); - const request = this.duration.request(); - if (!request) return; - const { horizon, rules: currentRules } = request; - this.declarationEntry.set(false); - if (!this.planningHorizon()) this.planningHorizon.set(horizon); - const version = ++this.calculationVersion; - this.isLoading.set(true); - this.isCancelling.set(false); - this.loadingProgress.set(0); - this.liveMessage.set(this.tr().loading); - let calculation: PlanningCalculationContext; - let completed = false; - try { - calculation = this.calendarService.createPlanningCalculation({ - ...request, - balances: request.balances.map((balance) => ({ - ...balance, - label: this.translationService.getDayTypeLabel(balance.type), - })), - labels: Object.fromEntries( - currentRules.map((rule) => [rule.id, rule.name ?? rule.id]), - ), - progress: (stage) => { - if (version === this.calculationVersion) - this.loadingProgress.set(this.stageProgress(stage)); - }, + effect(() => { + const cancelling = this.isCancelling(); + untracked(() => { + if (this.wasCancelling && !cancelling) this.resultsTitle()?.nativeElement.focus(); + this.wasCancelling = cancelling; }); - } catch (error) { - this.resetCalculationState(horizon); - this.planningHorizon.set(horizon); - this.failCalculation(error, version); - return; - } - this.runningCalculation = calculation; - void calculation.outcome - .then((outcome) => { - if (version !== this.calculationVersion) return; - this.resetCalculationState(horizon); - this.activeCalculation = calculation; - this.userInput.planningSnapshotSignal.set(calculation.snapshot); - this.planningHorizon.set(horizon); - this.applyOutcome(outcome); - this.loadArtifacts(calculation); - completed = outcome.kind === 'Plan' && !this.isCancelling(); - }) - .catch((error: unknown) => { - if (version !== this.calculationVersion) return; - this.resetCalculationState(horizon); - this.activeCalculation = calculation; - this.userInput.planningSnapshotSignal.set(calculation.snapshot); - this.planningHorizon.set(horizon); - this.applyOutcome({ - kind: 'PlanningIncomplete', - completedStages: [], - reasons: [ - { - code: 'planner-error', - message: - error instanceof Error - ? error.message - : 'The planner failed unexpectedly.', - }, - ], - }); - this.loadArtifacts(calculation); - }) - .finally(() => { - if (version !== this.calculationVersion) return; - this.runningCalculation = null; - this.loadingProgress.set(100); - this.isLoading.set(false); - if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus(); - this.isCancelling.set(false); - if (completed && !this.destroyRef.destroyed) + }); + effect(() => { + const accepted = this.calculations.accepted(); + if (!accepted) return; + untracked(() => { + this.closeStatistics(); + this.selectedPlacementIndex.set(0); + this.planDetailsOpen.set(false); + this.clearCounterfactual(); + const plan = this.displayedPlan(); + this.selectedDates.set( + plan + ? this.calendarService.selectedDatesForPlan(plan) + : this.calendarService.getSelectedDatesForHorizon(accepted.horizon), + ); + this.liveMessage.set(this.outcomeLabel(accepted.outcome)); + if (accepted.successful && !this.destroyRef.destroyed) afterNextRender( () => { - if (version === this.calculationVersion && !this.destroyRef.destroyed) + if ( + this.calculations.accepted() === accepted && + !this.destroyRef.destroyed && + accepted.snapshot + ) this.duration.recordCompletion( - calculation.snapshot, - performance.now() - requestedAt, + accepted.snapshot, + performance.now() - accepted.requestedAt, ); }, { injector: this.injector }, ); }); + }); + effect(() => { + const explanation = this.calculations.explanation(); + if (explanation?.kind !== 'PlanExplanation') 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?.code === 'explanation-error') + untracked(() => + this.liveMessage.set( + `${this.tr().planning.explanationError} ${reason.message}`, + ), + ); + }); + this.calculations.start(); } protected cancelCalculation(): void { - if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return; - this.isCancelling.set(true); + if (!this.isLoading() || this.isCancelling()) return; + this.calculations.cancel(); this.liveMessage.set(this.tr().planning.cancelling); - this.runningCalculation.cancel(); - } - - private failCalculation(error: unknown, version: number): void { - if (version !== this.calculationVersion) return; - const reason = { - code: 'planner-error', - message: error instanceof Error ? error.message : 'The planner failed unexpectedly.', - }; - this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] }); - this.explanationError.set(reason); - this.loadingProgress.set(100); - this.isLoading.set(false); - } - - private resetCalculationState(horizon: { start: string; end: string }): void { - this.closeStatistics(); - this.activeCalculation = null; - this.userInput.planningSnapshotSignal.set(null); - this.planningOutcome.set(null); - this.planningExplanation.set(null); - this.explanationError.set(null); - this.selectedPlacementIndex.set(0); - this.planDetailsOpen.set(false); - this.clearCounterfactual(); - this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon)); - } - - 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)); - return; - } - this.selectedDates.set( - this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!), - ); } protected formatStatistic(value: number | null): string { @@ -823,7 +708,7 @@ export class CalendarYear { selection: CounterfactualSelection, openDialog = false, ): Promise { - const calculation = this.activeCalculation; + const calculation = this.calculations.accepted()?.calculation; if (!calculation) return; this.counterfactualSelection = selection; const version = ++this.counterfactualVersion; @@ -838,7 +723,10 @@ export class CalendarYear { ); try { const result = await calculation.analyzeCounterfactual(selection); - if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) + if ( + version !== this.counterfactualVersion || + calculation !== this.calculations.accepted()?.calculation + ) return; if (result.kind === 'Counterfactual') { this.counterfactual.set(result); @@ -849,7 +737,10 @@ export class CalendarYear { this.liveMessage.set(this.tr().planning.counterfactualReady); } } catch (error) { - if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) + if ( + version !== this.counterfactualVersion || + calculation !== this.calculations.accepted()?.calculation + ) return; const message = error instanceof Error ? error.message : String(error); this.counterfactualError.set(message); @@ -914,10 +805,10 @@ export class CalendarYear { } protected ruleLabel(ruleId: string): string { - const rule = this.activeCalculation?.snapshot.rules.find( - (candidate) => candidate.id === ruleId, - ); - return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId; + const rule = this.calculations + .snapshot() + ?.rules.find((candidate) => candidate.id === ruleId); + return rule?.name ?? this.calculations.snapshot()?.labels[ruleId] ?? ruleId; } protected balanceLabel(explanation: PlacementExplanation): string { @@ -949,9 +840,9 @@ export class CalendarYear { protected objectiveCopy(id: string) { if (id.startsWith('optimization:')) { const ruleId = id.slice('optimization:'.length); - const rule = this.activeCalculation?.snapshot.rules.find( - (candidate) => candidate.id === ruleId, - ); + const rule = this.calculations + .snapshot() + ?.rules.find((candidate) => candidate.id === ruleId); const copy = this.tr().planning.explanation.objectives['optimization']; return { ...copy, @@ -969,9 +860,9 @@ export class CalendarYear { const copy = this.tr().planning.explanation.objectives[key]; const rule = key === 'preference' - ? this.activeCalculation?.snapshot.rules.find( - (candidate) => candidate.id === id.slice('preference:'.length), - ) + ? this.calculations + .snapshot() + ?.rules.find((candidate) => candidate.id === id.slice('preference:'.length)) : undefined; return ( copy && { @@ -1056,20 +947,4 @@ export class CalendarYear { : this.tr().planning.cancelled; return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete; } - - private stageProgress(stage: string): number { - if (stage === 'complete') return 100; - if (stage === 'validation') return 5; - if (stage === 'obligations') return 20; - if (stage === 'at-risk') return 35; - if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50; - if (stage === 'holiday-week-value') return 58; - if (stage === 'non-working-day-gain') return 65; - if (stage === 'longest-work-sequence') return 72; - if (stage === 'short-sequence-deficit') return 79; - if (stage === 'supporting-placements') return 86; - if (stage === 'expiry') return 93; - if (stage === 'dates') return 98; - return this.loadingProgress(); - } } diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts index bf6ff3b..97f8e6f 100644 --- a/src/app/components/user-input/user-input.spec.ts +++ b/src/app/components/user-input/user-input.spec.ts @@ -7,12 +7,19 @@ import { MatDialog } from '@angular/material/dialog'; import { MatDialogHarness } from '@angular/material/dialog/testing'; import { MatSelectHarness } from '@angular/material/select/testing'; import { MatSlideToggleHarness } from '@angular/material/slide-toggle/testing'; -import { vi } from 'vitest'; +import { vi, type MockInstance } from 'vitest'; import { UserInput } from './user-input'; import { UserInputService } from '../../services/user-input-service'; +import { CalendarService } from '../../services/calendar-service'; +import { PlanningCalculationService } from '../../services/planning-calculation.service'; import { UserSettings } from '../user-settings/user-settings'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; -import { createPlanningCalculation, PlanningRequest } from '../../services/leave-planner.service'; +import { + createPlanningCalculation, + type PlanningCalculationContext, + type PlanningOutcome, + PlanningRequest, +} from '../../services/leave-planner.service'; import { TranslationService } from '../../services/translation-service'; const presets = createOptimizationPresetRules(); @@ -21,6 +28,8 @@ describe('UserInput', () => { let component: UserInput; let fixture: ComponentFixture; let userInputService: UserInputService; + let createCalculation: MockInstance; + let calculations: PlanningCalculationService; beforeEach(async () => { localStorage.clear(); @@ -45,6 +54,11 @@ describe('UserInput', () => { fixture = TestBed.createComponent(UserInput); component = fixture.componentInstance; userInputService = TestBed.inject(UserInputService); + calculations = TestBed.inject(PlanningCalculationService); + // Unless a test supplies a result, a started calculation stays pending. + createCalculation = vi + .spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation') + .mockImplementation(() => ({ outcome: new Promise(() => undefined) }) as never); fixture.detectChanges(); await fixture.whenStable(); } @@ -91,16 +105,25 @@ describe('UserInput', () => { async function calculate(request: PlanningRequest): Promise { const browserWindow = globalThis.window; - let calculation: ReturnType; + let calculation: PlanningCalculationContext; try { vi.stubGlobal('window', undefined); calculation = createPlanningCalculation(request); } finally { vi.stubGlobal('window', browserWindow); } - const outcome = await calculation.outcome; - userInputService.planningSnapshotSignal.set(calculation.snapshot); - userInputService.planningOutcomeSignal.set(outcome); + await show(calculation); + } + + async function show( + calculation: PlanningCalculationContext, + outcome?: PlanningOutcome, + ): Promise { + const shown = outcome ? { ...calculation, outcome: Promise.resolve(outcome) } : calculation; + createCalculation.mockReturnValueOnce(shown); + calculations.start(); + await shown.outcome; + await Promise.resolve(); fixture.detectChanges(); } @@ -155,19 +178,18 @@ describe('UserInput', () => { expect(card.textContent).toContain('Aucune action nécessaire'); expect(card.textContent).not.toContain('Disponible seulement'); expect(root.querySelector('.remaining-history')).toBeNull(); - const outcome = JSON.stringify(userInputService.planningOutcomeSignal()); + const outcome = JSON.stringify(calculations.outcome()); expect(card.querySelector('details')).toBeNull(); expect(card.textContent).not.toContain('trace'); - expect(JSON.stringify(userInputService.planningOutcomeSignal())).toBe(outcome); + expect(JSON.stringify(calculations.outcome())).toBe(outcome); - const plan = userInputService.displayedPlanSignal()!; - userInputService.planningOutcomeSignal.set({ + const plan = calculations.displayedPlan()!; + await show(calculations.accepted()!.calculation!, { kind: 'PlanningIncomplete', incumbent: plan, completedStages: [], reasons: [{ code: 'user-cancelled', message: 'Cancelled' }], }); - fixture.detectChanges(); expect(card.textContent).toContain('Sa non-utilisation n’est pas une décision définitive'); expect(card.textContent).not.toContain('Conservé pour plus tard'); expect(root.querySelector('.remaining-history')).toBeNull(); @@ -185,7 +207,7 @@ describe('UserInput', () => { TestBed.inject(CalendarSettingsService).setPlanningDates('2026-10-04', '2029-10-03'); fixture.detectChanges(); expect(estimateText()).not.toBe(initial); - expect(userInputService.computeTriggerSignal()).toBe(0); + expect(createCalculation).not.toHaveBeenCalled(); }); it('saves both calendar selections as inclusive planning dates without recalculating', async () => { @@ -218,7 +240,7 @@ describe('UserInput', () => { start: '2024-02-27', lastPlanningDate: '2024-03-09', }); - expect(userInputService.computeTriggerSignal()).toBe(0); + expect(createCalculation).not.toHaveBeenCalled(); }); it('prevents calculation while planning dates are invalid and re-enables it after correction', async () => { @@ -233,11 +255,11 @@ describe('UserInput', () => { expect(calculate.getAttribute('aria-describedby')).toBe('planning-dates-error'); calculate.click(); component.triggerComputation(); - expect(userInputService.computeTriggerSignal()).toBe(0); + expect(createCalculation).not.toHaveBeenCalled(); editInput('input[name="planningStart"]', '29/02/2024'); expect(calculate.disabled).toBe(false); calculate.click(); - expect(userInputService.computeTriggerSignal()).toBe(1); + expect(createCalculation).toHaveBeenCalledTimes(1); }); it.each(['', '30/02/2024', 'not-a-date', '2'])( @@ -282,7 +304,7 @@ describe('UserInput', () => { expect(last.getAttribute('aria-invalid')).toBe('false'); expect(last.getAttribute('aria-describedby')).not.toContain('planning-dates-error'); expect(settings.planningHorizon()).toEqual({ start: '2024-02-29', end: '2024-03-01' }); - expect(userInputService.computeTriggerSignal()).toBe(0); + expect(createCalculation).not.toHaveBeenCalled(); }); it('should initialize with vacation days from service', () => { @@ -518,7 +540,7 @@ describe('UserInput', () => { fixture.nativeElement.querySelector('.calculate-button').click(); await fixture.whenStable(); - expect(userInputService.computeTriggerSignal()).toBe(1); + expect(createCalculation).toHaveBeenCalledTimes(1); expect(userInputService.vacationNumberSignal()).toEqual(initialBalances); expect(localStorage.getItem('leaveBalances')).toBeNull(); }); diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts index 3ae0ef6..9cd2299 100644 --- a/src/app/components/user-input/user-input.ts +++ b/src/app/components/user-input/user-input.ts @@ -15,6 +15,7 @@ import { VacDay } from '../../services/calendar-service'; import type { LeaveType } from '../../services/leave-planner.service'; import { TranslationService } from '../../services/translation-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; +import { PlanningCalculationService } from '../../services/planning-calculation.service'; import { PlanningDurationService } from '../../services/planning-duration.service'; import { addPlanningDays, isPlanningDate } from '../../services/planning-date'; import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter'; @@ -45,6 +46,7 @@ export class UserInput { protected readonly declaredTypes: readonly LeaveType[] = ['cp', 'rtt', 'other']; protected readonly calendarSettings = inject(CalendarSettingsService); protected readonly duration = inject(PlanningDurationService); + private readonly calculations = inject(PlanningCalculationService); protected readonly translationService = inject(TranslationService); private readonly dateAdapter = inject>(DateAdapter); protected readonly planningDates = computed(() => { @@ -72,22 +74,21 @@ export class UserInput { } protected readonly remainingDays = computed(() => - (this.userInputService.displayedPlanSignal()?.remainingBalances ?? []).reduce( + (this.calculations.displayedPlan()?.remainingBalances ?? []).reduce( (total, balance) => total + balance.quantity, 0, ), ); protected readonly hasAtRiskDays = computed(() => - this.userInputService - .displayedPlanSignal() + this.calculations + .displayedPlan() ?.remainingBalances.some((balance) => balance.classification !== 'deferred'), ); protected remainingDaysDetails = computed(() => { - const plan = this.userInputService.displayedPlanSignal(); + const plan = this.calculations.displayedPlan(); if (!plan) return []; - const incomplete = - this.userInputService.planningOutcomeSignal()?.kind === 'PlanningIncomplete'; + const incomplete = this.calculations.outcome()?.kind === 'PlanningIncomplete'; const copy = this.tr().userInput; return plan.remainingBalances.map((balance) => { const code = @@ -147,10 +148,7 @@ export class UserInput { } private ruleName(id: string): string { - return ( - this.userInputService.planningSnapshotSignal()?.rules.find((rule) => rule.id === id) - ?.name ?? id - ); + return this.calculations.snapshot()?.rules.find((rule) => rule.id === id)?.name ?? id; } protected hasRemainingDays = computed(() => this.remainingDays() > 0); @@ -178,6 +176,6 @@ export class UserInput { public triggerComputation() { if (this.calendarSettings.planningHorizonError()) return; - this.userInputService.requestComputation(); + this.calculations.start(); } } diff --git a/src/app/services/planning-calculation.service.spec.ts b/src/app/services/planning-calculation.service.spec.ts new file mode 100644 index 0000000..5fc0f25 --- /dev/null +++ b/src/app/services/planning-calculation.service.spec.ts @@ -0,0 +1,301 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { CalendarService } from './calendar-service'; +import { CalendarSettingsService } from './calendar-settings-service'; +import type { + PlanOutcome, + PlanningCalculationContext, + PlanningExplanation, + PlanningOutcome, + PlanningReason, +} from './leave-planner.service'; +import { PlanningCalculationService } from './planning-calculation.service'; +import { UserInputService } from './user-input-service'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(); + const explanation = deferred(); + const explanationError = deferred(); + 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; + +const flush = () => new Promise((resolve) => setTimeout(resolve)); + +describe('PlanningCalculationService', () => { + let create: ReturnType; + 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); + }); +}); diff --git a/src/app/services/planning-calculation.service.ts b/src/app/services/planning-calculation.service.ts new file mode 100644 index 0000000..e7871e5 --- /dev/null +++ b/src/app/services/planning-calculation.service.ts @@ -0,0 +1,221 @@ +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 { + 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(null); + private readonly acceptedState = signal(null); + private readonly explanationState = signal(null); + private readonly explanationErrorState = signal(null); + + 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 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({ 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.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()) 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); + } +} diff --git a/src/app/services/planning-duration.service.spec.ts b/src/app/services/planning-duration.service.spec.ts index eabe86a..c4ed358 100644 --- a/src/app/services/planning-duration.service.spec.ts +++ b/src/app/services/planning-duration.service.spec.ts @@ -5,6 +5,7 @@ import { RuleService, RuleType } from './rule-service'; import { UserInputService } from './user-input-service'; import { createPlanningCalculation } from './leave-planner.service'; import { PlanningDurationService } from './planning-duration.service'; +import { PlanningCalculationService } from './planning-calculation.service'; import { OPTIMIZATION_PRESETS } from './planning-objectives'; describe('Planning duration estimate', () => { @@ -31,7 +32,8 @@ describe('Planning duration estimate', () => { it('offers a coarse seconds range on the first launch without calculating', () => { const duration = TestBed.inject(PlanningDurationService); expect(duration.estimate()).toEqual({ minimum: 1, maximum: 3, unit: 'seconds' }); - expect(TestBed.inject(UserInputService).computeTriggerSignal()).toBe(0); + expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false); + expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull(); expect(TestBed.inject(RuleService).rules).toEqual([]); }); @@ -47,7 +49,7 @@ describe('Planning duration estimate', () => { }); const duration = TestBed.inject(PlanningDurationService); expect(Number.isFinite(duration.estimate()!.maximum)).toBe(true); - duration.recordCompletion(duration.request()!, 20_000); + duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, 20_000); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); }); @@ -81,19 +83,20 @@ describe('Planning duration estimate', () => { expect(duration.estimate()).toEqual(moreLeave); settings.setPlanningDates('2026-12-31', '2024-01-01'); expect(duration.estimate()).toBeNull(); - expect(input.computeTriggerSignal()).toBe(0); + expect(TestBed.inject(PlanningCalculationService).isCalculating()).toBe(false); + expect(TestBed.inject(PlanningCalculationService).accepted()).toBeNull(); }); it('refines the submitted configuration using elapsed calendar time and displays minutes for a long wait', () => { const duration = TestBed.inject(PlanningDurationService); - const submitted = duration.request()!; + const submitted = TestBed.inject(PlanningCalculationService).request()!; duration.recordCompletion(submitted, 120_000); expect(duration.estimate()).toEqual({ minimum: 1, maximum: 6, unit: 'minutes' }); }); it('calibrates against the submitted snapshot even when the form has since changed', () => { const duration = TestBed.inject(PlanningDurationService); - const submitted = duration.request()!; + const submitted = TestBed.inject(PlanningCalculationService).request()!; TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2026-12-31'); duration.recordCompletion(submitted, 20_000); expect(duration.estimate()!.maximum).toBeGreaterThan(60); @@ -114,14 +117,16 @@ describe('Planning duration estimate', () => { placeType: SelectableDayType.CP, }); const duration = TestBed.inject(PlanningDurationService); - const calculation = createPlanningCalculation(duration.request()!); + const calculation = createPlanningCalculation( + TestBed.inject(PlanningCalculationService).request()!, + ); duration.recordCompletion(calculation.snapshot, 20_000); expect(duration.estimate()).toEqual({ minimum: 10, maximum: 60, unit: 'seconds' }); }); it('uses recent typical waits rather than an isolated slow calculation, and resets on a fresh visit', () => { const duration = TestBed.inject(PlanningDurationService); - const submitted = duration.request()!; + const submitted = TestBed.inject(PlanningCalculationService).request()!; for (const elapsed of [20_000, 25_000, 900_000]) duration.recordCompletion(submitted, elapsed); expect(duration.estimate()).toEqual({ minimum: 12, maximum: 75, unit: 'seconds' }); @@ -136,7 +141,7 @@ describe('Planning duration estimate', () => { it.each([0, -1, NaN, Infinity])('ignores unusable elapsed measurements: %s', (elapsed) => { const duration = TestBed.inject(PlanningDurationService); const initial = duration.estimate(); - duration.recordCompletion(duration.request()!, elapsed); + duration.recordCompletion(TestBed.inject(PlanningCalculationService).request()!, elapsed); expect(duration.estimate()).toEqual(initial); }); }); diff --git a/src/app/services/planning-duration.service.ts b/src/app/services/planning-duration.service.ts index 83e22cd..e6192e2 100644 --- a/src/app/services/planning-duration.service.ts +++ b/src/app/services/planning-duration.service.ts @@ -1,9 +1,7 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import dayjs from 'dayjs'; -import { CalendarSettingsService } from './calendar-settings-service'; import type { PlanningDate, PlanningRequest } from './leave-planner.service'; -import { RuleService } from './rule-service'; -import { UserInputService } from './user-input-service'; +import { PlanningCalculationService } from './planning-calculation.service'; export interface PlanningDurationRange { minimum: number; @@ -54,32 +52,11 @@ function initialDurationMs(request: PlanningRequest): number { @Injectable({ providedIn: 'root' }) export class PlanningDurationService { - private readonly settings = inject(CalendarSettingsService); - private readonly userInput = inject(UserInputService); - private readonly rules = inject(RuleService); + private readonly calculations = inject(PlanningCalculationService); private readonly calibration = signal([]); - readonly 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(() => { - const request = this.request(); + const request = this.calculations.request(); if (!request) return null; const factors = [...this.calibration()].sort((a, b) => a - b); const middle = Math.floor(factors.length / 2); diff --git a/src/app/services/rule-migration.spec.ts b/src/app/services/rule-migration.spec.ts index 5d46631..eb9a6e9 100644 --- a/src/app/services/rule-migration.spec.ts +++ b/src/app/services/rule-migration.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { RuleService, RuleType } from './rule-service'; import { vi } from 'vitest'; import { CalendarSettingsService } from './calendar-settings-service'; -import { PlanningDurationService } from './planning-duration.service'; +import { PlanningCalculationService } from './planning-calculation.service'; import { UserInputService } from './user-input-service'; import { SelectableDayType } from './calendar-service'; import { createPlanningCalculation } from './leave-planner.service'; @@ -260,7 +260,7 @@ describe('saved rule migration', () => { expiryDate: new Date('2024-01-05'), }, ]); - const request = TestBed.inject(PlanningDurationService).request()!; + const request = TestBed.inject(PlanningCalculationService).request()!; expect(request.rules).toHaveLength(6); expect(JSON.parse(localStorage.getItem('vacationRules')!).version).toBe(1); vi.stubGlobal('window', undefined); diff --git a/src/app/services/user-input-service.spec.ts b/src/app/services/user-input-service.spec.ts index 703cfe2..f4e5260 100644 --- a/src/app/services/user-input-service.spec.ts +++ b/src/app/services/user-input-service.spec.ts @@ -46,7 +46,6 @@ describe('UserInputService', () => { { date: '2024-01-02', type: 'other' }, ]); expect(service.vacationNumberSignal()).toBe(balances); - expect(service.computeTriggerSignal()).toBe(0); expect(localStorage.getItem('leaveBalances')).toBeNull(); }); @@ -119,7 +118,6 @@ describe('UserInputService', () => { ]); expect(save).toHaveBeenCalledTimes(3); expect(service.vacationNumberSignal()).toBe(balances); - expect(service.computeTriggerSignal()).toBe(0); }); it('restores an empty list when saved declarations are unreadable', () => { diff --git a/src/app/services/user-input-service.ts b/src/app/services/user-input-service.ts index 0fcb093..a5291d4 100644 --- a/src/app/services/user-input-service.ts +++ b/src/app/services/user-input-service.ts @@ -1,4 +1,4 @@ -import { computed, Injectable, signal } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { SelectableDayType, VacDay } from './calendar-service'; import dayjs from 'dayjs'; import { @@ -6,7 +6,7 @@ import { isWorkingPlanningDate, parseDeclaredDays, } from './declared-non-working-days'; -import type { LeaveType, PlanningOutcome, PlanningSnapshot } from './leave-planner.service'; +import type { LeaveType } from './leave-planner.service'; @Injectable({ providedIn: 'root', @@ -161,19 +161,6 @@ export class UserInputService { readonly vacationNumberSignal = signal( this.getSavedValues() ?? this.getInitialValues(), ); - readonly planningOutcomeSignal = signal(null); - readonly planningSnapshotSignal = signal(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(null); - public updateBalances(updater: (balances: VacDay[]) => VacDay[]): void { this.vacationNumberSignal.update(updater); try { @@ -193,9 +180,4 @@ export class UserInputService { console.warn('Failed to save leave balances to localStorage.', error); } } - - public requestComputation(): void { - this.computationRequestedAtMs.set(performance.now()); - this.computeTriggerSignal.update((count) => count + 1); - } }