diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md new file mode 100644 index 0000000..a93d986 --- /dev/null +++ b/docs/benchmark/README.md @@ -0,0 +1,28 @@ +# Mobile leave-planner benchmark + +Open the deployed application at `/benchmark` on the reference phone. Tap +**Run benchmark**, wait for the five scenarios to finish, then tap **Copy +report** and save the tab-separated output with the date and build identifier. + +The harness measures preparation, asynchronous calculation, and total wall +time separately. It records the outcome, last completed quality stage, +placement count, and determinism between repetitions. `PlanningIncomplete` is +not a conflict: its last completed stage and incumbent are retained. + +## Protocol + +- Record phone model, OS version, browser/version, application build, and + battery level. +- Use a cool phone, unplugged, with other applications closed. Record whether + the device became warm or throttled during the run. +- Run the benchmark three times after one warm-up run. Do not interact with + the page while a run is active. +- Representative and loaded scenarios should normally complete below five + seconds; every scenario must return within ten seconds. These are release + measurements, not CI timing assertions. +- Keep the copied report unchanged, including any incomplete outcomes or + hardware limitations. No mobile-device measurement is claimed by the source + tree until a human records it here. + +The browser page uses the same `planLeave(request)` operation as the calendar +and adds no solver or backend dependency. diff --git a/src/app/app.html b/src/app/app.html index e60e2bc..d29cf09 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,10 +1,13 @@ -
-

beach_access {{ title() }}

