diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts index 1ef97f7..441d57c 100644 --- a/src/app/benchmark/leave-planner-benchmark.spec.ts +++ b/src/app/benchmark/leave-planner-benchmark.spec.ts @@ -7,6 +7,8 @@ import { } from './leave-planner-benchmark'; describe('leave planner benchmark protocol', () => { + beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); + afterEach(() => vi.useRealTimers()); // Warm-up and two repetitions include complete trace delivery after planning. it('keeps seven deterministic scenarios including two and three years and a later earned consumer', async () => { const scenarios = benchmarkScenarios(); 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 f6ebc4a..01dcbb1 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -407,6 +407,288 @@ describe('CalendarYear planning outcome boundary', () => { beforeEach(() => localStorage.clear()); afterEach(() => localStorage.clear()); + it.each([false, true])( + 'shows fixed rule credits separately from balance-consuming placements (partial: %s)', + async (partial) => { + const core = await import('../../services/leave-planner.core'); + const declaredDays = partial + ? [{ date: '2024-01-10', type: 'cp' as const }] + : ['08', '09', '10'].map((day) => ({ + date: `2024-01-${day}`, + type: 'cp' as const, + })); + const submitted = { + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: partial + ? [ + { + id: 'cp', + type: 'cp' as const, + quantity: 1, + availableFrom: '2024-01-12', + expiryDate: '2024-01-12', + }, + ] + : [], + declaredDays, + labels: { 'two-cp': 'Two CP including booked leave' }, + rules: [ + { + id: 'two-cp', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-08', + endDate: '2024-01-12', + placeDays: 2, + placeType: 'cp', + }, + ], + }; + let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; + const outcome = core.calculatePlanningInternal( + submitted, + undefined, + (value) => (artifacts = value), + ); + if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`); + localStorage.setItem('leaveBalances', '[]'); + localStorage.setItem('vacationRules', '[]'); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [provideZonelessChangeDetection()], + }).compileComponents(); + vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue({ + ...calculationFor(outcome), + explanation: Promise.resolve(artifacts.explanation), + trace: Promise.resolve(artifacts.trace), + }); + const settings = TestBed.inject(CalendarSettingsService); + settings.setPlanningDates('2024-01-08', '2024-01-12'); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const root: HTMLElement = fixture.nativeElement; + const summary = + root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ') ?? ''; + expect(summary).toContain('Two CP including booked leave'); + expect(summary).toContain( + partial + ? '1 Already booked + 1 leave days used = 2 / 2' + : '2 Already booked + 0 leave days used = 2 / 2', + ); + expect(summary).toContain('Paid Leave'); + expect(summary).toContain('no remaining balance debit'); + for (const day of declaredDays) { + const cell = root.querySelector( + `button[data-date="${day.date}"]`, + )!; + expect(cell.classList).toContain('cp'); + expect(cell.getAttribute('aria-label')).toContain('Already booked'); + } + if (!partial) + expect(summary).not.toContain( + fixture.componentInstance['displayDate']('2024-01-10'), + ); + expect(artifacts.explanation).toMatchObject({ + timeWindowContributions: outcome.timeWindowContributions, + }); + TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-10', 'rtt'); + settings.setPlanningDates('2025-01-01', '2025-01-31'); + fixture.detectChanges(); + expect(root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ')).toBe( + summary, + ); + }, + ); + + it.each([ + ['2024-05-31', '2024-06-01', '2024-06-02', [], 3, 2], + [ + '2024-07-31', + '2024-08-01', + '2024-08-01', + [{ date: '2024-08-01', type: 'cp' as const }], + 2, + 0, + ], + ] as const)( + 'shows complete declared-only periods and context dates from %s', + async (start, end, contextDate, outsideDeclarations, duration, gain) => { + const core = await import('../../services/leave-planner.core'); + const submitted = { + horizon: { start, end }, + balances: [], + rules: [], + declaredDays: [{ date: start, type: 'other' as const }, ...outsideDeclarations], + }; + let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; + const outcome = core.calculatePlanningInternal( + submitted, + undefined, + (value) => (artifacts = value), + ); + if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`); + localStorage.setItem('leaveBalances', '[]'); + localStorage.setItem('vacationRules', '[]'); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [provideZonelessChangeDetection()], + }).compileComponents(); + const calculation = { + ...calculationFor(outcome), + explanation: Promise.resolve(artifacts.explanation), + trace: Promise.resolve(artifacts.trace), + }; + vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue( + calculation, + ); + const settings = TestBed.inject(CalendarSettingsService); + settings.setPlanningDates(start, start); + const input = TestBed.inject(UserInputService); + input.toggleDeclaredDay(start, 'other'); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const root: HTMLElement = fixture.nativeElement; + const summary = + root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ') ?? ''; + expect(summary).toContain(`${duration} days off in a row`); + expect(summary).toContain(`${gain} weekend & holiday days`); + expect(summary).toContain('0 leave days used'); + expect(summary).toContain('no remaining balance debit'); + const declared = root.querySelector(`button[data-date="${start}"]`)!; + expect(declared.classList).toContain('other'); + expect(declared.getAttribute('aria-label')).toContain('Already booked'); + const context = root.querySelector( + `button[data-date="${contextDate}"]`, + )!; + expect(context.disabled).toBe(true); + expect(context.classList).toContain('context-only'); + expect(context.classList).toContain('selected'); + expect(context.getAttribute('aria-label')).toContain( + 'Context date outside the planning horizon', + ); + if (outsideDeclarations.length) { + expect(context.classList).toContain('cp'); + expect(context.getAttribute('aria-label')).toContain('Already booked'); + } + input.toggleDeclaredDay(start, 'cp'); + settings.setPlanningDates('2025-01-01', '2025-01-31'); + fixture.detectChanges(); + expect(root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ')).toBe( + summary, + ); + expect(declared.getAttribute('aria-label')).toContain('Other'); + expect(fixture.componentInstance['displayedPlan']()?.metrics.nonWorkingDayGain).toBe( + gain, + ); + expect(input.vacationNumberSignal()).toEqual([]); + }, + ); + + it.each([false, true])( + 'keeps adjacent context declarations attached to the historical anchors (fixed anchor: %s)', + async (fixedAnchor) => { + const core = await import('../../services/leave-planner.core'); + const placementDate = fixedAnchor ? '2024-07-30' : '2024-07-31'; + const submitted = { + horizon: { start: '2024-07-30', end: '2024-08-01' }, + balances: [ + { + id: 'rtt', + type: 'rtt' as const, + quantity: 1, + availableFrom: placementDate, + expiryDate: placementDate, + }, + ], + rules: [], + declaredDays: [ + { date: '2024-08-01', type: 'cp' as const }, + ...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []), + ], + }; + let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {}; + const outcome = core.calculatePlanningInternal( + submitted, + undefined, + (value) => (artifacts = value), + ); + if (outcome.kind !== 'Plan' || !artifacts.trace) + throw new Error('Expected a plan and its original trace.'); + const trace = artifacts.trace; + const phaseIndex = trace.phases.findIndex((phase) => + phase.decisions.some((decision) => + decision.deltas.some( + (delta) => delta.kind === 'placement-added' && delta.date === placementDate, + ), + ), + ); + const decisionIndex = trace.phases[phaseIndex].decisions.findIndex((decision) => + decision.deltas.some( + (delta) => delta.kind === 'placement-added' && delta.date === placementDate, + ), + ); + localStorage.setItem('leaveBalances', '[]'); + localStorage.setItem('vacationRules', '[]'); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [provideZonelessChangeDetection()], + }).compileComponents(); + const calculation = calculationFor(outcome, artifacts.explanation, trace); + vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue( + calculation, + ); + TestBed.inject(CalendarSettingsService).setPlanningDates('2024-07-30', '2024-07-31'); + TestBed.inject(TranslationService).setLanguage('en'); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const root: HTMLElement = fixture.nativeElement; + activateWithKeyboard(root.querySelector('.trace-explorer summary')!); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + activateWithKeyboard( + root.querySelectorAll('.trace-phases > button')[phaseIndex], + ); + fixture.detectChanges(); + for (let index = 0; index < decisionIndex; index++) { + activateWithKeyboard( + root.querySelector('[aria-label="Next decision"]')!, + ); + fixture.detectChanges(); + } + activateWithKeyboard(root.querySelector('.trace-toolbar button')!); + fixture.detectChanges(); + const before = root.querySelector('button[data-date="2024-08-01"]'); + if (fixedAnchor) { + expect(before?.classList).toContain('selected'); + expect(before?.classList).toContain('cp'); + } else expect(before).toBeNull(); + activateWithKeyboard(root.querySelectorAll('.trace-toolbar button')[1]); + fixture.detectChanges(); + const after = root.querySelector('button[data-date="2024-08-01"]')!; + expect(after.classList).toContain('selected'); + expect(after.classList).toContain('cp'); + expect(after.classList).toContain('context-only'); + expect(after.disabled).toBe(true); + expect(after.getAttribute('aria-label')).toContain('Already booked'); + expect(fixture.componentInstance['traceState']()?.placements).toEqual( + outcome.placements, + ); + TestBed.inject(UserInputService).toggleDeclaredDay('2024-08-01', 'rtt'); + fixture.detectChanges(); + expect(after.getAttribute('aria-label')).toContain('CP'); + }, + ); + it('previews a growing and shrinking range across months, then saves it once on release', async () => { localStorage.setItem('app.language', '"fr"'); localStorage.setItem('leaveBalances', '[]'); diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 6bdfc43..75ee49f 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -146,6 +146,87 @@
{{ plan.metrics.longestWorkSequence }}
+ @for (contribution of plan.timeWindowContributions ?? []; track contribution.ruleId) { +
+

{{ contribution.label }}

+

+ {{ contribution.declaredDays.length }} {{ tr().bookedDays.booked }} + + {{ contribution.placementDates.length }} + {{ tr().planning.explanation.leaveDays }} = + {{ + contribution.declaredDays.length + contribution.placementDates.length + }} + / {{ contribution.requested }} +

+

+ @for (day of contribution.declaredDays; track day.date) { + {{ displayDate(day.date) }} · {{ tr().dayTypes[day.type] }} + } +

+

{{ tr().bookedDays.fixed }}

+
+ } + @if (plan.calculationDate) { +

{{ tr().planning.calculationDate }} : {{ displayDate(plan.calculationDate) }}

+ } + @for ( + contribution of plan.ifThenContributions ?? []; + track contribution.ruleId + contribution.occurrence + ) { +
+

{{ contribution.label }} · {{ displayDate(contribution.occurrence) }}

+

+ {{ contribution.declaredDays.length }} {{ tr().bookedDays.booked }} + + {{ contribution.placementDates.length }} + {{ tr().planning.explanation.leaveDays }} = + {{ contribution.requested }} / {{ contribution.requested }} +

+ @for (day of contribution.declaredDays; track day.date) { + {{ displayDate(day.date) }} · {{ tr().dayTypes[day.type] }} + } + @if (contribution.declaredDays.length) { +

{{ tr().bookedDays.fixed }}

+ } + @if (contribution.earnedEntitlement; as earned) { +

+ {{ + earned.added + ? tr().planning.futureEntitlement + : tr().planning.includedEntitlement + }} + : {{ earned.quantity }} {{ tr().dayTypes[earned.type] }} +

+

+ {{ tr().planning.details.available }} + {{ displayDate(earned.availableFrom) }} · + {{ tr().planning.details.expires }} {{ displayDate(earned.expiryDate) }} +

+ } +
+ } + @for (period of plan.timeOffPeriods; track period.start) { + @if (period.declaredDates?.length) { +
+

+ {{ tr().planning.explanation.breakTitle }} · + {{ displayDate(period.start) }} – {{ displayDate(period.end) }} +

+

+ {{ period.declaredDates!.length }} {{ tr().bookedDays.booked }} + + {{ period.placementDates.length }} + {{ tr().planning.explanation.leaveDays }} + + {{ period.nonWorkingDates.length }} + {{ tr().planning.explanation.freeDays }} = + {{ period.duration }} {{ tr().planning.explanation.daysOff }} +

+

{{ tr().bookedDays.fixed }}

+
+ } + } }
@@ -257,6 +338,13 @@ {{ displayDate(period.start) }} – {{ displayDate(period.end) }}
+ @if (period.declaredDates?.length) { + {{ period.declaredDates!.length }}{{ tr().bookedDays.booked }} + + + } {{ period.placementDates.length }}{{ tr().planning.explanation.leaveDays }} { const horizon = this.calendarHorizon(); if (!horizon) return []; - let first = new Date(`${horizon.start}T00:00:00`); - let last = new Date(`${horizon.end}T00:00:00`); + const plan = this.traceState() ?? 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); - for (const period of this.declarationEntry() - ? [] - : (this.displayedPlan()?.timeOffPeriods ?? [])) { - for (const date of period.nonWorkingDates) { - const contextDate = new Date(`${date}T00:00:00`); - if (date < horizon.start && contextDate < first) first = contextDate; - else if (date >= horizon.end && contextDate > last) last = contextDate; - } - } const result: { monthIndex: number; year: number }[] = []; const cursor = new Date(first); cursor.setDate(1); @@ -711,6 +708,7 @@ export class CalendarYear { this.planningTrace.set({ status: 'failed', initialState: { + calculationDate: calculation.snapshot.calculationDate, horizon: calculation.snapshot.horizon, ...(calculation.snapshot.declaredDays ? { declaredDays: calculation.snapshot.declaredDays } @@ -1306,6 +1304,7 @@ export class CalendarYear { })), }; const snapshot = (): PlanningTraceState => ({ + calculationDate: trace.initialState.calculationDate, horizon: { ...trace.initialState.horizon }, ...(trace.initialState.declaredDays ? { declaredDays: trace.initialState.declaredDays } diff --git a/src/app/services/calendar-service.spec.ts b/src/app/services/calendar-service.spec.ts index db9aeb7..fa703a0 100644 --- a/src/app/services/calendar-service.spec.ts +++ b/src/app/services/calendar-service.spec.ts @@ -1,6 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; -import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service'; +import { + CalendarService, + calendarProjectionHorizon, + DayType, + SelectedDate, + SelectedDates, +} from './calendar-service'; import { DateRange } from '@angular/material/datepicker'; describe('CalendarService', () => { @@ -87,6 +93,70 @@ describe('CalendarService', () => { expect(horizon).toEqual({ start: '2024-04-02', end: '2024-04-03' }); }); + it('projects a declared-only period and adjacent outside declarations while leaving unrelated declarations unselected', async () => { + const { calculatePlanningInternal } = await import('./leave-planner.core'); + const horizon = { start: '2024-05-31', end: '2024-06-01' }; + const outcome = calculatePlanningInternal({ + horizon, + balances: [], + rules: [], + declaredDays: [ + { date: '2024-05-31', type: 'other' }, + { date: '2024-06-03', type: 'cp' }, + { date: '2024-06-05', type: 'rtt' }, + ], + }); + if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`); + const selected = service.selectedDatesForPlan(outcome); + const date = (key: string) => new Date(`${key}T00:00:00`); + expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER); + expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND); + expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP); + expect(selected.isSelected(date('2024-06-05'))).toBe(false); + expect( + selected.datesSelected + .find( + (selection) => + selection.type === null && + selection.range.start?.getTime() === date('2024-05-31').getTime(), + ) + ?.getLengthInDays(), + ).toBe(4); + expect(outcome.placements).toEqual([]); + expect(outcome.horizon).toEqual(horizon); + }); + + it('projects historical declarations from the actual anchors instead of the final plan periods', () => { + const horizon = { start: '2024-07-30', end: '2024-08-01' }; + const declaredDays = [ + { date: '2024-08-01', type: 'cp' as const }, + { date: '2024-08-05', type: 'rtt' as const }, + ]; + const before = { horizon, declaredDays, placements: [] }; + expect(calendarProjectionHorizon(before)).toEqual(horizon); + expect(service.selectedDatesForPlan(before).isSelected(new Date('2024-08-01'))).toBe(false); + const after = { + ...before, + placements: [ + { + date: '2024-07-31', + balanceId: 'rtt', + type: 'rtt' as const, + ruleIds: [], + supporting: false, + }, + ], + }; + const selected = service.selectedDatesForPlan(after); + expect(calendarProjectionHorizon(after)).toEqual({ + start: horizon.start, + end: '2024-08-02', + }); + expect(selected.getClasses(new Date('2024-08-01T00:00:00'))).toContain(DayType.CP); + expect(selected.isSelected(new Date('2024-08-05T00:00:00'))).toBe(false); + expect(horizon).toEqual({ start: '2024-07-30', end: '2024-08-01' }); + }); + describe('getHolidays', () => { it('should return an array of holidays', () => { const holidays = service.getHolidays(); diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index ab4ba31..68cdd4e 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { DateRange } from '@angular/material/datepicker'; -import { frenchPublicHolidaysForYear } from './declared-non-working-days'; +import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days'; import dayjs from 'dayjs'; import weekOfYear from 'dayjs/plugin/weekOfYear'; import isoWeek from 'dayjs/plugin/isoWeek'; @@ -11,6 +11,7 @@ import { LeaveType, PlanOutcome, PlanningReason, + PlanningTraceState, RemainingClassification, } from './leave-planner.service'; import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date'; @@ -18,6 +19,41 @@ import { addPlanningDays, addPlanningYears, planningDateInParis } from './planni dayjs.extend(weekOfYear); dayjs.extend(isoWeek); +type CalendarPlan = Pick & { + horizon?: PlanningTraceState['horizon']; + timeOffPeriods?: PlanOutcome['timeOffPeriods']; +}; + +export function calendarProjectionHorizon(plan: CalendarPlan) { + if (!plan.horizon) return undefined; + const horizon = { ...plan.horizon }; + if (plan.timeOffPeriods) { + for (const period of plan.timeOffPeriods) { + if (period.start < horizon.start) horizon.start = period.start; + if (period.end >= horizon.end) horizon.end = addPlanningDays(period.end, 1); + } + return horizon; + } + const declaredDates = new Set(plan.declaredDays?.map(({ date }) => date)); + const anchors = [...plan.placements, ...(plan.declaredDays ?? [])] + .map(({ date }) => date) + .filter((date) => date >= horizon.start && date < horizon.end) + .sort(); + if (!anchors.length) return horizon; + for (const [anchor, direction] of [ + [anchors[0], -1], + [anchors[anchors.length - 1], 1], + ] as const) { + let date = addPlanningDays(anchor, direction); + while (declaredDates.has(date) || !isWorkingPlanningDate(date)) { + if (date < horizon.start) horizon.start = date; + if (date >= horizon.end) horizon.end = addPlanningDays(date, 1); + date = addPlanningDays(date, direction); + } + } + return horizon; +} + @Injectable({ providedIn: 'root', }) @@ -32,20 +68,8 @@ export class CalendarService { return { start, end: addPlanningYears(start, 1) }; } - selectedDatesForPlan( - plan: Pick & { - horizon?: { start: string; end: string }; - timeOffPeriods?: PlanOutcome['timeOffPeriods']; - }, - ): SelectedDates { - const projectionHorizon = plan.horizon ? { ...plan.horizon } : undefined; - if (projectionHorizon) { - for (const period of plan.timeOffPeriods ?? []) { - if (period.start < projectionHorizon.start) projectionHorizon.start = period.start; - if (period.end >= projectionHorizon.end) - projectionHorizon.end = addPlanningDays(period.end, 1); - } - } + selectedDatesForPlan(plan: CalendarPlan): SelectedDates { + const projectionHorizon = calendarProjectionHorizon(plan); const selected = projectionHorizon ? this.getSelectedDatesForHorizon(projectionHorizon) : this.getSelectedDatesForYear(); @@ -53,8 +77,8 @@ export class CalendarService { ...plan.placements, ...(plan.declaredDays ?? []).filter( (day) => - !plan.horizon || - (day.date >= plan.horizon.start && day.date < plan.horizon.end), + !projectionHorizon || + (day.date >= projectionHorizon.start && day.date < projectionHorizon.end), ), ]) { selected.push( diff --git a/src/app/services/leave-planner-cancellation.spec.ts b/src/app/services/leave-planner-cancellation.spec.ts index 06bfe97..783cc4a 100644 --- a/src/app/services/leave-planner-cancellation.spec.ts +++ b/src/app/services/leave-planner-cancellation.spec.ts @@ -11,6 +11,9 @@ const request = { rules: [], }; +beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); +afterEach(() => vi.useRealTimers()); + describe('user cancellation', () => { const workers: CancellableWorker[] = []; const currentWorker = () => workers[workers.length - 1]; @@ -43,6 +46,128 @@ describe('user cancellation', () => { expect((await calculation.trace)?.status).toBe('incomplete'); }); + it('retains the same declared-only periods and metrics in a cancelled valid plan', async () => { + const core = await import('./leave-planner.core'); + const submitted = { + horizon: { start: '2024-01-02', end: '2024-01-09' }, + balances: [], + rules: [], + declaredDays: [{ date: '2024-01-05', type: 'other' as const }], + }; + let interrupted = false; + let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; + const outcome = await core.calculatePlanningAsyncInternal( + { + ...submitted, + progress: (stage: string) => { + if (stage === 'non-working-day-gain') interrupted = true; + }, + }, + undefined, + (value) => (artifacts = value), + undefined, + undefined, + () => interrupted, + ); + const completed = core.calculatePlanningInternal(submitted); + if ( + outcome.kind !== 'PlanningIncomplete' || + !outcome.incumbent || + completed.kind !== 'Plan' + ) + throw new Error('Expected an interrupted valid plan and its completed counterpart.'); + expect(outcome.reasons[0].code).toBe('user-cancelled'); + expect(outcome.incumbent.timeOffPeriods).toEqual(completed.timeOffPeriods); + expect(outcome.incumbent.metrics).toEqual(completed.metrics); + expect(artifacts.explanation).toMatchObject({ + kind: 'PlanExplanation', + declaredDays: submitted.declaredDays, + timeOffPeriods: completed.timeOffPeriods, + placements: [], + incomplete: { reason: { code: 'user-cancelled' } }, + }); + expect(artifacts.trace?.status).toBe('incomplete'); + expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays); + }); + + it('retains compatible fixed rule credits and incompatible declarations in a cancelled valid plan', async () => { + const core = await import('./leave-planner.core'); + const submitted = { + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [ + { + id: 'cp', + type: 'cp' as const, + quantity: 1, + availableFrom: '2024-01-12', + expiryDate: '2025-01-01', + }, + ], + declaredDays: [ + { date: '2024-01-10', type: 'cp' as const }, + { date: '2024-01-11', type: 'rtt' as const }, + ], + rules: [ + { id: 'exact-cp', force: 'obligation', placeDays: 2, placeType: 'cp' }, + { id: 'preferred-rtt', force: 'preference', placeDays: 1, placeType: 'rtt' }, + ].map((rule) => ({ + ...rule, + type: 'time_window', + startDate: '2024-01-08', + endDate: '2024-01-12', + })), + }; + let interrupted = false; + let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; + const outcome = await core.calculatePlanningAsyncInternal( + { + ...submitted, + progress: (stage: string) => { + if (stage === 'holiday-week-value') interrupted = true; + }, + }, + undefined, + (value) => (artifacts = value), + undefined, + undefined, + () => interrupted, + ); + const completed = core.calculatePlanningInternal(submitted); + if ( + outcome.kind !== 'PlanningIncomplete' || + !outcome.incumbent || + completed.kind !== 'Plan' + ) + throw new Error('Expected an interrupted valid plan and a completed counterpart.'); + expect(outcome.reasons[0].code).toBe('user-cancelled'); + expect(outcome.incumbent.declaredDays).toEqual(submitted.declaredDays); + expect(outcome.incumbent.placements).toMatchObject([ + { date: '2024-01-12', type: 'cp', ruleIds: ['exact-cp'] }, + ]); + expect(outcome.incumbent.preferences).toMatchObject([{ requested: 1, satisfied: 1 }]); + expect(outcome.incumbent.timeWindowContributions).toEqual( + completed.timeWindowContributions, + ); + expect(outcome.incumbent.timeWindowContributions).toMatchObject([ + { + ruleId: 'exact-cp', + declaredDays: [submitted.declaredDays[0]], + placementDates: ['2024-01-12'], + }, + { + ruleId: 'preferred-rtt', + declaredDays: [submitted.declaredDays[1]], + placementDates: [], + }, + ]); + expect(artifacts.explanation).toMatchObject({ + kind: 'PlanExplanation', + timeWindowContributions: completed.timeWindowContributions, + incomplete: { reason: { code: 'user-cancelled' } }, + }); + expect(artifacts.finalState?.declaredDays).toEqual(submitted.declaredDays); + }); + it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => { vi.stubGlobal('window', undefined); const replaced = createPlanningCalculation(request); @@ -140,7 +265,7 @@ describe('user cancellation', () => { type: 'result', outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] }, }); - expect(await calculation.outcome).toEqual(cancelled); + expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' }); expect(currentWorker().terminate).not.toHaveBeenCalled(); currentWorker().emit({ type: 'artifacts', artifacts: {} }); await calculation.trace; @@ -214,7 +339,7 @@ describe('user cancellation', () => { }, }); currentWorker().emit({ type: 'artifacts-complete' }); - expect(await calculation.outcome).toEqual(cancelled); + expect(await calculation.outcome).toEqual({ ...cancelled, calculationDate: '2024-01-01' }); expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' }); expect(await calculation.trace).toEqual(trace); }); @@ -280,7 +405,7 @@ describe('user cancellation', () => { }; currentWorker().emit({ type: 'result', outcome }); currentWorker().emit({ type: 'artifacts', artifacts: {} }); - expect(await newer.outcome).toEqual(outcome); + expect(await newer.outcome).toEqual({ ...outcome, calculationDate: '2024-01-01' }); await newer.trace; const sent = currentWorker().postMessage.mock.calls.length; newer.cancel(); diff --git a/src/app/services/leave-planner-explanation.spec.ts b/src/app/services/leave-planner-explanation.spec.ts index 0bba11c..5bae59d 100644 --- a/src/app/services/leave-planner-explanation.spec.ts +++ b/src/app/services/leave-planner-explanation.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createPlanningCalculation } from './leave-planner.service'; +import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service'; const balance = ( id: string, @@ -12,7 +12,245 @@ const balance = ( beforeEach(() => vi.stubGlobal('window', undefined)); afterEach(() => vi.unstubAllGlobals()); +beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); +afterEach(() => vi.useRealTimers()); + describe('planner explanation and trace seam', () => { + it('replays a reported occurrence without borrowing its future balance from the final plan', async () => { + vi.setSystemTime(new Date('2024-05-03T12:00:00Z')); + const core = await import('./leave-planner.core'); + let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; + const outcome = core.calculatePlanningInternal( + { + horizon: { start: '2024-04-29', end: '2024-05-08' }, + declaredDays: [ + { date: '2024-04-29', type: 'cp' }, + { date: '2024-04-30', type: 'cp' }, + { date: '2024-05-03', type: 'other' }, + ], + balances: [balance('cp', 'cp', 1, '2025-12-31')], + rules: [ + { + id: 'producer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placement: 'before', + placeDays: 2, + placeType: 'cp', + rewardDays: 1, + rewardType: 'other', + }, + { + id: 'extend', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-02', + endDate: '2024-05-02', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-04-29', + endDate: '2024-05-07', + placeDays: 2, + placeType: 'other', + }, + ], + }, + undefined, + (value) => (artifacts = value), + ); + if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState) + throw new Error('Expected the extended generator to finance a traced plan.'); + const replay = { + balances: artifacts.trace.initialState.balances.map((item) => ({ ...item })), + placements: new Map( + artifacts.trace.initialState.placements.map((item) => [item.date, item]), + ), + earnedActivations: [...artifacts.trace.initialState.earnedActivations], + }; + const earnedId = 'earned:producer:2024-05-01'; + let recognized = false; + for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions)) { + applyReplayDeltas(replay, decision.deltas, artifacts.finalState); + if ( + decision.deltas.some( + ({ kind, balanceId }) => kind === 'earned-activated' && balanceId === earnedId, + ) + ) { + recognized = true; + expect(replay.earnedActivations.map(({ id }) => id)).toContain(earnedId); + expect(replay.balances.map(({ id }) => id)).not.toContain(earnedId); + } + } + expect(recognized).toBe(true); + expect(replay.balances).toEqual(artifacts.finalState.balances); + expect([...replay.placements.values()]).toEqual(artifacts.finalState.placements); + expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations); + expect(replay.balances.find(({ id }) => id === earnedId)).toMatchObject({ + remaining: 0, + availableFrom: '2024-05-06', + expiryDate: '2025-05-06', + }); + }); + + it.each([5, 8])( + 'keeps %s unrelated obligation branches linear when a declared entitlement is already reported', + async (count) => { + vi.setSystemTime(new Date('2024-05-06T12:00:00Z')); + const core = await import('./leave-planner.core'); + const dates = [ + '2024-04-17', + '2024-04-18', + '2024-04-19', + '2024-04-22', + '2024-04-23', + '2024-04-24', + '2024-04-25', + '2024-04-26', + ].slice(0, count); + const branchCounts: number[] = []; + for (const rewardDays of [0, 1]) { + let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; + const outcome = core.calculatePlanningInternal( + { + horizon: { start: '2024-04-17', end: '2024-05-08' }, + declaredDays: [ + { date: '2024-04-29', type: 'cp' }, + { date: '2024-04-30', type: 'cp' }, + ], + balances: [ + { + id: 'other', + type: 'other', + quantity: dates.length, + availableFrom: '2024-04-17', + expiryDate: '2025-12-31', + }, + ], + rules: [ + ...dates.map((date) => ({ + id: date, + type: 'time_window' as const, + force: 'obligation' as const, + startDate: date, + endDate: date, + placeDays: 1, + placeType: 'other' as const, + })), + { + id: 'producer', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placement: 'before', + placeDays: 2, + placeType: 'cp', + rewardDays, + rewardType: 'other', + }, + ], + }, + undefined, + (value) => (artifacts = value), + ); + if (outcome.kind !== 'Plan' || !artifacts.trace) + throw new Error('Expected a traced completed plan.'); + expect(outcome.placements.map(({ date }) => date)).toEqual(dates); + expect(outcome.remainingBalances).toEqual([]); + branchCounts.push( + artifacts.trace.phases + .find(({ name }) => name === 'obligations') + ?.decisions.filter(({ deltas }) => + deltas.some(({ placement }) => placement !== undefined), + ).length ?? 0, + ); + if (rewardDays) + expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ + availableFrom: '2024-05-02', + added: false, + }); + } + expect(branchCounts[0]).toBeGreaterThan(0); + expect(branchCounts[1]).toBe(branchCounts[0]); + }, + ); + + it('removes an entitlement from the original replay when a real quality move shortens its generator into today', async () => { + vi.setSystemTime(new Date('2024-05-06T12:00:00Z')); + const core = await import('./leave-planner.core'); + let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; + const outcome = core.calculatePlanningInternal( + { + horizon: { start: '2024-05-01', end: '2024-05-11' }, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 1, + availableFrom: '2024-05-06', + expiryDate: '2024-05-07', + }, + ], + declaredDays: [ + { date: '2024-05-02', type: 'cp' }, + { date: '2024-05-03', type: 'cp' }, + ], + rules: [ + { + id: 'producer', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placeDays: 2, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'other', + }, + ], + }, + undefined, + (value) => (artifacts = value), + ); + if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState) + throw new Error('Expected a traced completed plan.'); + expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]); + expect(outcome.remainingBalances).toEqual([]); + expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ + availableFrom: '2024-05-06', + added: false, + }); + const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions); + expect(decisions.flatMap(({ deltas }) => deltas)).toContainEqual({ + kind: 'balance-removed', + balanceId: 'earned:producer:2024-05-01', + remainingBefore: 1, + }); + const initial = artifacts.trace.initialState; + const replay = { + balances: initial.balances.map((balance) => ({ ...balance })), + placements: new Map( + initial.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: [...placement.ruleIds] }, + ]), + ), + earnedActivations: initial.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; + for (const decision of decisions) applyReplayDeltas(replay, decision.deltas); + expect(replay.balances).toEqual(artifacts.finalState.balances); + expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations); + expect([...replay.placements.values()]).toEqual(outcome.placements); + }); + it('records only actual evaluations and publishes non-working dates as metadata', async () => { const calculation = createPlanningCalculation({ horizon: { start: '2024-01-01', end: '2024-01-07' }, diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 9d5837a..53fbe85 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -2,6 +2,7 @@ import { DeclaredNonWorkingDay, parseDeclaredDays, frenchPublicHolidaysForYear, + isWorkingPlanningDate, } from './declared-non-working-days'; import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date'; export type { DeclaredNonWorkingDay } from './declared-non-working-days'; @@ -78,11 +79,13 @@ export interface PlanningReason { export interface InvalidInputOutcome { kind: 'InvalidInput'; + calculationDate?: string; reasons: PlanningReason[]; } export interface PlanningConflictOutcome { kind: 'PlanningConflict'; + calculationDate?: string; reasons: PlanningReason[]; conflictingRuleIds: string[]; } @@ -114,10 +117,31 @@ export interface TimeOffPeriod { end: string; duration: number; placementDates: string[]; + declaredDates?: string[]; nonWorkingDates: string[]; nonWorkingDayGain: number; } +export interface TimeWindowContribution { + ruleId: string; + label: string; + requested: number; + declaredDays: readonly DeclaredNonWorkingDay[]; + placementDates: readonly string[]; +} + +export interface IfThenContribution extends TimeWindowContribution { + occurrence: string; + earnedEntitlement?: Readonly<{ + id: string; + type: LeaveType; + quantity: number; + availableFrom: string; + expiryDate: string; + added: boolean; + }>; +} + export interface PreferenceResult { ruleId: string; requested: number; @@ -192,7 +216,11 @@ export interface PlacementExplanation { export interface PlanExplanation { kind: 'PlanExplanation'; + calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; + timeOffPeriods?: readonly TimeOffPeriod[]; + timeWindowContributions?: readonly TimeWindowContribution[]; + ifThenContributions?: readonly IfThenContribution[]; placements: readonly PlacementExplanation[]; /** Present when the explained plan is the incumbent of an interrupted calculation. */ incomplete?: IncompleteExplanation; @@ -200,12 +228,14 @@ export interface PlanExplanation { export interface ConflictExplanation { kind: 'ConflictExplanation'; + calculationDate?: string; conflictingRules: readonly Readonly<{ id: string; label: string }>[]; bottleneck: PlanningReason; } export interface IncompleteExplanation { kind: 'IncompleteExplanation'; + calculationDate?: string; reason: PlanningReason; completedStages: readonly string[]; uncompletedStages: readonly string[]; @@ -214,6 +244,7 @@ export interface IncompleteExplanation { export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation; export interface PlanningSnapshot { + calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; horizon: Readonly<{ start: string; end: string }>; balances: readonly Readonly[]; @@ -230,6 +261,7 @@ export type TraceCandidateStatus = | 'ineligible'; export interface PlanningTraceState { + calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; horizon: Readonly<{ start: string; end: string }>; balances: readonly Readonly<{ @@ -258,6 +290,7 @@ export interface PlanningTraceDelta { | 'placement-moved' | 'placement-updated' | 'balance-changed' + | 'balance-removed' | 'earned-activated'; date?: string; fromDate?: string; @@ -434,7 +467,10 @@ export interface PlanningMetrics { export interface PlanOutcome { kind: 'Plan'; + calculationDate?: string; declaredDays?: readonly DeclaredNonWorkingDay[]; + timeWindowContributions?: readonly TimeWindowContribution[]; + ifThenContributions?: readonly IfThenContribution[]; horizon: { start: string; end: string }; placements: LeavePlacement[]; timeOffPeriods: TimeOffPeriod[]; @@ -447,6 +483,7 @@ export interface PlanOutcome { export interface PlanningIncompleteOutcome { kind: 'PlanningIncomplete'; + calculationDate?: string; incumbent?: PlanOutcome; completedStages: string[]; lastCompletedStage?: string; @@ -511,6 +548,7 @@ interface State { } interface RuntimePlanningRequest { + calculationDate: string; horizon?: unknown; balances?: unknown; declaredDays?: unknown; @@ -523,6 +561,7 @@ interface RuntimePlanningRequest { } interface RuntimeRecord { + calculationDate?: unknown; horizon?: unknown; start?: unknown; end?: unknown; @@ -555,15 +594,18 @@ interface RuntimeRecord { interface Requirement { rule: NormalizedRule; + requiredPlacements: number; anchor: string; index: number; occurrenceDate?: string; + optional?: boolean; patterns?: string[][]; candidates?: string[]; } const obligationRequirementCache = new WeakMap(); const preferencePatternCache = new WeakMap>(); +const declaredWindowCache = new WeakMap>(); type MutableTraceCandidate = PlanningTraceCandidate & { candidates?: never }; type MutableTraceDecision = { @@ -741,6 +783,7 @@ class TraceRecorder { const initialState = this.initialStateSent ? undefined : { + calculationDate: this.initialState.calculationDate, horizon: { ...this.initialState.horizon }, ...(this.initialState.declaredDays ? { @@ -923,21 +966,26 @@ class TraceRecorder { const previous = this.previousPlacements.get(date); return previous && JSON.stringify(previous) !== JSON.stringify(placement); }); - const balancesChanged = balances.some((balance) => { - const previous = this.previousBalances.get(balance.id); - return ( - !previous || - previous.remaining !== balance.remaining || - previous.availableFrom !== balance.availableFrom || - previous.expiryDate !== balance.expiryDate || - previous.quantity !== balance.quantity || - previous.type !== balance.type || - previous.earned !== balance.earned || - previous.sourceRuleId !== balance.sourceRuleId - ); - }); - const earnedChanged = balances.some( - (balance) => balance.earned && !this.previousEarned.has(balance.id), + const removedBalances = [...this.previousBalances.values()].filter( + (previous) => !balances.some((balance) => balance.id === previous.id), + ); + const balancesChanged = + removedBalances.length > 0 || + balances.some((balance) => { + const previous = this.previousBalances.get(balance.id); + return ( + !previous || + previous.remaining !== balance.remaining || + previous.availableFrom !== balance.availableFrom || + previous.expiryDate !== balance.expiryDate || + previous.quantity !== balance.quantity || + previous.type !== balance.type || + previous.earned !== balance.earned || + previous.sourceRuleId !== balance.sourceRuleId + ); + }); + const earnedChanged = earnedActivations.some( + (activation) => !this.previousEarned.has(activation.id), ); if ( !removed.length && @@ -1039,24 +1087,25 @@ class TraceRecorder { remainingAfter: balance.remaining, balance: { ...balance }, }); - if (balance.earned && !this.previousEarned.has(balance.id)) { - const activation = earnedActivations.find( - (candidate) => candidate.id === balance.id, - ); + } + for (const balance of removedBalances) { + this.recordDelta(decision, { + kind: 'balance-removed', + balanceId: balance.id, + remainingBefore: balance.remaining, + }); + } + for (const activation of earnedActivations) { + if (!this.previousEarned.has(activation.id)) this.recordDelta(decision, { kind: 'earned-activated', - balanceId: balance.id, - activation: activation - ? { ...activation, dates: [...activation.dates] } - : undefined, + balanceId: activation.id, + activation: { ...activation, dates: [...activation.dates] }, }); - } } this.previousPlacements = current; this.previousBalances = new Map(balances.map((balance) => [balance.id, { ...balance }])); - this.previousEarned = new Set( - balances.filter((balance) => balance.earned).map((balance) => balance.id), - ); + this.previousEarned = new Set(earnedActivations.map((activation) => activation.id)); } finish( @@ -1286,10 +1335,14 @@ function dateKey(value: unknown): string | null { return null; } -function runtimeRequest(value: unknown): RuntimePlanningRequest { - if (typeof value !== 'object' || value === null) return {}; - const source = value as RuntimeRecord; +function runtimeRequest(value: unknown, calculationDate?: string): RuntimePlanningRequest { + const source = recordValue(value) ?? {}; return { + calculationDate: + calculationDate ?? + (isPlanningDate(source.calculationDate) + ? source.calculationDate + : planningDateInParis(new Date())), horizon: source.horizon, balances: source.balances, declaredDays: source.declaredDays, @@ -1329,14 +1382,13 @@ function dateRange(start: string, endExclusive: string): string[] { return result; } -function defaultHorizon(now = new Date()): { start: string; end: string } { - const start = planningDateInParis(now); +function defaultHorizon(start: string): { start: string; end: string } { return { start, end: addPlanningYears(start, 1) }; } function holidaySet(horizon: { start: string; end: string }): Set { - const first = Number(horizon.start.slice(0, 4)) - 1; - const last = Number(horizon.end.slice(0, 4)) + 1; + const first = Number(addDays(horizon.start, -400).slice(0, 4)); + const last = Number(addDays(horizon.end, 400).slice(0, 4)); const result = new Set(); for (let year = first; year <= last; year++) { for (const holiday of frenchPublicHolidaysForYear(year)) result.add(holiday); @@ -1361,31 +1413,26 @@ interface HolidayWeekCandidate { function holidayWeekCandidates(context: SearchContext): HolidayWeekCandidate[] { const candidates = new Map(); + const inHorizon = (date: string) => date >= context.horizon.start && date < context.horizon.end; for (const holiday of [...context.holidays].sort(compareDate)) { - if ( - compareDate(holiday, context.horizon.start) < 0 || - compareDate(holiday, context.horizon.end) >= 0 - ) - continue; const weekday = utcDate(holiday).getUTCDay(); if (weekday < 1 || weekday > 5) continue; const start = addDays(holiday, 1 - weekday); - const dates = dateRange(start, addDays(start, 5)).filter((date) => - isWorking(date, context.holidays), - ); + const weekdays = dateRange(start, addDays(start, 5)); + const dates = weekdays.filter((date) => isWorking(date, context.holidays)); if ( !dates.length || - dates.some( - (date) => - compareDate(date, context.horizon.start) < 0 || - compareDate(date, context.horizon.end) >= 0, - ) + !dates.some(inHorizon) || + (!inHorizon(holiday) && !dates.some((date) => context.declaredDates.has(date))) || + dates.some((date) => !inHorizon(date) && !context.declaredDates.has(date)) ) continue; - const value = Math.min(weekday, 6 - weekday); - const previous = candidates.get(start); - if (previous) previous.value += value; - else candidates.set(start, { start, dates, value }); + const value = weekdays.reduce( + (sum, date, index) => + sum + (context.holidays.has(date) ? Math.min(index + 1, 5 - index) : 0), + 0, + ); + candidates.set(start, { start, dates, value }); } return [...candidates.values()].sort( (a, b) => b.value - a.value || compareDate(a.start, b.start), @@ -1396,7 +1443,11 @@ function holidayWeekValue(state: State, context: SearchContext): number { return context.holidayWeeks.reduce( (value, candidate) => value + - (candidate.dates.every((date) => state.placements.has(date)) ? candidate.value : 0), + (candidate.dates.every( + (date) => state.placements.has(date) || context.declaredDates.has(date), + ) + ? candidate.value + : 0), 0, ); } @@ -1424,14 +1475,11 @@ function cloneState(state: State): State { }; } -function planningTraceState( - state: State, - horizon: { start: string; end: string }, - declaredDays: readonly DeclaredNonWorkingDay[] = [], -): PlanningTraceState { +function planningTraceState(state: State, context: SearchContext): PlanningTraceState { return { - horizon, - ...(declaredDays.length ? { declaredDays } : {}), + calculationDate: context.request.calculationDate, + horizon: context.horizon, + ...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}), balances: state.balances.map((balance) => ({ ...balance })), placements: [...state.placements.values()] .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)) @@ -1516,6 +1564,15 @@ function planningTraceDeltas( }); } } + const remainingIds = new Set(after.balances.map((balance) => balance.id)); + for (const balance of before.balances) { + if (!remainingIds.has(balance.id)) + deltas.push({ + kind: 'balance-removed', + balanceId: balance.id, + remainingBefore: balance.remaining, + }); + } const previousActivations = new Set( before.earnedActivations.map((activation) => activation.id), ); @@ -1650,7 +1707,7 @@ function retainAndRecordState( acceptedCandidate?: MutableTraceCandidate, ): void { observeTrace(context, (trace) => { - const snapshot = planningTraceState(state, context.horizon, context.declaredDays); + const snapshot = planningTraceState(state, context); if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate); trace.recordPlan( snapshot.placements, @@ -1917,7 +1974,7 @@ function normalizeRequest(request: unknown): { } { const source = runtimeRequest(request); const rawHorizon = recordValue(source.horizon); - const fallbackHorizon = defaultHorizon(); + const fallbackHorizon = defaultHorizon(source.calculationDate); const start = dateKey(rawHorizon?.start) ?? fallbackHorizon.start; const end = dateKey(rawHorizon?.end) ?? fallbackHorizon.end; const reasons: PlanningReason[] = []; @@ -1991,17 +2048,30 @@ function futurePreferenceProtection( const futureRules = rules.slice(startIndex); for (const [offset, rule] of futureRules.entries()) { if (!rule.enabled || rule.force !== 'preference') continue; + if ( + rule.type === 'time_window' && + declaredWindowDays(rule, context).length >= rule.placeDays + ) + continue; const priorityWeight = futureRules.length - offset; const dates = rule.type === 'time_window' ? context.workingDays.filter( (date) => + !context.declaredDates.has(date) && compareDate(date, rule.startDate!) >= 0 && compareDate(date, rule.endDate!) <= 0, ) - : occurrences(rule, context).flatMap((occurrence) => - patternsFor(rule, occurrence, context).flat(), - ); + : occurrences(rule, context).flatMap((occurrence) => { + const patterns = patternsFor(rule, occurrence, context); + return patterns.some((pattern) => + pattern.every((date) => + declaredPatternDay(date, rule.placeType, context), + ), + ) + ? [] + : patterns.flat().filter((date) => !context.declaredDates.has(date)); + }); for (const date of dates) { context.preferenceDemandByDate.set( date, @@ -2106,7 +2176,10 @@ function buildRequirements( const candidates = compareDate(start, end) <= 0 ? context.workingDays.filter( - (date) => compareDate(date, start) >= 0 && compareDate(date, end) <= 0, + (date) => + !context.declaredDates.has(date) && + compareDate(date, start) >= 0 && + compareDate(date, end) <= 0, ) : []; const orderedCandidates = candidates.sort( @@ -2117,23 +2190,34 @@ function buildRequirements( ); requirements.push({ rule, + requiredPlacements: rule.placeDays - declaredWindowDays(rule, context).length, anchor: start, index: index++, candidates: orderedCandidates, }); } else { for (const occurrence of occurrences(rule, context)) { + const patterns = patternsFor(rule, occurrence, context).sort( + (a, b) => + patternConflictScore(rule.placeType, a, context) - + patternConflictScore(rule.placeType, b, context) || + compareDateLists(a, b), + ); requirements.push({ rule, + requiredPlacements: Math.min( + rule.placeDays, + ...patterns.map( + (pattern) => + pattern.filter( + (date) => !declaredPatternDay(date, rule.placeType, context), + ).length, + ), + ), anchor: occurrence, occurrenceDate: occurrence, index: index++, - patterns: patternsFor(rule, occurrence, context).sort( - (a, b) => - patternConflictScore(rule.placeType, a, context) - - patternConflictScore(rule.placeType, b, context) || - compareDateLists(a, b), - ), + patterns, }); } } @@ -2142,24 +2226,27 @@ function buildRequirements( // TIME_WINDOW consumer even when the window starts earlier; availability // checks still prevent an earned balance from funding an earlier date, while // this ordering keeps valid later chains visible to the joint search. - const ordered = requirements.sort( - (a, b) => - Number(a.rule.type !== 'if_then') - Number(b.rule.type !== 'if_then') || - compareDate(a.anchor, b.anchor) || - a.index - b.index, - ); + const ordered = requirements.sort(compareRequirements); if (force === 'obligation' && rules === context.rules) obligationRequirementCache.set(context, ordered); return ordered; } +function compareRequirements(a: Requirement, b: Requirement): number { + return ( + Number(a.rule.type !== 'if_then') - Number(b.rule.type !== 'if_then') || + compareDate(a.anchor, b.anchor) || + a.index - b.index + ); +} + function obligationBranchEstimate(requirements: Requirement[]): number { let estimate = 1; for (const requirement of requirements) { let branches = requirement.patterns?.length ?? 1; if (!requirement.patterns) { const candidates = requirement.candidates?.length ?? 0; - const needed = requirement.rule.placeDays; + const needed = requirement.requiredPlacements; branches = 1; for (let index = 1; index <= needed; index++) { if (index > candidates) { @@ -2182,6 +2269,50 @@ function selectorMatches(selector: LeaveSelector, type: LeaveType): boolean { return selector === 'any' || selector === type; } +function declaredWindowDays(rule: NormalizedRule, context: SearchContext) { + if (rule.type !== 'time_window' || !context.declaredDays.length) return []; + let byRule = declaredWindowCache.get(context); + if (!byRule) { + byRule = new Map(); + declaredWindowCache.set(context, byRule); + } + let days = byRule.get(rule.id); + if (!days) { + days = context.declaredDays + .filter( + (day) => + selectorMatches(rule.placeType, day.type) && + day.date >= context.horizon.start && + day.date < context.horizon.end && + day.date >= rule.startDate! && + day.date <= rule.endDate!, + ) + .sort((a, b) => compareDate(a.date, b.date)) + .slice(0, rule.placeDays); + byRule.set(rule.id, days); + } + return days; +} + +function declaredPatternDay(date: string, selector: LeaveSelector, context: SearchContext) { + if (!context.declaredDates.has(date)) return undefined; + return context.declaredDays.find( + (day) => day.date === date && selectorMatches(selector, day.type), + ); +} + +function patternDateSatisfied( + date: string, + rule: NormalizedRule, + state: State, + context: SearchContext, +): boolean { + return Boolean( + declaredPatternDay(date, rule.placeType, context) || + state.placements.get(date)?.ruleIds.has(rule.id), + ); +} + function assignDate( state: State, date: string, @@ -2498,42 +2629,22 @@ function addEarnedBalance( ): State { if (!rule.rewardDays || !rule.rewardType) return state; const result = cloneState(state); - const allDates = [...dates, occurrence].sort(compareDate); - let start = allDates[0]; - let end = allDates[allDates.length - 1]; - // A generated pattern may share a period with placements already chosen for - // another rule. Use the complete merged period so the entitlement cannot be - // acquired before that shared time-off period ends. - if (dates.length) { - const mergedPeriod = periodData(state, context).find((period) => - dates.some((date) => period.placementDates.includes(date)), - ); - if (mergedPeriod) { - start = mergedPeriod.start; - end = mergedPeriod.end; - } - } - while (!isWorking(addDays(start, -1), context.holidays)) start = addDays(start, -1); - while (!isWorking(addDays(end, 1), context.holidays)) end = addDays(end, 1); - const acquisition = addDays(end, 1); const id = `earned:${rule.id}:${occurrence}`; + const activation = { id, ruleId: rule.id, occurrence, dates: [...dates] }; + if (!result.earnedActivations.some((item) => item.id === id)) + result.earnedActivations.push(activation); + const bounds = earnedPeriodBounds(state, activation, context); + if (bounds.availableFrom <= context.request.calculationDate) return result; if (!result.balances.some((balance) => balance.id === id)) { result.balances.push({ id, type: rule.rewardType, quantity: rule.rewardDays, remaining: rule.rewardDays, - availableFrom: acquisition, - expiryDate: addPlanningYears(acquisition, 1), + ...bounds, earned: true, sourceRuleId: rule.id, }); - result.earnedActivations.push({ - id, - ruleId: rule.id, - occurrence, - dates: [...dates], - }); } return result; } @@ -2547,7 +2658,9 @@ function earnedPeriodBounds( let start = allDates[0] ?? activation.occurrence; let end = allDates[allDates.length - 1] ?? activation.occurrence; const mergedPeriod = periodData(state, context).find((period) => - activation.dates.some((date) => period.placementDates.includes(date)), + activation.dates.some( + (date) => period.placementDates.includes(date) || period.declaredDates?.includes(date), + ), ); if (mergedPeriod) { start = mergedPeriod.start; @@ -2560,19 +2673,41 @@ function earnedPeriodBounds( } /** - * Reconcile generated balances only after all placements in this branch are - * known. A later placement can extend the generating merged period, so a + * Reconcile generated balances whenever a branch gains placements. + * A later placement can extend the generating merged period, so a * provisional reward must not finance an earlier placement in that same final * period. */ function finalizeEarnedState(state: State, context: SearchContext): State | null { if (!state.earnedActivations.length) return state; - const result = cloneState(state); + let result = cloneState(state); for (const activation of result.earnedActivations) { context.check(); - const balance = result.balances.find((item) => item.id === activation.id); - if (!balance) continue; const bounds = earnedPeriodBounds(result, activation, context); + if (bounds.availableFrom <= context.request.calculationDate) { + if ( + [...result.placements.values()].some( + (placement) => placement.balanceId === activation.id, + ) + ) + return null; + result.balances = result.balances.filter((balance) => balance.id !== activation.id); + continue; + } + let balance = result.balances.find((item) => item.id === activation.id); + if (!balance) { + const rule = context.rules.find((rule) => rule.id === activation.ruleId); + if (!rule) return null; + result = addEarnedBalance( + result, + rule, + activation.occurrence, + activation.dates, + context, + ); + balance = result.balances.find((item) => item.id === activation.id); + if (!balance) continue; + } balance.availableFrom = bounds.availableFrom; balance.expiryDate = bounds.expiryDate; for (const placement of result.placements.values()) { @@ -2601,6 +2736,12 @@ function* assignDatesVariants( compareDate(date, context.horizon.end) >= 0 ) return []; + if (context.declaredDates.has(date)) { + if (declaredPatternDay(date, rule.placeType, context)) + return yield* assignDatesVariants(state, rest, rule, context); + rejectDeclaredDate(context, date); + return []; + } const result: State[] = []; for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) { yield; @@ -2618,6 +2759,7 @@ function* satisfyObligations( solutionLimit = OBLIGATION_SOLUTION_LIMIT, branchPath: readonly ObligationBranchReference[] = [], parentDecisionId?: string, + chooseOrder = true, ): PlanningSteps { yield; context.check(); @@ -2654,6 +2796,74 @@ function* satisfyObligations( return solutions ? null : finalized; } const requirement = requirements[index]; + // Extending a reported generator requires filling its first working gap. + // Only move requirements that can fill that gap ahead of a funded consumer. + const extensionDates = + chooseOrder && context.horizon.end > context.request.calculationDate + ? state.earnedActivations.map( + (activation) => + state.balances.find((balance) => balance.id === activation.id) + ?.availableFrom ?? + earnedPeriodBounds(state, activation, context).availableFrom, + ) + : []; + const extensions = extensionDates.length + ? requirements + .slice(index + 1) + .flatMap((candidate, offset) => + ( + candidate.patterns + ? candidate.patterns.some((pattern) => + pattern.some((date) => extensionDates.includes(date)), + ) + : candidate.requiredPlacements > 0 && + candidate.candidates?.some((date) => extensionDates.includes(date)) + ) + ? [index + offset + 1] + : [], + ) + : []; + if (extensions.length) { + for (const next of [index, ...extensions]) { + yield; + const ordered = + next === index + ? requirements + : [ + ...requirements.slice(0, index), + requirements[next], + ...requirements.slice(index, next), + ...requirements.slice(next + 1), + ]; + const complete = yield* satisfyObligations( + ordered, + state, + context, + index, + solutions, + solutionLimit, + branchPath, + parentDecisionId, + false, + ); + if (complete && !solutions) return complete; + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) break; + } + return null; + } + if (requirement.optional) { + const complete = yield* satisfyObligations( + requirements, + state, + context, + index + 1, + solutions, + solutionLimit, + branchPath, + parentDecisionId, + ); + if (complete && !solutions) return complete; + } if (requirement.patterns) { for (const pattern of requirement.patterns) { yield; @@ -2664,13 +2874,17 @@ function* satisfyObligations( context, )) { yield; - const candidate = addEarnedBalance( - assigned, - requirement.rule, - requirement.occurrenceDate!, - pattern, + const candidate = finalizeEarnedState( + addEarnedBalance( + assigned, + requirement.rule, + requirement.occurrenceDate!, + pattern, + context, + ), context, ); + if (!candidate) continue; const branch = recordTraceBranchDecision( context, state, @@ -2704,7 +2918,7 @@ function* satisfyObligations( return null; } const candidates = requirement.candidates ?? []; - const needed = requirement.rule.placeDays; + const needed = requirement.requiredPlacements; const choose = function* ( position: number, picked: string[], @@ -2713,16 +2927,18 @@ function* satisfyObligations( yield; context.check(); if (picked.length === needed) { + const finalized = finalizeEarnedState(current, context); + if (!finalized) return null; const branch = recordTraceBranchDecision( context, state, - current, + finalized, parentDecisionId, picked, ); const complete = yield* satisfyObligations( requirements, - current, + finalized, context, index + 1, solutions, @@ -2907,14 +3123,19 @@ function* preferenceTimeWindow( : rule.endDate!; if (compareDate(start, end) > 0) return { state, count: 0 }; let result = state; - let count = 0; + let count = declaredWindowDays(rule, context).length; const shareableDates = new Set( [...state.placements.values()] .filter((placement) => selectorMatches(rule.placeType, placement.type)) .map((placement) => placement.date), ); const candidates = context.workingDays - .filter((item) => compareDate(item, start) >= 0 && compareDate(item, end) <= 0) + .filter( + (item) => + !context.declaredDates.has(item) && + compareDate(item, start) >= 0 && + compareDate(item, end) <= 0, + ) .sort((a, b) => { return ( Number(!shareableDates.has(a)) - Number(!shareableDates.has(b)) || @@ -3086,9 +3307,14 @@ function* preferenceIfThen( const variants = yield* assignDatesVariants(current, pattern, rule, context); for (const variant of variants) { yield; + const finalized = finalizeEarnedState( + addEarnedBalance(variant, rule, occurrence, pattern, context), + context, + ); + if (!finalized) continue; yield* search( index + 1, - addEarnedBalance(variant, rule, occurrence, pattern, context), + finalized, count + 1, futureScore + patternConflictScore(rule.placeType, pattern, context), ); @@ -3320,6 +3546,7 @@ function preferenceReason( rule.type === 'time_window' ? context.workingDays.filter( (date) => + !context.declaredDates.has(date) && compareDate(date, rule.startDate!) >= 0 && compareDate(date, rule.endDate!) <= 0, ) @@ -3493,6 +3720,11 @@ function* applyPreferenceRule( context.reservedTypes = new Set( preferenceRules .slice(preferenceIndex + 1) + .filter((rule) => + buildRequirements([rule], context, rule.force).some( + (requirement) => requirement.requiredPlacements > 0, + ), + ) .map((item) => item.placeType) .filter((selector): selector is LeaveType => selector !== 'any'), ); @@ -4057,7 +4289,9 @@ function* optimizeMovablePlacements( if (exhaustive) return true; const touchesNonWorking = !isWorking(addDays(date, -1), context.holidays) || - !isWorking(addDays(date, 1), context.holidays); + !isWorking(addDays(date, 1), context.holidays) || + context.declaredDates.has(addDays(date, -1)) || + context.declaredDates.has(addDays(date, 1)); return touchesNonWorking || index % 7 === 0; }); if (!exhaustive) { @@ -4274,7 +4508,9 @@ function* completeHolidayWeek( protectedDates: Set, ): PlanningSteps { const targetDates = new Set(candidate.dates); - const missing = candidate.dates.filter((date) => !state.placements.has(date)); + const missing = candidate.dates.filter( + (date) => !state.placements.has(date) && !context.declaredDates.has(date), + ); if (!missing.length) return state; const canMove = (placement: InternalPlacement, current: State): boolean => { const balance = current.balances.find((item) => item.id === placement.balanceId); @@ -4360,7 +4596,9 @@ function* optimizeHolidayWeeks( if (candidates.length) recordTraceDecision(context, []); const baseline = qualityMetrics(result, context, preferences); const completed = candidates.filter((candidate) => - candidate.dates.every((date) => result.placements.has(date)), + candidate.dates.every( + (date) => result.placements.has(date) || context.declaredDates.has(date), + ), ); let best: State | null = null; let bestMetrics = baseline; @@ -4368,6 +4606,9 @@ function* optimizeHolidayWeeks( for (const candidate of candidates) { yield; context.check(); + const candidateDates = candidate.dates.filter( + (date) => !context.declaredDates.has(date), + ); const protectedDates = new Set( completed .filter((week) => week.value >= candidate.value) @@ -4391,7 +4632,7 @@ function* optimizeHolidayWeeks( preferences, baseline, metrics, - candidate.dates, + candidateDates, undefined, () => holidayWeekProtectionTraceDetails(baseline, metrics, preferences), ); @@ -4409,7 +4650,7 @@ function* optimizeHolidayWeeks( preferences, baseline, metrics, - candidate.dates, + candidateDates, undefined, () => { const protectedDetails = holidayWeekProtectionTraceDetails( @@ -4696,7 +4937,9 @@ function compatibleWithRules( } function periodData(state: State, context: SearchContext): TimeOffPeriod[] { - const dates = [...state.placements.keys()].sort(compareDate); + const dates = [...new Set([...state.placements.keys(), ...context.declaredDates])] + .filter((date) => date >= context.horizon.start && date < context.horizon.end) + .sort(compareDate); if (!dates.length) return []; const groups: string[][] = []; let group = [dates[0]]; @@ -4713,26 +4956,44 @@ function periodData(state: State, context: SearchContext): TimeOffPeriod[] { } } groups.push(group); - return groups.map((placementDates) => { + const isNaturalNonWorkingDate = (date: string) => + context.calendarIndex.has(date) + ? !context.workingCalendar.has(date) + : !isWorkingPlanningDate(date); + const dateAt = (index: number) => + context.calendarDays[index] ?? addDays(context.calendarDays[0], index); + return groups.map((anchors) => { context.check(); - let startIndex = context.calendarIndex.get(placementDates[0])!; - let endIndex = context.calendarIndex.get(placementDates[placementDates.length - 1])!; - while (startIndex > 0 && !context.workingCalendar.has(context.calendarDays[startIndex - 1])) - startIndex--; + let startIndex = context.calendarIndex.get(anchors[0])!; + let endIndex = context.calendarIndex.get(anchors[anchors.length - 1])!; while ( - endIndex + 1 < context.calendarDays.length && - !context.workingCalendar.has(context.calendarDays[endIndex + 1]) - ) + context.declaredDates.has(dateAt(startIndex - 1)) || + isNaturalNonWorkingDate(dateAt(startIndex - 1)) + ) { + context.check(); + startIndex--; + } + while ( + context.declaredDates.has(dateAt(endIndex + 1)) || + isNaturalNonWorkingDate(dateAt(endIndex + 1)) + ) { + context.check(); endIndex++; - const start = context.calendarDays[startIndex]; - const end = context.calendarDays[endIndex]; - const allDates = context.calendarDays.slice(startIndex, endIndex + 1); - const nonWorkingDates = allDates.filter((date) => !context.workingCalendar.has(date)); + } + const start = dateAt(startIndex); + const end = dateAt(endIndex); + const allDates = + startIndex >= 0 && endIndex < context.calendarDays.length + ? context.calendarDays.slice(startIndex, endIndex + 1) + : dateRange(start, addDays(end, 1)); + const nonWorkingDates = allDates.filter(isNaturalNonWorkingDate); + const declaredDates = allDates.filter((date) => context.declaredDates.has(date)); return { start, end, duration: allDates.length, - placementDates: [...placementDates], + placementDates: anchors.filter((date) => state.placements.has(date)), + ...(declaredDates.length ? { declaredDates } : {}), nonWorkingDates, nonWorkingDayGain: nonWorkingDates.length, }; @@ -4752,20 +5013,20 @@ function qualityMetrics( preferences: readonly PreferenceResult[], ): PlanningMetrics { const periods = periodData(state, context); - const placed = new Set(state.placements.keys()); + const timeOffDates = new Set([...state.placements.keys(), ...context.declaredDates]); const sequences: number[] = []; let current = 0; - let hasPlacement = false; + let hasTimeOff = false; for (const date of context.workingDays) { context.check(); - if (placed.has(date)) { + if (timeOffDates.has(date)) { sequences.push(current); current = 0; - hasPlacement = true; + hasTimeOff = true; } else current++; } if (context.workingDays.length) sequences.push(current); - const internal = hasPlacement ? sequences.slice(1, -1) : []; + const internal = hasTimeOff ? sequences.slice(1, -1) : []; const shortSequenceDeficit = internal.reduce( (sum, length) => sum + (length > 0 && length < 5 ? 5 - length : 0), 0, @@ -4824,7 +5085,7 @@ function unsatisfiedObligationCount(state: State, context: SearchContext): numbe let unsatisfied = 0; for (const requirement of buildRequirements(context.rules, context, 'obligation')) { if (!requirement.patterns) { - if ((placementCounts.get(requirement.rule.id) ?? 0) !== requirement.rule.placeDays) + if ((placementCounts.get(requirement.rule.id) ?? 0) !== requirement.requiredPlacements) unsatisfied++; continue; } @@ -4832,7 +5093,7 @@ function unsatisfiedObligationCount(state: State, context: SearchContext): numbe for (const pattern of requirement.patterns) { if ( pattern.every((date) => - state.placements.get(date)?.ruleIds.has(requirement.rule.id), + patternDateSatisfied(date, requirement.rule, state, context), ) ) { complete = true; @@ -4919,6 +5180,90 @@ function displayLabel(value: unknown, fallback: string): string { return typeof value === 'string' && value.trim() ? value : fallback; } +function timeWindowContributions(state: State, context: SearchContext): TimeWindowContribution[] { + if (!context.declaredDays.length) return []; + const labels = recordValue(context.request.labels) as Record | undefined; + const identities = recordValue(context.request.identities) as + | Record + | undefined; + return context.rules + .filter((rule) => rule.enabled && rule.type === 'time_window') + .flatMap((rule) => { + const declaredDays = declaredWindowDays(rule, context); + return declaredDays.length + ? [ + { + ruleId: rule.id, + label: displayLabel( + labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, + rule.id, + ), + requested: rule.placeDays, + declaredDays, + placementDates: [...state.placements.values()] + .filter((placement) => placement.ruleIds.has(rule.id)) + .map(({ date }) => date) + .sort(compareDate), + }, + ] + : []; + }); +} + +function ifThenContributions(state: State, context: SearchContext): IfThenContribution[] { + const labels = recordValue(context.request.labels) as Record | undefined; + const identities = recordValue(context.request.identities) as + | Record + | undefined; + return context.rules + .filter((rule) => rule.enabled && rule.type === 'if_then') + .flatMap((rule) => + occurrences(rule, context).flatMap((occurrence) => { + const id = `earned:${rule.id}:${occurrence}`; + const activation = state.earnedActivations.find((item) => item.id === id); + const pattern = + activation?.dates ?? + patternsFor(rule, occurrence, context).find((dates) => + dates.every((date) => patternDateSatisfied(date, rule, state, context)), + ); + if (!pattern) return []; + const declaredDays = pattern.flatMap((date) => { + const day = declaredPatternDay(date, rule.placeType, context); + return day ? [day] : []; + }); + if (!declaredDays.length && !activation) return []; + const bounds = activation + ? earnedPeriodBounds(state, activation, context) + : undefined; + return [ + { + ruleId: rule.id, + label: displayLabel( + labels?.[rule.id] ?? identities?.[rule.id] ?? rule.name, + rule.id, + ), + occurrence, + requested: rule.placeDays, + declaredDays, + placementDates: pattern.filter((date) => !context.declaredDates.has(date)), + ...(activation && bounds + ? { + earnedEntitlement: { + id, + type: rule.rewardType!, + quantity: rule.rewardDays!, + ...bounds, + added: + bounds.availableFrom > context.request.calculationDate, + }, + } + : {}), + }, + ]; + }), + ); +} + function planExplanation( state: State, context: SearchContext, @@ -4934,9 +5279,15 @@ function planExplanation( const labels = recordValue(source.labels) as Record | undefined; const identities = recordValue(source.identities) as Record | undefined; const vector = objectiveVector(metrics, preferences, state, context); + const windowContributions = timeWindowContributions(state, context); + const patternContributions = ifThenContributions(state, context); return { kind: 'PlanExplanation', + calculationDate: context.request.calculationDate, ...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}), + timeOffPeriods: periods, + ...(windowContributions.length ? { timeWindowContributions: windowContributions } : {}), + ...(patternContributions.length ? { ifThenContributions: patternContributions } : {}), placements: placements.map((placement) => { const balance = rawBalances .map(recordValue) @@ -5092,9 +5443,14 @@ function makePlan( supporting: placement.supporting, })); const remaining = remainingBalances(state, context); + const windowContributions = timeWindowContributions(state, context); + const patternContributions = ifThenContributions(state, context); const plan: PlanOutcome = { kind: 'Plan', + calculationDate: context.request.calculationDate, ...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}), + ...(windowContributions.length ? { timeWindowContributions: windowContributions } : {}), + ...(patternContributions.length ? { ifThenContributions: patternContributions } : {}), horizon: context.horizon, placements, timeOffPeriods: periods, @@ -5170,7 +5526,7 @@ function obligationConflict( let generatedCapacityBottleneck = false; for (const requirement of requirements) { if (requirement.rule.placeType === 'any') continue; - const required = requirement.patterns?.[0]?.length ?? requirement.rule.placeDays; + const required = requirement.requiredPlacements; const capacity = context.initialBalances .filter((balance) => balance.type === requirement.rule.placeType) .reduce((sum, balance) => sum + balance.quantity, 0); @@ -5194,7 +5550,9 @@ function obligationConflict( requirements.some( (requirement) => (requirement.patterns !== undefined && requirement.patterns.length === 0) || - (requirement.candidates !== undefined && requirement.candidates.length === 0), + (requirement.candidates !== undefined && + requirement.requiredPlacements > 0 && + requirement.candidates.length === 0), ) ) return { @@ -5679,12 +6037,10 @@ function* prepareCalendar( }, progress: () => void, ): PlanningSteps { + const start = addDays(horizon.start, -400); + const end = addDays(horizon.end, 400); const holidays = new Set(); - for ( - let year = Number(horizon.start.slice(0, 4)) - 1; - year <= Number(horizon.end.slice(0, 4)) + 1; - year++ - ) { + for (let year = Number(start.slice(0, 4)); year <= Number(end.slice(0, 4)); year++) { yield true; for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday); progress(); @@ -5694,12 +6050,7 @@ function* prepareCalendar( const calendarIndex = new Map(); const workingCalendar = new Set(); const workingPrefix = [0]; - const end = addDays(horizon.end, 400); - for ( - let date = addDays(horizon.start, -400); - compareDate(date, end) < 0; - date = addDays(date, 1) - ) { + for (let date = start; compareDate(date, end) < 0; date = addDays(date, 1)) { yield; calendarIndex.set(date, calendarDays.length); calendarDays.push(date); @@ -5724,7 +6075,7 @@ function* calculatePlanningSteps( const source = runtimeRequest(request); const normalized = normalizeRequest(source); if (normalized.reasons.length) { - const outcome = invalidInputFor(source)!; + const outcome = { ...invalidInputFor(source)!, calculationDate: source.calculationDate }; outcomeCallback?.(outcome); artifactsCallback?.({}); return outcome; @@ -5741,12 +6092,14 @@ function* calculatePlanningSteps( if (!(error instanceof UserCancelled)) throw error; const outcome: PlanningIncompleteOutcome = { kind: 'PlanningIncomplete', + calculationDate: source.calculationDate, completedStages: ['validation'], lastCompletedStage: 'validation', reasons: [reason('user-cancelled', 'The calculation was cancelled by the user.')], }; const explanation: IncompleteExplanation = { kind: 'IncompleteExplanation', + calculationDate: source.calculationDate, reason: outcome.reasons[0], completedStages: outcome.completedStages, uncompletedStages: ['preparation', 'obligations'], @@ -5758,6 +6111,7 @@ function* calculatePlanningSteps( trace: { status: 'incomplete', initialState: { + calculationDate: source.calculationDate, horizon, balances: normalized.balances.map((balance) => ({ ...balance, @@ -5816,6 +6170,7 @@ function* calculatePlanningSteps( const traceRecorder = artifactsCallback ? new TraceRecorder( { + calculationDate: source.calculationDate, horizon, balances: normalized.balances.map((balance) => ({ ...balance, @@ -5945,7 +6300,7 @@ function* calculatePlanningSteps( observeCallback(context, () => checkpointCallback( makePlan(retained!.state, context, retained!.preferences, stages), - planningTraceState(retained!.state, horizon, context.declaredDays), + planningTraceState(retained!.state, context), ), ); } @@ -5954,6 +6309,7 @@ function* calculatePlanningSteps( } }; const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => { + outcome.calculationDate = source.calculationDate; const planningFinishedAt = now(); const planningExcludedTime = context.traceTime + context.observerTime; outcomeCallback?.(outcome); @@ -6015,18 +6371,14 @@ function* calculatePlanningSteps( ); } try { - if (explanationState) - finalState = planningTraceState( - explanationState, - context.horizon, - context.declaredDays, - ); + if (explanationState) finalState = planningTraceState(explanationState, context); } catch (error) { explanationError ??= reason( 'explanation-error', error instanceof Error ? error.message : String(error), ); } + if (explanation) explanation.calculationDate = source.calculationDate; let explanationDeliveryTime: number; const explanationDeliveryStarted = now(); try { @@ -6063,6 +6415,7 @@ function* calculatePlanningSteps( trace = { status: 'failed', initialState: { + calculationDate: source.calculationDate, horizon, balances: normalized.balances.map((balance) => ({ ...balance, @@ -6138,11 +6491,7 @@ function* calculatePlanningSteps( let checkpointSnapshot: PlanningTraceState; const captureStarted = now(); try { - checkpointSnapshot = planningTraceState( - checkpointState, - context.horizon, - context.declaredDays, - ); + checkpointSnapshot = planningTraceState(checkpointState, context); } finally { const captureTime = now() - captureStarted; context.traceTime += captureTime; @@ -6154,6 +6503,22 @@ function* calculatePlanningSteps( try { markProgress(context, 'validation'); yield true; + // Entirely fixed patterns already hold before any placement choices, + // including preference producers needed by mandatory consumers. + if (context.declaredDays.length) { + for (const rule of context.rules) { + if (!rule.enabled || rule.type !== 'if_then' || !rule.rewardDays) continue; + for (const occurrence of occurrences(rule, context)) { + yield; + const pattern = patternsFor(rule, occurrence, context).find((dates) => + dates.every((date) => declaredPatternDay(date, rule.placeType, context)), + ); + if (pattern) + state = addEarnedBalance(state, rule, occurrence, pattern, context); + } + } + if (state !== initialState) retainAndRecordState(context, state); + } // At-risk quantity is the first objective, but equivalent placements can // still preserve later preferences by sharing their compatible dates. context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); @@ -6162,6 +6527,7 @@ function* calculatePlanningSteps( const obligationRequirements = buildRequirements(normalized.rules, context, 'obligation'); context.reservedTypes = new Set( obligationRequirements + .filter((requirement) => requirement.requiredPlacements > 0) .map((requirement) => requirement.rule.placeType) .filter((selector): selector is LeaveType => selector !== 'any'), ); @@ -6169,6 +6535,7 @@ function* calculatePlanningSteps( yield; if ( !requirement.candidates || + !requirement.requiredPlacements || requirement.candidates.length !== 1 || requirement.rule.placeType === 'any' ) @@ -6179,16 +6546,31 @@ function* calculatePlanningSteps( context.sharedTypesByDate.set(requirement.candidates[0], types); } const obligationSolutions: State[] = []; - const hasEarnedObligation = obligationRequirements.some( - (requirement) => (requirement.rule.rewardDays ?? 0) > 0, - ); + const optionalProducers = obligationRequirements.some( + (requirement) => requirement.requiredPlacements > 0, + ) + ? buildRequirements(normalized.rules, context, 'preference') + .filter( + (requirement) => + requirement.patterns?.length && + requirement.rule.rewardDays && + requirement.requiredPlacements > 0, + ) + .map((requirement) => ({ ...requirement, optional: true })) + : []; + const jointRequirements = optionalProducers.length + ? [...obligationRequirements, ...optionalProducers].sort(compareRequirements) + : obligationRequirements; + const hasEarnedObligation = + optionalProducers.length > 0 || + obligationRequirements.some((requirement) => (requirement.rule.rewardDays ?? 0) > 0); const obligationSolutionLimit = hasEarnedObligation || obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT ? 0 : OBLIGATION_SOLUTION_LIMIT; yield* satisfyObligations( - obligationRequirements, + jointRequirements, state, context, 0, @@ -6263,6 +6645,7 @@ function* calculatePlanningSteps( context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); context.reservedTypes = new Set( obligationRequirements + .filter((requirement) => requirement.requiredPlacements > 0) .map((requirement) => requirement.rule.placeType) .filter((selector): selector is LeaveType => selector !== 'any'), ); @@ -6413,7 +6796,10 @@ export function explainIncompleteCheckpoint( interruption: PlanningReason, ): PlanningArtifacts { try { - const source = runtimeRequest(request); + const source = runtimeRequest( + request, + incumbent.calculationDate ?? checkpoint.calculationDate, + ); const normalized = normalizeRequest(source); if (normalized.reasons.length || !normalized.horizon) throw new Error('The checkpoint explanation request is invalid.'); @@ -6498,7 +6884,7 @@ export function explainIncompleteCheckpoint( uncompletedStages, }, }, - finalState: planningTraceState(state, horizon, context.declaredDays), + finalState: planningTraceState(state, context), }; } catch (error) { return { @@ -6590,7 +6976,7 @@ export function historicalCounterfactualReasons( selected: LeavePlacement, options?: CounterfactualBaselineOptions, ): readonly HistoricalCounterfactualReason[] { - const source = runtimeRequest(request); + const source = runtimeRequest(request, sourceState.calculationDate); const normalized = normalizeRequest(source); const horizon = normalized.horizon ?? sourceState.horizon; const context = counterfactualContext(source, normalized, horizon); @@ -6719,7 +7105,7 @@ function counterfactualRuleValid( pattern.every( (candidateDate) => candidateDate === date || - baseline.placements.get(candidateDate)?.ruleIds.has(rule.id) === true, + patternDateSatisfied(candidateDate, rule, baseline, context), ), ), ); @@ -6741,14 +7127,18 @@ function counterfactualObligationsValid( [...candidate.placements.values()].filter((placement) => placement.ruleIds.has(rule.id), ).length; - return count(previous) !== rule.placeDays || count(state) === rule.placeDays; + const requiredPlacements = + rule.placeDays - declaredWindowDays(rule, context).length; + return ( + count(previous) !== requiredPlacements || count(state) === requiredPlacements + ); } return occurrences(rule, context).every((occurrence) => { const patterns = patternsFor(rule, occurrence, context); const complete = (candidate: State) => patterns.some((pattern) => - pattern.every( - (date) => candidate.placements.get(date)?.ruleIds.has(rule.id) === true, + pattern.every((date) => + patternDateSatisfied(date, rule, candidate, context), ), ); return !complete(previous) || complete(state); @@ -6782,14 +7172,14 @@ function preferenceResultsForState(state: State, context: SearchContext): Prefer rule.type === 'time_window' ? rule.placeDays : occurrencePatterns.length; const satisfied = rule.type === 'time_window' - ? [...state.placements.values()].filter((placement) => + ? declaredWindowDays(rule, context).length + + [...state.placements.values()].filter((placement) => placement.ruleIds.has(rule.id), ).length : occurrencePatterns.filter((patterns) => patterns.some((pattern) => - pattern.every( - (date) => - state.placements.get(date)?.ruleIds.has(rule.id) === true, + pattern.every((date) => + patternDateSatisfied(date, rule, state, context), ), ), ).length; @@ -6810,7 +7200,10 @@ export function analyzeCounterfactualInternal( finalStateSnapshot?: PlanningTraceState, options?: CounterfactualBaselineOptions, ): CounterfactualAnalysis { - const source = runtimeRequest(request); + const source = runtimeRequest( + request, + plan.calculationDate ?? finalStateSnapshot?.calculationDate, + ); const normalized = normalizeRequest(source); const horizon = normalized.horizon ?? plan.horizon; const context = counterfactualContext(source, normalized, horizon); diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts index a023af2..9bd5584 100644 --- a/src/app/services/leave-planner.service.spec.ts +++ b/src/app/services/leave-planner.service.spec.ts @@ -45,7 +45,1119 @@ async function runInNonBrowser(request: Parameters[0]) { } } +beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); +afterEach(() => vi.useRealTimers()); + +describe('declared holiday patterns and captured entitlement date', () => { + const producer = { + id: 'producer', + type: 'if_then' as const, + force: 'obligation' as const, + dayOfWeek: 3, + placement: 'before' as const, + placeDays: 2, + placeType: 'cp' as const, + rewardDays: 1, + rewardType: 'other' as const, + }; + const fixed = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' as const })); + const request = { + horizon: { start: '2024-04-29', end: '2024-05-08' }, + balances: [], + rules: [producer], + declaredDays: fixed, + }; + + it.each([ + ['before', ['2024-04-29', '2024-04-30']], + ['after', ['2024-05-02', '2024-05-03']], + ['around', ['2024-04-30', '2024-05-02']], + ['auto', ['2024-04-29', '2024-04-30']], + ] as const)( + 'credits the normal %s pattern, fully declared or mixed, without duplicate TIME_WINDOW debits', + async (placement, dates) => { + for (const mixed of [false, true]) { + const declaredDays = (mixed ? dates.slice(0, 1) : dates).map((date) => ({ + date, + type: 'cp' as const, + })); + const result = isPlan( + await runInNonBrowser({ + ...request, + declaredDays, + balances: mixed ? [balance('cp', 'cp', 1, '2025-12-31')] : [], + rules: [ + { ...producer, placement, rewardDays: 0 }, + { + id: 'shared', + type: 'time_window', + force: 'obligation', + startDate: dates[0], + endDate: dates[1], + placeDays: 2, + placeType: 'cp', + }, + ], + }), + ); + expect(result.placements.map(({ date }) => date)).toEqual(mixed ? [dates[1]] : []); + expect( + result.placements.every( + ({ ruleIds }) => ruleIds.includes('producer') && ruleIds.includes('shared'), + ), + ).toBe(true); + expect(result.ifThenContributions).toMatchObject([ + { + occurrence: '2024-05-01', + declaredDays, + placementDates: mixed ? [dates[1]] : [], + }, + ]); + expect(result.declaredDays).toEqual(declaredDays); + } + }, + ); + + it.each(['cp', 'rtt', 'other', 'any'] as const)( + 'accepts compatible %s declarations in an entirely fixed preference', + async (placeType) => { + const declaredDays = fixed.map((day, index) => ({ + ...day, + type: + placeType === 'any' + ? index + ? ('other' as const) + : ('rtt' as const) + : placeType, + })); + const result = isPlan( + await runInNonBrowser({ + ...request, + declaredDays, + rules: [{ ...producer, force: 'preference', placeType }], + }), + ); + expect(result.preferences).toMatchObject([ + { ruleId: 'producer', satisfied: 1, completeOccurrences: 1 }, + ]); + expect(result.placements).toEqual([]); + expect(result.remainingBalances).toMatchObject([ + { + id: 'earned:producer:2024-05-01', + quantity: 1, + availableFrom: '2024-05-02', + expiryDate: '2025-05-02', + }, + ]); + }, + ); + + it('does not skip an incompatible normal position, complete a partial preference, or invent a holiday', async () => { + const declaredDays = [fixed[0], { date: '2024-04-30', type: 'rtt' as const }]; + const conflict = await runInNonBrowser({ + ...request, + declaredDays, + balances: [balance('cp', 'cp', 3)], + }); + expect(conflict.kind).toBe('PlanningConflict'); + if (conflict.kind === 'PlanningConflict') + expect(conflict.conflictingRuleIds).toContain('producer'); + const preference = isPlan( + await runInNonBrowser({ + ...request, + declaredDays, + rules: [{ ...producer, force: 'preference' }], + }), + ); + expect(preference.preferences[0].satisfied).toBe(0); + expect(preference.remainingBalances).toEqual([]); + const noHoliday = isPlan( + await runInNonBrowser({ + ...request, + horizon: { start: '2024-05-13', end: '2024-05-17' }, + declaredDays: fixed.map(({ type }, index) => ({ + type, + date: `2024-05-${13 + index}`, + })), + }), + ); + expect(noHoliday.ifThenContributions).toBeUndefined(); + expect(noHoliday.remainingBalances).toEqual([]); + expect( + ( + await runInNonBrowser({ + ...request, + horizon: { start: '2024-04-30', end: '2024-05-08' }, + }) + ).kind, + ).toBe('PlanningConflict'); + }); + + it('reserves only the unfilled positions of future IF_THEN preferences', async () => { + const result = isPlan( + await runInNonBrowser({ + ...request, + balances: [ + balance('cp', 'cp', 1, '2025-12-31'), + balance('rtt', 'rtt', 1, '2025-06-01'), + ], + rules: [ + { + id: 'any-first', + type: 'time_window', + force: 'preference', + startDate: '2024-05-06', + endDate: '2024-05-06', + placeDays: 1, + placeType: 'any', + }, + { ...producer, force: 'preference', rewardDays: 0 }, + { + id: 'rtt-last', + type: 'time_window', + force: 'preference', + startDate: '2024-05-07', + endDate: '2024-05-07', + placeDays: 1, + placeType: 'rtt', + }, + ], + }), + ); + expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1, 1]); + expect(result.placements).toMatchObject([ + { date: '2024-05-06', type: 'cp' }, + { date: '2024-05-07', type: 'rtt' }, + ]); + }); + + it('requires every real occurrence and creates each rule/occurrence entitlement only once', async () => { + const submitted = { + ...request, + horizon: { start: '2024-04-29', end: '2024-05-10' }, + rules: [ + { ...producer, placeDays: 1 }, + { ...producer, id: 'second', placeDays: 1 }, + ], + declaredDays: [ + { date: '2024-04-30', type: 'cp' as const }, + { date: '2024-05-07', type: 'cp' as const }, + ], + }; + const result = isPlan(await runInNonBrowser(submitted)); + expect(result.remainingBalances.map(({ id }) => id).sort()).toEqual([ + 'earned:producer:2024-05-01', + 'earned:producer:2024-05-08', + 'earned:second:2024-05-01', + 'earned:second:2024-05-08', + ]); + expect(result.remainingBalances.every(({ quantity }) => quantity === 1)).toBe(true); + expect( + ( + await runInNonBrowser({ + ...submitted, + declaredDays: submitted.declaredDays.slice(0, 1), + }) + ).kind, + ).toBe('PlanningConflict'); + }); + + it.each([ + ['2024-05-01', true], + ['2024-05-02', false], + ['2024-05-03', false], + ] as const)( + 'adds only strictly future rights on %s, also for newly placed historical patterns', + async (today, added) => { + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + for (const declared of [true, false]) { + const result = isPlan( + await runInNonBrowser({ + ...request, + declaredDays: declared ? fixed : [], + balances: declared ? [] : [balance('cp', 'cp', 2, '2025-12-31')], + }), + ); + expect(result.calculationDate).toBe(today); + expect(result.remainingBalances.filter(({ earned }) => earned)).toHaveLength( + added ? 1 : 0, + ); + expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ + added, + availableFrom: '2024-05-02', + }); + } + }, + ); + + it.each([ + ['2024-01-01', '2024-05-03'], + ['2024-05-03', '2024-05-03'], + ['2024-05-03', '2024-04-29'], + ])( + 'reconciles an externally funded extension on %s with consumer starting %s and rejects a self-financing extension', + async (today, consumerStart) => { + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + const extender = { + id: 'extend', + type: 'time_window' as const, + force: 'obligation' as const, + startDate: '2024-05-02', + endDate: '2024-05-02', + placeDays: 1, + placeType: 'cp' as const, + }; + const result = isPlan( + await runInNonBrowser({ + ...request, + declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }], + balances: [balance('cp', 'cp', 1, '2025-12-31')], + rules: [ + producer, + extender, + { + id: 'later', + type: 'time_window', + force: 'obligation', + startDate: consumerStart, + endDate: '2024-05-07', + placeDays: 2, + placeType: 'other', + }, + ], + }), + ); + expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ + availableFrom: '2024-05-06', + expiryDate: '2025-05-06', + }); + expect( + result.placements.find(({ balanceId }) => balanceId.startsWith('earned:'))?.date, + ).toBe('2024-05-07'); + const impossible = await runInNonBrowser({ + ...request, + declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }], + rules: [producer, { ...extender, placeType: 'other' }], + }); + expect(impossible.kind).toBe('PlanningConflict'); + }, + ); + + it.each(['2024-04-29', '2025-05-07'])( + 'reconciles a future entitlement expiry before its consumer starting %s', + async (consumerStart) => { + vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); + const { isWorkingPlanningDate } = await import('./declared-non-working-days'); + const { addPlanningDays } = await import('./planning-date'); + const declaredDays: import('./leave-planner.core').DeclaredNonWorkingDay[] = [...fixed]; + for (let date = '2024-05-03'; date <= '2025-05-05'; date = addPlanningDays(date, 1)) { + if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'rtt' }); + } + const result = isPlan( + await runInNonBrowser({ + ...request, + horizon: { start: '2024-04-29', end: '2025-05-08' }, + declaredDays, + balances: [balance('cp', 'cp', 1, '2025-12-31')], + rules: [ + { ...producer, force: 'preference' }, + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: consumerStart, + endDate: '2025-05-07', + placeDays: 1, + placeType: 'other', + }, + { + id: 'extend', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-02', + endDate: '2024-05-02', + placeDays: 1, + placeType: 'cp', + }, + ], + }), + ); + expect(result.placements).toMatchObject([ + { date: '2024-05-02', balanceId: 'cp' }, + { + date: '2025-05-07', + balanceId: 'earned:producer:2024-05-01', + ruleIds: ['consumer'], + }, + ]); + expect( + result.ifThenContributions?.find( + ({ ruleId, occurrence }) => + ruleId === 'producer' && occurrence === '2024-05-01', + )?.earnedEntitlement, + ).toMatchObject({ availableFrom: '2025-05-06', expiryDate: '2026-05-06', added: true }); + }, + ); + + it('funds a later IF_THEN obligation after an obligatory window extends a reported generator', async () => { + vi.setSystemTime(new Date('2024-05-03T12:00:00Z')); + const result = isPlan( + await runInNonBrowser({ + ...request, + horizon: { start: '2024-04-29', end: '2024-05-11' }, + declaredDays: [...fixed, { date: '2024-05-03', type: 'other' }], + balances: [balance('cp', 'cp', 1, '2025-12-31')], + rules: [ + { ...producer, force: 'preference' }, + { + id: 'extend', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-02', + endDate: '2024-05-02', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'consumer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 4, + placement: 'after', + placeDays: 1, + placeType: 'other', + rewardDays: 0, + }, + ], + }), + ); + expect(result.placements).toMatchObject([ + { date: '2024-05-02', balanceId: 'cp', ruleIds: ['extend'] }, + { date: '2024-05-10', balanceId: 'earned:producer:2024-05-01', ruleIds: ['consumer'] }, + ]); + expect( + result.ifThenContributions?.find( + ({ ruleId, occurrence }) => ruleId === 'producer' && occurrence === '2024-05-01', + )?.earnedEntitlement, + ).toMatchObject({ availableFrom: '2024-05-06', added: true }); + }); + + it('compares a generating preference with an ordinary obligation solution before spending higher-priority leave', async () => { + vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-29', end: '2024-05-07' }, + declaredDays: [{ date: '2024-04-30', type: 'rtt' }], + balances: [ + balance('cp', 'cp', 1, '2025-12-31'), + balance('rtt', 'rtt', 1, '2025-12-31'), + ], + rules: [ + { + id: 'mandatory', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-06', + endDate: '2024-05-06', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'first', + type: 'time_window', + force: 'preference', + startDate: '2024-04-29', + endDate: '2024-04-29', + placeDays: 1, + placeType: 'cp', + }, + { + ...producer, + force: 'preference', + placement: 'around', + placeType: 'rtt', + rewardType: 'cp', + }, + ], + }), + ); + expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1]); + expect(result.placements.find(({ date }) => date === '2024-04-29')?.balanceId).toBe('cp'); + expect(result.placements.find(({ date }) => date === '2024-05-06')?.balanceId).toBe( + 'earned:producer:2024-05-01', + ); + expect(result.ifThenContributions?.[0].earnedEntitlement).toMatchObject({ + availableFrom: '2024-05-03', + added: true, + }); + }); + + it.each([false, true])( + 'chooses between competing generators in the original preference order (reversed: %s)', + async (reversed) => { + vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); + const producers = [ + { ...producer, id: 'before', force: 'preference' as const }, + { + ...producer, + id: 'after', + force: 'preference' as const, + placement: 'after' as const, + }, + ]; + if (reversed) producers.reverse(); + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-29', end: '2024-05-11' }, + declaredDays: [ + { date: '2024-04-29', type: 'cp' }, + { date: '2024-05-03', type: 'cp' }, + { date: '2024-05-06', type: 'other' }, + ], + balances: [balance('cp', 'cp', 1, '2025-12-31')], + rules: [ + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-10', + endDate: '2024-05-10', + placeDays: 1, + placeType: 'other', + }, + ...producers, + ], + }), + ); + expect( + result.preferences.map(({ ruleId, satisfied }) => ({ ruleId, satisfied })), + ).toEqual([ + { ruleId: producers[0].id, satisfied: 1 }, + { ruleId: producers[1].id, satisfied: 0 }, + ]); + expect(result.placements.find(({ type }) => type === 'cp')?.date).toBe( + reversed ? '2024-05-02' : '2024-04-30', + ); + expect(result.placements.find(({ date }) => date === '2024-05-10')?.balanceId).toBe( + `earned:${producers[0].id}:2024-05-01`, + ); + expect( + result.ifThenContributions?.filter(({ earnedEntitlement }) => earnedEntitlement), + ).toHaveLength(1); + }, + ); + + it('keeps a future right usable on its expiry date and excludes comparisons before availability or after expiry', async () => { + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + ...request, + horizon: { start: '2024-04-29', end: '2025-05-07' }, + declaredDays: [fixed[1]], + rules: [ + { ...producer, force: 'preference', placeDays: 1 }, + { + id: 'expiry-consumer', + type: 'time_window', + force: 'preference', + startDate: '2025-05-02', + endDate: '2025-05-02', + placeDays: 1, + placeType: 'other', + }, + ], + }); + const result = isPlan(await calculation.outcome); + const placement = result.placements.find( + ({ balanceId }) => balanceId === 'earned:producer:2024-05-01', + ); + expect(placement?.date).toBe('2025-05-02'); + if (!placement) + throw new Error('Expected the earned entitlement to fund its last valid date.'); + const comparison = await calculation.analyzeCounterfactual(placement); + if (comparison.kind !== 'Counterfactual') throw new Error('Expected date comparisons.'); + expect(comparison.results.find(({ date }) => date === '2024-04-29')?.reason?.code).toBe( + 'unavailable', + ); + expect(comparison.results.find(({ date }) => date === '2025-05-02')?.status).toBe( + 'eligible', + ); + expect(comparison.results.find(({ date }) => date === '2025-05-05')?.reason?.code).toBe( + 'expired', + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it.each(['worker-unavailable', 'deadline', 'validation'])( + 'keeps the launch date in %s outcomes after midnight', + async (failure) => { + vi.stubGlobal('Worker', undefined); + vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); + const calculation = createPlanningCalculation({ + ...request, + ...(failure === 'deadline' ? { deadlineMs: -1 } : {}), + ...(failure === 'validation' ? { balances: [balance('invalid', 'cp', -1)] } : {}), + }); + vi.setSystemTime(new Date('2024-05-01T22:00:01Z')); + expect(calculation.snapshot.calculationDate).toBe('2024-05-01'); + expect(await calculation.outcome).toMatchObject({ + calculationDate: '2024-05-01', + kind: failure === 'worker-unavailable' ? 'PlanningIncomplete' : 'InvalidInput', + }); + vi.unstubAllGlobals(); + }, + ); + it('includes declared context beyond the horizon in the complete generator period', async () => { + const result = isPlan( + await runInNonBrowser({ + ...request, + horizon: { start: '2024-04-29', end: '2024-05-02' }, + declaredDays: [ + ...fixed, + { date: '2024-05-02', type: 'rtt' }, + { date: '2024-05-03', type: 'other' }, + ], + }), + ); + expect(result.remainingBalances).toMatchObject([ + { availableFrom: '2024-05-06', expiryDate: '2025-05-06' }, + ]); + expect(result.timeOffPeriods).toMatchObject([ + { + end: '2024-05-05', + declaredDates: ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'], + }, + ]); + }); + + it('captures Paris before the direct import wait and keeps snapshot, late artifacts and comparisons on that date', async () => { + vi.stubGlobal('window', undefined); + try { + vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); + const calculation = createPlanningCalculation({ + ...request, + rules: [ + producer, + { + id: 'later', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-03', + endDate: '2024-05-03', + placeDays: 1, + placeType: 'other', + }, + ], + }); + expect(calculation.snapshot.calculationDate).toBe('2024-05-01'); + vi.setSystemTime(new Date('2024-05-01T22:00:01Z')); + const result = isPlan(await calculation.outcome); + expect(result.calculationDate).toBe('2024-05-01'); + expect(await calculation.explanation).toMatchObject({ + calculationDate: '2024-05-01', + declaredDays: fixed, + ifThenContributions: result.ifThenContributions, + }); + const trace = await calculation.trace; + expect(trace?.initialState.calculationDate).toBe('2024-05-01'); + const comparison = await calculation.analyzeCounterfactual(result.placements[0]); + expect(comparison.kind).toBe('Counterfactual'); + if (comparison.kind === 'Counterfactual') { + expect( + comparison.results.find(({ date }) => date === '2024-04-30')?.reason?.code, + ).toBe('declared-day'); + expect(comparison.results.find(({ date }) => date === '2024-05-02')?.status).toBe( + 'ineligible', + ); + expect(comparison.results.find(({ date }) => date === '2024-05-03')?.status).toBe( + 'eligible', + ); + } + const consumerDecision = trace?.phases + .flatMap(({ decisions }) => decisions) + .find(({ deltas }) => + deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')), + ); + expect(consumerDecision).toBeDefined(); + if (consumerDecision) + expect( + ( + await calculation.analyzeCounterfactual({ + decisionId: consumerDecision.id, + placement: result.placements[0], + }) + ).kind, + ).toBe('Counterfactual'); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + describe('planLeave', () => { + it.each([ + ['obligation', false], + ['obligation', true], + ['preference', false], + ['preference', true], + ] as const)( + 'uses declared holiday-pattern CP to earn future Other leave for the remaining TIME_WINDOW day (producer: %s, mixed: %s)', + async (force, mixed) => { + vi.setSystemTime(new Date('2024-04-28T12:00:00Z')); + try { + const declaredDays = [ + ...(mixed ? [] : [{ date: '2024-04-29', type: 'cp' as const }]), + { date: '2024-04-30', type: 'cp' as const }, + { date: '2024-05-06', type: 'other' as const }, + ]; + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-29', end: '2024-05-07' }, + balances: mixed ? [balance('cp', 'cp', 1, '2025-12-31')] : [], + declaredDays, + rules: [ + { + id: 'producer', + type: 'if_then', + force, + dayOfWeek: 3, + placement: 'before', + placeDays: 2, + placeType: 'cp', + rewardDays: 1, + rewardType: 'other', + }, + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-03', + endDate: '2024-05-06', + placeDays: 2, + placeType: 'other', + }, + ], + }), + ); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.placements.filter(({ type }) => type === 'other')).toEqual([ + { + date: '2024-05-03', + type: 'other', + balanceId: 'earned:producer:2024-05-01', + ruleIds: ['consumer'], + supporting: false, + }, + ]); + expect(result.placements.filter(({ type }) => type === 'cp')).toHaveLength( + mixed ? 1 : 0, + ); + expect(result.remainingBalances).toEqual([]); + } finally { + vi.useRealTimers(); + } + }, + ); + + it.each(['obligation', 'preference'] as const)( + 'completes five CP days with four remaining CP and a fixed Wednesday CP (%s)', + async (force) => { + const declaredDays = [{ date: '2024-01-10', type: 'cp' as const }]; + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [balance('cp', 'cp', 4, '2024-01-12')], + declaredDays, + rules: [ + { + id: 'week', + type: 'time_window', + force, + startDate: '2024-01-08', + endDate: '2024-01-12', + placeDays: 5, + placeType: 'cp', + }, + ], + }), + ); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.placements.map(({ date }) => date)).toEqual([ + '2024-01-08', + '2024-01-09', + '2024-01-11', + '2024-01-12', + ]); + expect(result.placements.every(({ ruleIds }) => ruleIds.includes('week'))).toBe(true); + expect(result.remainingBalances).toEqual([]); + expect(result.timeWindowContributions).toEqual([ + { + ruleId: 'week', + label: 'week', + requested: 5, + declaredDays, + placementDates: result.placements.map(({ date }) => date), + }, + ]); + if (force === 'preference') + expect(result.preferences).toMatchObject([{ requested: 5, satisfied: 5 }]); + }, + ); + + it.each(['cp', 'rtt', 'other'] as const)( + 'shares a fixed %s day between typed and ANY obligations and preferences without a balance', + async (type) => { + const declaredDays = [{ date: '2024-01-10', type }]; + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [], + declaredDays, + rules: (['obligation', 'preference'] as const).flatMap((force) => + ([type, 'any'] as const).map((placeType) => ({ + id: `${force}-${placeType}`, + type: 'time_window', + force, + startDate: '2024-01-08', + endDate: '2024-01-12', + placeDays: 1, + placeType, + })), + ), + }), + ); + expect(result.placements).toEqual([]); + expect(result.remainingBalances).toEqual([]); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([1, 1]); + expect(result.metrics.preferenceSatisfaction).toEqual([1, 1]); + expect(result.timeWindowContributions).toHaveLength(4); + for (const contribution of result.timeWindowContributions ?? []) + expect(contribution).toMatchObject({ + requested: 1, + declaredDays, + placementDates: [], + }); + }, + ); + + it('caps each rule contribution at its quantity while preserving every surplus declaration', async () => { + const declaredDays = [ + { date: '2024-01-10', type: 'cp' as const }, + { date: '2024-01-08', type: 'cp' as const }, + { date: '2024-01-09', type: 'cp' as const }, + ]; + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [], + declaredDays, + rules: [ + { id: 'exact', force: 'obligation', placeDays: 2, placeType: 'cp' }, + { id: 'preferred', force: 'preference', placeDays: 1, placeType: 'any' }, + ].map((rule) => ({ + ...rule, + type: 'time_window', + startDate: '2024-01-08', + endDate: '2024-01-12', + })), + }), + ); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.placements).toEqual([]); + expect(result.preferences).toMatchObject([{ requested: 1, satisfied: 1 }]); + expect(result.timeWindowContributions).toMatchObject([ + { + ruleId: 'exact', + requested: 2, + declaredDays: [declaredDays[1], declaredDays[2]], + placementDates: [], + }, + { + ruleId: 'preferred', + requested: 1, + declaredDays: [declaredDays[1]], + placementDates: [], + }, + ]); + }); + + it('credits inclusive window endpoints only inside the submitted half-open horizon', async () => { + const declaredDays = ['08', '09', '10', '11', '12'].map((day) => ({ + date: `2024-01-${day}`, + type: 'cp' as const, + })); + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-09', end: '2024-01-12' }, + balances: [], + declaredDays, + rules: [ + { + id: 'left', + force: 'obligation', + startDate: '2024-01-08', + endDate: '2024-01-10', + placeDays: 2, + }, + { + id: 'right', + force: 'obligation', + startDate: '2024-01-10', + endDate: '2024-01-12', + placeDays: 2, + }, + { + id: 'before', + force: 'preference', + startDate: '2024-01-08', + endDate: '2024-01-08', + placeDays: 1, + }, + { + id: 'after', + force: 'preference', + startDate: '2024-01-12', + endDate: '2024-01-12', + placeDays: 1, + }, + ].map((rule) => ({ ...rule, type: 'time_window', placeType: 'cp' })), + }), + ); + expect(result.placements).toEqual([]); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.preferences.map(({ satisfied }) => satisfied)).toEqual([0, 0]); + expect( + result.timeWindowContributions?.map(({ declaredDays }) => + declaredDays.map(({ date }) => date), + ), + ).toEqual([ + ['2024-01-09', '2024-01-10'], + ['2024-01-10', '2024-01-11'], + ]); + }); + + it('keeps an incompatible fixed RTT while placing additional CP on other dates', async () => { + const declaredDays = [{ date: '2024-01-10', type: 'rtt' as const }]; + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [balance('cp', 'cp', 3, '2024-01-12')], + declaredDays, + rules: [ + { + id: 'cp-only', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-08', + endDate: '2024-01-12', + placeDays: 3, + placeType: 'cp', + }, + ], + }), + ); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.placements).toHaveLength(3); + expect( + result.placements.every(({ date, type }) => date !== '2024-01-10' && type === 'cp'), + ).toBe(true); + expect(result.remainingBalances).toEqual([]); + expect(result.timeWindowContributions).toBeUndefined(); + }); + + it('preserves preference order when a partially declared higher preference needs the shared date', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [ + balance('cp', 'cp', 1, '2025-01-01'), + balance('rtt', 'rtt', 1, '2025-01-01'), + ], + declaredDays: [{ date: '2024-01-10', type: 'cp' }], + rules: [ + { + id: 'higher-cp', + startDate: '2024-01-10', + endDate: '2024-01-11', + placeDays: 2, + placeType: 'cp', + }, + { + id: 'lower-rtt', + startDate: '2024-01-11', + endDate: '2024-01-11', + placeDays: 1, + placeType: 'rtt', + }, + ].map((rule) => ({ ...rule, type: 'time_window', force: 'preference' })), + }), + ); + expect(result.preferences).toMatchObject([ + { ruleId: 'higher-cp', requested: 2, satisfied: 2 }, + { + ruleId: 'lower-rtt', + requested: 1, + satisfied: 0, + reason: { code: 'higher-priority' }, + }, + ]); + expect(result.placements).toContainEqual( + expect.objectContaining({ date: '2024-01-11', type: 'cp', ruleIds: ['higher-cp'] }), + ); + expect(result.declaredDays).toEqual([{ date: '2024-01-10', type: 'cp' }]); + }); + + it('keeps the joint search exhaustive when fixed credits leave a small number of new placements', async () => { + const core = await import('./leave-planner.core'); + let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; + const result = isPlan( + core.calculatePlanningInternal( + { + horizon: { start: '2024-01-08', end: '2024-04-17' }, + balances: [ + balance('cp', 'cp', 1, '2025-01-01'), + balance('rtt', 'rtt', 1, '2025-01-01'), + ], + declaredDays: [ + { date: '2024-01-08', type: 'cp' }, + { date: '2024-01-09', type: 'rtt' }, + ], + rules: ['cp', 'rtt'].map((placeType) => ({ + id: placeType, + type: 'time_window', + force: 'obligation', + startDate: '2024-01-08', + endDate: '2024-04-16', + placeDays: 2, + placeType, + })), + }, + undefined, + (value) => (artifacts = value), + ), + ); + expect(result.placements).toHaveLength(2); + expect(result.placements.map(({ type }) => type).sort()).toEqual(['cp', 'rtt']); + const evaluated = + artifacts.trace?.phases.flatMap((phase) => + phase.decisions.flatMap((decision) => decision.candidates), + ) ?? []; + expect(evaluated.some(({ reason }) => reason?.code === 'obligation-solution-limit')).toBe( + false, + ); + expect( + result.timeWindowContributions?.map(({ declaredDays }) => declaredDays.length), + ).toEqual([1, 1]); + }); + + it('does not explain a fixed contribution as a free date when a higher typed preference occupies the rest of the window', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [ + balance('cp', 'cp', 1, '2025-01-01'), + balance('rtt', 'rtt', 1, '2025-01-01'), + ], + declaredDays: [{ date: '2024-01-10', type: 'cp' }], + rules: [ + { + id: 'higher-rtt', + startDate: '2024-01-11', + endDate: '2024-01-11', + placeDays: 1, + placeType: 'rtt', + }, + { + id: 'lower-cp', + startDate: '2024-01-10', + endDate: '2024-01-11', + placeDays: 2, + placeType: 'cp', + }, + ].map((rule) => ({ ...rule, type: 'time_window', force: 'preference' })), + }), + ); + expect(result.preferences).toMatchObject([ + { ruleId: 'higher-rtt', satisfied: 1 }, + { ruleId: 'lower-cp', satisfied: 1, reason: { code: 'higher-priority' } }, + ]); + }); + + it('explains an impossible joint obligation after compatible declarations have been credited', async () => { + const core = await import('./leave-planner.core'); + let artifacts: import('./leave-planner.core').PlanningArtifacts = {}; + const declaredDays = [{ date: '2024-01-08', type: 'cp' as const }]; + const result = core.calculatePlanningInternal( + { + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 1)], + declaredDays, + labels: { 'three-cp': 'Three CP including Monday', 'one-rtt': 'Wednesday RTT' }, + rules: [ + { + id: 'three-cp', + startDate: '2024-01-08', + endDate: '2024-01-10', + placeDays: 3, + placeType: 'cp', + }, + { + id: 'one-rtt', + startDate: '2024-01-10', + endDate: '2024-01-10', + placeDays: 1, + placeType: 'rtt', + }, + ].map((rule) => ({ ...rule, type: 'time_window', force: 'obligation' })), + }, + undefined, + (value) => (artifacts = value), + ); + expect(result).toMatchObject({ + kind: 'PlanningConflict', + conflictingRuleIds: ['three-cp', 'one-rtt'], + reasons: [{ code: 'joint-date-conflict' }], + }); + expect(artifacts.explanation).toMatchObject({ + kind: 'ConflictExplanation', + conflictingRules: [ + { id: 'three-cp', label: 'Three CP including Monday' }, + { id: 'one-rtt', label: 'Wednesday RTT' }, + ], + bottleneck: { code: 'joint-date-conflict' }, + }); + expect(artifacts.trace?.initialState.declaredDays).toEqual(declaredDays); + }); + + it('distinguishes insufficient compatible leave from malformed rules without converting a fixed RTT', async () => { + const submitted = { + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [balance('cp', 'cp', 4)], + declaredDays: [{ date: '2024-01-10', type: 'rtt' as const }], + rules: [ + { + id: 'cp-only', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-08', + endDate: '2024-01-12', + placeDays: 5, + placeType: 'cp', + }, + ], + }; + expect(await runInNonBrowser(submitted)).toMatchObject({ + kind: 'PlanningConflict', + reasons: [{ code: 'typed-capacity' }], + }); + submitted.rules[0].placeDays = -1; + expect(await runInNonBrowser(submitted)).toMatchObject({ kind: 'InvalidInput' }); + expect(submitted.declaredDays).toEqual([{ date: '2024-01-10', type: 'rtt' }]); + }); + it('keeps declared non-working days fixed without consuming their remaining balances', async () => { const declaredDays = [ { date: '2024-01-02', type: 'cp' as const }, @@ -80,6 +1192,306 @@ describe('planLeave', () => { expect(result.remainingBalances).toEqual([]); }); + it('forms Friday–Sunday from an ordinary declared Other day without a balance debit', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-02', end: '2024-01-09' }, + balances: [], + rules: [], + declaredDays: [{ date: '2024-01-05', type: 'other' }], + }), + ); + expect(result.placements).toEqual([]); + expect(result.remainingBalances).toEqual([]); + expect(result.timeOffPeriods).toEqual([ + { + start: '2024-01-05', + end: '2024-01-07', + duration: 3, + placementDates: [], + declaredDates: ['2024-01-05'], + nonWorkingDates: ['2024-01-06', '2024-01-07'], + nonWorkingDayGain: 2, + }, + ]); + expect(result.metrics).toMatchObject({ nonWorkingDayGain: 2, longestWorkSequence: 3 }); + }); + + it('merges declarations, placements, weekends and national holidays without counting dates twice', async () => { + const declaredDays = [ + { date: '2024-05-06', type: 'cp' as const }, + { date: '2024-05-07', type: 'rtt' as const }, + { date: '2024-05-10', type: 'other' as const }, + ]; + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-05-06', end: '2024-05-14' }, + balances: [ + { ...balance('cp', 'cp', 1, '2024-05-13'), availableFrom: '2024-05-13' }, + ], + rules: [], + declaredDays, + }), + ); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.placements.map(({ date }) => date)).toEqual(['2024-05-13']); + expect(result.remainingBalances).toEqual([]); + expect(result.timeOffPeriods).toEqual([ + { + start: '2024-05-04', + end: '2024-05-13', + duration: 10, + placementDates: ['2024-05-13'], + declaredDates: declaredDays.map(({ date }) => date), + nonWorkingDates: [ + '2024-05-04', + '2024-05-05', + '2024-05-08', + '2024-05-09', + '2024-05-11', + '2024-05-12', + ], + nonWorkingDayGain: 6, + }, + ]); + expect(result.metrics).toMatchObject({ nonWorkingDayGain: 6, holidayWeekValue: 5 }); + }); + + it('uses declared days to split work sequences while weekends and holidays alone never split them', async () => { + const withDeclarations = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-02', end: '2024-01-20' }, + balances: [], + rules: [], + declaredDays: [ + { date: '2024-01-05', type: 'other' }, + { date: '2024-01-09', type: 'cp' }, + ], + }), + ); + expect(withDeclarations.metrics).toMatchObject({ + longestWorkSequence: 8, + shortSequenceDeficit: 4, + }); + const withoutDeclarations = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-05-06', end: '2024-05-15' }, + balances: [], + rules: [], + }), + ); + expect(withoutDeclarations.timeOffPeriods).toEqual([]); + expect(withoutDeclarations.metrics).toMatchObject({ + longestWorkSequence: 5, + shortSequenceDeficit: 0, + nonWorkingDayGain: 0, + }); + }); + + it('extends only anchored periods with adjacent outside declarations and keeps placements inside the submitted horizon', async () => { + const selectedHorizon = { start: '2024-01-03', end: '2024-01-05' }; + const declaredDays = [ + { date: '2024-01-02', type: 'cp' as const }, + { date: '2024-01-03', type: 'other' as const }, + { date: '2024-01-05', type: 'rtt' as const }, + { date: '2024-01-10', type: 'other' as const }, + ]; + const result = isPlan( + await runInNonBrowser({ + horizon: selectedHorizon, + balances: [ + { ...balance('cp', 'cp', 2, '2024-01-04'), availableFrom: '2024-01-04' }, + ], + rules: [], + declaredDays, + }), + ); + expect(result.horizon).toEqual(selectedHorizon); + expect(result.declaredDays).toEqual(declaredDays); + expect(result.placements.map(({ date }) => date)).toEqual(['2024-01-04']); + expect(result.remainingBalances[0].quantity).toBe(1); + expect(result.timeOffPeriods).toHaveLength(1); + expect(result.timeOffPeriods[0]).toMatchObject({ + start: '2023-12-30', + end: '2024-01-07', + duration: 9, + placementDates: ['2024-01-04'], + declaredDates: ['2024-01-02', '2024-01-03', '2024-01-05'], + nonWorkingDayGain: 5, + }); + const unanchored = isPlan( + await runInNonBrowser({ + horizon: selectedHorizon, + balances: [], + rules: [], + declaredDays: declaredDays.filter(({ date }) => date !== '2024-01-03'), + }), + ); + expect(unanchored.timeOffPeriods).toEqual([]); + expect(unanchored.metrics.nonWorkingDayGain).toBe(0); + }); + + it.each([ + ['2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'], + ['2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'], + ])( + 'keeps explicit historical and multi-year bounds from %s to %s around declarations', + async (start, end, date, periodStart, periodEnd) => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start, end }, + balances: [], + rules: [], + declaredDays: [{ date, type: 'other' }], + }), + ); + expect(result.horizon).toEqual({ start, end }); + expect(result.placements).toEqual([]); + expect(result.timeOffPeriods).toHaveLength(1); + expect(result.timeOffPeriods[0]).toMatchObject({ + start: periodStart, + end: periodEnd, + declaredDates: [date], + }); + }, + ); + + it.each([ + [false, '2024-05-06', '2024-05-13'], + [true, '2024-05-06', '2024-05-13'], + [false, '2024-05-06', '2024-05-10'], + [false, '2024-05-06', '2024-05-09'], + [false, '2024-05-10', '2024-05-11'], + [true, '2024-05-10', '2024-05-11'], + ] as const)( + 'completes a genuine public-holiday week with fixed declarations and additional placement: %s', + async (mixed, start, end) => { + const declaredDays = ['2024-05-06', '2024-05-07', ...(mixed ? [] : ['2024-05-10'])].map( + (date) => ({ date, type: 'other' as const }), + ); + const result = isPlan( + await runInNonBrowser({ + horizon: { start, end }, + rules: [], + declaredDays, + balances: mixed + ? [{ ...balance('cp', 'cp', 1, '2024-05-10'), availableFrom: '2024-05-10' }] + : [], + }), + ); + expect(result.metrics.holidayWeekValue).toBe(5); + expect(result.placements.map(({ date }) => date)).toEqual(mixed ? ['2024-05-10'] : []); + expect(result.timeOffPeriods[0].nonWorkingDates).not.toContain('2024-05-06'); + }, + ); + + it('does not treat a fully declared ordinary week as a national public-holiday week', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-13' }, + balances: [], + rules: [], + declaredDays: [ + '2024-01-08', + '2024-01-09', + '2024-01-10', + '2024-01-11', + '2024-01-12', + ].map((date) => ({ date, type: 'other' })), + }), + ); + expect(result.metrics).toMatchObject({ + holidayWeekValue: 0, + nonWorkingDayGain: 4, + longestWorkSequence: 0, + }); + }); + + it.each([ + { + declaredStart: '2024-01-02', + declaredEnd: '2025-04-30', + horizon: { start: '2024-01-02', end: '2024-01-03' }, + period: { start: '2023-12-30', end: '2025-04-29' }, + }, + { + declaredStart: '2024-12-30', + declaredEnd: '2026-01-06', + horizon: { start: '2024-12-30', end: '2024-12-31' }, + period: { start: '2024-12-28', end: '2026-01-05' }, + }, + { + declaredStart: '2023-11-29', + declaredEnd: '2025-01-03', + horizon: { start: '2025-01-02', end: '2025-01-03' }, + period: { start: '2023-11-29', end: '2025-01-02' }, + }, + ])( + 'keeps a contiguous outside declaration chain $declaredStart–$declaredEnd across the prepared calendar buffer', + async ({ declaredStart, declaredEnd, horizon, period }) => { + const { isWorkingPlanningDate } = await import('./declared-non-working-days'); + const { addPlanningDays } = await import('./planning-date'); + const declaredDays: NonNullable[number][] = []; + for (let date = declaredStart; date < declaredEnd; date = addPlanningDays(date, 1)) + if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' }); + const result = isPlan( + await runInNonBrowser({ horizon, balances: [], rules: [], declaredDays }), + ); + expect(result.timeOffPeriods).toHaveLength(1); + expect(result.timeOffPeriods[0]).toMatchObject({ + ...period, + declaredDates: declaredDays.map(({ date }) => date), + }); + expect(result.timeOffPeriods[0].duration).toBe( + result.timeOffPeriods[0].nonWorkingDates.length + declaredDays.length, + ); + expect(result.placements).toEqual([]); + }, + ); + + it('preserves holiday-week priority over a larger natural-day gain around declarations', async () => { + const request: PlanningRequest = { + horizon: { start: '2026-11-01', end: '2026-12-01' }, + balances: [balance('cp', 'cp', 2, '2026-11-30')], + rules: [], + declaredDays: [ + { date: '2026-11-09', type: 'other' }, + { date: '2026-11-10', type: 'rtt' }, + ], + }; + const result = isPlan(await runInNonBrowser(request)); + expect(result.placements.map(({ date }) => date)).toEqual(['2026-11-12', '2026-11-13']); + expect(result.metrics).toMatchObject({ + unplacedAtRisk: 0, + holidayWeekValue: 3, + nonWorkingDayGain: 5, + }); + const withPreference = isPlan( + await runInNonBrowser({ + ...request, + rules: [ + { + id: 'preferred-friday', + type: 'time_window', + force: 'preference', + startDate: '2026-11-20', + endDate: '2026-11-20', + placeDays: 1, + placeType: 'cp', + }, + ], + }), + ); + expect(withPreference.preferences[0].satisfied).toBe(1); + expect(withPreference.metrics).toMatchObject({ unplacedAtRisk: 0, holidayWeekValue: 0 }); + const supporting = isPlan( + await runInNonBrowser({ ...request, balances: [balance('cp', 'cp', 2, '2027-01-01')] }), + ); + expect(supporting.placements.map(({ date }) => date)).toEqual(['2026-11-12', '2026-11-13']); + expect(supporting.placements.every(({ supporting }) => supporting)).toBe(true); + expect(supporting.metrics).toMatchObject({ holidayWeekValue: 3, supportingPlacements: 2 }); + }); + it.each([ null, {}, @@ -606,6 +2018,7 @@ describe('planLeave', () => { }); it('replays the current preference when an earlier retry creates its entitlement', async () => { + vi.setSystemTime(new Date('2021-01-01T12:00:00Z')); const result = isPlan( await runInNonBrowser({ horizon: { start: '2021-05-20', end: '2021-11-03' }, @@ -1656,6 +3069,233 @@ describe('planLeave transport', () => { }); describe('planning calculation context', () => { + it('explains fixed rule credits without debit and keeps them in comparisons after the inputs change', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const submitted: PlanningRequest = { + horizon: { start: '2024-01-08', end: '2024-01-20' }, + balances: [balance('remaining-cp', 'cp', 1, '2025-01-01')], + declaredDays: [{ date: '2024-01-10', type: 'cp' }], + labels: { exact: 'Two CP including Wednesday', preferred: 'Two days this week' }, + rules: [ + { id: 'exact', force: 'obligation', placeType: 'cp' }, + { id: 'preferred', force: 'preference', placeType: 'any' }, + ].map((rule) => ({ + ...rule, + type: 'time_window', + startDate: '2024-01-08', + endDate: '2024-01-12', + placeDays: 2, + })), + }; + const originalDeclarations = structuredClone(submitted.declaredDays); + const calculation = createPlanningCalculation(submitted); + submitted.declaredDays![0].date = '2024-01-11'; + submitted.declaredDays![0].type = 'rtt'; + submitted.rules[0].placeDays = 4; + if ('endDate' in submitted.rules[0]) submitted.rules[0].endDate = '2024-01-19'; + submitted.horizon.end = '2024-01-11'; + submitted.labels = { exact: 'Edited later' }; + const outcome = isPlan(await calculation.outcome); + expect(outcome.placements).toHaveLength(1); + expect(outcome.remainingBalances).toEqual([]); + expect(outcome.preferences).toMatchObject([{ requested: 2, satisfied: 2 }]); + expect(outcome.timeWindowContributions).toEqual([ + { + ruleId: 'exact', + label: 'Two CP including Wednesday', + requested: 2, + declaredDays: originalDeclarations, + placementDates: [outcome.placements[0].date], + }, + { + ruleId: 'preferred', + label: 'Two days this week', + requested: 2, + declaredDays: originalDeclarations, + placementDates: [outcome.placements[0].date], + }, + ]); + const explanation = await calculation.explanation; + if (explanation?.kind !== 'PlanExplanation') + throw new Error('Expected a stable explanation.'); + expect(explanation.timeWindowContributions).toEqual(outcome.timeWindowContributions); + expect(explanation.placements).toHaveLength(1); + expect(explanation.placements[0].comparison).toMatchObject({ + before: { + objectives: expect.arrayContaining([ + expect.objectContaining({ id: 'obligation-validity', value: 1 }), + expect.objectContaining({ id: 'preference:preferred', value: 1 }), + ]), + }, + after: { + objectives: expect.arrayContaining([ + expect.objectContaining({ id: 'obligation-validity', value: 0 }), + expect.objectContaining({ id: 'preference:preferred', value: 2 }), + ]), + }, + }); + const trace = await calculation.trace; + expect(trace?.initialState.declaredDays).toEqual(originalDeclarations); + const deltas = + trace?.phases.flatMap((phase) => + phase.decisions.flatMap((decision) => decision.deltas), + ) ?? []; + const placements = deltas.filter((delta) => delta.kind === 'placement-added'); + expect(placements.length).toBeGreaterThan(0); + for (const delta of placements) { + expect(delta.placement?.date).not.toBe('2024-01-10'); + } + const debits = deltas.filter( + (delta) => + delta.kind === 'balance-changed' && + delta.remainingBefore! > delta.remainingAfter!, + ); + expect(debits.length).toBeGreaterThan(0); + for (const debit of debits) + expect(debit.remainingBefore! - debit.remainingAfter!).toBe(1); + const before = structuredClone(outcome); + const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); + expect(analysis.results.find(({ date }) => date === '2024-01-10')).toMatchObject({ + status: 'ineligible', + reason: { code: 'declared-day' }, + }); + expect(analysis.results.find(({ date }) => date === '2024-01-15')).toMatchObject({ + status: 'ineligible', + reason: { code: 'rule-invalid' }, + }); + expect(analysis.results.find(({ date }) => date === '2024-01-11')).toMatchObject({ + status: 'eligible', + comparison: { + after: { + objectives: expect.arrayContaining([ + expect.objectContaining({ id: 'obligation-validity', value: 0 }), + expect.objectContaining({ id: 'preference:preferred', value: 2 }), + ]), + }, + }, + }); + expect( + analysis.results.find(({ date }) => date === outcome.placements[0].date)?.comparison + ?.after, + ).toEqual(explanation.placements[0].objectiveVector); + expect(outcome).toEqual(before); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('keeps a cross-year declared extension in stable explanations and restored date comparisons', async () => { + const { isWorkingPlanningDate } = await import('./declared-non-working-days'); + const { addPlanningDays } = await import('./planning-date'); + const declaredDays: NonNullable[number][] = []; + for (let date = '2024-12-31'; date < '2026-01-06'; date = addPlanningDays(date, 1)) + if (isWorkingPlanningDate(date)) declaredDays.push({ date, type: 'other' }); + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-12-30', end: '2024-12-31' }, + balances: [ + { ...balance('cp', 'cp', 1, '2024-12-30'), availableFrom: '2024-12-30' }, + ], + rules: [], + declaredDays, + }); + const outcome = isPlan(await calculation.outcome); + expect(outcome.timeOffPeriods[0]).toMatchObject({ + start: '2024-12-28', + end: '2026-01-05', + }); + expect(outcome.timeOffPeriods[0].nonWorkingDates).toContain('2026-01-01'); + const explanation = await calculation.explanation; + if (explanation?.kind !== 'PlanExplanation') + throw new Error('Expected a stable plan explanation.'); + expect(explanation.timeOffPeriods).toEqual(outcome.timeOffPeriods); + const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); + expect(analysis.results[0].comparison?.after).toEqual( + explanation.placements[0].objectiveVector, + ); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('keeps declared periods and objective comparisons tied to the same snapshot after current inputs change', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const submitted: PlanningRequest = { + horizon: { start: '2026-11-01', end: '2026-12-01' }, + balances: [balance('cp', 'cp', 2, '2026-11-30')], + rules: [], + declaredDays: [ + { date: '2026-11-09', type: 'other' }, + { date: '2026-11-10', type: 'rtt' }, + ], + }; + const original = structuredClone(submitted.declaredDays); + const calculation = createPlanningCalculation(submitted); + submitted.declaredDays![0].date = '2026-11-20'; + submitted.declaredDays![0].type = 'cp'; + submitted.horizon.end = '2026-11-12'; + const outcome = isPlan(await calculation.outcome); + const explanation = await calculation.explanation; + if (explanation?.kind !== 'PlanExplanation') + throw new Error('Expected a stable plan explanation.'); + expect(explanation.declaredDays).toEqual(original); + expect(explanation.timeOffPeriods).toEqual(outcome.timeOffPeriods); + for (const placement of explanation.placements) { + expect(placement.timeOffPeriod).toEqual(outcome.timeOffPeriods[0]); + expect(placement.finalMetrics).toEqual(outcome.metrics); + expect(placement.firstDecisiveObjective).toBe('unplaced-at-risk'); + } + const trace = await calculation.trace; + expect(trace?.initialState.declaredDays).toEqual(original); + const evaluated = trace?.phases.flatMap((phase) => + phase.decisions.flatMap((decision) => decision.candidates), + ); + const fixedDates = new Set(original?.map(({ date }) => date)); + for (const candidate of evaluated?.filter(({ status }) => status === 'chosen') ?? []) + expect( + (candidate.dates ?? [candidate.date]).some( + (date) => date && fixedDates.has(date), + ), + ).toBe(false); + expect( + evaluated?.some( + (candidate) => + candidate.comparison?.after.objectives.some( + (objective) => + objective.id === 'holiday-week-value' && objective.value === 3, + ) && + candidate.comparison.after.objectives.some( + (objective) => + objective.id === 'non-working-day-gain' && objective.value === 5, + ), + ), + ).toBe(true); + const before = structuredClone(outcome); + const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); + const restored = analysis.results.find( + ({ date }) => date === outcome.placements[0].date, + ); + expect(restored?.comparison?.after).toEqual(explanation.placements[0].objectiveVector); + const alternative = analysis.results.find(({ date }) => date === '2026-11-20'); + expect(alternative?.status).toBe('eligible'); + expect(alternative?.comparison?.after.objectives).toContainEqual( + expect.objectContaining({ id: 'holiday-week-value', value: 0 }), + ); + expect(alternative?.comparison?.after.objectives).toContainEqual( + expect.objectContaining({ id: 'non-working-day-gain', value: 7 }), + ); + expect(outcome).toEqual(before); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + it('detaches declared dates and types across results, explanations, trace and comparisons', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); @@ -3404,7 +5044,8 @@ describe('planning calculation context', () => { const browserWindow = globalThis.window; vi.stubGlobal('window', {}); vi.stubGlobal('Worker', SlowTraceWorker); - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance'] }); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'performance', 'Date'] }); + vi.setSystemTime(new Date('2024-01-01T12:00:00Z')); try { const calculation = createPlanningCalculation(request); let traceReady = false; diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts index 3c30ab9..889053a 100644 --- a/src/app/services/leave-planner.service.ts +++ b/src/app/services/leave-planner.service.ts @@ -28,9 +28,11 @@ import type { TraceCandidateStatus, TraceCounterfactualSelection, } from './leave-planner.core'; +import { planningDateInParis } from './planning-date'; export type { IfThenPlanningRule, + IfThenContribution, ConflictExplanation, CounterfactualAnalysis, CounterfactualSelection, @@ -76,6 +78,7 @@ export type { RemainingClassification, RuleForce, TimeOffPeriod, + TimeWindowContribution, TimeWindowPlanningRule, TraceCounterfactualSelection, TraceCandidateStatus, @@ -117,6 +120,7 @@ interface UntrustedRecord { } interface TransportSnapshot { + calculationDate?: string; horizon?: { start?: unknown; end?: unknown }; balances?: unknown; declaredDays?: unknown; @@ -154,7 +158,7 @@ function cloneUnknown(value: unknown, seen = new WeakMap()): un function snapshotValue(value: unknown): unknown { if (value instanceof Date) - return Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString(); + return Number.isNaN(value.getTime()) ? 'Invalid Date' : planningDateInParis(value); if (Array.isArray(value)) return value.map(snapshotValue); if (typeof value !== 'object' || value === null) return value; const result: Record = {}; @@ -189,6 +193,7 @@ function publicSnapshot(request: TransportSnapshot): PlanningSnapshot { : []; const labels = stringMap(request.labels); return deepFreeze({ + calculationDate: request.calculationDate, horizon: { start: String(snapshotValue(horizon.start) ?? ''), end: String(snapshotValue(horizon.end) ?? ''), @@ -355,6 +360,7 @@ function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState { const horizon = snapshot.horizon ?? {}; const balances = Array.isArray(snapshot.balances) ? snapshot.balances : []; return { + calculationDate: snapshot.calculationDate, horizon: { start: String(horizon.start ?? ''), end: String(horizon.end ?? ''), @@ -1043,6 +1049,10 @@ export function applyReplayDeltas( ruleIds: [...delta.placement.ruleIds], }); const balanceId = delta.balanceId ?? delta.balance?.id; + if (delta.kind === 'balance-removed') { + state.balances = state.balances.filter((balance) => balance.id !== balanceId); + continue; + } if (balanceId) { let index = state.balances.findIndex((candidate) => candidate.id === balanceId); const changedBalance = delta.balance?.id === balanceId ? delta.balance : undefined; @@ -1050,7 +1060,7 @@ export function applyReplayDeltas( state.balances.push({ ...changedBalance }); index = state.balances.length - 1; } - if (index < 0) { + if (index < 0 && delta.kind !== 'earned-activated') { const source = finalState?.balances.find((candidate) => candidate.id === balanceId); if (source) { state.balances.push({ ...source }); @@ -1082,6 +1092,7 @@ export function applyReplayDeltas( function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState { return { + calculationDate: trace.initialState.calculationDate, horizon: { ...trace.initialState.horizon }, ...(trace.initialState.declaredDays ? { declaredDays: trace.initialState.declaredDays } @@ -1562,8 +1573,9 @@ export interface PlanningCalculationContext { } export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext { + const calculationDate = planningDateInParis(new Date()); cancelLatestWorkers(); - const transport = snapshotRequest(request); + const transport = { ...snapshotRequest(request), calculationDate }; const snapshot = publicSnapshot(transport); const id = `calculation:${++calculationSequence}`; latestCalculationId = id; @@ -1764,8 +1776,16 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal id, snapshot, cancel: () => run.cancel?.(), - outcome: run.outcome, - explanation: run.explanation, + outcome: run.outcome.then((result) => ({ + ...result, + calculationDate, + ...(result.kind === 'PlanningIncomplete' && result.incumbent + ? { incumbent: { ...result.incumbent, calculationDate } } + : {}), + })), + explanation: run.explanation.then((result) => + result ? { ...result, calculationDate } : undefined, + ), explanationError: run.explanationError, trace: retainedArtifacts.then((artifacts) => artifacts.trace), timings: run.timings, diff --git a/src/app/services/leave-planner.worker.spec.ts b/src/app/services/leave-planner.worker.spec.ts index a1cfb55..d6a0531 100644 --- a/src/app/services/leave-planner.worker.spec.ts +++ b/src/app/services/leave-planner.worker.spec.ts @@ -5,13 +5,28 @@ import { join, resolve } from 'node:path'; import { Worker as NodeWorker } from 'node:worker_threads'; import { promisify } from 'node:util'; import { vi } from 'vitest'; -import { createPlanningCalculation } from './leave-planner.service'; +import { + createPlanningCalculation, + type PlanningCalculationContext, +} from './leave-planner.service'; +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CalendarYear } from '../components/calendar-year/calendar-year'; +import { CalendarService, SelectableDayType } from './calendar-service'; +import { CalendarSettingsService } from './calendar-settings-service'; +import { UserInputService } from './user-input-service'; +import { RuleService, RuleType } from './rule-service'; + +beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); +afterEach(() => vi.useRealTimers()); describe('real planning worker cancellation', () => { let directory: string; let bundle: string; let afterCheckpoint: (() => void) | undefined; let heartbeats = 0; + let holdArtifacts = false; + const heldArtifactMessages: (() => void)[] = []; let simulateLongCalculation = false; let simulatedElapsed = 0; const threads = new Set(); @@ -41,6 +56,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); beforeEach(() => { afterCheckpoint = undefined; + holdArtifacts = false; + heldArtifactMessages.length = 0; + localStorage.clear(); heartbeats = 0; simulateLongCalculation = false; simulatedElapsed = 0; @@ -55,6 +73,18 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); simulatedElapsed = data.elapsed; return; } + if ( + holdArtifacts && + [ + 'explanation', + 'artifacts-start', + 'trace-status-index', + 'artifacts-complete', + ].includes(data.type) + ) { + heldArtifactMessages.push(() => this.onmessage?.({ data } as MessageEvent)); + return; + } if (data.type === 'heartbeat') heartbeats++; this.onmessage?.({ data } as MessageEvent); if (data.type === 'checkpoint') afterCheckpoint?.(); @@ -74,10 +104,196 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); afterEach(async () => { await Promise.all([...threads].map((thread) => thread.terminate())); threads.clear(); + localStorage.clear(); vi.unstubAllGlobals(); }); afterAll(() => rmSync(directory, { recursive: true, force: true })); + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + 'submits declared earned chains from the calendar and retains actual artifacts after edits (worker: %s, cancelled: %s)', + async (worker, cancelled) => { + localStorage.clear(); + localStorage.setItem('app.language', '"fr"'); + localStorage.setItem('leaveBalances', '[]'); + localStorage.setItem('vacationRules', '[]'); + vi.setSystemTime(new Date('2024-05-01T21:59:59Z')); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [provideZonelessChangeDetection()], + }).compileComponents(); + TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-29', '2024-05-07'); + const input = TestBed.inject(UserInputService); + for (const date of ['2024-04-29', '2024-04-30']) input.toggleDeclaredDay(date, 'cp'); + input.toggleDeclaredDay('2024-05-06', 'other'); + const rules = TestBed.inject(RuleService); + rules.addRule({ + id: 'producer', + name: 'CP autour du férié', + enabled: true, + force: 'preference', + type: RuleType.IF_THEN, + dayOfWeek: 3, + placement: 'before', + placeDays: 2, + placeType: SelectableDayType.CP, + rewardDays: 1, + rewardType: SelectableDayType.OTHER, + }); + rules.addRule({ + id: 'consumer', + name: 'Période avec droit futur', + enabled: true, + force: 'obligation', + type: RuleType.TIME_WINDOW, + startDate: new Date('2024-05-03T00:00:00'), + endDate: new Date('2024-05-06T00:00:00'), + placeDays: 2, + placeType: SelectableDayType.OTHER, + }); + const calendar = TestBed.inject(CalendarService); + const original = calendar.createPlanningCalculation.bind(calendar); + let calculation!: PlanningCalculationContext; + const calculate = vi + .spyOn(calendar, 'createPlanningCalculation') + .mockImplementation((request) => { + const browserWindow = globalThis.window; + if (!worker) vi.stubGlobal('window', undefined); + try { + calculation = original({ + ...request, + progress: (stage) => { + request.progress?.(stage); + if (cancelled && stage === 'non-working-day-gain') { + fixture.detectChanges(); + fixture.nativeElement + .querySelector('button.cancel-calculation') + .click(); + } + }, + }); + return calculation; + } finally { + if (!worker) vi.stubGlobal('window', browserWindow); + } + }); + holdArtifacts = worker; + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + expect(calculate).toHaveBeenCalledOnce(); + expect(calculation.snapshot.calculationDate).toBe('2024-05-01'); + vi.setSystemTime(new Date('2024-05-01T22:00:01Z')); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe(cancelled ? 'PlanningIncomplete' : 'Plan'); + const plan = + outcome.kind === 'Plan' + ? outcome + : outcome.kind === 'PlanningIncomplete' + ? outcome.incumbent + : undefined; + if (!plan) throw new Error('Expected a valid earned-chain plan.'); + expect(plan.calculationDate).toBe('2024-05-01'); + expect(plan.placements).toMatchObject([ + { + date: '2024-05-03', + balanceId: 'earned:producer:2024-05-01', + type: 'other', + ruleIds: ['consumer'], + }, + ]); + if (outcome.kind === 'PlanningIncomplete') + expect(outcome.reasons[0].code).toBe('user-cancelled'); + const originalDeclarations = structuredClone(calculation.snapshot.declaredDays); + input.toggleDeclaredDay('2024-04-30', 'rtt'); + rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' }); + input.updateBalances(() => []); + if (worker) { + await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0)); + let explained = false; + void calculation.explanation.then(() => (explained = true)); + await Promise.resolve(); + expect(explained).toBe(false); + holdArtifacts = false; + for (const deliver of heldArtifactMessages.splice(0)) deliver(); + } + const explanation = await calculation.explanation; + const trace = await calculation.trace; + await fixture.whenStable(); + fixture.detectChanges(); + expect(explanation).toMatchObject({ + kind: 'PlanExplanation', + calculationDate: '2024-05-01', + declaredDays: originalDeclarations, + ifThenContributions: [ + { + label: 'CP autour du férié', + earnedEntitlement: { + availableFrom: '2024-05-02', + expiryDate: '2025-05-02', + added: true, + }, + }, + ], + }); + expect(trace?.initialState).toMatchObject({ + calculationDate: '2024-05-01', + declaredDays: originalDeclarations, + }); + expect(trace?.status).toBe(cancelled ? 'incomplete' : 'ready'); + const core = await import('./leave-planner.core'); + const direct = core.calculatePlanningInternal(calculation.snapshot); + if (direct.kind !== 'Plan') + throw new Error(`Expected the same direct plan: ${JSON.stringify(direct)}`); + expect(plan.placements).toEqual(direct.placements); + expect(plan.ifThenContributions).toEqual(direct.ifThenContributions); + const root: HTMLElement = fixture.nativeElement; + expect(root.textContent).toContain('Droit futur généré'); + expect(root.textContent).toContain('CP autour du férié'); + expect(root.textContent).not.toContain('Règle modifiée après calcul'); + expect( + root.querySelector('[data-date="2024-04-30"]')?.getAttribute('aria-label'), + ).toContain('CP'); + root.querySelector('button.compare-dates-button')!.click(); + await vi.waitFor(() => + expect(fixture.componentInstance['counterfactualLoading']()).toBe(false), + ); + const comparison = fixture.componentInstance['counterfactual'](); + expect(comparison?.kind).toBe('Counterfactual'); + expect( + comparison?.results.find(({ date }) => date === '2024-04-30')?.reason?.code, + ).toBe('declared-day'); + expect(comparison?.results.find(({ date }) => date === '2024-05-02')?.status).toBe( + 'ineligible', + ); + expect(comparison?.results.find(({ date }) => date === '2024-05-03')?.status).toBe( + 'eligible', + ); + fixture.componentInstance['showFinalPlan'](); + if (!trace) throw new Error('Expected the original trace.'); + const phaseIndex = trace.phases.findIndex(({ decisions }) => + decisions.some(({ deltas }) => + deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')), + ), + ); + expect(phaseIndex).toBeGreaterThanOrEqual(0); + fixture.componentInstance['selectTracePhase'](phaseIndex); + const decisionIndex = trace.phases[phaseIndex].decisions.findIndex(({ deltas }) => + deltas.some(({ placement }) => placement?.balanceId.startsWith('earned:')), + ); + fixture.componentInstance['selectTraceDecision'](decisionIndex); + fixture.componentInstance['showTraceMoment']('after'); + expect(fixture.componentInstance['selectedTraceTransition']()?.after).toMatchObject({ + calculationDate: '2024-05-01', + declaredDays: originalDeclarations, + }); + fixture.destroy(); + }, + ); + it.each([false, true])( 'keeps declarations fixed in direct and worker planning and date comparisons (cancelled: %s)', async (cancelled) => { @@ -219,6 +435,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); let cancelledAt = 0; afterCheckpoint = () => { afterCheckpoint = undefined; + holdArtifacts = false; + heldArtifactMessages.length = 0; + localStorage.clear(); setTimeout(() => { cancelledAt = performance.now(); calculation.cancel(); diff --git a/src/app/services/leave-planner.worker.ts b/src/app/services/leave-planner.worker.ts index 74e2883..f5c5165 100644 --- a/src/app/services/leave-planner.worker.ts +++ b/src/app/services/leave-planner.worker.ts @@ -23,6 +23,8 @@ import { } from './leave-planner.core'; type WorkerPlanningRequest = { + calculationDate?: string; + declaredDays?: unknown; horizon?: { start?: unknown; end?: unknown }; balances?: unknown; rules?: unknown; diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index 8821e95..7f20b75 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -128,6 +128,9 @@ export interface AppTranslations { }; }; planning: { + calculationDate: string; + futureEntitlement: string; + includedEntitlement: string; comparison: { title: string; candidate: string; diff --git a/src/i18n/de.json b/src/i18n/de.json index baed9f2..afb974c 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -127,6 +127,9 @@ } }, "planning": { + "calculationDate": "Berechnungsdatum", + "futureEntitlement": "Erzeugter künftiger Anspruch", + "includedEntitlement": "Anspruch bereits in den gemeldeten Guthaben enthalten", "title": "Ihr optimiertes Jahr", "daysPlaced": "Eingeplante Urlaubstage", "daysGained": "Gewonnene arbeitsfreie Tage", diff --git a/src/i18n/en.json b/src/i18n/en.json index a99f330..1eb47c0 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -127,6 +127,9 @@ } }, "planning": { + "calculationDate": "Calculation date", + "futureEntitlement": "Future entitlement generated", + "includedEntitlement": "Entitlement already included in reported balances", "title": "Your optimized year", "daysPlaced": "Leave days placed", "daysGained": "Non-working days gained", diff --git a/src/i18n/es.json b/src/i18n/es.json index fc3c900..3e59e30 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -127,6 +127,9 @@ } }, "planning": { + "calculationDate": "Fecha del cálculo", + "futureEntitlement": "Derecho futuro generado", + "includedEntitlement": "Derecho ya incluido en los saldos declarados", "title": "Tu año optimizado", "daysPlaced": "Días de vacaciones asignados", "daysGained": "Días no laborables ganados", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 969c833..08743b3 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -127,6 +127,9 @@ } }, "planning": { + "calculationDate": "Date du calcul", + "futureEntitlement": "Droit futur généré", + "includedEntitlement": "Droit déjà inclus dans les soldes reportés", "title": "Votre année optimisée", "daysPlaced": "Jours de congé placés", "daysGained": "Jours non travaillés gagnés",