-
- - - - -
-
+@if (showPlanner()) { +
+

beach_access {{ title() }}

+
+ + + + +
+
+} + diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index dc39edb..2a7ee89 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,3 +1,9 @@ import { Routes } from '@angular/router'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: 'benchmark', + loadComponent: () => + import('./benchmark/benchmark-page').then((module) => module.BenchmarkPage), + }, +]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index bb852d2..5c3b428 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -22,4 +22,16 @@ describe('App', () => { const compiled = fixture.nativeElement as HTMLElement; expect(compiled.querySelector('h1')?.textContent).toContain('vac-optimizer'); }); + + it('does not mount the normal planner shell on the benchmark route', () => { + const originalPath = window.location.pathname; + history.pushState({}, '', '/benchmark'); + try { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('app-calendar-year')).toBeNull(); + } finally { + history.replaceState({}, '', originalPath); + } + }); }); diff --git a/src/app/app.ts b/src/app/app.ts index 5e97ef1..477e68f 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { provideNativeDateAdapter } from '@angular/material/core'; import { MatDivider } from '@angular/material/divider'; @@ -6,6 +6,11 @@ import { UserInput } from './components/user-input/user-input'; import { CalendarYear } from './components/calendar-year/calendar-year'; import { DarkLightModeButton } from './components/dark-light-mode-button/dark-light-mode-button'; import { MatIcon } from '@angular/material/icon'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; + +function isBenchmarkUrl(url: string): boolean { + return url.split(/[?#]/, 1)[0].replace(/\/+$/, '') === '/benchmark'; +} @Component({ selector: 'app-root', @@ -15,8 +20,8 @@ import { MatIcon } from '@angular/material/icon'; UserInput, CalendarYear, DarkLightModeButton, - DarkLightModeButton, MatIcon, + RouterOutlet, ], templateUrl: './app.html', styleUrl: './app.scss', @@ -24,5 +29,16 @@ import { MatIcon } from '@angular/material/icon'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class App { + private readonly router = inject(Router); protected readonly title = signal('vac-optimizer'); + protected readonly showPlanner = signal( + !isBenchmarkUrl(typeof window !== 'undefined' ? window.location.pathname : this.router.url), + ); + + constructor() { + this.router.events.subscribe((event) => { + if (event instanceof NavigationEnd) + this.showPlanner.set(!isBenchmarkUrl(event.urlAfterRedirects)); + }); + } } diff --git a/src/app/benchmark/benchmark-page.ts b/src/app/benchmark/benchmark-page.ts new file mode 100644 index 0000000..131519c --- /dev/null +++ b/src/app/benchmark/benchmark-page.ts @@ -0,0 +1,44 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { benchmarkReport, runLeavePlannerBenchmark } from './leave-planner-benchmark'; +import { TranslationService } from '../services/translation-service'; + +@Component({ + selector: 'app-benchmark-page', + template: ` +
+

{{ tr().benchmark.title }}

+

{{ tr().benchmark.instructions }}

+ + +
{{ report() }}
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BenchmarkPage { + private readonly translationService = inject(TranslationService); + readonly running = signal(false); + readonly report = signal(''); + + get tr() { + return this.translationService.tr; + } + + async run(): Promise { + this.running.set(true); + try { + this.report.set(benchmarkReport(await runLeavePlannerBenchmark(3))); + } finally { + this.running.set(false); + } + } + + async copy(): Promise { + if (this.report() && typeof navigator !== 'undefined' && navigator.clipboard) + await navigator.clipboard.writeText(this.report()); + } +} diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts new file mode 100644 index 0000000..21922ef --- /dev/null +++ b/src/app/benchmark/leave-planner-benchmark.spec.ts @@ -0,0 +1,37 @@ +import { vi } from 'vitest'; +import { + benchmarkReport, + benchmarkScenarios, + runLeavePlannerBenchmark, +} from './leave-planner-benchmark'; + +describe('leave planner benchmark protocol', () => { + it('keeps five deterministic scenarios and exercises a later earned consumer', async () => { + const scenarios = benchmarkScenarios(); + expect(scenarios).toHaveLength(5); + const chain = scenarios.find((scenario) => scenario.name === 'chained earned entitlements'); + expect(chain?.request.rules?.map((rule) => rule.id)).toEqual([ + 'wednesday-holidays', + 'thursday-consumer', + ]); + + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const measurements = await runLeavePlannerBenchmark(1); + const chained = measurements.find( + (measurement) => measurement.scenario === 'chained earned entitlements', + ); + const loaded = measurements.find( + (measurement) => measurement.scenario === 'loaded year', + ); + expect(chained?.outcome).toBe('Plan'); + expect(chained?.placements).toBeGreaterThanOrEqual(3); + expect(loaded?.outcome).toBe('Plan'); + expect(loaded?.deterministic).toBe(true); + expect(benchmarkReport(measurements).split('\n')).toHaveLength(6); + } finally { + vi.stubGlobal('window', browserWindow); + } + }, 30_000); +}); diff --git a/src/app/benchmark/leave-planner-benchmark.ts b/src/app/benchmark/leave-planner-benchmark.ts new file mode 100644 index 0000000..6a870c6 --- /dev/null +++ b/src/app/benchmark/leave-planner-benchmark.ts @@ -0,0 +1,207 @@ +import { planLeave, PlanningOutcome, PlanningRequest } from '../services/leave-planner.service'; + +export interface BenchmarkScenario { + name: string; + request: PlanningRequest; +} + +export interface BenchmarkMeasurement { + scenario: string; + repetition: number; + preparationMs: number; + calculationMs: number; + wallMs: number; + outcome: PlanningOutcome['kind']; + lastCompletedStage?: string; + placements: number; + deterministic: boolean; +} + +const balance = ( + id: string, + type: 'cp' | 'rtt' | 'other', + quantity: number, + expiryDate = '2024-12-31', +) => ({ + id, + type, + quantity, + availableFrom: '2024-01-01', + expiryDate, +}); + +export function benchmarkScenarios(): BenchmarkScenario[] { + const horizon = { start: '2024-01-01', end: '2025-01-01' }; + return [ + { + name: 'representative year', + request: { + horizon, + balances: [balance('cp', 'cp', 20), balance('rtt', 'rtt', 10)], + rules: [], + }, + }, + { + name: 'typed obligations', + request: { + horizon, + balances: [balance('cp', 'cp', 5), balance('rtt', 'rtt', 5)], + rules: [ + { + id: 'cp-window', + type: 'time_window', + force: 'obligation', + startDate: '2024-04-01', + endDate: '2024-04-12', + placeDays: 2, + placeType: 'cp', + }, + { + id: 'rtt-window', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-01', + endDate: '2024-05-12', + placeDays: 2, + placeType: 'rtt', + }, + ], + }, + }, + { + name: 'chained earned entitlements', + request: { + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 0, '2026-01-01')], + rules: [ + { + id: 'wednesday-holidays', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'thursday-consumer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 4, + placeDays: 1, + placeType: 'rtt', + placement: 'before', + rewardDays: 0, + }, + ], + }, + }, + { + name: 'deliberate conflict', + request: { + horizon, + balances: [balance('cp', 'cp', 1)], + rules: [ + { + id: 'rtt-required', + type: 'time_window', + force: 'obligation', + startDate: '2024-04-01', + endDate: '2024-04-05', + placeDays: 1, + placeType: 'rtt', + }, + ], + }, + }, + { + name: 'loaded year', + request: { + horizon, + balances: Array.from({ length: 12 }, (_, index) => + balance(`cp-${index}`, index % 2 ? 'rtt' : 'cp', 12), + ), + rules: Array.from({ length: 8 }, (_, index) => ({ + id: `window-${index}`, + type: 'time_window' as const, + force: index < 3 ? ('obligation' as const) : ('preference' as const), + startDate: `2024-${String(index + 1).padStart(2, '0')}-01`, + endDate: `2024-${String(index + 1).padStart(2, '0')}-28`, + placeDays: 3, + placeType: index % 2 ? ('rtt' as const) : ('any' as const), + })), + }, + }, + ]; +} + +function resultSummary(outcome: PlanningOutcome): { + outcome: PlanningOutcome['kind']; + lastCompletedStage?: string; + placements: number; +} { + if (outcome.kind === 'Plan') + return { + outcome: outcome.kind, + lastCompletedStage: outcome.lastCompletedStage, + placements: outcome.placements.length, + }; + if (outcome.kind === 'PlanningIncomplete') + return { + outcome: outcome.kind, + lastCompletedStage: outcome.lastCompletedStage, + placements: outcome.incumbent?.placements.length ?? 0, + }; + return { outcome: outcome.kind, placements: 0 }; +} + +export async function runLeavePlannerBenchmark(repetitions = 1): Promise { + const measurements: BenchmarkMeasurement[] = []; + for (const scenario of benchmarkScenarios()) { + let previous: string | undefined; + for (let repetition = 1; repetition <= repetitions; repetition++) { + const preparationStart = performance.now(); + const request = structuredClone(scenario.request); + const preparationMs = performance.now() - preparationStart; + const calculationStart = performance.now(); + const outcome = await planLeave(request); + const calculationMs = performance.now() - calculationStart; + const summary = resultSummary(outcome); + const stable = JSON.stringify(outcome); + measurements.push({ + scenario: scenario.name, + repetition, + preparationMs, + calculationMs, + wallMs: preparationMs + calculationMs, + ...summary, + deterministic: previous === undefined || previous === stable, + }); + previous = stable; + } + } + return measurements; +} + +export function benchmarkReport(measurements: BenchmarkMeasurement[]): string { + const header = + 'scenario\trepetition\tpreparation_ms\tcalculation_ms\twall_ms\toutcome\tlast_stage\tplacements\tdeterministic'; + return [ + header, + ...measurements.map((item) => + [ + item.scenario, + item.repetition, + item.preparationMs.toFixed(1), + item.calculationMs.toFixed(1), + item.wallMs.toFixed(1), + item.outcome, + item.lastCompletedStage ?? '', + item.placements, + item.deterministic, + ].join('\t'), + ), + ].join('\n'); +} diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts new file mode 100644 index 0000000..f486201 --- /dev/null +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -0,0 +1,146 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; +import { CalendarYear } from './calendar-year'; +import { CalendarService, SelectedDates, SelectableDayType } from '../../services/calendar-service'; +import { + PlanOutcome, + PlanningConflictOutcome, + PlanningIncompleteOutcome, + InvalidInputOutcome, + PlanningOutcome, +} from '../../services/leave-planner.service'; +import { UserInputService } from '../../services/user-input-service'; + +const invalid: InvalidInputOutcome = { + kind: 'InvalidInput', + reasons: [], +}; + +const conflict: PlanningConflictOutcome = { + kind: 'PlanningConflict', + reasons: [], + conflictingRuleIds: ['rule'], +}; + +const incomplete: PlanningIncompleteOutcome = { + kind: 'PlanningIncomplete', + completedStages: [], + reasons: [], +}; + +const plan: PlanOutcome = { + kind: 'Plan', + horizon: { start: '2024-01-01', end: '2025-01-01' }, + placements: [], + timeOffPeriods: [], + preferences: [], + remainingBalances: [], + metrics: { + unplacedAtRisk: 0, + nonWorkingDayGain: 0, + longestWorkSequence: 0, + shortSequenceDeficit: 0, + supportingPlacements: 0, + preferenceSatisfaction: [], + }, + completedStages: ['dates'], + lastCompletedStage: 'dates', +}; + +describe('CalendarYear planning outcome boundary', () => { + it.each([ + ['InvalidInput', invalid], + ['PlanningConflict', conflict], + ['PlanningIncomplete without incumbent', incomplete], + ] as const)('clears stale calendar results for %s', async (_, outcome: PlanningOutcome) => { + const calendar = { + getSelectedDatesForYear: vi.fn(() => new SelectedDates()), + getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), + planLeave: vi.fn(async () => outcome), + selectedDatesForPlan: vi.fn(() => new SelectedDates()), + }; + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const userInput = TestBed.inject(UserInputService); + userInput.remainingVacationDaysSignal.set([ + { + id: 'stale', + type: SelectableDayType.CP, + numberOfDays: 1, + expiryDate: new Date('2025-01-01'), + }, + ]); + const fixture: ComponentFixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(userInput.remainingVacationDaysSignal()).toEqual([]); + }); + + it('keeps a returned incumbent/result visible only when an outcome supplies one', async () => { + const calendar = { + getSelectedDatesForYear: vi.fn(() => new SelectedDates()), + getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), + planLeave: vi.fn(async () => plan), + selectedDatesForPlan: vi.fn(() => new SelectedDates()), + }; + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const userInput = TestBed.inject(UserInputService); + userInput.remainingVacationDaysSignal.set([]); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + expect(userInput.remainingVacationDaysSignal()).toEqual([]); + }); + + it('turns planner rejection into an incomplete error and clears loading', async () => { + const calendar = { + getSelectedDatesForYear: vi.fn(() => new SelectedDates()), + getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), + planLeave: vi.fn(async () => { + throw new Error('planner exploded'); + }), + selectedDatesForPlan: vi.fn(() => new SelectedDates()), + }; + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const userInput = TestBed.inject(UserInputService); + userInput.remainingVacationDaysSignal.set([ + { + id: 'stale', + type: SelectableDayType.CP, + numberOfDays: 1, + expiryDate: new Date('2025-01-01'), + }, + ]); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + const outcome = fixture.componentInstance['planningOutcome'](); + expect(outcome?.kind).toBe('PlanningIncomplete'); + if (outcome?.kind === 'PlanningIncomplete') + expect(outcome.reasons[0].code).toBe('planner-error'); + expect(fixture.componentInstance['isLoading']()).toBe(false); + expect(userInput.remainingVacationDaysSignal()).toEqual([]); + }); +}); diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index c27778d..9f86263 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -10,6 +10,17 @@ } + @if (planningOutcome()?.kind === 'InvalidInput') { + + } @else if (planningOutcome()?.kind === 'PlanningConflict') { + + } @else if (planningOutcome()?.kind === 'PlanningIncomplete') { +

+ {{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }} +

+ } @else if (planningOutcome()?.kind === 'Plan') { +

{{ tr().planning.complete }}

+ } @for (month of months; track $index) { diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 0fba6e8..5a1c3f9 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -7,6 +7,8 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { DateCacheService } from '../../services/date-cache.service'; import { RuleService } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; +import { PlanOutcome, PlanningOutcome } from '../../services/leave-planner.service'; +import { SelectableDayType, VacDay } from '../../services/calendar-service'; @Component({ selector: 'app-calendar-year', @@ -28,6 +30,30 @@ export class CalendarYear { protected isLoading = signal(true); protected loadingProgress = signal(0); + protected planningOutcome = signal(null); + private calculationVersion = 0; + + protected hasPlannerError(): boolean { + const outcome = this.planningOutcome(); + return ( + outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error' + ); + } + + private stageProgress(stage: string): number { + if (stage === 'complete') return 100; + if (stage === 'validation') return 5; + if (stage === 'obligations') return 20; + if (stage === 'at-risk') return 35; + if (stage.startsWith('preference:')) return 50; + if (stage === 'non-working-day-gain') return 65; + if (stage === 'longest-work-sequence') return 72; + if (stage === 'short-sequence-deficit') return 79; + if (stage === 'supporting-placements') return 86; + if (stage === 'expiry') return 93; + if (stage === 'dates') return 98; + return this.loadingProgress(); + } // Use DateCacheService instead of creating Date objects private get currentMonth() { @@ -43,37 +69,90 @@ export class CalendarYear { this.userInput.computeTriggerSignal(); this.selectedDates = this.calendarService.getSelectedDatesForYear(); + this.planningOutcome.set(null); + this.userInput.remainingVacationDaysSignal.set([]); const vacationData = untracked(() => this.userInput.vacationNumberSignal()); const currentRules = untracked(() => this.ruleService.rules); - console.log('Optimizing vacations with data:', vacationData); - console.log('Using rules:', currentRules); - this.isLoading.set(true); this.loadingProgress.set(0); + const version = ++this.calculationVersion; // Defer execution to allow UI to update queueMicrotask(() => { void (async () => { - if (this.selectedDates) { - // Create a deep copy to avoid mutating the original signal data - const vacationDaysCopy = vacationData.map((vd) => ({ ...vd })); - const remainingDays = await this.selectedDates.optimizeVacations( - vacationDaysCopy, - currentRules, - this.calendarService, - (progress: number) => { - this.loadingProgress.set(progress); + try { + const outcome = await this.calendarService.planLeave({ + horizon: this.calendarService.getDefaultPlanningHorizon(), + balances: vacationData.map((balance) => ({ + id: balance.id, + type: balance.type, + quantity: balance.numberOfDays, + availableFrom: balance.availableDate, + expiryDate: balance.expiryDate, + })), + rules: currentRules, + progress: (stage) => { + if (version === this.calculationVersion) + this.loadingProgress.set(this.stageProgress(stage)); }, - ); - // Update the remaining vacation days signal - this.userInput.remainingVacationDaysSignal.set(remainingDays); + }); + if (version !== this.calculationVersion) return; + this.applyOutcome(outcome); + } catch (error) { + if (version !== this.calculationVersion) return; + this.applyOutcome({ + kind: 'PlanningIncomplete', + completedStages: [], + reasons: [ + { + code: 'planner-error', + message: + error instanceof Error + ? error.message + : 'The planner failed unexpectedly.', + }, + ], + }); + } finally { + if (version === this.calculationVersion) { + this.loadingProgress.set(100); + this.isLoading.set(false); + } } - this.isLoading.set(false); })(); }); }); } + private applyOutcome(outcome: PlanningOutcome): void { + this.planningOutcome.set(outcome); + const plan = + outcome.kind === 'Plan' + ? outcome + : outcome.kind === 'PlanningIncomplete' + ? outcome.incumbent + : undefined; + if (plan) { + this.selectedDates = this.calendarService.selectedDatesForPlan(plan); + this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan)); + return; + } + this.selectedDates = this.calendarService.getSelectedDatesForYear(); + this.userInput.remainingVacationDaysSignal.set([]); + } + + private toLegacyBalances(plan: PlanOutcome): VacDay[] { + return plan.remainingBalances.map((balance) => ({ + id: balance.id, + type: balance.type as SelectableDayType, + numberOfDays: balance.quantity, + availableDate: new Date(`${balance.availableFrom}T00:00:00`), + expiryDate: new Date(`${balance.expiryDate}T00:00:00`), + classification: balance.classification, + reason: balance.reason, + })); + } + protected selectedDates: SelectedDates = new SelectedDates(); getYear(monthIndex: number): number { diff --git a/src/app/components/rules-panel/rules-panel.html b/src/app/components/rules-panel/rules-panel.html index 4208ba8..80db3fe 100644 --- a/src/app/components/rules-panel/rules-panel.html +++ b/src/app/components/rules-panel/rules-panel.html @@ -37,12 +37,17 @@ >
- diff --git a/src/app/components/userInput/day-input/day-input.spec.ts b/src/app/components/userInput/day-input/day-input.spec.ts index 3e45396..206bf42 100644 --- a/src/app/components/userInput/day-input/day-input.spec.ts +++ b/src/app/components/userInput/day-input/day-input.spec.ts @@ -28,4 +28,15 @@ describe('DayInput', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('emits an availability-date change without mutating the input value', () => { + const emitted: VacDay[] = []; + component.dayChange.subscribe((value) => emitted.push(value)); + const availableDate = new Date('2025-02-03T00:00:00'); + + component.addAvailabilityEvent({ value: availableDate } as never); + + expect(emitted[0].availableDate).toBe(availableDate); + expect(mockVacDay.availableDate).toBeUndefined(); + }); }); diff --git a/src/app/components/userInput/day-input/day-input.ts b/src/app/components/userInput/day-input/day-input.ts index 1cbadf5..b3da0a1 100644 --- a/src/app/components/userInput/day-input/day-input.ts +++ b/src/app/components/userInput/day-input/day-input.ts @@ -122,6 +122,11 @@ export class DayInput { } addEvent(event: MatDatepickerInputEvent) { - this.dayChange.emit({ ...this.day(), expiryDate: event.value! }); + if (event.value) this.dayChange.emit({ ...this.day(), expiryDate: event.value }); + } + + addAvailabilityEvent(event: MatDatepickerInputEvent) { + const value = event.value; + if (value) this.dayChange.emit({ ...this.day(), availableDate: value }); } } diff --git a/src/app/services/calendar-service.spec.ts b/src/app/services/calendar-service.spec.ts index f12bccd..a0378ae 100644 --- a/src/app/services/calendar-service.spec.ts +++ b/src/app/services/calendar-service.spec.ts @@ -1,16 +1,7 @@ import { TestBed } from '@angular/core/testing'; -import { vi } from 'vitest'; import { provideZonelessChangeDetection } from '@angular/core'; -import { - CalendarService, - DayType, - SelectedDate, - SelectedDates, - SelectableDayType, - VacDay, -} from './calendar-service'; +import { CalendarService, DayType, SelectedDate, SelectedDates } from './calendar-service'; import { DateRange } from '@angular/material/datepicker'; -import { IfThenRule, TimeWindowRule, RuleType } from './rule-service'; describe('CalendarService', () => { let service: CalendarService; @@ -26,6 +17,20 @@ describe('CalendarService', () => { expect(service).toBeTruthy(); }); + it('derives the planning horizon from the instant in Europe/Paris', () => { + const previousTimeZone = process.env['TZ']; + process.env['TZ'] = 'Pacific/Auckland'; + try { + expect(service.getDefaultPlanningHorizon(new Date('2026-08-28T01:00:00Z'))).toEqual({ + start: '2026-08-28', + end: '2027-08-28', + }); + } finally { + if (previousTimeZone === undefined) delete process.env['TZ']; + else process.env['TZ'] = previousTimeZone; + } + }); + describe('getHolidays', () => { it('should return an array of holidays', () => { const holidays = service.getHolidays(); @@ -188,14 +193,6 @@ describe('SelectedDates', () => { expect(selectedDates.datesSelected.length).toBeGreaterThan(2); }); - it('should compute heuristics', () => { - const selectedDates = new SelectedDates(); - const date = new Date(2024, 0, 1); - selectedDates.push(new SelectedDate(DayType.CP, new DateRange(date, date))); - const heuristic = selectedDates.computeHeuristics(); - expect(typeof heuristic).toBe('number'); - }); - it('should return correct classes', () => { const selectedDates = new SelectedDates(); const date = new Date(2024, 0, 1); @@ -211,254 +208,4 @@ describe('SelectedDates', () => { const tooltip = selectedDates.getTooltip(date); expect(tooltip).toBeTruthy(); }); - - describe('optimizeVacations and rules engine', () => { - let calendarService: CalendarService; - let selectedDates: SelectedDates; - - beforeEach(() => { - calendarService = TestBed.inject(CalendarService); - selectedDates = new SelectedDates(); - }); - - it('should execute rules sequentially in order', async () => { - const vacationDays: VacDay[] = []; - const rules: (TimeWindowRule | IfThenRule)[] = [ - { id: '1', type: RuleType.IF_THEN, enabled: true } as IfThenRule, - { id: '2', type: RuleType.TIME_WINDOW, enabled: true } as TimeWindowRule, - { id: '3', type: RuleType.IF_THEN, enabled: true } as IfThenRule, - ]; - - const applyTimeWindowRulesSpy = vi.spyOn(selectedDates, 'applyTimeWindowRules'); - const applyIfThenRulesSpy = vi.spyOn(selectedDates, 'applyIfThenRules'); - - await selectedDates.optimizeVacations(vacationDays, rules, calendarService); - - expect(applyIfThenRulesSpy.mock.calls.length).toBe(2); - expect(applyTimeWindowRulesSpy.mock.calls.length).toBe(1); - - // First rule is IF_THEN - expect(applyIfThenRulesSpy.mock.calls[0][1][0].id).toBe('1'); - // Second rule is TIME_WINDOW - expect(applyTimeWindowRulesSpy.mock.calls[0][1][0].id).toBe('2'); - // Third rule is IF_THEN - expect(applyIfThenRulesSpy.mock.calls[1][1][0].id).toBe('3'); - }); - - it('should apply time window rules', () => { - const futureYear = new Date().getFullYear() + 2; - const vacationDays: VacDay[] = [ - { - id: '1', - type: SelectableDayType.CP, - numberOfDays: 5, - expiryDate: new Date(futureYear + 1, 11, 31), - }, - ]; - const rules: TimeWindowRule[] = [ - { - id: 't1', - name: 'Time Window', - type: RuleType.TIME_WINDOW, - enabled: true, - startDate: new Date(futureYear, 11, 20), - endDate: new Date(futureYear, 11, 25), - placeDays: 3, - placeType: SelectableDayType.CP, - }, - ]; - - // Mock monthsInAYearFromNow to return a controlled range of days - vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([ - [ - [ - new Date(futureYear, 11, 20), - new Date(futureYear, 11, 21), - new Date(futureYear, 11, 22), - new Date(futureYear, 11, 23), - new Date(futureYear, 11, 24), - ], - ], - ]); - - selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService); - - // Should have placed 3 days - const placedCount = selectedDates._datesSelected.filter( - (d) => d.type === DayType.CP, - ).length; - expect(placedCount).toBe(3); - // Should have consumed 3 days from the pool - expect(vacationDays[0].numberOfDays).toBe(2); - }); - - it('should apply if-then rules', () => { - const futureYear = new Date().getFullYear() + 2; - const vacationDays: VacDay[] = [ - { - id: '1', - type: SelectableDayType.CP, - numberOfDays: 5, - expiryDate: new Date(futureYear + 1, 11, 31), - }, - ]; - const rules: IfThenRule[] = [ - { - id: 'i1', - name: 'Samedi Malin', - type: RuleType.IF_THEN, - enabled: true, - dayOfWeek: 6, // Saturday - placeDays: 2, - placeType: SelectableDayType.CP, - placement: 'auto', - rewardDays: 1, - rewardType: SelectableDayType.OTHER, - }, - ]; - - // Setup a mock holiday on a Saturday - // Use a date far in the future so it doesn't fail the "past date" check - const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5); // Dec 5 is usually Saturday or close, let's just make sure we get a Saturday - // To guarantee it's a Saturday for the test: - while (saturdayHoliday.getDay() !== 6) { - saturdayHoliday.setDate(saturdayHoliday.getDate() + 1); - } - selectedDates._datesSelected.push( - new SelectedDate( - DayType.CLOSED_DAY, - new DateRange(saturdayHoliday, saturdayHoliday), - ), - ); - - selectedDates.applyIfThenRules(vacationDays, rules); - - // Should have placed 2 CP days (e.g. Thu/Fri, Mon/Tue, or Fri/Mon) - const placedCP = selectedDates._datesSelected.filter( - (d) => d.type === DayType.CP, - ).length; - expect(placedCP).toBe(2); - - // Should have consumed 2 CP days - expect(vacationDays[0].numberOfDays).toBe(3); - - // Should have added 1 OTHER reward day - const rewardDay = vacationDays.find((vd) => vd.type === SelectableDayType.OTHER); - expect(rewardDay).toBeDefined(); - expect(rewardDay?.numberOfDays).toBe(1); - }); - - it('should apply time window rules with ANY type', () => { - const futureYear = new Date().getFullYear() + 2; - const vacationDays: VacDay[] = [ - { - id: '1', - type: SelectableDayType.CP, - numberOfDays: 2, - expiryDate: new Date(futureYear, 11, 31), - }, - { - id: '2', - type: SelectableDayType.RTT, - numberOfDays: 2, - expiryDate: new Date(futureYear - 1, 11, 31), // Expires earlier - }, - ]; - const rules: TimeWindowRule[] = [ - { - id: 'tw1', - name: 'Vacances', - type: RuleType.TIME_WINDOW, - enabled: true, - startDate: new Date(futureYear, 6, 1), - endDate: new Date(futureYear, 6, 15), - placeDays: 3, - placeType: SelectableDayType.ANY, - }, - ]; - - vi.spyOn(calendarService, 'monthsInAYearFromNow').mockReturnValue([ - [ - [ - new Date(futureYear, 6, 1), - new Date(futureYear, 6, 2), - new Date(futureYear, 6, 3), - new Date(futureYear, 6, 4), - new Date(futureYear, 6, 5), - ], - ], - ]); - - selectedDates.applyTimeWindowRules(vacationDays, rules, calendarService); - - // Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP - const placedRTT = selectedDates._datesSelected.filter( - (d) => d.type === DayType.RTT, - ).length; - const placedCP = selectedDates._datesSelected.filter( - (d) => d.type === DayType.CP, - ).length; - expect(placedRTT).toBe(2); - expect(placedCP).toBe(1); - - // Should have consumed 2 RTT and 1 CP - expect(vacationDays[0].numberOfDays).toBe(1); // CP remaining - expect(vacationDays[1].numberOfDays).toBe(0); // RTT remaining - }); - - it('should apply if-then rules with ANY type', () => { - const futureYear = new Date().getFullYear() + 2; - const vacationDays: VacDay[] = [ - { - id: '1', - type: SelectableDayType.CP, - numberOfDays: 2, - expiryDate: new Date(futureYear + 2, 11, 31), - }, - { - id: '2', - type: SelectableDayType.RTT, - numberOfDays: 2, - expiryDate: new Date(futureYear + 1, 11, 31), // Expires earlier - }, - ]; - const rules: IfThenRule[] = [ - { - id: 'i1', - name: 'Rule', - type: RuleType.IF_THEN, - enabled: true, - dayOfWeek: 6, // Saturday - placeDays: 3, - placeType: SelectableDayType.ANY, - placement: 'auto', - rewardDays: 0, - rewardType: SelectableDayType.CP, - }, - ]; - - const saturdayHoliday = new Date(new Date().getFullYear() + 2, 11, 5); - while (saturdayHoliday.getDay() !== 6) { - saturdayHoliday.setDate(saturdayHoliday.getDate() + 1); - } - selectedDates._datesSelected.push( - new SelectedDate( - DayType.CLOSED_DAY, - new DateRange(saturdayHoliday, saturdayHoliday), - ), - ); - - selectedDates.applyIfThenRules(vacationDays, rules); - - // Should have placed 3 days total: 2 RTT (earlier expiry) and 1 CP - const placedRTT = selectedDates._datesSelected.filter( - (d) => d.type === DayType.RTT, - ).length; - const placedCP = selectedDates._datesSelected.filter( - (d) => d.type === DayType.CP, - ).length; - expect(placedRTT).toBe(2); - expect(placedCP).toBe(1); - }); - }); }); diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index b2cbfe4..aa2e8a9 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -5,7 +5,14 @@ import dayjs from 'dayjs'; import weekOfYear from 'dayjs/plugin/weekOfYear'; import isoWeek from 'dayjs/plugin/isoWeek'; import { DateCacheService } from './date-cache.service'; -import { VacationRule, RuleType, IfThenRule, TimeWindowRule } from './rule-service'; +import { + planLeave as planLeaveOperation, + LeaveType, + PlanOutcome, + PlanningReason, + RemainingClassification, +} from './leave-planner.service'; +import { addPlanningYears, planningDateInParis } from './planning-date'; dayjs.extend(weekOfYear); dayjs.extend(isoWeek); @@ -22,6 +29,29 @@ const allTypesOptions: HolidaysTypes.Options = { export class CalendarService { private dateCache = inject(DateCacheService); + readonly planLeave = planLeaveOperation; + + getDefaultPlanningHorizon(now = new Date()): { start: string; end: string } { + const start = planningDateInParis(now); + return { start, end: addPlanningYears(start, 1) }; + } + + selectedDatesForPlan(plan: PlanOutcome): SelectedDates { + const selected = this.getSelectedDatesForYear(); + for (const placement of plan.placements) { + selected.push( + new SelectedDate( + dayTypeForLeaveType[placement.type], + new DateRange( + new Date(`${placement.date}T00:00:00`), + new Date(`${placement.date}T00:00:00`), + ), + ), + ); + } + return selected; + } + // Cache for holiday timestamps private holidayTimestampsCache: Set | null = null; private holidayCacheYear: number | null = null; @@ -111,10 +141,17 @@ export class CalendarService { // Logic to return holidays for the given year const hd = new Holidays('FR', allTypesOptions); - const holidays = hd.getHolidays(currentYear, lang); - const holidaysPlusOne = hd.getHolidays(currentYear + 1, lang); + const holidays = hd + .getHolidays(currentYear, lang) + .filter((holiday) => holiday.type === 'public'); + const holidaysPlusOne = hd + .getHolidays(currentYear + 1, lang) + .filter((holiday) => holiday.type === 'public'); holidays.push(...holidaysPlusOne); - const holidaysDate: Date[] = holidays.map((holiday) => new Date(holiday.date)); + const holidaysDate: Date[] = holidays.map((holiday) => { + const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0]; + return dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date); + }); // Update cache this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime())); @@ -188,7 +225,11 @@ export type VacDay = { id: string; type: SelectableDayType; numberOfDays: number; + /** Legacy balances omit this value; the planner then uses the horizon start. */ + availableDate?: Date; expiryDate: Date; + classification?: RemainingClassification; + reason?: PlanningReason; }; export type Week = (Date | null)[]; @@ -211,6 +252,12 @@ export enum SelectableDayType { ANY = 'any', } +const dayTypeForLeaveType: Record = { + cp: DayType.CP, + rtt: DayType.RTT, + other: DayType.OTHER, +}; + export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = { [DayType.WEEKEND]: 'Weekend', [DayType.CLOSED_DAY]: 'Jour Férié', @@ -305,10 +352,6 @@ export class SelectedDates implements SelectedDateInterface { private minDate: Date | null = null; private maxDate: Date | null = null; - // Constant to indicate no valid strategy was found - private static readonly NO_VALID_STRATEGY_HEURISTIC = -1; - private static readonly MAX_DAYS_WITH_SAME_HEURISTIC = 9; - update(): void { this.datesSelected = this._datesSelected.slice(); this.grouping(); @@ -470,565 +513,4 @@ export class SelectedDates implements SelectedDateInterface { .filter((tooltip) => tooltip) .join(' + '); } - - computeHeuristics(): number { - if (this.datesSelected.length === 0) { - return 0; - } - - let totalScore = 0; - - // Sort all dates by start date to find next closest dates - const sorted = [...this.datesSelected] - .filter((date) => date.type !== null) - .sort((a, b) => { - const aStart = a.range.start?.getTime() ?? 0; - const bStart = b.range.start?.getTime() ?? 0; - return aStart - bStart; - }); - - // PRIMARY: Optimize adjacency - reduce "little holes" between vacation periods - for (let i = 0; i < sorted.length - 1; i++) { - const current = sorted[i]; - const next = sorted[i + 1]; - - // Get dates as UTC strings to remove timezone issues (handles DST properly) - const currentEndDate = new Date(current.range.end ?? new Date()); - const nextStartDate = new Date(next.range.start ?? new Date()); - - // Convert to date-only strings (YYYY-MM-DD) to compare calendar days - const currentEndString = currentEndDate.toISOString().split('T')[0]; - const nextStartString = nextStartDate.toISOString().split('T')[0]; - - // Parse back to dates at UTC midnight - const currentEndDateNormalized = new Date(currentEndString + 'T00:00:00Z'); - const nextStartDateNormalized = new Date(nextStartString + 'T00:00:00Z'); - - const currentEndTime = currentEndDateNormalized.getTime(); - const nextStartTime = nextStartDateNormalized.getTime(); - - const oneDayInMs = 24 * 60 * 60 * 1000; - - // Check if adjacent (next day) - this is the best case! - if (nextStartTime - currentEndTime === oneDayInMs) { - totalScore += 15; // 50% increased bonus for adjacency; adjacency is highly valued in the scoring system - } else if (nextStartTime > currentEndTime) { - // Penalty based on days between the two dates - const daysBetween = Math.floor((nextStartTime - currentEndTime) / oneDayInMs) - 1; - // Ensure daysBetween is non-negative before taking sqrt - totalScore -= Math.sqrt(Math.max(0, daysBetween)); - } - } - - // SECONDARY: Penalize uneven monthly distribution to encourage spread - const monthCounts = new Map(); // Use year*12+month as key to avoid strings - for (const selectedDate of this.datesSelected) { - if (!selectedDate.range.start || !selectedDate.range.end) continue; - - // Count days in this selected date range - const daysInRange = selectedDate.getLengthInDays(); - - // For each day in the range, add it to the appropriate month - const currentDate = new Date(selectedDate.range.start); - for (let i = 0; i < daysInRange; i++) { - const monthKey = currentDate.getFullYear() * 12 + currentDate.getMonth(); - monthCounts.set(monthKey, (monthCounts.get(monthKey) ?? 0) + 1); - currentDate.setDate(currentDate.getDate() + 1); - } - } - - // Apply small penalty for uneven distribution (secondary priority) - for (const count of monthCounts.values()) { - totalScore -= count; // Smaller penalty than gap filling - } - - return totalScore; - } - - async optimizeVacations( - vacationDays: VacDay[], - rules: VacationRule[], - calendarService: CalendarService, - progressCallback?: (progress: number) => void, - ): Promise { - // Calculate total days to process - const totalDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0); - let processedDays = 0; - - // Execute custom rules sequentially in the exact order they appear in the list - for (const rule of rules) { - if (!rule.enabled) continue; - - if (rule.type === RuleType.TIME_WINDOW) { - this.applyTimeWindowRules(vacationDays, [rule as TimeWindowRule], calendarService); - } else if (rule.type === RuleType.IF_THEN) { - this.applyIfThenRules(vacationDays, [rule as IfThenRule]); - } - } - - // Helper function to update progress, throttled to every 5% - let lastReportedProgress = 0; - const PROGRESS_STEP = 5; // percent - const updateProgress = async () => { - if (progressCallback && totalDays > 0) { - const progress = Math.round((processedDays / totalDays) * 100); - if (progress - lastReportedProgress >= PROGRESS_STEP || progress === 100) { - progressCallback(progress); - lastReportedProgress = progress; - // Allow UI to update with a microtask - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - }; - - await updateProgress(); - - // Track VacDays that can't find valid placements - const exhaustedVacDayIds = new Set(); - - // Process vacation days ordered by expiration date (earliest first) - while (vacationDays.some((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id))) { - // Sort by expiration date to use days that expire first, excluding exhausted ones - const sortedVacDays = [...vacationDays] - .filter((vd) => vd.numberOfDays > 0 && !exhaustedVacDayIds.has(vd.id)) - .sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime()); - - if (sortedVacDays.length === 0) { - break; - } - - const vacDayToUse = sortedVacDays[0]; - const result = this.lookForVacation(vacDayToUse, calendarService); - - if (result.heuristic === SelectedDates.NO_VALID_STRATEGY_HEURISTIC) { - // Mark this VacDay as exhausted and try other vacation days - exhaustedVacDayIds.add(vacDayToUse.id); - continue; - } - - vacDayToUse.numberOfDays -= 1; - processedDays++; - await updateProgress(); - } - - // Final progress update (100%) - if (progressCallback) { - progressCallback(100); - } - - // Inform user if not all days were placed - const remainingDays = vacationDays.reduce((sum, vd) => sum + vd.numberOfDays, 0); - if (remainingDays > 0) { - console.warn( - `Optimization stopped: ${remainingDays} of ${totalDays} vacation days remain unplaced. ` + - `Too many days have equivalent placement quality - please place remaining days manually.`, - ); - } - - return vacationDays; - } - - applyTimeWindowRules( - vacationDays: VacDay[], - rules: TimeWindowRule[], - calendarService: CalendarService, - ): void { - for (const rule of rules) { - let daysToPlace = rule.placeDays; - - const consumeDays = (count: number): boolean => { - let needed = count; - const availableDays = vacationDays - .filter( - (vd) => - (rule.placeType === SelectableDayType.ANY || - vd.type === rule.placeType) && - vd.numberOfDays > 0, - ) - .sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime()); - - for (const vd of availableDays) { - if (needed === 0) break; - const take = Math.min(vd.numberOfDays, needed); - vd.numberOfDays -= take; - needed -= take; - } - return needed === 0; - }; - - const totalAvailable = vacationDays - .filter( - (vd) => rule.placeType === SelectableDayType.ANY || vd.type === rule.placeType, - ) - .reduce((sum, vd) => sum + vd.numberOfDays, 0); - - if (totalAvailable < daysToPlace) { - console.warn( - `Insufficient ${rule.placeType} days for Time Window Rule "${rule.name}". Needs ${daysToPlace}, has ${totalAvailable}. Placing ${totalAvailable} days instead.`, - ); - daysToPlace = totalAvailable; - } - - if (daysToPlace <= 0) continue; - - const getNextAvailableType = (): DayType => { - const sorted = vacationDays - .filter( - (vd) => - (rule.placeType === SelectableDayType.ANY || - vd.type === rule.placeType) && - vd.numberOfDays > 0, - ) - .sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime()); - return sorted.length > 0 - ? (sorted[0].type as unknown as DayType) - : ((rule.placeType === SelectableDayType.ANY - ? SelectableDayType.CP - : rule.placeType) as unknown as DayType); - }; - - const candidates: Date[] = []; - const daysOfYear = calendarService.monthsInAYearFromNow(); - const start = new Date(rule.startDate).getTime(); - const end = new Date(rule.endDate).getTime(); - - for (const month of daysOfYear) { - for (const week of month) { - for (const day of week) { - if (day) { - const dayTime = day.getTime(); - if (dayTime >= start && dayTime <= end && !this.isSelected(day)) { - candidates.push(day); - } - } - } - } - } - - for (let i = 0; i < daysToPlace; i++) { - if (candidates.length === 0) break; - - const currentPlaceType = getNextAvailableType(); - let bestCandidate: Date | null = null; - let bestHeuristic = -Infinity; - let bestCandidateIndex = -1; - - for (let j = 0; j < candidates.length; j++) { - const day = candidates[j]; - if (this.isSelected(day)) continue; - - const tempDatesBackup = [...this._datesSelected]; - this._datesSelected.push( - new SelectedDate(currentPlaceType, new DateRange(day, day)), - ); - this.datesSelected = this._datesSelected.slice(); - this.grouping(); - - const heuristic = this.computeHeuristics(); - - this._datesSelected = [...tempDatesBackup]; - this.datesSelected = this._datesSelected.slice(); - this.grouping(); - - if (heuristic > bestHeuristic) { - bestHeuristic = heuristic; - bestCandidate = day; - bestCandidateIndex = j; - } - } - - if (bestCandidate) { - this.push( - new SelectedDate( - currentPlaceType, - new DateRange(bestCandidate, bestCandidate), - ), - ); - consumeDays(1); - candidates.splice(bestCandidateIndex, 1); - } - } - } - } - - applyIfThenRules(vacationDays: VacDay[], rules: IfThenRule[]): void { - const nowTime = new Date().setHours(0, 0, 0, 0); - - const consumeDays = (count: number, type: SelectableDayType, useByDate: Date): boolean => { - let daysToConsume = count; - const useByTime = useByDate.getTime(); - const availableDays = vacationDays - .filter( - (vd) => - (type === SelectableDayType.ANY || vd.type === type) && - vd.numberOfDays > 0 && - vd.expiryDate.getTime() >= useByTime, - ) - .sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime()); - - for (const vd of availableDays) { - if (daysToConsume === 0) break; - const take = Math.min(vd.numberOfDays, daysToConsume); - vd.numberOfDays -= take; - daysToConsume -= take; - } - return daysToConsume === 0; - }; - - const checkAvailableDays = ( - count: number, - type: SelectableDayType, - useByDate: Date, - ): boolean => { - const useByTime = useByDate.getTime(); - const total = vacationDays - .filter( - (vd) => - (type === SelectableDayType.ANY || vd.type === type) && - vd.expiryDate.getTime() >= useByTime, - ) - .reduce((sum, vd) => sum + vd.numberOfDays, 0); - return total >= count; - }; - - const addRewardDays = (count: number, type: SelectableDayType): void => { - if (count <= 0) return; - const existing = vacationDays.find((vd) => vd.type === type); - if (existing) { - existing.numberOfDays += count; - } else { - vacationDays.push({ - id: crypto.randomUUID(), - type: type, - numberOfDays: count, - expiryDate: new Date(new Date().getFullYear() + 10, 11, 31), - }); - } - }; - - const getWorkingDays = (startDate: Date, direction: 1 | -1, count: number): Date[] => { - const result: Date[] = []; - const current = new Date(startDate); - while (result.length < count) { - current.setDate(current.getDate() + direction); - const isWeekend = current.getDay() === 0 || current.getDay() === 6; - const isHoliday = this._datesSelected.some( - (d) => - d.type === DayType.CLOSED_DAY && - d.range.start?.getTime() === current.getTime(), - ); - if (!isWeekend && !isHoliday) { - result.push(new Date(current)); - } - } - return result; - }; - - for (const rule of rules) { - const matchingHolidays = this._datesSelected.filter( - (date) => - date.type === DayType.CLOSED_DAY && - date.range.start?.getDay() === rule.dayOfWeek, - ); - - for (const holiday of matchingHolidays) { - if (!holiday.range.start) continue; - const holidayDate = new Date(holiday.range.start); - - const N = rule.placeDays; - if (N <= 0) continue; - - const placementOptions: Date[][] = []; - - if (rule.placement === 'before' || rule.placement === 'auto') { - placementOptions.push(getWorkingDays(holidayDate, -1, N)); - } - if (rule.placement === 'after' || rule.placement === 'auto') { - placementOptions.push(getWorkingDays(holidayDate, 1, N)); - } - if (rule.placement === 'around' || rule.placement === 'auto') { - const beforeCount = Math.ceil(N / 2); - const afterCount = Math.floor(N / 2); - const aroundDates = [ - ...getWorkingDays(holidayDate, -1, beforeCount), - ...getWorkingDays(holidayDate, 1, afterCount), - ]; - placementOptions.push(aroundDates); - } - - const strategies: { apply: () => void; heuristic: number }[] = []; - const tempDatesBackup = [...this._datesSelected]; - - for (const dates of placementOptions) { - const allValid = dates.every( - (d) => d.getTime() >= nowTime && !this.isSelected(d), - ); - if (!allValid) continue; - - const maxDate = new Date(Math.max(...dates.map((d) => d.getTime()))); - if (!checkAvailableDays(N, rule.placeType, maxDate)) continue; - - const simulatedDays = vacationDays.map((vd) => ({ ...vd })); - const datesWithTypes: { date: Date; type: DayType }[] = []; - for (const d of dates) { - const sorted = simulatedDays - .filter( - (vd) => - (rule.placeType === SelectableDayType.ANY || - vd.type === rule.placeType) && - vd.numberOfDays > 0 && - vd.expiryDate.getTime() >= maxDate.getTime(), - ) - .sort((a, b) => a.expiryDate.getTime() - b.expiryDate.getTime()); - - let chosenType = (rule.placeType === SelectableDayType.ANY - ? SelectableDayType.CP - : rule.placeType) as unknown as DayType; - if (sorted.length > 0) { - sorted[0].numberOfDays--; - chosenType = sorted[0].type as unknown as DayType; - } - datesWithTypes.push({ date: d, type: chosenType }); - } - - for (const dt of datesWithTypes) { - this.push(new SelectedDate(dt.type, new DateRange(dt.date, dt.date))); - } - const heuristic = this.computeHeuristics(); - - strategies.push({ - apply: () => { - this._datesSelected = [...tempDatesBackup]; - for (const dt of datesWithTypes) { - this.push( - new SelectedDate( - dt.type, - new DateRange(dt.date, dt.date), - ), - ); - } - consumeDays(N, rule.placeType, maxDate); - addRewardDays(rule.rewardDays, rule.rewardType); - }, - heuristic: heuristic, - }); - - this._datesSelected = [...tempDatesBackup]; - this.datesSelected = this._datesSelected.slice(); - this.grouping(); - } - - if (strategies.length > 0) { - const bestStrategy = strategies.reduce((best, current) => - current.heuristic > best.heuristic ? current : best, - ); - bestStrategy.apply(); - } else { - console.warn( - `Could not apply If-Then Rule "${rule.name}" for holiday on ${holidayDate.toLocaleDateString()}. Candidates invalid or insufficient days.`, - ); - } - } - } - } - - lookForVacation( - vacDay: VacDay, - calendarService: CalendarService, - ): { apply: () => void; heuristic: number } { - const daysOfYear = calendarService.monthsInAYearFromNow(); - const now = new Date(); - now.setHours(0, 0, 0, 0); - const nowTime = now.getTime(); - const expiryTime = vacDay.expiryDate.getTime(); - - // Preprocess: filter out past days, already selected days, and days after expiry date - const candidateDays: Date[] = []; - for (const month of daysOfYear) { - for (const week of month) { - for (const day of week) { - if (day) { - day.setHours(0, 0, 0, 0); - const dayTime = day.getTime(); - if (dayTime >= nowTime && dayTime <= expiryTime && !this.isSelected(day)) { - candidateDays.push(day); - } - } - } - } - } - - // Early exit if no candidates - if (candidateDays.length === 0) { - return { apply: () => {}, heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC }; - } - - const vacType = vacDay.type as unknown as DayType; - const tempDatesBackup = [...this._datesSelected]; - let bestStrategy: { apply: () => void; heuristic: number; value: Date } | null = null; - let bestHeuristic = -Infinity; - let daysWithBestHeuristic = 0; - - // Evaluate each candidate day - for (const day of candidateDays) { - // Reset to backup state - this._datesSelected = [...tempDatesBackup]; - this.datesSelected = this._datesSelected.slice(); - this.grouping(); - - // Mark this day as vacation (reuse the day object since it's already normalized) - this.datesSelected.push(new SelectedDate(vacType, new DateRange(day, day))); - - const heuristic = this.computeHeuristics(); - - // Track only if better than current best - if (heuristic > bestHeuristic) { - bestHeuristic = heuristic; - daysWithBestHeuristic = 1; - const capturedDay = day; - bestStrategy = { - apply: () => { - this._datesSelected = [...tempDatesBackup]; - this.push( - new SelectedDate( - vacType, - new DateRange(capturedDay, capturedDay), - ), - ); - }, - heuristic: heuristic, - value: capturedDay, - }; - } else if (heuristic === bestHeuristic) { - daysWithBestHeuristic++; - } - } - - // Check if too many days have the same best heuristic after evaluating all days - if (daysWithBestHeuristic > SelectedDates.MAX_DAYS_WITH_SAME_HEURISTIC) { - // Restore backup before stopping - this._datesSelected = [...tempDatesBackup]; - this.datesSelected = this._datesSelected.slice(); - this.grouping(); - - // Stop optimization - let user decide manually - return { - apply: () => {}, - heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC, - }; - } - - // Restore backup before returning - this._datesSelected = [...tempDatesBackup]; - this.datesSelected = this._datesSelected.slice(); - this.grouping(); - - // Apply best strategy if found - if (bestStrategy) { - bestStrategy.apply(); - return bestStrategy; - } - - return { apply: () => {}, heuristic: SelectedDates.NO_VALID_STRATEGY_HEURISTIC }; - } } diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts new file mode 100644 index 0000000..a1cd8e4 --- /dev/null +++ b/src/app/services/leave-planner.core.ts @@ -0,0 +1,2442 @@ +import Holidays from 'date-holidays'; +import { addPlanningYears, planningDateInParis } from './planning-date'; + +/** Date-only values are serialized as YYYY-MM-DD at the planning boundary. */ +export type PlanningDate = string | Date; +export type LeaveType = 'cp' | 'rtt' | 'other'; +export type LeaveSelector = LeaveType | 'any'; +export type RuleForce = 'obligation' | 'preference'; +export type PlacementMode = 'before' | 'after' | 'around' | 'auto'; + +export interface PlanningHorizon { + start: PlanningDate; + end: PlanningDate; +} + +export interface LeaveBalanceInput { + id: string; + type: LeaveType | string; + quantity: number; + availableFrom?: PlanningDate; + expiryDate: PlanningDate; +} + +export interface TimeWindowPlanningRule { + id: string; + name?: string; + type: 'time_window' | 'TIME_WINDOW' | string; + enabled?: boolean; + force?: RuleForce | string; + startDate: PlanningDate; + endDate: PlanningDate; + placeDays: number; + placeType: LeaveSelector | string; +} + +export interface IfThenPlanningRule { + id: string; + name?: string; + type: 'if_then' | 'IF_THEN' | string; + enabled?: boolean; + force?: RuleForce | string; + dayOfWeek: number; + placeDays: number; + placeType: LeaveSelector | string; + placement: PlacementMode | string; + rewardDays: number; + rewardType?: LeaveType | string; +} + +export type PlanningRule = TimeWindowPlanningRule | IfThenPlanningRule; + +export interface PlanningRequest { + horizon: PlanningHorizon; + balances: readonly LeaveBalanceInput[]; + rules: readonly PlanningRule[]; + /** A testable deadline in milliseconds. The production default is ten seconds. */ + deadlineMs?: number; + progress?: (stage: string) => void; +} + +export interface PlanningReason { + code: string; + message: string; + ruleIds?: string[]; +} + +export interface InvalidInputOutcome { + kind: 'InvalidInput'; + reasons: PlanningReason[]; +} + +export interface PlanningConflictOutcome { + kind: 'PlanningConflict'; + reasons: PlanningReason[]; + conflictingRuleIds: string[]; +} + +export type RemainingClassification = 'unavoidable-expiry' | 'at-risk' | 'deferred'; + +export interface RemainingBalance { + id: string; + type: LeaveType; + quantity: number; + availableFrom: string; + expiryDate: string; + classification: RemainingClassification; + reason: PlanningReason; + earned: boolean; + sourceRuleId?: string; +} + +export interface LeavePlacement { + date: string; + balanceId: string; + type: LeaveType; + ruleIds: string[]; + supporting: boolean; +} + +export interface TimeOffPeriod { + start: string; + end: string; + duration: number; + placementDates: string[]; + nonWorkingDates: string[]; + nonWorkingDayGain: number; +} + +export interface PreferenceResult { + ruleId: string; + requested: number; + satisfied: number; + completeOccurrences: number; + reason?: PlanningReason; +} + +export interface PlanningMetrics { + unplacedAtRisk: number; + nonWorkingDayGain: number; + longestWorkSequence: number; + shortSequenceDeficit: number; + supportingPlacements: number; + preferenceSatisfaction: number[]; +} + +export interface PlanOutcome { + kind: 'Plan'; + horizon: { start: string; end: string }; + placements: LeavePlacement[]; + timeOffPeriods: TimeOffPeriod[]; + preferences: PreferenceResult[]; + remainingBalances: RemainingBalance[]; + metrics: PlanningMetrics; + completedStages: string[]; + lastCompletedStage: string; +} + +export interface PlanningIncompleteOutcome { + kind: 'PlanningIncomplete'; + incumbent?: PlanOutcome; + completedStages: string[]; + lastCompletedStage?: string; + reasons: PlanningReason[]; +} + +export type PlanningOutcome = + | InvalidInputOutcome + | PlanningConflictOutcome + | PlanningIncompleteOutcome + | PlanOutcome; + +interface NormalizedBalance { + id: string; + type: LeaveType; + quantity: number; + availableFrom: string; + expiryDate: string; + earned: boolean; + sourceRuleId?: string; +} + +interface NormalizedRule { + id: string; + name: string; + type: 'time_window' | 'if_then'; + enabled: boolean; + force: RuleForce; + placeDays: number; + placeType: LeaveSelector; + startDate?: string; + endDate?: string; + dayOfWeek?: number; + placement?: PlacementMode; + rewardDays?: number; + rewardType?: LeaveType; +} + +interface InternalBalance extends NormalizedBalance { + remaining: number; +} + +interface InternalPlacement { + date: string; + balanceId: string; + type: LeaveType; + ruleIds: Set; + supporting: boolean; +} + +interface EarnedActivation { + id: string; + ruleId: string; + occurrence: string; + dates: string[]; +} + +interface State { + balances: InternalBalance[]; + placements: Map; + earnedActivations: EarnedActivation[]; +} + +interface RuntimePlanningRequest { + horizon?: unknown; + balances?: unknown; + rules?: unknown; + deadlineMs?: unknown; + progress?: unknown; +} + +interface RuntimeRecord { + horizon?: unknown; + start?: unknown; + end?: unknown; + balances?: unknown; + rules?: unknown; + deadlineMs?: unknown; + progress?: unknown; + id?: unknown; + name?: unknown; + type?: unknown; + quantity?: unknown; + availableFrom?: unknown; + expiryDate?: unknown; + enabled?: unknown; + force?: unknown; + startDate?: unknown; + endDate?: unknown; + placeDays?: unknown; + placeType?: unknown; + dayOfWeek?: unknown; + placement?: unknown; + rewardDays?: unknown; + rewardType?: unknown; +} + +interface Requirement { + rule: NormalizedRule; + anchor: string; + index: number; + occurrenceDate?: string; + patterns?: string[][]; + candidates?: string[]; +} + +interface SearchContext { + horizon: { start: string; end: string }; + holidays: Set; + calendarDays: string[]; + calendarIndex: Map; + workingCalendar: Set; + workingPrefix: number[]; + workingDays: string[]; + initialBalances: NormalizedBalance[]; + rules: NormalizedRule[]; + deadline: number; + started: number; + request: RuntimePlanningRequest; + nodes: number; + check: () => void; + reservedTypes: Set; + sharedTypesByDate: Map>; + protectedTypesByDate: Map>; + preferenceDemandByDate: Map; + anyPreferenceDemandByDate: Map; +} + +class DeadlineReached extends Error {} + +const DAY_MS = 86_400_000; +const MAX_PLANNING_MS = 10_000; +const CORE_DEADLINE_MARGIN_MS = 50; +// ponytail: cap the number of feasible obligation branches previewed for a +// loaded year; replace with bounded branch-and-bound if mobile evidence shows +// that this ceiling misses a better lexicographic assignment. +const OBLIGATION_SOLUTION_LIMIT = 4096; +const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const VALID_TYPES = new Set(['cp', 'rtt', 'other']); +const VALID_SELECTORS = new Set(['cp', 'rtt', 'other', 'any']); +const VALID_MODES = new Set(['before', 'after', 'around', 'auto']); +const holidaysByYear = new Map>(); + +function dateKey(value: unknown): string | null { + if (typeof value === 'string') { + // date-holidays returns date-only strings with a time suffix. Treat the + // leading calendar date as data, rather than letting the host timezone + // shift it across a midnight boundary. + const dateOnly = value.match(/^(\d{4}-\d{2}-\d{2})(?:$|\s|T)/)?.[1]; + if (dateOnly && DATE_RE.test(dateOnly)) { + const date = utcDate(dateOnly); + return date.toISOString().slice(0, 10) === dateOnly ? dateOnly : null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : planningDateInParis(parsed); + } + if (value instanceof Date && !Number.isNaN(value.getTime())) return planningDateInParis(value); + return null; +} + +function runtimeRequest(value: unknown): RuntimePlanningRequest { + if (typeof value !== 'object' || value === null) return {}; + const source = value as RuntimeRecord; + return { + horizon: source.horizon, + balances: source.balances, + rules: source.rules, + deadlineMs: source.deadlineMs, + progress: source.progress, + }; +} + +function recordValue(value: unknown): RuntimeRecord | undefined { + return typeof value === 'object' && value !== null ? (value as RuntimeRecord) : undefined; +} + +function utcDate(key: string): Date { + return new Date(`${key}T00:00:00Z`); +} + +function addDays(key: string, days: number): string { + return new Date(utcDate(key).getTime() + days * DAY_MS).toISOString().slice(0, 10); +} + +function compareDate(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function compareText(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +function dateRange(start: string, endExclusive: string): string[] { + const result: string[] = []; + for (let date = start; compareDate(date, endExclusive) < 0; date = addDays(date, 1)) + result.push(date); + return result; +} + +function defaultHorizon(now = new Date()): { start: string; end: string } { + const start = planningDateInParis(now); + return { start, end: addPlanningYears(start, 1) }; +} + +function holidaysForYear(year: number): Set { + const cached = holidaysByYear.get(year); + if (cached) return new Set(cached); + const calendar = new Holidays('FR', { languages: ['en'] }); + const holidays = new Set(); + for (const holiday of calendar.getHolidays(year, 'en')) { + if (holiday.type !== 'public') continue; + const key = dateKey(holiday.date); + if (key) holidays.add(key); + } + holidaysByYear.set(year, holidays); + return new Set(holidays); +} + +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 result = new Set(); + for (let year = first; year <= last; year++) { + for (const holiday of holidaysForYear(year)) result.add(holiday); + } + return result; +} + +function isWeekend(key: string): boolean { + const day = utcDate(key).getUTCDay(); + return day === 0 || day === 6; +} + +function isWorking(key: string, holidays: Set): boolean { + return !isWeekend(key) && !holidays.has(key); +} + +function reason(code: string, message: string, ruleIds?: string[]): PlanningReason { + return ruleIds?.length ? { code, message, ruleIds } : { code, message }; +} + +function cloneState(state: State): State { + return { + balances: state.balances.map((balance) => ({ ...balance })), + placements: new Map( + [...state.placements.entries()].map(([date, placement]) => [ + date, + { + ...placement, + ruleIds: new Set(placement.ruleIds), + }, + ]), + ), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; +} + +function normalizeType(value: unknown): LeaveType | null { + const normalized = String(value ?? '').toLowerCase(); + return VALID_TYPES.has(normalized as LeaveType) ? (normalized as LeaveType) : null; +} + +function normalizeSelector(value: unknown): LeaveSelector | null { + const normalized = String(value ?? '').toLowerCase(); + return VALID_SELECTORS.has(normalized as LeaveSelector) ? (normalized as LeaveSelector) : null; +} + +function normalizeForce(value: unknown): RuleForce | null { + if (value === undefined || value === '') return 'preference'; + const normalized = String(value).toLowerCase(); + if (normalized === 'obligation' || normalized === 'preference') return normalized; + return null; +} + +function normalizeRuleType(value: unknown): 'time_window' | 'if_then' | null { + const normalized = String(value ?? '').toLowerCase(); + return normalized === 'time_window' || normalized === 'if_then' ? normalized : null; +} + +function normalizeMode(value: unknown): PlacementMode | null { + const normalized = String(value ?? '').toLowerCase(); + return VALID_MODES.has(normalized as PlacementMode) ? (normalized as PlacementMode) : null; +} + +function compareDateLists(a: readonly string[], b: readonly string[]): number { + for (let index = 0; index < Math.min(a.length, b.length); index++) { + const compared = compareDate(a[index], b[index]); + if (compared) return compared; + } + return a.length - b.length; +} + +function asInteger(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) ? value : null; +} + +function normalizeBalances( + request: RuntimePlanningRequest, + horizon: { start: string; end: string }, +): { + balances: NormalizedBalance[]; + reasons: PlanningReason[]; +} { + const rawInput = request.balances; + const reasons: PlanningReason[] = []; + if (!Array.isArray(rawInput)) { + return { + balances: [], + reasons: [reason('balances', 'Leave balances must be an array.')], + }; + } + const raw = rawInput; + const ids = new Set(); + const balances: NormalizedBalance[] = []; + for (const input of raw) { + const value = recordValue(input); + const id = typeof value?.id === 'string' ? value.id : ''; + const type = normalizeType(value?.type); + const quantity = asInteger(value?.quantity); + const rawAvailableFrom = value?.availableFrom; + const availableFrom = dateKey(rawAvailableFrom) ?? horizon.start; + const expiryDate = dateKey(value?.expiryDate); + if (!id) reasons.push(reason('balance-id', 'Each leave balance needs a stable identity.')); + else if (ids.has(id)) + reasons.push(reason('duplicate-balance-id', `Leave balance id "${id}" is duplicated.`)); + else ids.add(id); + if (!type) + reasons.push( + reason('balance-type', `Leave balance "${id}" has an invalid leave type.`), + ); + if (quantity === null || quantity < 0) { + reasons.push( + reason( + 'balance-quantity', + `Leave balance "${id}" needs a non-negative integer quantity.`, + ), + ); + } + if (!expiryDate) + reasons.push( + reason('balance-expiry', `Leave balance "${id}" needs a valid expiry date.`), + ); + if (rawAvailableFrom !== undefined && !dateKey(rawAvailableFrom)) + reasons.push( + reason( + 'balance-availability', + `Leave balance "${id}" needs a valid availability date.`, + ), + ); + // A legacy balance without availability is anchored at the horizon start. + // If it already expired before that horizon, keep it reportable as + // unavoidable expiry instead of turning migration into invalid input. + if ( + expiryDate && + compareDate(availableFrom, expiryDate) > 0 && + rawAvailableFrom !== undefined + ) { + reasons.push( + reason( + 'balance-dates', + `Leave balance "${id}" must be available on or before expiry.`, + ), + ); + } + if (type && quantity !== null && quantity >= 0 && expiryDate) { + balances.push({ + id, + type, + quantity, + availableFrom, + expiryDate, + earned: false, + }); + } + } + return { balances, reasons }; +} + +function normalizeRules(request: RuntimePlanningRequest): { + rules: NormalizedRule[]; + reasons: PlanningReason[]; +} { + const rawInput = request.rules; + const reasons: PlanningReason[] = []; + if (!Array.isArray(rawInput)) { + return { rules: [], reasons: [reason('rules', 'Planning rules must be an array.')] }; + } + const raw = rawInput; + const ids = new Set(); + const rules: NormalizedRule[] = []; + for (const input of raw) { + const value = recordValue(input); + if (value?.enabled === false) continue; + const id = typeof value?.id === 'string' ? value.id : ''; + const type = normalizeRuleType(value?.type); + const forceValue = normalizeForce(value?.force); + const force = forceValue ?? 'preference'; + if (!id) reasons.push(reason('rule-id', 'Each planning rule needs a stable identity.')); + else if (ids.has(id)) + reasons.push(reason('duplicate-rule-id', `Planning rule id "${id}" is duplicated.`)); + else ids.add(id); + if (!forceValue) + reasons.push(reason('rule-force', `Planning rule "${id}" has an invalid force.`)); + if (!type) { + reasons.push(reason('rule-type', `Planning rule "${id}" has an unsupported type.`)); + continue; + } + const placeDays = asInteger(value?.placeDays); + const placeType = normalizeSelector(value?.placeType); + if (placeDays === null || placeDays < 0) { + reasons.push( + reason( + 'rule-quantity', + `Planning rule "${id}" needs a non-negative integer quantity.`, + ), + ); + continue; + } + if (!placeType) + reasons.push( + reason('rule-selector', `Planning rule "${id}" has an invalid leave selector.`), + ); + const base = { + id, + name: typeof value?.name === 'string' ? value.name : id, + type, + enabled: value?.enabled !== false, + force, + placeDays, + placeType: placeType ?? 'any', + }; + if (type === 'time_window') { + const startDate = dateKey(value?.startDate); + const endDate = dateKey(value?.endDate); + if (!startDate || !endDate || compareDate(startDate, endDate) > 0) { + reasons.push( + reason( + 'rule-window', + `TIME_WINDOW rule "${id}" needs an inclusive, ordered date window.`, + ), + ); + continue; + } + rules.push({ ...base, startDate, endDate, type: 'time_window' }); + continue; + } + const dayOfWeek = asInteger(value?.dayOfWeek); + const mode = normalizeMode(value?.placement); + const rewardDays = asInteger(value?.rewardDays); + const rawRewardType = value?.rewardType; + const rewardType = normalizeType(rawRewardType); + if (dayOfWeek === null || dayOfWeek < 0 || dayOfWeek > 6) { + reasons.push( + reason('rule-weekday', `IF_THEN rule "${id}" needs a weekday from 0 to 6.`), + ); + } + if (!mode) + reasons.push( + reason('rule-mode', `IF_THEN rule "${id}" has an unsupported placement mode.`), + ); + if (rewardDays === null || rewardDays < 0) { + reasons.push( + reason( + 'earned-quantity', + `IF_THEN rule "${id}" needs a non-negative earned quantity.`, + ), + ); + } + if ((rewardDays ?? 0) > 0 && !rewardType) { + reasons.push( + reason( + 'earned-type', + `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`, + ), + ); + } else if (rawRewardType !== undefined && !rewardType) { + reasons.push( + reason( + 'earned-type', + `IF_THEN rule "${id}" needs CP, RTT, or Other as its earned type.`, + ), + ); + } + if ( + placeType && + mode && + dayOfWeek !== null && + rewardDays !== null && + (rewardDays === 0 ? rawRewardType === undefined || rewardType : rewardType) + ) { + rules.push({ + ...base, + type: 'if_then', + dayOfWeek, + placement: mode, + rewardDays, + rewardType: rewardType ?? undefined, + }); + } + } + return { rules, reasons }; +} + +function normalizeRequest(request: unknown): { + horizon?: { start: string; end: string }; + balances: NormalizedBalance[]; + rules: NormalizedRule[]; + reasons: PlanningReason[]; +} { + const source = runtimeRequest(request); + const rawHorizon = recordValue(source.horizon); + const fallbackHorizon = defaultHorizon(); + const start = dateKey(rawHorizon?.start) ?? fallbackHorizon.start; + const end = dateKey(rawHorizon?.end) ?? fallbackHorizon.end; + const reasons: PlanningReason[] = []; + if (!rawHorizon) { + reasons.push(reason('horizon', 'The planning horizon is required.')); + } else if (!dateKey(rawHorizon.start)) { + reasons.push( + reason('horizon-start', 'The planning horizon start must be a valid calendar date.'), + ); + } + if (rawHorizon && !dateKey(rawHorizon.end)) { + reasons.push( + reason('horizon-end', 'The planning horizon end must be a valid calendar date.'), + ); + } + if (rawHorizon && compareDate(start, end) >= 0) { + reasons.push(reason('horizon-order', 'The planning horizon must have start before end.')); + } + if (compareDate(end, addPlanningYears(start, 1)) > 0) { + reasons.push( + reason('horizon-length', 'The planning horizon may not exceed twelve months.'), + ); + } + const configuredDeadline = source.deadlineMs; + if ( + configuredDeadline !== undefined && + (typeof configuredDeadline !== 'number' || + !Number.isFinite(configuredDeadline) || + configuredDeadline < 0) + ) { + reasons.push( + reason('deadline', 'The planning deadline must be a finite non-negative number.'), + ); + } + const balanceResult = normalizeBalances(source, { start, end }); + const ruleResult = normalizeRules(source); + reasons.push(...balanceResult.reasons, ...ruleResult.reasons); + return { + horizon: reasons.some((item) => item.code === 'horizon' || item.code.startsWith('horizon-')) + ? undefined + : { start, end }, + balances: balanceResult.balances, + rules: ruleResult.rules, + reasons, + }; +} + +export function invalidInputFor(request: unknown): InvalidInputOutcome | undefined { + const normalized = normalizeRequest(request); + if (!normalized.reasons.length) return undefined; + return { + kind: 'InvalidInput', + reasons: normalized.reasons, + }; +} + +function futurePreferenceProtection( + rules: NormalizedRule[], + startIndex: number, + context: SearchContext, +): Map> { + const protectedTypes = new Map>(); + context.preferenceDemandByDate = new Map(); + context.anyPreferenceDemandByDate = new Map(); + const futureRules = rules.slice(startIndex); + for (const [offset, rule] of futureRules.entries()) { + if (!rule.enabled || rule.force !== 'preference') continue; + const priorityWeight = futureRules.length - offset; + const dates = + rule.type === 'time_window' + ? context.workingDays.filter( + (date) => + compareDate(date, rule.startDate!) >= 0 && + compareDate(date, rule.endDate!) <= 0, + ) + : occurrences(rule, context).flatMap((occurrence) => + patternsFor(rule, occurrence, context).flat(), + ); + for (const date of dates) { + context.preferenceDemandByDate.set( + date, + (context.preferenceDemandByDate.get(date) ?? 0) + priorityWeight, + ); + if (rule.placeType === 'any') { + context.anyPreferenceDemandByDate.set( + date, + (context.anyPreferenceDemandByDate.get(date) ?? 0) + priorityWeight, + ); + continue; + } + const types = protectedTypes.get(date) ?? new Set(); + types.add(rule.placeType); + protectedTypes.set(date, types); + } + } + return protectedTypes; +} + +function occurrences(rule: NormalizedRule, context: SearchContext): string[] { + if (rule.type !== 'if_then') return []; + return dateRange(context.horizon.start, context.horizon.end).filter((date) => { + return context.holidays.has(date) && utcDate(date).getUTCDay() === rule.dayOfWeek; + }); +} + +function workingNeighbors( + date: string, + direction: 1 | -1, + count: number, + context: SearchContext, +): string[] { + const result: string[] = []; + for ( + let current = addDays(date, direction); + result.length < count; + current = addDays(current, direction) + ) { + context.check?.(); + if (isWorking(current, context.holidays)) result.push(current); + // A one-year horizon makes an unbounded malformed search unnecessary. + if ( + compareDate(current, addDays(context.horizon.start, -400)) < 0 || + compareDate(current, addDays(context.horizon.end, 400)) > 0 + ) + break; + } + return result; +} + +function patternsFor(rule: NormalizedRule, holiday: string, context: SearchContext): string[][] { + const count = rule.placeDays; + const make = (before: number, after: number) => + [ + ...workingNeighbors(holiday, -1, before, context), + ...workingNeighbors(holiday, 1, after, context).reverse(), + ].sort(compareDate); + const patterns: string[][] = []; + if (rule.placement === 'before') patterns.push(make(count, 0)); + else if (rule.placement === 'after') patterns.push(make(0, count)); + else if (rule.placement === 'around') { + for (let before = 1; before < count; before++) patterns.push(make(before, count - before)); + } else { + for (let before = 0; before <= count; before++) patterns.push(make(before, count - before)); + } + return patterns + .filter( + (pattern) => + pattern.length === count && + pattern.every( + (date) => + compareDate(date, context.horizon.start) >= 0 && + compareDate(date, context.horizon.end) < 0, + ), + ) + .sort(compareDateLists); +} + +function buildRequirements( + rules: NormalizedRule[], + context: SearchContext, + force: RuleForce, +): Requirement[] { + const requirements: Requirement[] = []; + let index = 0; + for (const rule of rules) { + if (!rule.enabled || rule.force !== force) continue; + if (rule.type === 'time_window') { + const start = + compareDate(rule.startDate!, context.horizon.start) < 0 + ? context.horizon.start + : rule.startDate!; + const end = + compareDate(rule.endDate!, context.horizon.end) >= 0 + ? addDays(context.horizon.end, -1) + : rule.endDate!; + const candidates = + compareDate(start, end) <= 0 + ? context.workingDays.filter( + (date) => compareDate(date, start) >= 0 && compareDate(date, end) <= 0, + ) + : []; + const orderedCandidates = candidates.sort( + (a, b) => + preferenceDateConflictScore(rule.placeType, a, context) - + preferenceDateConflictScore(rule.placeType, b, context) || + compareDate(a, b), + ); + requirements.push({ + rule, + anchor: start, + index: index++, + candidates: orderedCandidates, + }); + } else { + for (const occurrence of occurrences(rule, context)) { + requirements.push({ + rule, + 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), + ), + }); + } + } + } + // IF_THEN occurrences are entitlement producers. Resolve them before a + // 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. + return 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, + ); +} + +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; + branches = 1; + for (let index = 1; index <= needed; index++) { + if (index > candidates) { + branches = 1; + break; + } + branches = Math.min( + EXACT_OBLIGATION_BRANCH_LIMIT + 1, + (branches * (candidates - needed + index)) / index, + ); + } + } + estimate = Math.min(EXACT_OBLIGATION_BRANCH_LIMIT + 1, estimate * branches); + if (estimate > EXACT_OBLIGATION_BRANCH_LIMIT) return estimate; + } + return estimate; +} + +function selectorMatches(selector: LeaveSelector, type: LeaveType): boolean { + return selector === 'any' || selector === type; +} + +function assignDate( + state: State, + date: string, + selector: LeaveSelector, + ruleId: string, + context: SearchContext, +): State | null { + return assignDateVariants(state, date, selector, ruleId, context)[0] ?? null; +} + +function assignDateVariants( + state: State, + date: string, + selector: LeaveSelector, + ruleId: string, + context: SearchContext, +): State[] { + context.check(); + const existing = state.placements.get(date); + if (existing) { + if (!selectorMatches(selector, existing.type)) return []; + if (ruleId) { + const rule = context.rules.find((item) => item.id === ruleId); + if ( + rule?.type === 'time_window' && + (compareDate(date, rule.startDate!) < 0 || compareDate(date, rule.endDate!) > 0) + ) + return []; + } + const result = cloneState(state); + if (ruleId) result.placements.get(date)!.ruleIds.add(ruleId); + return [result]; + } + const balances = state.balances + .filter( + (balance) => + balance.remaining > 0 && + selectorMatches(selector, balance.type) && + compareDate(balance.availableFrom, date) <= 0 && + compareDate(date, balance.expiryDate) <= 0, + ) + .sort((a, b) => { + if (selector === 'any') { + const sharedTypes = context.sharedTypesByDate.get(date) ?? new Set(); + if (sharedTypes.has(a.type) !== sharedTypes.has(b.type)) + return sharedTypes.has(a.type) ? -1 : 1; + // A later typed preference can share a placement on this date. Prefer + // that type when an ANY preference has an otherwise equivalent choice; + // this preserves the later lexicographic criterion without consuming a + // scarce balance on a date it cannot use. + const protectedTypes = + context.protectedTypesByDate.get(date) ?? new Set(); + if ( + protectedTypes.size && + protectedTypes.has(a.type) !== protectedTypes.has(b.type) + ) + return protectedTypes.has(a.type) ? -1 : 1; + if (context.reservedTypes.has(a.type) !== context.reservedTypes.has(b.type)) { + return context.reservedTypes.has(a.type) ? 1 : -1; + } + } + return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id); + }); + return balances.map((balance) => { + const result = cloneState(state); + const target = result.balances.find((item) => item.id === balance.id)!; + target.remaining--; + result.placements.set(date, { + date, + balanceId: target.id, + type: target.type, + ruleIds: ruleId ? new Set([ruleId]) : new Set(), + supporting: false, + }); + return result; + }); +} + +function assignDateToBalance( + state: State, + date: string, + balanceId: string, + context: SearchContext, + ruleIds = new Set(), +): State | null { + context.check(); + if (state.placements.has(date)) return null; + const balance = state.balances.find((item) => item.id === balanceId); + if ( + !balance || + balance.remaining <= 0 || + compareDate(balance.availableFrom, date) > 0 || + compareDate(date, balance.expiryDate) > 0 + ) + return null; + const result = cloneState(state); + const target = result.balances.find((item) => item.id === balanceId)!; + target.remaining--; + result.placements.set(date, { + date, + balanceId, + type: balance.type, + ruleIds: new Set(ruleIds), + supporting: false, + }); + return result; +} + +function assignDateToBalanceInPlace( + state: State, + date: string, + balanceId: string, + context: SearchContext, +): boolean { + context.check(); + if (state.placements.has(date)) return false; + const balance = state.balances.find((item) => item.id === balanceId); + if ( + !balance || + balance.remaining <= 0 || + compareDate(balance.availableFrom, date) > 0 || + compareDate(date, balance.expiryDate) > 0 + ) + return false; + balance.remaining--; + state.placements.set(date, { + date, + balanceId, + type: balance.type, + ruleIds: new Set(), + supporting: false, + }); + return true; +} + +function addEarnedBalance( + state: State, + rule: NormalizedRule, + occurrence: string, + dates: string[], + context: SearchContext, +): 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}`; + 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), + earned: true, + sourceRuleId: rule.id, + }); + result.earnedActivations.push({ + id, + ruleId: rule.id, + occurrence, + dates: [...dates], + }); + } + return result; +} + +function earnedPeriodBounds( + state: State, + activation: EarnedActivation, + context: SearchContext, +): { availableFrom: string; expiryDate: string } { + const allDates = [...activation.dates, activation.occurrence].sort(compareDate); + 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)), + ); + 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 availableFrom = addDays(end, 1); + return { availableFrom, expiryDate: addPlanningYears(availableFrom, 1) }; +} + +/** + * Reconcile generated balances only after all placements in this branch are + * known. 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); + 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); + balance.availableFrom = bounds.availableFrom; + balance.expiryDate = bounds.expiryDate; + for (const placement of result.placements.values()) { + if ( + placement.balanceId === activation.id && + (compareDate(placement.date, balance.availableFrom) < 0 || + compareDate(placement.date, balance.expiryDate) > 0) + ) + return null; + } + } + return result; +} + +function assignDatesVariants( + state: State, + dates: string[], + rule: NormalizedRule, + context: SearchContext, +): State[] { + if (!dates.length) return [state]; + const [date, ...rest] = dates; + if ( + !isWorking(date, context.holidays) || + compareDate(date, context.horizon.start) < 0 || + compareDate(date, context.horizon.end) >= 0 + ) + return []; + const result: State[] = []; + for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) { + result.push(...assignDatesVariants(next, rest, rule, context)); + } + return result; +} + +function satisfyObligations( + requirements: Requirement[], + state: State, + context: SearchContext, + index = 0, + solutions?: State[], + solutionLimit = OBLIGATION_SOLUTION_LIMIT, +): State | null { + context.check(); + if (index >= requirements.length) { + const finalized = finalizeEarnedState(state, context); + if (!finalized) return null; + if (solutions) solutions.push(finalized); + return solutions ? null : finalized; + } + const requirement = requirements[index]; + if (requirement.patterns) { + for (const pattern of requirement.patterns) { + for (const assigned of assignDatesVariants(state, pattern, requirement.rule, context)) { + const candidate = addEarnedBalance( + assigned, + requirement.rule, + requirement.occurrenceDate!, + pattern, + context, + ); + const complete = satisfyObligations( + requirements, + candidate, + context, + index + 1, + solutions, + solutionLimit, + ); + if (complete && !solutions) return complete; + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) + return null; + } + } + return null; + } + const candidates = requirement.candidates ?? []; + const needed = requirement.rule.placeDays; + const choose = (position: number, picked: string[], current: State): State | null => { + context.check(); + if (picked.length === needed) { + const complete = satisfyObligations( + requirements, + current, + context, + index + 1, + solutions, + solutionLimit, + ); + if (complete && !solutions) return complete; + return null; + } + if (candidates.length - position < needed - picked.length) return null; + for (let i = position; i < candidates.length; i++) { + for (const next of assignDateVariants( + current, + candidates[i], + requirement.rule.placeType, + requirement.rule.id, + context, + )) { + const complete = choose(i + 1, [...picked, candidates[i]], next); + if (complete && !solutions) return complete; + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) + return null; + } + } + return null; + }; + return choose(0, [], state); +} + +function placeAtRisk(state: State, context: SearchContext): State { + // At-risk placement is a greedy pass over one state. Clone it once, then + // mutate that private copy; cloning the whole placement map for every day is + // quadratic for a loaded year and does not change the observable result. + const canMutate = state.earnedActivations.length === 0; + let result = canMutate ? cloneState(state) : state; + const balances = result.balances + .filter( + (balance) => + compareDate(balance.expiryDate, context.horizon.start) >= 0 && + compareDate(balance.expiryDate, context.horizon.end) <= 0, + ) + .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id)); + for (const balance of balances) { + const availableDates = [...context.workingDays].sort( + (a, b) => + preferenceDateConflictScore(balance.type, a, context) - + preferenceDateConflictScore(balance.type, b, context) || compareDate(a, b), + ); + while (true) { + context.check(); + const current = result.balances.find((item) => item.id === balance.id)!; + if (current.remaining <= 0) break; + let nextState: State | null = null; + for (const candidate of availableDates) { + context.check(); + if ( + !result.placements.has(candidate) && + compareDate(current.availableFrom, candidate) <= 0 && + compareDate(candidate, current.expiryDate) <= 0 + ) { + const assigned = canMutate + ? assignDateToBalanceInPlace(result, candidate, current.id, context) + ? result + : null + : assignDateToBalance(result, candidate, current.id, context); + const feasible = assigned ? finalizeEarnedState(assigned, context) : null; + if (feasible) { + nextState = feasible; + break; + } + } + } + if (!nextState) break; + result = nextState; + } + } + return result; +} + +function preferenceTimeWindow( + state: State, + rule: NormalizedRule, + context: SearchContext, +): { state: State; count: number } { + const start = + compareDate(rule.startDate!, context.horizon.start) < 0 + ? context.horizon.start + : rule.startDate!; + const end = + compareDate(rule.endDate!, context.horizon.end) >= 0 + ? addDays(context.horizon.end, -1) + : rule.endDate!; + if (compareDate(start, end) > 0) return { state, count: 0 }; + let result = state; + let count = 0; + 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) + .sort((a, b) => { + return ( + Number(!shareableDates.has(a)) - Number(!shareableDates.has(b)) || + preferenceDateConflictScore(rule.placeType, a, context) - + preferenceDateConflictScore(rule.placeType, b, context) || + compareDate(a, b) + ); + }); + for (const date of candidates) { + context.check(); + if (count >= rule.placeDays) break; + const next = assignDate(result, date, rule.placeType, rule.id, context); + // A later preference can extend the generating period of an earned + // entitlement. Reject that candidate immediately instead of allowing an + // optional preference to turn the final plan into a conflict. + const feasible = next ? finalizeEarnedState(next, context) : null; + if (feasible) { + result = feasible; + count++; + } + } + return { state: result, count }; +} + +function preferenceIfThen( + state: State, + rule: NormalizedRule, + context: SearchContext, +): { state: State; count: number } { + const matchingOccurrences = occurrences(rule, context); + let bestState = state; + let bestCount = 0; + let bestFutureScore = Number.POSITIVE_INFINITY; + let bestMetrics: PlanningMetrics | undefined; + const search = (index: number, current: State, count: number, futureScore: number): void => { + context.check(); + if (count + matchingOccurrences.length - index < bestCount) return; + if (index >= matchingOccurrences.length) { + const finalized = finalizeEarnedState(current, context); + if (!finalized) return; + const metrics = qualityMetrics(finalized, context, []); + const betterTie = + futureScore < bestFutureScore || + (futureScore === bestFutureScore && + bestMetrics !== undefined && + (compareMetrics(metrics, bestMetrics) < 0 || + (compareMetrics(metrics, bestMetrics) === 0 && + compareText(placementTieKey(finalized), placementTieKey(bestState)) < + 0))); + if (count > bestCount || (count === bestCount && betterTie)) { + bestCount = count; + bestState = finalized; + bestFutureScore = futureScore; + bestMetrics = metrics; + } + return; + } + const occurrence = matchingOccurrences[index]; + const patterns = patternsFor(rule, occurrence, context).sort( + (a, b) => + patternConflictScore(rule.placeType, a, context) - + patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b), + ); + for (const pattern of patterns) { + const variants = assignDatesVariants(current, pattern, rule, context); + for (const variant of variants) { + search( + index + 1, + addEarnedBalance(variant, rule, occurrence, pattern, context), + count + 1, + futureScore + patternConflictScore(rule.placeType, pattern, context), + ); + } + } + // A preference may leave an occurrence unplaced. Exploring this branch after + // complete patterns keeps the result deterministic while still maximizing the + // number of complete occurrences. + search(index + 1, current, count, futureScore); + }; + search(0, state, 0, 0); + return { state: bestState, count: bestCount }; +} + +function preferenceDateConflictScore( + selector: LeaveSelector, + date: string, + context: SearchContext, +): number { + const protectedTypes = context.protectedTypesByDate.get(date); + const demand = context.preferenceDemandByDate.get(date) ?? 0; + const anyDemand = context.anyPreferenceDemandByDate.get(date) ?? 0; + if (!protectedTypes?.size && !demand) return 0; + // An ANY preference should prefer a date protected by a later preference: + // sharing one compatible placement lets both lexicographic criteria win. + if (selector === 'any') return -(demand * 2 + (protectedTypes?.size ?? 0)); + if (protectedTypes?.has(selector)) return -(demand * 2 + 1); + // An untyped future preference can share any leave type, while a typed + // future preference for another type must not be allowed to steal this slot. + if (protectedTypes?.size) return anyDemand ? -anyDemand : protectedTypes.size; + return demand ? -demand : 0; +} + +function patternConflictScore( + selector: LeaveSelector, + pattern: string[], + context: SearchContext, +): number { + return pattern.reduce( + (score, date) => score + preferenceDateConflictScore(selector, date, context), + 0, + ); +} + +function preferenceReason( + rule: NormalizedRule, + count: number, + context: SearchContext, + state: State, +): PlanningReason | undefined { + const requested = rule.type === 'if_then' ? occurrences(rule, context).length : rule.placeDays; + if (count >= requested) return undefined; + + const candidateDates = + rule.type === 'time_window' + ? context.workingDays.filter( + (date) => + compareDate(date, rule.startDate!) >= 0 && + compareDate(date, rule.endDate!) <= 0, + ) + : occurrences(rule, context).flatMap((occurrence) => + patternsFor(rule, occurrence, context).flat(), + ); + if (!candidateDates.length) + return reason( + 'horizon', + `Preference "${rule.name}" has no placeable date in the horizon.`, + [rule.id], + ); + + // Include generated balances: an earned entitlement is a real source for + // later preferences and must participate in both type and exhaustion reasons. + const matchingBalances = state.balances.filter((balance) => + selectorMatches(rule.placeType, balance.type), + ); + const availableBalances = matchingBalances.filter((balance) => + candidateDates.some( + (date) => + compareDate(balance.availableFrom, date) <= 0 && + compareDate(date, balance.expiryDate) <= 0, + ), + ); + const currentRuleIndex = context.rules.findIndex((item) => item.id === rule.id); + const consumedByHigherPriority = matchingBalances.some( + (balance) => + balance.remaining === 0 && + [...state.placements.values()].some( + (placement) => + placement.balanceId === balance.id && + [...placement.ruleIds].some((id) => { + const index = context.rules.findIndex((item) => item.id === id); + return index >= 0 && index < currentRuleIndex; + }), + ), + ); + const consumedByAtRisk = matchingBalances.some( + (balance) => + balance.remaining === 0 && + isAtRisk(balance, context) && + [...state.placements.values()].some( + (placement) => placement.balanceId === balance.id && placement.ruleIds.size === 0, + ), + ); + const freeEligibleDate = candidateDates.some( + (date) => + !state.placements.has(date) && + state.balances.some( + (balance) => + balance.remaining > 0 && + selectorMatches(rule.placeType, balance.type) && + compareDate(balance.availableFrom, date) <= 0 && + compareDate(date, balance.expiryDate) <= 0, + ), + ); + const compatibleExisting = candidateDates.some((date) => { + const placement = state.placements.get(date); + return placement && selectorMatches(rule.placeType, placement.type); + }); + const blockedByHigherPriority = candidateDates.some((date) => { + const placement = state.placements.get(date); + const sourceBalance = placement + ? state.balances.find((balance) => balance.id === placement.balanceId) + : undefined; + return ( + placement && + !selectorMatches(rule.placeType, placement.type) && + ((sourceBalance ? isAtRisk(sourceBalance, context) : false) || + [...placement.ruleIds].some((id) => { + const index = context.rules.findIndex((item) => item.id === id); + return index >= 0 && index < currentRuleIndex; + })) + ); + }); + let code: string; + if (blockedByHigherPriority && !freeEligibleDate && !compatibleExisting) + code = 'higher-priority'; + else if (!matchingBalances.length && !compatibleExisting) code = 'type'; + else if (!availableBalances.length) code = 'availability'; + else if ( + (consumedByHigherPriority || consumedByAtRisk) && + !freeEligibleDate && + !compatibleExisting + ) + code = 'higher-priority'; + else code = 'capacity'; + return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]); +} + +function applyPreferenceRules( + state: State, + preferenceRules: NormalizedRule[], + context: SearchContext, +): { state: State; preferences: PreferenceResult[] } { + let result = state; + const preferences: PreferenceResult[] = []; + for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { + result = applyPreferenceStep( + result, + preferenceRules, + preferenceIndex, + preferences, + context, + ); + } + return { state: result, preferences }; +} + +function applyPreferenceStep( + state: State, + preferenceRules: NormalizedRule[], + preferenceIndex: number, + preferences: PreferenceResult[], + context: SearchContext, +): State { + let result = state; + const applied = applyPreferenceRule( + result, + preferenceRules[preferenceIndex], + preferenceRules, + preferenceIndex, + context, + ); + result = applied.state; + preferences[preferenceIndex] = applied.preference; + // A lower-priority rule can be the producer for an entitlement needed by + // an earlier rule. Replay incomplete preferences left-to-right until no + // satisfaction improves, preserving lexicographic priority across a chain. + let remainingImprovements = preferences + .slice(0, preferenceIndex + 1) + .reduce((total, preference) => total + preference.requested, 0); + while (remainingImprovements > 0) { + context.check(); + let improved = false; + for (let retryIndex = 0; retryIndex <= preferenceIndex; retryIndex++) { + context.check(); + const previous = preferences[retryIndex]; + if (previous.satisfied >= previous.requested) continue; + const retried = applyPreferenceRule( + result, + preferenceRules[retryIndex], + preferenceRules, + retryIndex, + context, + ); + if (retried.preference.satisfied <= previous.satisfied) continue; + remainingImprovements -= retried.preference.satisfied - previous.satisfied; + result = retried.state; + preferences[retryIndex] = retried.preference; + improved = true; + } + if (!improved) break; + } + return result; +} + +function applyPreferenceRule( + state: State, + rule: NormalizedRule, + preferenceRules: NormalizedRule[], + preferenceIndex: number, + context: SearchContext, +): { state: State; preference: PreferenceResult } { + context.check(); + context.reservedTypes = new Set( + preferenceRules + .slice(preferenceIndex + 1) + .map((item) => item.placeType) + .filter((selector): selector is LeaveType => selector !== 'any'), + ); + context.protectedTypesByDate = futurePreferenceProtection( + preferenceRules, + preferenceIndex + 1, + context, + ); + const applied = + rule.type === 'time_window' + ? preferenceTimeWindow(state, rule, context) + : preferenceIfThen(state, rule, context); + const requested = + rule.type === 'time_window' ? rule.placeDays : occurrences(rule, context).length; + return { + state: applied.state, + preference: { + ruleId: rule.id, + requested, + satisfied: applied.count, + completeOccurrences: rule.type === 'if_then' ? applied.count : 0, + reason: preferenceReason(rule, applied.count, context, applied.state), + }, + }; +} + +function placementTieKey(state: State): string { + return [...state.placements.values()] + .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)) + .map((placement) => `${placement.date}:${placement.balanceId}`) + .join('|'); +} + +function expiryTieKey(state: State): string { + return [...state.placements.values()] + .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)) + .map((placement) => { + const balance = state.balances.find((item) => item.id === placement.balanceId); + return `${balance?.expiryDate ?? ''}:${placement.balanceId}`; + }) + .join('|'); +} + +function comparePreferenceVectors(a: readonly number[], b: readonly number[]): number { + for (let index = 0; index < Math.max(a.length, b.length); index++) { + const left = a[index] ?? 0; + const right = b[index] ?? 0; + if (left !== right) return right - left; + } + return 0; +} + +/** Compare complete previews so obligation DFS does not commit to its first feasible branch. */ +function compareObligationPreviews( + a: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, + b: { state: State; preferences: PreferenceResult[]; metrics: PlanningMetrics }, +): number { + if (a.metrics.unplacedAtRisk !== b.metrics.unplacedAtRisk) + return a.metrics.unplacedAtRisk - b.metrics.unplacedAtRisk; + const preferenceComparison = comparePreferenceVectors( + a.metrics.preferenceSatisfaction, + b.metrics.preferenceSatisfaction, + ); + if (preferenceComparison) return preferenceComparison; + const metrics = compareMetrics( + { ...a.metrics, unplacedAtRisk: 0 }, + { ...b.metrics, unplacedAtRisk: 0 }, + ); + if (metrics) return metrics; + const expiry = compareText(expiryTieKey(a.state), expiryTieKey(b.state)); + if (expiry) return expiry; + return compareText(placementTieKey(a.state), placementTieKey(b.state)); +} + +function optimizeMovablePlacements( + state: State, + context: SearchContext, + preferences: PreferenceResult[], + stage: 'non-working-day-gain' | 'longest-work-sequence' | 'short-sequence-deficit' | 'dates', +): State { + let result = state; + // Small plans are common and can be searched exhaustively. Keep the fixed + // sparse calendar candidates for loaded years so quality work stays within + // the mobile budget without making ordinary scenarios depend on sampling. + // ponytail: loaded plans sample non-working-adjacent and weekly dates; upgrade + // to interval branch-and-bound if a mobile benchmark shows a missed quality tie. + const exhaustive = result.placements.size <= 24; + const movableWindowDates = new Set( + [...result.placements.values()] + .filter((placement) => isMovableTimeWindow(placement, context)) + .flatMap((placement) => + [...placement.ruleIds] + .map((id) => context.rules.find((rule) => rule.id === id)) + .filter( + ( + rule, + ): rule is NormalizedRule & { + type: 'time_window'; + startDate: string; + endDate: string; + } => rule?.type === 'time_window', + ) + .flatMap((rule) => + context.workingDays.filter( + (date) => + compareDate(date, rule.startDate) >= 0 && + compareDate(date, rule.endDate) <= 0, + ), + ), + ), + ); + const candidateDates = context.workingDays.filter((date, index) => { + if (exhaustive) return true; + const touchesNonWorking = + !isWorking(addDays(date, -1), context.holidays) || + !isWorking(addDays(date, 1), context.holidays); + return touchesNonWorking || index % 7 === 0; + }); + for (const original of [...result.placements.values()] + .filter( + (placement) => placement.ruleIds.size === 0 || isMovableTimeWindow(placement, context), + ) + .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))) { + const current = result.placements.get(original.date); + if (!current || (current.ruleIds.size > 0 && !isMovableTimeWindow(current, context))) + continue; + const balance = result.balances.find((item) => item.id === current.balanceId); + if ( + !balance || + (!current.ruleIds.size && !isAtRisk(balance, context) && !current.supporting) + ) + continue; + const baseline = qualityMetrics(result, context, preferences); + const without = cloneState(result); + without.placements.delete(original.date); + without.balances.find((item) => item.id === current.balanceId)!.remaining++; + let best = result; + let bestMetrics = baseline; + let bestDate = original.date; + const placementCandidates = + current.ruleIds.size > 0 + ? context.workingDays.filter((date) => movableWindowDates.has(date)) + : candidateDates; + for (const date of placementCandidates) { + if (date === original.date) continue; + if (!compatibleWithRules(date, current.type, current.ruleIds, context)) continue; + const candidate = assignDateToBalance( + without, + date, + current.balanceId, + context, + current.ruleIds, + ); + if (!candidate) continue; + const feasibleCandidate = finalizeEarnedState(candidate, context); + if (!feasibleCandidate) continue; + feasibleCandidate.placements.get(date)!.supporting = current.supporting; + const metrics = qualityMetrics(feasibleCandidate, context, preferences); + if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue; + if ( + metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length || + metrics.preferenceSatisfaction.some( + (value, index) => value !== baseline.preferenceSatisfaction[index], + ) + ) + continue; + const better = + stage === 'non-working-day-gain' + ? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain + : stage === 'longest-work-sequence' + ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && + metrics.longestWorkSequence < bestMetrics.longestWorkSequence + : stage === 'short-sequence-deficit' + ? metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && + metrics.longestWorkSequence === bestMetrics.longestWorkSequence && + metrics.shortSequenceDeficit < bestMetrics.shortSequenceDeficit + : metrics.nonWorkingDayGain === bestMetrics.nonWorkingDayGain && + metrics.longestWorkSequence === bestMetrics.longestWorkSequence && + metrics.shortSequenceDeficit === bestMetrics.shortSequenceDeficit && + date < bestDate; + if (better) { + best = feasibleCandidate; + bestMetrics = metrics; + bestDate = date; + } + } + result = best; + } + return result; +} + +function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean { + return ( + a.unplacedAtRisk === b.unplacedAtRisk && + a.nonWorkingDayGain === b.nonWorkingDayGain && + a.longestWorkSequence === b.longestWorkSequence && + a.shortSequenceDeficit === b.shortSequenceDeficit && + a.preferenceSatisfaction.length === b.preferenceSatisfaction.length && + a.preferenceSatisfaction.every((value, index) => value === b.preferenceSatisfaction[index]) + ); +} + +function optimizeExpiryPlacements( + state: State, + context: SearchContext, + preferences: PreferenceResult[], +): State { + let result = state; + for (const original of [...result.placements.values()] + .filter( + (placement) => placement.ruleIds.size === 0 || isMovableTimeWindow(placement, context), + ) + .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))) { + context.check(); + const current = result.placements.get(original.date); + if (!current) continue; + const source = result.balances.find((balance) => balance.id === current.balanceId); + if (!source) continue; + const baseline = qualityMetrics(result, context, preferences); + const without = cloneState(result); + without.placements.delete(current.date); + const released = without.balances.find((balance) => balance.id === source.id); + if (released) released.remaining++; + let best = result; + let bestExpiry = expiryTieKey(result); + for (const replacement of without.balances + .filter( + (balance) => + balance.id !== source.id && + balance.type === current.type && + balance.remaining > 0 && + compareDate(balance.expiryDate, source.expiryDate) < 0 && + compareDate(balance.availableFrom, current.date) <= 0 && + compareDate(current.date, balance.expiryDate) <= 0, + ) + .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { + const candidate = assignDateToBalance( + without, + current.date, + replacement.id, + context, + current.ruleIds, + ); + if (!candidate) continue; + const feasibleCandidate = finalizeEarnedState(candidate, context); + if (!feasibleCandidate) continue; + feasibleCandidate.placements.get(current.date)!.supporting = current.supporting; + const metrics = qualityMetrics(feasibleCandidate, context, preferences); + const expiry = expiryTieKey(feasibleCandidate); + if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) { + best = feasibleCandidate; + bestExpiry = expiry; + } + } + result = best; + } + return result; +} + +function isAtRisk(balance: InternalBalance, context: SearchContext): boolean { + return ( + compareDate(balance.expiryDate, context.horizon.start) >= 0 && + compareDate(balance.expiryDate, context.horizon.end) <= 0 + ); +} + +function isMovableTimeWindow(placement: InternalPlacement, context: SearchContext): boolean { + if (!placement.ruleIds.size) return false; + return [...placement.ruleIds].every((id) => { + const rule = context.rules.find((item) => item.id === id); + return rule?.enabled && rule.type === 'time_window'; + }); +} + +function compatibleWithRules( + date: string, + type: LeaveType, + ruleIds: Set, + context: SearchContext, +): boolean { + if (!isWorking(date, context.holidays)) return false; + for (const id of ruleIds) { + const rule = context.rules.find((item) => item.id === id); + if ( + !rule || + rule.type !== 'time_window' || + !selectorMatches(rule.placeType, type) || + compareDate(date, rule.startDate!) < 0 || + compareDate(date, rule.endDate!) > 0 + ) + return false; + } + return true; +} + +function periodData(state: State, context: SearchContext): TimeOffPeriod[] { + const dates = [...state.placements.keys()].sort(compareDate); + if (!dates.length) return []; + const groups: string[][] = []; + let group = [dates[0]]; + for (let i = 1; i < dates.length; i++) { + context.check(); + const previousIndex = context.calendarIndex.get(dates[i - 1])!; + const currentIndex = context.calendarIndex.get(dates[i])!; + const workingInGap = + context.workingPrefix[currentIndex] - context.workingPrefix[previousIndex + 1]; + if (workingInGap === 0) group.push(dates[i]); + else { + groups.push(group); + group = [dates[i]]; + } + } + groups.push(group); + return groups.map((placementDates) => { + 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--; + while ( + endIndex + 1 < context.calendarDays.length && + !context.workingCalendar.has(context.calendarDays[endIndex + 1]) + ) + 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)); + return { + start, + end, + duration: allDates.length, + placementDates: [...placementDates], + nonWorkingDates, + nonWorkingDayGain: nonWorkingDates.length, + }; + }); +} + +function qualityMetrics( + state: State, + context: SearchContext, + preferences: PreferenceResult[], +): PlanningMetrics { + const periods = periodData(state, context); + const placed = new Set(state.placements.keys()); + const sequences: number[] = []; + let current = 0; + let hasPlacement = false; + for (const date of context.workingDays) { + context.check(); + if (placed.has(date)) { + sequences.push(current); + current = 0; + hasPlacement = true; + } else current++; + } + if (context.workingDays.length) sequences.push(current); + const internal = hasPlacement ? sequences.slice(1, -1) : []; + const shortSequenceDeficit = internal.reduce( + (sum, length) => sum + (length > 0 && length < 5 ? 5 - length : 0), + 0, + ); + const atRiskBalances = state.balances.filter( + (balance) => + compareDate(balance.expiryDate, context.horizon.start) >= 0 && + compareDate(balance.expiryDate, context.horizon.end) <= 0, + ); + const atRisk = atRiskBalances + .filter((balance) => balance.quantity > 0) + .reduce( + (sum, balance) => + sum + + Math.max( + 0, + balance.quantity - + (state.balances.find((item) => item.id === balance.id)?.remaining ?? 0), + ), + 0, + ); + const totalRisk = atRiskBalances.reduce((sum, balance) => sum + balance.quantity, 0); + const unplacedAtRisk = totalRisk - atRisk; + return { + unplacedAtRisk, + nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0), + longestWorkSequence: sequences.length ? Math.max(...sequences) : 0, + shortSequenceDeficit, + supportingPlacements: [...state.placements.values()].filter( + (placement) => placement.supporting, + ).length, + preferenceSatisfaction: preferences.map((preference) => preference.satisfied), + }; +} + +function remainingBalances(state: State, context: SearchContext): RemainingBalance[] { + return state.balances + .filter((balance) => balance.remaining > 0) + .map((balance) => { + const classification: RemainingClassification = + compareDate(balance.expiryDate, context.horizon.start) < 0 + ? 'unavoidable-expiry' + : compareDate(balance.expiryDate, context.horizon.end) <= 0 + ? 'at-risk' + : 'deferred'; + const code = + classification === 'unavoidable-expiry' ? 'unavoidable-expiry' : classification; + return { + id: balance.id, + type: balance.type, + quantity: balance.remaining, + availableFrom: balance.availableFrom, + expiryDate: balance.expiryDate, + classification, + reason: reason( + code, + classification === 'unavoidable-expiry' + ? 'This balance expired before the planning horizon.' + : classification === 'at-risk' + ? 'This balance expires within the planning horizon.' + : 'This balance remains available after the planning horizon.', + ), + earned: balance.earned, + sourceRuleId: balance.sourceRuleId, + }; + }); +} + +function makePlan( + state: State, + context: SearchContext, + preferences: PreferenceResult[], + stages: string[], +): PlanOutcome { + const periods = periodData(state, context); + const metrics = qualityMetrics(state, context, preferences); + const placements = [...state.placements.values()] + .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)) + .map((placement) => ({ + date: placement.date, + balanceId: placement.balanceId, + type: placement.type, + ruleIds: [...placement.ruleIds].sort(), + supporting: placement.supporting, + })); + const remaining = remainingBalances(state, context); + return { + kind: 'Plan', + horizon: context.horizon, + placements, + timeOffPeriods: periods, + preferences, + remainingBalances: remaining, + metrics, + completedStages: [...stages], + lastCompletedStage: stages[stages.length - 1] ?? 'validation', + }; +} + +function markProgress(context: SearchContext, stage: string): void { + if (typeof context.request.progress === 'function') context.request.progress(stage); +} + +function supportingPlacements( + state: State, + context: SearchContext, + preferences: PreferenceResult[], +): State { + let result = state; + while (true) { + context.check(); + const before = qualityMetrics(result, context, preferences); + let best: State | null = null; + let bestMetrics: PlanningMetrics | null = null; + for (const balance of result.balances + .filter( + (item) => + compareDate(item.expiryDate, context.horizon.end) > 0 && item.remaining > 0, + ) + .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { + for (const date of context.workingDays) { + context.check(); + if ( + result.placements.has(date) || + compareDate(balance.availableFrom, date) > 0 || + compareDate(date, balance.expiryDate) > 0 + ) + continue; + const candidate = assignDateToBalance(result, date, balance.id, context); + if (!candidate) continue; + candidate.placements.get(date)!.supporting = true; + const feasibleCandidate = finalizeEarnedState(candidate, context); + if (!feasibleCandidate) continue; + const metrics = qualityMetrics(feasibleCandidate, context, preferences); + const preservesHigherMetrics = + metrics.unplacedAtRisk === before.unplacedAtRisk && + metrics.preferenceSatisfaction.length === + before.preferenceSatisfaction.length && + metrics.preferenceSatisfaction.every( + (value, index) => value === before.preferenceSatisfaction[index], + ) && + metrics.nonWorkingDayGain >= before.nonWorkingDayGain && + metrics.longestWorkSequence <= before.longestWorkSequence && + metrics.shortSequenceDeficit <= before.shortSequenceDeficit; + const useful = + preservesHigherMetrics && + (metrics.nonWorkingDayGain > before.nonWorkingDayGain || + metrics.longestWorkSequence < before.longestWorkSequence || + metrics.shortSequenceDeficit < before.shortSequenceDeficit); + if (!useful) continue; + if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) { + best = feasibleCandidate; + bestMetrics = metrics; + } + } + } + if (!best) return result; + result = best; + } +} + +function pruneRedundantSupportingPlacements( + state: State, + context: SearchContext, + preferences: PreferenceResult[], +): State { + let result = state; + let removed = true; + while (removed) { + context.check(); + removed = false; + const baseline = qualityMetrics(result, context, preferences); + for (const placement of [...result.placements.values()].filter((item) => item.supporting)) { + context.check(); + const candidate = cloneState(result); + candidate.placements.delete(placement.date); + const balance = candidate.balances.find((item) => item.id === placement.balanceId); + if (balance) balance.remaining++; + const metrics = qualityMetrics(candidate, context, preferences); + if ( + metrics.unplacedAtRisk === baseline.unplacedAtRisk && + metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain && + metrics.longestWorkSequence <= baseline.longestWorkSequence && + metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit + ) { + result = candidate; + removed = true; + break; + } + } + } + return result; +} + +function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number { + if (a.unplacedAtRisk !== b.unplacedAtRisk) return a.unplacedAtRisk - b.unplacedAtRisk; + if (a.nonWorkingDayGain !== b.nonWorkingDayGain) + return b.nonWorkingDayGain - a.nonWorkingDayGain; + if (a.longestWorkSequence !== b.longestWorkSequence) + return a.longestWorkSequence - b.longestWorkSequence; + if (a.shortSequenceDeficit !== b.shortSequenceDeficit) + return a.shortSequenceDeficit - b.shortSequenceDeficit; + return a.supportingPlacements - b.supportingPlacements; +} + +export type PlanningCheckpoint = (plan: PlanOutcome) => void; + +/** @internal Worker-only synchronous core. Application code must use planLeave. */ +export function calculatePlanningInternal( + request: unknown, + checkpointCallback?: PlanningCheckpoint, +): PlanningOutcome { + const source = runtimeRequest(request); + const normalized = normalizeRequest(source); + if (normalized.reasons.length) { + return invalidInputFor(source)!; + } + const horizon = normalized.horizon!; + const holidays = holidaySet(horizon); + const workingDays = dateRange(horizon.start, horizon.end).filter((date) => + isWorking(date, holidays), + ); + const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400)); + const calendarIndex = new Map(calendarDays.map((date, index) => [date, index])); + const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays))); + const workingPrefix = [0]; + for (const date of calendarDays) + workingPrefix.push( + workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)), + ); + const started = + typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); + const configuredLimit = + typeof source.deadlineMs === 'number' ? source.deadlineMs : MAX_PLANNING_MS; + const deadline = Math.min(Math.max(0, configuredLimit), MAX_PLANNING_MS); + // Leave a small scheduling margin so the synchronous SSR/test fallback also + // observes the public ten-second wall-clock ceiling; browser workers have a + // separate watchdog in planInWorker. + const coreDeadline = + deadline === MAX_PLANNING_MS ? deadline - CORE_DEADLINE_MARGIN_MS : deadline; + const context = { + horizon, + holidays, + calendarDays, + calendarIndex, + workingCalendar, + workingPrefix, + workingDays, + initialBalances: normalized.balances, + rules: normalized.rules, + deadline: coreDeadline, + started, + request: source, + nodes: 0, + check: () => undefined, + reservedTypes: new Set(), + sharedTypesByDate: new Map(), + protectedTypesByDate: new Map(), + preferenceDemandByDate: new Map(), + anyPreferenceDemandByDate: new Map(), + } as SearchContext; + context.check = () => { + context.nodes++; + if (context.deadline <= 1 || context.nodes % 16 === 0) { + const now = + typeof performance !== 'undefined' && performance.now + ? performance.now() + : Date.now(); + if (now - context.started >= context.deadline) throw new DeadlineReached(); + } + }; + const initialState: State = { + balances: normalized.balances.map((balance) => ({ + ...balance, + remaining: balance.quantity, + })), + placements: new Map(), + earnedActivations: [], + }; + const stages: string[] = ['validation']; + let state = initialState; + const preferences: PreferenceResult[] = []; + let checkpoint: PlanOutcome | undefined; + try { + markProgress(context, 'validation'); + // 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); + markProgress(context, 'obligations'); + const obligationRequirements = buildRequirements(normalized.rules, context, 'obligation'); + context.reservedTypes = new Set( + obligationRequirements + .map((requirement) => requirement.rule.placeType) + .filter((selector): selector is LeaveType => selector !== 'any'), + ); + for (const requirement of obligationRequirements) { + if ( + !requirement.candidates || + requirement.candidates.length !== 1 || + requirement.rule.placeType === 'any' + ) + continue; + const types = + context.sharedTypesByDate.get(requirement.candidates[0]) ?? new Set(); + types.add(requirement.rule.placeType); + context.sharedTypesByDate.set(requirement.candidates[0], types); + } + const obligationSolutions: State[] = []; + const hasEarnedObligation = obligationRequirements.some( + (requirement) => (requirement.rule.rewardDays ?? 0) > 0, + ); + const obligationSolutionLimit = + hasEarnedObligation || + obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT + ? 0 + : OBLIGATION_SOLUTION_LIMIT; + satisfyObligations( + obligationRequirements, + state, + context, + 0, + obligationSolutions, + obligationSolutionLimit, + ); + if (!obligationSolutions.length) { + const ids = [ + ...new Set(obligationRequirements.map((requirement) => requirement.rule.id)), + ]; + return { + kind: 'PlanningConflict', + conflictingRuleIds: ids, + reasons: [ + reason( + 'obligation-infeasible', + 'The enabled obligations cannot be satisfied jointly within the horizon and available leave balances.', + ids, + ), + ], + }; + } + const preferenceRules = normalized.rules.filter( + (item) => item.enabled && item.force === 'preference', + ); + let bestPreview: { + obligationState: State; + state: State; + preferences: PreferenceResult[]; + metrics: PlanningMetrics; + } | null = null; + for (const obligationSolution of obligationSolutions) { + context.check(); + const atRiskState = placeAtRisk(cloneState(obligationSolution), context); + const preview = applyPreferenceRules(atRiskState, preferenceRules, context); + const previewState = finalizeEarnedState(preview.state, context); + if (!previewState) continue; + const candidate = { + obligationState: obligationSolution, + state: previewState, + preferences: preview.preferences, + metrics: qualityMetrics(previewState, context, preview.preferences), + }; + if (!bestPreview || compareObligationPreviews(candidate, bestPreview) < 0) + bestPreview = candidate; + } + if (!bestPreview) { + const ids = [ + ...new Set(obligationRequirements.map((requirement) => requirement.rule.id)), + ]; + return { + kind: 'PlanningConflict', + conflictingRuleIds: ids, + reasons: [ + reason( + 'obligation-infeasible', + 'The enabled obligations cannot be satisfied jointly within the horizon and available leave balances.', + ids, + ), + ], + }; + } + state = bestPreview.obligationState; + stages.push('obligations'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + markProgress(context, 'at-risk'); + context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); + context.reservedTypes = new Set( + obligationRequirements + .map((requirement) => requirement.rule.placeType) + .filter((selector): selector is LeaveType => selector !== 'any'), + ); + state = placeAtRisk(state, context); + stages.push('at-risk'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { + const rule = preferenceRules[preferenceIndex]; + markProgress(context, `preference:${rule.id}`); + state = applyPreferenceStep( + state, + preferenceRules, + preferenceIndex, + preferences, + context, + ); + stages.push(`preference:${rule.id}`); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + } + markProgress(context, 'non-working-day-gain'); + context.check(); + state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain'); + stages.push('non-working-day-gain'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + markProgress(context, 'longest-work-sequence'); + context.check(); + state = optimizeMovablePlacements(state, context, preferences, 'longest-work-sequence'); + stages.push('longest-work-sequence'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + markProgress(context, 'short-sequence-deficit'); + context.check(); + state = optimizeMovablePlacements(state, context, preferences, 'short-sequence-deficit'); + stages.push('short-sequence-deficit'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + markProgress(context, 'supporting-placements'); + state = supportingPlacements(state, context, preferences); + state = pruneRedundantSupportingPlacements(state, context, preferences); + stages.push('supporting-placements'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + markProgress(context, 'expiry'); + context.check(); + state = optimizeExpiryPlacements(state, context, preferences); + stages.push('expiry'); + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + markProgress(context, 'dates'); + context.check(); + state = optimizeMovablePlacements(state, context, preferences, 'dates'); + stages.push('dates'); + const finalized = finalizeEarnedState(state, context); + if (!finalized) { + return { + kind: 'PlanningConflict', + conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId), + reasons: [ + reason( + 'earned-entitlement-order', + 'Generated leave became available only after its final merged time-off period.', + ), + ], + }; + } + state = finalized; + checkpoint = makePlan(state, context, preferences, stages); + checkpointCallback?.(checkpoint); + markProgress(context, 'complete'); + return checkpoint; + } catch (error) { + if (!(error instanceof DeadlineReached)) throw error; + const reasons = [ + reason( + 'deadline', + `Planning stopped after ${context.deadline}ms before all quality stages completed.`, + ), + ]; + return { + kind: 'PlanningIncomplete', + incumbent: checkpoint, + completedStages: checkpoint?.completedStages ?? [], + lastCompletedStage: checkpoint?.lastCompletedStage, + reasons, + }; + } +} diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts new file mode 100644 index 0000000..b10a6f8 --- /dev/null +++ b/src/app/services/leave-planner.service.spec.ts @@ -0,0 +1,1067 @@ +import { vi } from 'vitest'; +import { PlanningOutcome, PlanningRequest, planLeave } from './leave-planner.service'; + +const horizon = { start: '2024-01-01', end: '2025-01-01' }; +const balance = ( + id: string, + type: 'cp' | 'rtt' | 'other', + quantity: number, + expiryDate = '2024-12-31', +) => ({ + id, + type, + quantity, + availableFrom: '2024-01-01', + expiryDate, +}); + +function isPlan(outcome: PlanningOutcome) { + if (outcome.kind !== 'Plan') throw new Error(`Expected plan, got ${outcome.kind}`); + return outcome; +} + +async function runInNonBrowser(request: Parameters[0]) { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + return await planLeave(request); + } finally { + vi.stubGlobal('window', browserWindow); + } +} + +describe('planLeave', () => { + it('rejects malformed horizons before calculation', async () => { + const result = await planLeave({ + horizon: { start: '2025-01-01', end: '2024-01-01' }, + balances: [], + rules: [], + }); + + expect(result.kind).toBe('InvalidInput'); + if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order'); + }); + + it.each([ + [ + 'invalid balance quantity', + { + balances: [{ id: 'cp', type: 'cp', quantity: -1, expiryDate: '2025-01-01' }], + rules: [], + }, + 'balance-quantity', + ], + [ + 'invalid balance expiry', + { + balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: 'not-a-date' }], + rules: [], + }, + 'balance-expiry', + ], + [ + 'invalid rule selector', + { + balances: [], + rules: [ + { + id: 'rule', + type: 'time_window', + startDate: '2024-01-01', + endDate: '2024-01-02', + placeDays: 1, + placeType: 'holiday', + }, + ], + }, + 'rule-selector', + ], + ] as const)('returns a table-driven validation reason for %s', async (_, request, code) => { + const result = await runInNonBrowser({ horizon, ...request }); + expect(result.kind).toBe('InvalidInput'); + if (result.kind === 'InvalidInput') + expect(result.reasons.some((candidate) => candidate.code === code)).toBe(true); + }); + + it('plans dated balances without mutating the request and classifies the remainder', async () => { + const request = { + horizon, + balances: [ + { ...balance('expired', 'cp', 1, '2023-12-31'), availableFrom: '2023-01-01' }, + balance('future', 'rtt', 1, '2026-01-01'), + ], + rules: [], + }; + const before = JSON.stringify(request); + const result = isPlan(await runInNonBrowser(request)); + + expect(JSON.stringify(request)).toBe(before); + expect(result.placements.length).toBeLessThanOrEqual(1); + expect(result.remainingBalances[0].classification).toBe('unavoidable-expiry'); + }); + + it('migrates a legacy balance without availability to the horizon start', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-06-01', end: '2024-06-02' }, + balances: [{ id: 'legacy', type: 'cp', quantity: 1, expiryDate: '2025-01-01' }], + rules: [], + }), + ); + expect(result.remainingBalances[0].availableFrom).toBe('2024-06-01'); + }); + + it.each([ + ['unavoidable-expiry', '2024-05-31', '2024-01-01'], + ['at-risk', '2024-06-10', '2024-06-10'], + ['deferred', '2024-07-01', '2024-06-11'], + ] as const)( + 'classifies a remaining balance as %s', + async (classification, expiryDate, availableFrom) => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-06-01', end: '2024-06-10' }, + balances: [ + { + id: classification, + type: 'cp', + quantity: 1, + availableFrom, + expiryDate, + }, + ], + rules: [], + }), + ); + expect(result.remainingBalances[0].classification).toBe(classification); + expect(result.remainingBalances[0].reason.code).toBe(classification); + }, + ); + + it('satisfies exact TIME_WINDOW obligations and reports typed conflicts', async () => { + const rule = { + id: 'cp-window', + type: 'time_window', + force: 'obligation', + startDate: '2024-03-01', + endDate: '2024-03-05', + placeDays: 2, + placeType: 'cp', + }; + const plan = isPlan( + await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 2)], rules: [rule] }), + ); + expect(plan.placements).toHaveLength(2); + expect(plan.placements.every((placement) => placement.ruleIds).valueOf()).toBe(true); + + const conflict = await runInNonBrowser({ + horizon, + balances: [balance('rtt', 'rtt', 2)], + rules: [rule], + }); + expect(conflict.kind).toBe('PlanningConflict'); + }); + + it('lets one ANY obligation preserve a typed balance for another obligation', async () => { + const rules = [ + { + id: 'any', + type: 'time_window', + force: 'obligation', + startDate: '2024-04-01', + endDate: '2024-04-02', + placeDays: 1, + placeType: 'any', + }, + { + id: 'cp', + type: 'time_window', + force: 'obligation', + startDate: '2024-04-01', + endDate: '2024-02-02', + placeDays: 1, + placeType: 'cp', + }, + ]; + // The second window is intentionally corrected below to keep this table readable. + rules[1].endDate = '2024-04-02'; + const plan = isPlan( + await runInNonBrowser({ + horizon, + balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 1)], + rules, + }), + ); + + expect(plan.placements).toHaveLength(2); + expect(plan.placements.find((placement) => placement.ruleIds.includes('cp'))?.type).toBe( + 'cp', + ); + expect( + plan.placements.some( + (placement) => + placement.ruleIds.includes('any') && placement.ruleIds.includes('cp'), + ), + ).toBe(true); + }); + + it('supports IF_THEN patterns, merged periods, and deterministic results', async () => { + const rule = { + id: 'holiday', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 2, + placeType: 'cp', + placement: 'auto', + rewardDays: 1, + rewardType: 'rtt', + }; + const request = { + horizon: { start: '2024-04-01', end: '2024-05-10' }, + balances: [balance('cp', 'cp', 4), balance('rtt', 'rtt', 1, '2026-01-01')], + rules: [rule], + }; + const first = isPlan(await runInNonBrowser(request)); + const second = isPlan(await runInNonBrowser(request)); + expect(first).toEqual(second); + expect(first.placements.length).toBeGreaterThanOrEqual(4); + expect(first.timeOffPeriods[0].duration).toBeGreaterThan(2); + }); + + it('selects a joint obligation assignment by later preference quality', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-02-01', end: '2024-02-10' }, + balances: [balance('cp', 'cp', 1)], + rules: [ + { + id: 'obligation', + type: 'time_window', + force: 'obligation', + startDate: '2024-02-01', + endDate: '2024-02-05', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'preference', + type: 'time_window', + force: 'preference', + startDate: '2024-02-05', + endDate: '2024-02-05', + placeDays: 1, + placeType: 'cp', + }, + ], + }), + ); + expect( + result.placements.find((placement) => placement.ruleIds.includes('obligation'))?.date, + ).toBe('2024-02-05'); + expect(result.preferences[0].satisfied).toBe(1); + }); + + it('preserves an at-risk balance while satisfying a deferred obligation', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-02', end: '2024-01-04' }, + balances: [ + { + ...balance('cp', 'cp', 1, '2024-01-02'), + availableFrom: '2024-01-02', + }, + { + ...balance('rtt', 'rtt', 1, '2025-01-01'), + availableFrom: '2024-01-02', + }, + ], + rules: [ + { + id: 'rtt-obligation', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-02', + endDate: '2024-01-03', + placeDays: 1, + placeType: 'rtt', + }, + ], + }), + ); + expect( + result.placements.find((placement) => placement.ruleIds.includes('rtt-obligation')) + ?.date, + ).toBe('2024-01-03'); + expect(result.placements.find((placement) => placement.balanceId === 'cp')?.date).toBe( + '2024-01-02', + ); + }); + + it('does not let a generating period reward finance an earlier merged period placement', async () => { + const result = await runInNonBrowser({ + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [balance('cp', 'cp', 2), balance('rtt', 'rtt', 3, '2026-01-01')], + rules: [ + { + id: 'producer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'before', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'consumer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 4, + placeDays: 4, + placeType: 'rtt', + placement: 'before', + rewardDays: 0, + }, + ], + }); + expect(result.kind).toBe('PlanningConflict'); + }); + + it('chains an earned balance into a later obligation with provenance', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-25', end: '2024-05-08' }, + balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')], + rules: [ + { + id: 'producer', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'consumer', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-07', + endDate: '2024-05-07', + placeDays: 1, + placeType: 'rtt', + }, + ], + }), + ); + const earned = result.placements.find((placement) => + placement.ruleIds.includes('consumer'), + ); + expect(earned?.balanceId).toBe('earned:producer:2024-05-01'); + expect(earned?.ruleIds).toEqual(['consumer']); + }); + + it('allows a later preference producer to fund an earlier consumer chronologically', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-25', end: '2024-05-08' }, + balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')], + rules: [ + { + id: 'consumer', + type: 'time_window', + force: 'preference', + startDate: '2024-05-07', + endDate: '2024-05-07', + placeDays: 1, + placeType: 'rtt', + }, + { + id: 'producer', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + ], + }), + ); + expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1]); + expect(result.placements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + date: '2024-05-02', + balanceId: 'cp', + ruleIds: ['producer'], + }), + expect.objectContaining({ + date: '2024-05-07', + balanceId: 'earned:producer:2024-05-01', + ruleIds: ['consumer'], + }), + ]), + ); + }); + + it('preserves preference priority when one later producer earns only one day', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-25', end: '2024-05-08' }, + balances: [balance('cp', 'cp', 1), balance('rtt', 'rtt', 0, '2026-01-01')], + rules: [ + { + id: 'first', + type: 'time_window', + force: 'preference', + startDate: '2024-05-06', + endDate: '2024-05-06', + placeDays: 1, + placeType: 'rtt', + }, + { + id: 'second', + type: 'time_window', + force: 'preference', + startDate: '2024-05-07', + endDate: '2024-05-07', + placeDays: 1, + placeType: 'rtt', + }, + { + id: 'producer', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + ], + }), + ); + expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 0, 1]); + expect(result.placements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ date: '2024-05-06', ruleIds: ['first'] }), + ]), + ); + }); + + it('replays the current preference when an earlier retry creates its entitlement', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2021-05-20', end: '2021-11-03' }, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 0, + availableFrom: '2021-05-20', + expiryDate: '2022-01-01', + }, + { + id: 'rtt', + type: 'rtt', + quantity: 1, + availableFrom: '2021-05-20', + expiryDate: '2021-08-01', + }, + ], + rules: [ + { + id: 'wednesday-producer', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 1, + rewardType: 'rtt', + }, + { + id: 'monday-chain', + type: 'if_then', + force: 'preference', + dayOfWeek: 1, + placeDays: 1, + placeType: 'rtt', + placement: 'after', + rewardDays: 1, + rewardType: 'cp', + }, + ], + }), + ); + expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 2]); + expect(result.placements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ date: '2021-07-15', ruleIds: ['wednesday-producer'] }), + expect.objectContaining({ date: '2021-11-02', ruleIds: ['monday-chain'] }), + ]), + ); + }); + + it('retains a useful supporting placement while preserving its provenance', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-05', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1, '2025-01-01')], + rules: [], + }), + ); + const supporting = result.placements.find((placement) => placement.supporting); + expect(supporting?.ruleIds).toEqual([]); + expect(supporting?.balanceId).toBe('cp'); + }); + + it('does not trade a short-sequence deficit for supporting placement gain', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-01', end: '2024-02-01' }, + balances: [balance('cp-four', 'cp', 4, '2025-01-01')], + rules: [], + }), + ); + expect(result.metrics.shortSequenceDeficit).toBe(0); + }); + + it('retains supporting provenance when a placement is moved', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-01', end: '2024-01-29' }, + balances: [balance('cp-six', 'cp', 6, '2025-01-01')], + rules: [], + }), + ); + expect(result.metrics.supportingPlacements).toBe( + result.placements.filter((placement) => placement.supporting).length, + ); + expect(result.placements).toHaveLength(3); + expect(result.placements.every((placement) => placement.supporting)).toBe(true); + }); + + it('orders non-working gain, longest sequence, and final dates lexicographically', async () => { + const twoDayPlan = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-02-01', end: '2024-03-01' }, + balances: [balance('cp', 'cp', 2, '2024-02-29')], + rules: [], + }), + ); + const fourDayPlan = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-02-01', end: '2024-03-01' }, + balances: [balance('cp', 'cp', 4, '2024-02-29')], + rules: [], + }), + ); + expect(twoDayPlan.placements.map((placement) => placement.date)).toEqual([ + '2024-02-09', + '2024-02-19', + ]); + expect(fourDayPlan.placements.map((placement) => placement.date)).toEqual([ + '2024-02-02', + '2024-02-09', + '2024-02-16', + '2024-02-23', + ]); + }); + + it('moves a time-window obligation to improve a later quality stage', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-08', end: '2024-01-15' }, + balances: [balance('cp', 'cp', 1)], + rules: [ + { + id: 'window', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-09', + endDate: '2024-01-12', + placeDays: 1, + placeType: 'cp', + }, + ], + }), + ); + expect(result.placements[0].date).toBe('2024-01-12'); + }); + + it('shares future ANY preference demand and preserves preference order', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-02-01', end: '2024-02-12' }, + balances: [balance('cp', 'cp', 1)], + rules: [ + { + id: 'first', + type: 'time_window', + force: 'preference', + startDate: '2024-02-05', + endDate: '2024-02-08', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'any', + type: 'time_window', + force: 'preference', + startDate: '2024-02-07', + endDate: '2024-02-09', + placeDays: 1, + placeType: 'any', + }, + ], + }), + ); + expect(result.placements[0].date).toBe('2024-02-07'); + expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1]); + }); + + it.each(['cp', 'any'] as const)( + 'shares an existing compatible placement before consuming a day for a %s preference', + async (lastSelector) => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-02', end: '2024-01-15' }, + balances: [balance('cp', 'cp', 3)], + rules: [ + { + id: 'high', + type: 'time_window', + force: 'preference', + startDate: '2024-01-05', + endDate: '2024-01-08', + placeDays: 2, + placeType: 'cp', + }, + { + id: 'middle', + type: 'time_window', + force: 'preference', + startDate: '2024-01-03', + endDate: '2024-01-10', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'low', + type: 'time_window', + force: 'preference', + startDate: '2024-01-12', + endDate: '2024-01-12', + placeDays: 1, + placeType: lastSelector, + }, + ], + }), + ); + expect(result.preferences.map((preference) => preference.satisfied)).toEqual([2, 1, 1]); + }, + ); + + it('uses the earliest unsatisfied preference slot as a cardinality look-ahead', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-02', end: '2024-01-08' }, + balances: [balance('cp', 'cp', 1)], + rules: [ + { + id: 'flexible', + type: 'time_window', + force: 'preference', + startDate: '2024-01-02', + endDate: '2024-01-05', + placeDays: 1, + placeType: 'any', + }, + { + id: 'second', + type: 'time_window', + force: 'preference', + startDate: '2024-01-05', + endDate: '2024-01-05', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'third', + type: 'time_window', + force: 'preference', + startDate: '2024-01-02', + endDate: '2024-01-02', + placeDays: 1, + placeType: 'cp', + }, + ], + }), + ); + expect(result.placements[0].date).toBe('2024-01-05'); + expect(result.preferences.map((preference) => preference.satisfied)).toEqual([1, 1, 0]); + }); + + it('classifies a later preference blocked by consumed higher-priority capacity', async () => { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-02', end: '2024-01-08' }, + balances: [balance('cp', 'cp', 1, '2025-01-01')], + rules: [ + { + id: 'high', + type: 'time_window', + force: 'preference', + startDate: '2024-01-02', + endDate: '2024-01-02', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'low', + type: 'time_window', + force: 'preference', + startDate: '2024-01-05', + endDate: '2024-01-05', + placeDays: 1, + placeType: 'cp', + }, + ], + }), + ); + expect(result.preferences[0].satisfied).toBe(1); + expect(result.preferences[1].reason?.code).toBe('higher-priority'); + }); + + it('keeps holiday occurrences date-only across host timezones', async () => { + const previousTimezone = process.env['TZ']; + process.env['TZ'] = 'Asia/Tokyo'; + try { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2023-12-29', end: '2024-01-05' }, + balances: [balance('cp', 'cp', 1)], + rules: [ + { + id: 'new-year', + type: 'if_then', + force: 'obligation', + dayOfWeek: 1, + placeDays: 1, + placeType: 'cp', + placement: 'after', + rewardDays: 0, + }, + ], + }), + ); + expect(result.placements[0].date).toBe('2024-01-02'); + } finally { + if (previousTimezone === undefined) delete process.env['TZ']; + else process.env['TZ'] = previousTimezone; + } + }); + + it('rejects around N=1 while supporting valid around splits', async () => { + const aroundOne = await runInNonBrowser({ + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [balance('cp-one', 'cp', 2)], + rules: [ + { + id: 'around-one', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'around', + rewardDays: 0, + }, + ], + }); + expect(aroundOne.kind).toBe('PlanningConflict'); + + const aroundPreference = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [balance('cp-pref', 'cp', 1)], + rules: [ + { + id: 'around-preference', + type: 'if_then', + force: 'preference', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'around', + rewardDays: 0, + }, + ], + }), + ); + expect(aroundPreference.preferences[0].satisfied).toBe(0); + + const aroundTwo = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-29', end: '2024-05-04' }, + balances: [balance('cp-two', 'cp', 2)], + rules: [ + { + id: 'around-two', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 2, + placeType: 'cp', + placement: 'around', + rewardDays: 0, + }, + ], + }), + ); + expect(aroundTwo.placements).toHaveLength(2); + + for (const mode of ['before', 'after', 'auto'] as const) { + const result = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [balance(`cp-${mode}`, 'cp', 2)], + rules: [ + { + id: `rule-${mode}`, + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: mode, + rewardDays: 0, + }, + ], + }), + ); + expect(result.placements).toHaveLength(2); + expect( + result.placements.every((placement) => placement.ruleIds.includes(`rule-${mode}`)), + ).toBe(true); + } + }); + + it('returns an incomplete outcome instead of a false conflict at an expired deadline', async () => { + const result = await planLeave({ + horizon, + balances: [balance('cp', 'cp', 5)], + rules: [], + deadlineMs: 0, + }); + expect(result.kind).toBe('PlanningIncomplete'); + }); + + it('reports worker-unavailable in a browser instead of running the core synchronously', async () => { + const result = await planLeave({ horizon, balances: [], rules: [] }); + expect(result.kind).toBe('PlanningIncomplete'); + if (result.kind === 'PlanningIncomplete') { + expect(result.reasons[0].code).toBe('worker-unavailable'); + expect(result.incumbent).toBeUndefined(); + expect(result.lastCompletedStage).toBeUndefined(); + expect(result.completedStages).toEqual([]); + } + }); + + it('keeps an expired browser request off the UI thread when a worker exists', async () => { + let started = false; + let terminated = false; + class ZeroBudgetWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + constructor() { + started = true; + } + + postMessage(): void {} + + terminate(): void { + terminated = true; + } + } + vi.stubGlobal('Worker', ZeroBudgetWorker); + try { + const result = await planLeave({ horizon, balances: [], rules: [], deadlineMs: 0 }); + expect(result.kind).toBe('PlanningIncomplete'); + expect(started).toBe(true); + expect(terminated).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe('planLeave transport', () => { + it('terminates an isolated calculation at its wall deadline with its incumbent', async () => { + const incumbent = isPlan( + await runInNonBrowser({ + horizon, + balances: [balance('cp', 'cp', 2)], + rules: [], + }), + ); + class HangingWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(): void { + this.onmessage?.({ + data: { type: 'checkpoint', plan: incumbent }, + } as MessageEvent); + } + + terminate(): void { + terminated = true; + } + } + let terminated = false; + vi.stubGlobal('Worker', HangingWorker); + + try { + const result = await planLeave({ + horizon, + balances: [balance('cp', 'cp', 2)], + rules: [], + deadlineMs: 10, + }); + expect(result.kind).toBe('PlanningIncomplete'); + if (result.kind === 'PlanningIncomplete') { + expect(result.incumbent?.kind).toBe('Plan'); + expect(result.lastCompletedStage).toBe('dates'); + } + expect(terminated).toBe(true); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('reports no completed stage when an isolated worker never checkpoints', async () => { + class SilentWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(): void { + // Deliberately never checkpoint or finish. + } + + terminate(): void {} + } + vi.stubGlobal('Worker', SilentWorker); + try { + const result = await planLeave({ + horizon, + balances: [balance('cp', 'cp', 2)], + rules: [], + deadlineMs: 10, + }); + expect(result.kind).toBe('PlanningIncomplete'); + if (result.kind === 'PlanningIncomplete') { + expect(result.incumbent).toBeUndefined(); + expect(result.completedStages).toEqual([]); + expect(result.lastCompletedStage).toBeUndefined(); + } + } finally { + vi.unstubAllGlobals(); + } + }); + + it('sends a detached, callback-free request to the worker transport', async () => { + const sourceDate = new Date('2024-01-01T00:00:00Z'); + const request = { + horizon: { start: sourceDate, end: '2025-01-01' }, + balances: [ + { + id: 'cp', + type: 'cp' as const, + quantity: 0, + availableFrom: sourceDate, + expiryDate: '2025-01-01', + }, + ], + rules: [], + }; + const expected = isPlan(await runInNonBrowser(request)); + let posted: { type: string; request: PlanningRequest } | undefined; + class InspectingWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(message: { type: string; request: PlanningRequest }): void { + posted = structuredClone(message); + this.onmessage?.({ data: { type: 'result', outcome: expected } } as MessageEvent); + } + + terminate(): void {} + } + vi.stubGlobal('Worker', InspectingWorker); + try { + const result = await planLeave(request); + expect(result.kind).toBe('Plan'); + expect(posted?.request).not.toBe(request); + expect(posted?.request.progress).toBeUndefined(); + expect(request.horizon.start).toBe(sourceDate); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('strips legacy aliases at the worker boundary', async () => { + const request = { + horizon, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 1, + expiryDate: '2025-01-01', + numberOfDays: 99, + availabilityDate: '2024-01-01', + }, + ], + rules: [], + } as unknown as PlanningRequest; + let posted: { type: string; request: PlanningRequest } | undefined; + class InspectingWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(message: { type: string; request: PlanningRequest }): void { + posted = structuredClone(message); + this.onmessage?.({ + data: { type: 'result', outcome: { kind: 'Plan' } }, + } as MessageEvent); + } + + terminate(): void {} + } + vi.stubGlobal('Worker', InspectingWorker); + try { + await planLeave(request); + expect(posted?.request.balances[0]).toEqual({ + id: 'cp', + type: 'cp', + quantity: 1, + availableFrom: undefined, + expiryDate: '2025-01-01', + }); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts new file mode 100644 index 0000000..8e3516e --- /dev/null +++ b/src/app/services/leave-planner.service.ts @@ -0,0 +1,288 @@ +import type { + InvalidInputOutcome, + PlanOutcome, + PlanningIncompleteOutcome, + PlanningOutcome, + PlanningRequest, +} from './leave-planner.core'; + +export type { + IfThenPlanningRule, + InvalidInputOutcome, + LeaveBalanceInput, + LeavePlacement, + LeaveSelector, + LeaveType, + PlanOutcome, + PlacementMode, + PlanningConflictOutcome, + PlanningDate, + PlanningHorizon, + PlanningIncompleteOutcome, + PlanningMetrics, + PlanningOutcome, + PlanningReason, + PlanningRequest, + PlanningRule, + PreferenceResult, + RemainingBalance, + RemainingClassification, + RuleForce, + TimeOffPeriod, + TimeWindowPlanningRule, +} from './leave-planner.core'; + +const MAX_PLANNING_MS = 10_000; + +interface UntrustedRecord { + horizon?: unknown; + start?: unknown; + end?: unknown; + balances?: unknown; + rules?: unknown; + deadlineMs?: unknown; + progress?: unknown; + id?: unknown; + name?: unknown; + type?: unknown; + quantity?: unknown; + availableFrom?: unknown; + expiryDate?: unknown; + enabled?: unknown; + force?: unknown; + startDate?: unknown; + endDate?: unknown; + placeDays?: unknown; + placeType?: unknown; + dayOfWeek?: unknown; + placement?: unknown; + rewardDays?: unknown; + rewardType?: unknown; +} + +interface PlanningSnapshot { + horizon?: { start?: unknown; end?: unknown }; + balances?: unknown; + rules?: unknown; + deadlineMs?: unknown; + progress?: unknown; +} + +function asRecord(value: unknown): UntrustedRecord | undefined { + return typeof value === 'object' && value !== null ? (value as UntrustedRecord) : undefined; +} + +function copyDate(value: unknown): unknown { + return value instanceof Date ? new Date(value.getTime()) : value; +} + +function snapshotRequest(request: unknown): PlanningSnapshot { + const source = asRecord(request) ?? {}; + const horizon = asRecord(source.horizon); + const balances = Array.isArray(source.balances) + ? source.balances.map((balance) => { + const value = asRecord(balance); + return { + id: value?.id, + type: value?.type, + quantity: value?.quantity, + availableFrom: + value?.availableFrom === undefined + ? undefined + : copyDate(value.availableFrom), + expiryDate: copyDate(value?.expiryDate), + }; + }) + : source.balances; + const rules = Array.isArray(source.rules) + ? source.rules.map((rule) => { + const value = asRecord(rule); + const base = { + id: value?.id, + name: value?.name, + type: value?.type, + enabled: value?.enabled, + force: value?.force, + }; + if (typeof value?.type === 'string' && value.type.toLowerCase() === 'time_window') { + return { + ...base, + startDate: copyDate(value.startDate), + endDate: copyDate(value.endDate), + placeDays: value.placeDays, + placeType: value.placeType, + }; + } + return { + ...base, + dayOfWeek: value?.dayOfWeek, + placeDays: value?.placeDays, + placeType: value?.placeType, + placement: value?.placement, + rewardDays: value?.rewardDays, + rewardType: value?.rewardType, + }; + }) + : source.rules; + return { + horizon: horizon + ? { + start: copyDate(horizon.start), + end: copyDate(horizon.end), + } + : undefined, + balances, + rules, + deadlineMs: source.deadlineMs, + progress: typeof source.progress === 'function' ? source.progress : undefined, + }; +} + +function deadlineFor(request: PlanningSnapshot): number { + const configured = typeof request.deadlineMs === 'number' ? request.deadlineMs : undefined; + return Math.min(Math.max(0, configured ?? MAX_PLANNING_MS), MAX_PLANNING_MS); +} + +function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undefined { + const deadline = request.deadlineMs; + if ( + deadline !== undefined && + (typeof deadline !== 'number' || !Number.isFinite(deadline) || deadline < 0) + ) + return { + kind: 'InvalidInput', + reasons: [ + { + code: 'deadline', + message: 'The planning deadline must be a finite non-negative number.', + }, + ], + }; + return undefined; +} + +type PlannerWorkerResponse = + | { type: 'progress'; stage: string } + | { type: 'checkpoint'; plan: PlanOutcome } + | { type: 'result'; outcome: PlanningOutcome } + | { type: 'error'; message: string }; + +function interruptedOutcome( + checkpoint: PlanOutcome | undefined, + deadline: number, + reasonCode = 'deadline', + message = `Planning stopped after ${deadline}ms before all quality stages completed.`, +): PlanningIncompleteOutcome { + return { + kind: 'PlanningIncomplete', + incumbent: checkpoint, + completedStages: checkpoint?.completedStages ?? [], + lastCompletedStage: checkpoint?.lastCompletedStage, + reasons: [{ code: reasonCode, message }], + }; +} + +function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise { + return new Promise((resolve) => { + let worker: Worker; + try { + worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { + type: 'module', + }); + } catch { + resolve( + interruptedOutcome( + undefined, + deadline, + 'worker-unavailable', + 'Planning could not start because isolated execution is unavailable.', + ), + ); + return; + } + + let settled = false; + let checkpoint: PlanOutcome | undefined; + const finish = (outcome: PlanningOutcome) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + worker.onmessage = null; + worker.onerror = null; + worker.terminate(); + resolve(outcome); + }; + const timeout = setTimeout( + () => finish(interruptedOutcome(checkpoint, deadline)), + Math.max(0, deadline - 1), + ); + + worker.onmessage = (event: MessageEvent) => { + const message = event.data; + if (!message) return; + if (message.type === 'progress') { + if (typeof snapshot.progress === 'function') snapshot.progress(message.stage); + return; + } + if (message.type === 'checkpoint') { + checkpoint = message.plan; + return; + } + if (message.type === 'result') { + finish(message.outcome); + return; + } + finish(interruptedOutcome(checkpoint, deadline, 'worker-error', message.message)); + }; + worker.onerror = () => + finish( + interruptedOutcome( + checkpoint, + deadline, + 'worker-error', + 'Planning worker failed before returning an outcome.', + ), + ); + try { + worker.postMessage({ + type: 'plan', + request: { ...snapshot, deadlineMs: deadline, progress: undefined }, + }); + } catch { + finish( + interruptedOutcome( + checkpoint, + deadline, + 'worker-error', + 'Planning input could not be sent to the isolated worker.', + ), + ); + } + }); +} + +export async function planLeave(request: PlanningRequest): Promise { + const snapshot = snapshotRequest(request); + const deadlineError = invalidDeadline(snapshot); + if (deadlineError) { + await new Promise((resolve) => setTimeout(resolve, 0)); + return deadlineError; + } + const deadline = deadlineFor(snapshot); + if (typeof window !== 'undefined') { + if (typeof Worker !== 'undefined') return planInWorker(snapshot, deadline); + await new Promise((resolve) => setTimeout(resolve, 0)); + const core = await import('./leave-planner.core'); + const validation = core.invalidInputFor(snapshot); + if (validation) return validation; + return interruptedOutcome( + undefined, + deadline, + 'worker-unavailable', + 'Planning could not start because isolated execution is unavailable.', + ); + } + await new Promise((resolve) => setTimeout(resolve, 0)); + const core = await import('./leave-planner.core'); + return core.calculatePlanningInternal(snapshot); +} diff --git a/src/app/services/leave-planner.worker.ts b/src/app/services/leave-planner.worker.ts new file mode 100644 index 0000000..51a3218 --- /dev/null +++ b/src/app/services/leave-planner.worker.ts @@ -0,0 +1,45 @@ +/// + +import type { PlanningOutcome, PlanOutcome } from './leave-planner.service'; +import { calculatePlanningInternal } from './leave-planner.core'; + +type WorkerPlanningRequest = { + horizon?: { start?: unknown; end?: unknown }; + balances?: unknown; + rules?: unknown; + deadlineMs?: unknown; + progress?: unknown; +}; + +type WorkerRequest = { + type: 'plan'; + request: WorkerPlanningRequest; +}; + +type WorkerResponse = + | { type: 'progress'; stage: string } + | { type: 'checkpoint'; plan: PlanOutcome } + | { type: 'result'; outcome: PlanningOutcome } + | { type: 'error'; message: string }; + +const worker = self as DedicatedWorkerGlobalScope; + +worker.onmessage = (event: MessageEvent) => { + if (event.data?.type !== 'plan') return; + try { + const outcome = calculatePlanningInternal( + { + ...event.data.request, + progress: (stage: string) => + worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse), + }, + (plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse), + ); + worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse); + } catch (error) { + worker.postMessage({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + } satisfies WorkerResponse); + } +}; diff --git a/src/app/services/planning-date.ts b/src/app/services/planning-date.ts new file mode 100644 index 0000000..099b7e9 --- /dev/null +++ b/src/app/services/planning-date.ts @@ -0,0 +1,18 @@ +export function planningDateInParis(value: Date): string { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/Paris', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(value); + const get = (type: string) => parts.find((part) => part.type === type)?.value ?? ''; + return `${get('year')}-${get('month')}-${get('day')}`; +} + +export function addPlanningYears(key: string, years: number): string { + const date = new Date(`${key}T00:00:00Z`); + const month = date.getUTCMonth(); + date.setUTCFullYear(date.getUTCFullYear() + years); + if (date.getUTCMonth() !== month) date.setUTCDate(0); + return date.toISOString().slice(0, 10); +} diff --git a/src/app/services/rule-service.spec.ts b/src/app/services/rule-service.spec.ts index d620684..1424237 100644 --- a/src/app/services/rule-service.spec.ts +++ b/src/app/services/rule-service.spec.ts @@ -31,6 +31,43 @@ describe('RuleService', () => { expect(christmasRule.endDate instanceof Date).toBe(true); }); + it('migrates legacy force values without changing rule order or dates', () => { + TestBed.resetTestingModule(); + localStorage.setItem( + 'vacationRules', + JSON.stringify([ + { + id: 'legacy-first', + name: 'First', + type: RuleType.TIME_WINDOW, + enabled: true, + startDate: '2024-02-01', + endDate: '2024-02-05', + placeDays: 1, + placeType: SelectableDayType.CP, + }, + { + id: 'legacy-second', + name: 'Second', + type: RuleType.TIME_WINDOW, + enabled: true, + force: 'obligation', + startDate: '2024-03-01', + endDate: '2024-03-02', + placeDays: 1, + placeType: SelectableDayType.RTT, + }, + ]), + ); + TestBed.configureTestingModule({}); + service = TestBed.inject(RuleService); + + expect(service.rules.map((rule) => rule.id)).toEqual(['legacy-first', 'legacy-second']); + expect(service.rules[0].force).toBe('preference'); + const first = service.rules[0] as TimeWindowRule; + expect(first.startDate.toISOString().slice(0, 10)).toBe('2024-02-01'); + }); + it('should add a new rule', () => { const newRule: IfThenRule = { id: 'test-rule', diff --git a/src/app/services/rule-service.ts b/src/app/services/rule-service.ts index 9771e06..bfe57ba 100644 --- a/src/app/services/rule-service.ts +++ b/src/app/services/rule-service.ts @@ -7,11 +7,15 @@ export enum RuleType { TIME_WINDOW = 'time_window', } +export type RuleForce = 'obligation' | 'preference'; + export interface BaseRule { id: string; name: string; type: RuleType; enabled: boolean; + /** Missing force is a legacy preference and is migrated on read. */ + force?: RuleForce; } export type PlacementStrategy = 'before' | 'after' | 'around' | 'auto'; @@ -41,6 +45,7 @@ interface SerializedRule { name: string; type: RuleType; enabled: boolean; + force?: RuleForce; dayOfWeek?: number; placeDays: number; placeType: SelectableDayType; @@ -63,6 +68,7 @@ export class RuleService { name: 'Samedi Malin', type: RuleType.IF_THEN, enabled: true, + force: 'preference', dayOfWeek: 6, // Saturday placeDays: 2, placeType: SelectableDayType.CP, @@ -75,6 +81,7 @@ export class RuleService { name: 'Vacances de Noël', type: RuleType.TIME_WINDOW, enabled: true, + force: 'preference', startDate: new Date(new Date().getFullYear(), 11, 20), // Dec 20 endDate: new Date(new Date().getFullYear() + 1, 0, 3), // Jan 3 next year placeDays: 5, @@ -98,22 +105,31 @@ export class RuleService { if (rule.type === RuleType.TIME_WINDOW) { return { ...rule, + force: rule.force ?? 'preference', startDate: new Date(rule.startDate as string), endDate: new Date(rule.endDate as string), } as TimeWindowRule; } - return rule as IfThenRule; + return { ...rule, force: rule.force ?? 'preference' } as IfThenRule; }); } public addRule(rule: VacationRule): void { - this.rawRulesSignal.update((rules) => [...rules, rule as unknown as SerializedRule]); + this.rawRulesSignal.update((rules) => [ + ...rules, + { ...rule, force: rule.force ?? 'preference' } as unknown as SerializedRule, + ]); } public updateRule(updatedRule: VacationRule): void { this.rawRulesSignal.update((rules) => rules.map((rule) => - rule.id === updatedRule.id ? (updatedRule as unknown as SerializedRule) : rule, + rule.id === updatedRule.id + ? ({ + ...updatedRule, + force: updatedRule.force ?? 'preference', + } as unknown as SerializedRule) + : rule, ), ); } diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index 825888b..a6f02c4 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -13,10 +13,22 @@ export interface AppTranslations { calculate: string; remainingDaysWarning: string; remainingDaysManualPlace: string; + deleteEntry: string; chooseDate: string; + availableDate: string; daySuffix: string; daysSuffix: string; expires: string; + remainingClassification: { + unavoidableExpiry: string; + atRisk: string; + deferred: string; + }; + remainingReason: { + unavoidableExpiry: string; + atRisk: string; + deferred: string; + }; }; dayTypes: { weekend: string; @@ -41,6 +53,7 @@ export interface AppTranslations { ifThenTemplate: string; timeWindowTemplate: string; editRule: string; + deleteRule: string; addRule: string; place: string; ruleName: string; @@ -49,6 +62,9 @@ export interface AppTranslations { type: string; position: string; getBonus: string; + force: string; + obligation: string; + preference: string; startDate: string; endDate: string; cancel: string; @@ -75,6 +91,20 @@ export interface AppTranslations { auto: string; }; }; + planning: { + invalidInput: string; + conflict: string; + incomplete: string; + error: string; + complete: string; + }; + benchmark: { + title: string; + instructions: string; + run: string; + running: string; + copy: string; + }; loading: string; weekdaysShort: string[]; } diff --git a/src/app/services/user-input-service.ts b/src/app/services/user-input-service.ts index 225c0c4..5918fac 100644 --- a/src/app/services/user-input-service.ts +++ b/src/app/services/user-input-service.ts @@ -10,11 +10,13 @@ export class UserInputService { } private getInitialValues(): VacDay[] { + const availableDate = new Date(); return [ { id: crypto.randomUUID(), type: SelectableDayType.CP, numberOfDays: this.getRandomNumber(), + availableDate, // today + 365 days expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), }, @@ -22,12 +24,14 @@ export class UserInputService { id: crypto.randomUUID(), type: SelectableDayType.RTT, numberOfDays: this.getRandomNumber(), + availableDate: new Date(availableDate), expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), }, { id: crypto.randomUUID(), type: SelectableDayType.OTHER, numberOfDays: this.getRandomNumber(), + availableDate: new Date(availableDate), expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), }, ]; @@ -38,6 +42,7 @@ export class UserInputService { id: crypto.randomUUID(), type: SelectableDayType.CP, numberOfDays: 1, + availableDate: new Date(), expiryDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), }; } diff --git a/src/i18n/de.json b/src/i18n/de.json index dd5f1da..c578bb7 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -9,10 +9,22 @@ "calculate": "Berechnen", "remainingDaysWarning": "{count} nicht platziert:", "remainingDaysManualPlace": "Bitte manuell platzieren.", + "deleteEntry": "Eintrag löschen", "chooseDate": "Datum auswählen", + "availableDate": "Verfügbar ab", "daySuffix": "Tag", "daysSuffix": "Tage", - "expires": "abl." + "expires": "abl.", + "remainingClassification": { + "unavoidableExpiry": "Unvermeidbarer Ablauf", + "atRisk": "Gefährdet", + "deferred": "Aufgeschoben" + }, + "remainingReason": { + "unavoidableExpiry": "vor dem Planungshorizont abgelaufen", + "atRisk": "läuft im Planungshorizont ab", + "deferred": "nach dem Planungshorizont verfügbar" + } }, "dayTypes": { "weekend": "Wochenende", @@ -37,6 +49,7 @@ "ifThenTemplate": "Wenn {type} dann {placement}", "timeWindowTemplate": "Zeitfenster {startDate} bis {endDate}", "editRule": "Regel bearbeiten", + "deleteRule": "Regel löschen", "addRule": "Regel hinzufügen", "place": "platzieren", "ruleName": "Regelname", @@ -45,6 +58,9 @@ "type": "Typ", "position": "Position", "getBonus": "Bonus erhalten", + "force": "Regelverbindlichkeit", + "obligation": "Verpflichtung", + "preference": "Präferenz", "startDate": "Startdatum", "endDate": "Enddatum", "cancel": "Abbrechen", @@ -71,6 +87,20 @@ "auto": "automatisch" } }, + "planning": { + "invalidInput": "Die Planungsdaten sind ungültig.", + "conflict": "Die Verpflichtungen können nicht gemeinsam erfüllt werden.", + "incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.", + "error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.", + "complete": "Plan berechnet." + }, + "benchmark": { + "title": "Benchmark der Urlaubsplanung", + "instructions": "Führen Sie die fünf festen Szenarien aus und kopieren Sie den Bericht.", + "run": "Benchmark starten", + "running": "Läuft…", + "copy": "Bericht kopieren" + }, "loading": "Wird geladen...", "weekdaysShort": ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"] } diff --git a/src/i18n/en.json b/src/i18n/en.json index b45532b..047c269 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -9,10 +9,22 @@ "calculate": "Calculate", "remainingDaysWarning": "{count} vacation day{plural} not placed:", "remainingDaysManualPlace": "Please place them manually.", + "deleteEntry": "Delete entry", "chooseDate": "Choose a date", + "availableDate": "Available from", "daySuffix": "day", "daysSuffix": "days", - "expires": "exp." + "expires": "exp.", + "remainingClassification": { + "unavoidableExpiry": "Unavoidable expiry", + "atRisk": "At risk", + "deferred": "Deferred" + }, + "remainingReason": { + "unavoidableExpiry": "expired before the horizon", + "atRisk": "expires within the horizon", + "deferred": "available after the horizon" + } }, "dayTypes": { "weekend": "Weekend", @@ -37,6 +49,7 @@ "ifThenTemplate": "If a holiday falls on a {day}, place {placeDays} day(s) {placeType} {placement} and get {rewardDays} day(s) {rewardType} as a bonus.", "timeWindowTemplate": "Place {placeDays} day(s) {placeType} between {startDate} and {endDate}.", "editRule": "Edit rule", + "deleteRule": "Delete rule", "addRule": "Add a rule", "place": "Place", "ruleName": "Rule name", @@ -45,6 +58,9 @@ "type": "Type", "position": "Position", "getBonus": "Get as bonus", + "force": "Rule force", + "obligation": "Obligation", + "preference": "Preference", "startDate": "Start date", "endDate": "End date", "cancel": "Cancel", @@ -71,6 +87,20 @@ "auto": "Auto (Best choice)" } }, + "planning": { + "invalidInput": "The planning input is invalid.", + "conflict": "The obligations cannot be satisfied together.", + "incomplete": "Calculation stopped before all quality stages completed.", + "error": "The planner encountered an unexpected error.", + "complete": "Plan calculated." + }, + "benchmark": { + "title": "Leave planner benchmark", + "instructions": "Run the five fixed scenarios and copy the resulting report.", + "run": "Run benchmark", + "running": "Running…", + "copy": "Copy report" + }, "loading": "Calculating...", "weekdaysShort": ["M", "T", "W", "T", "F", "S", "S"] } diff --git a/src/i18n/es.json b/src/i18n/es.json index 81fd019..aa856fc 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -9,10 +9,22 @@ "calculate": "Calcular", "remainingDaysWarning": "{count} día{plural} de vacaciones no colocado{plural}:", "remainingDaysManualPlace": "Por favor, colóquelos manualmente.", + "deleteEntry": "Eliminar entrada", "chooseDate": "Elija una fecha", + "availableDate": "Disponible desde", "daySuffix": "día", "daysSuffix": "días", - "expires": "exp." + "expires": "exp.", + "remainingClassification": { + "unavoidableExpiry": "Vencimiento inevitable", + "atRisk": "En riesgo", + "deferred": "Aplazado" + }, + "remainingReason": { + "unavoidableExpiry": "caducó antes del horizonte", + "atRisk": "caduca dentro del horizonte", + "deferred": "disponible después del horizonte" + } }, "dayTypes": { "weekend": "Fin de semana", @@ -37,6 +49,7 @@ "ifThenTemplate": "Si un día festivo cae en {day}, colocar {placeDays} día(s) {placeType} {placement} y obtener {rewardDays} día(s) {rewardType} de bonificación.", "timeWindowTemplate": "Colocar {placeDays} día(s) {placeType} entre el {startDate} y el {endDate}.", "editRule": "Editar regla", + "deleteRule": "Eliminar regla", "addRule": "Añadir una regla", "place": "Colocar", "ruleName": "Nombre de la regla", @@ -45,6 +58,9 @@ "type": "Tipo", "position": "Posición", "getBonus": "Obtener en bonificación", + "force": "Fuerza de la regla", + "obligation": "Obligación", + "preference": "Preferencia", "startDate": "Fecha de inicio", "endDate": "Fecha de fin", "cancel": "Cancelar", @@ -71,6 +87,20 @@ "auto": "Auto (Mejor opción)" } }, + "planning": { + "invalidInput": "Los datos de planificación no son válidos.", + "conflict": "Las obligaciones no se pueden satisfacer juntas.", + "incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.", + "error": "El planificador encontró un error inesperado.", + "complete": "Plan calculado." + }, + "benchmark": { + "title": "Prueba del planificador de vacaciones", + "instructions": "Ejecuta los cinco escenarios fijos y copia el informe resultante.", + "run": "Ejecutar prueba", + "running": "En curso…", + "copy": "Copiar informe" + }, "loading": "Calculando...", "weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"] } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index ee47547..7552702 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -9,10 +9,22 @@ "calculate": "Calculer", "remainingDaysWarning": "{count} jour{plural} de vacances non placé{plural}:", "remainingDaysManualPlace": "Veuillez les placer manuellement.", + "deleteEntry": "Supprimer l’entrée", "chooseDate": "Choisissez une date", + "availableDate": "Disponible à partir du", "daySuffix": "jour", "daysSuffix": "jours", - "expires": "exp." + "expires": "exp.", + "remainingClassification": { + "unavoidableExpiry": "Expiration inévitable", + "atRisk": "À risque", + "deferred": "Différé" + }, + "remainingReason": { + "unavoidableExpiry": "expiré avant l’horizon", + "atRisk": "expire pendant l’horizon", + "deferred": "disponible après l’horizon" + } }, "dayTypes": { "weekend": "Weekend", @@ -37,6 +49,7 @@ "ifThenTemplate": "Si un jour férié tombe un {day}, placer {placeDays} jour(s) {placeType} {placement} et obtenir {rewardDays} jour(s) {rewardType} en bonus.", "timeWindowTemplate": "Placer {placeDays} jour(s) {placeType} entre le {startDate} et le {endDate}.", "editRule": "Modifier la règle", + "deleteRule": "Supprimer la règle", "addRule": "Ajouter une règle", "place": "Placer", "ruleName": "Nom de la règle", @@ -45,6 +58,9 @@ "type": "Type", "position": "Position", "getBonus": "Obtenir en bonus", + "force": "Force de la règle", + "obligation": "Obligation", + "preference": "Préférence", "startDate": "Date de début", "endDate": "Date de fin", "cancel": "Annuler", @@ -71,6 +87,20 @@ "auto": "Auto (Meilleur choix)" } }, + "planning": { + "invalidInput": "Les données de planification sont invalides.", + "conflict": "Les obligations ne peuvent pas être satisfaites ensemble.", + "incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.", + "error": "Le planificateur a rencontré une erreur inattendue.", + "complete": "Plan calculé." + }, + "benchmark": { + "title": "Benchmark du planificateur de congés", + "instructions": "Lancez les cinq scénarios fixes et copiez le rapport obtenu.", + "run": "Lancer le benchmark", + "running": "En cours…", + "copy": "Copier le rapport" + }, "loading": "Calcul en cours...", "weekdaysShort": ["L", "M", "M", "J", "V", "S", "D"] }