diff --git a/CONTEXT.md b/CONTEXT.md index c6bb08f..5d68f33 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -96,6 +96,15 @@ A malformed request that cannot describe a plan, such as a reversed date window. **Planning outcome**: The result of a calculation: invalid input, a planning conflict, an optimization result, or an incomplete calculation. +**Stable plan explanation**: +A deterministic explanation derived from the final displayed plan. It identifies each placement's entitlement and rule provenance, contributions, full objective vector, and the first objective that distinguishes the plan with that placement restored from the same plan with only that placement removed. + +**Original decision trace**: +The untruncated phase-by-phase record captured by the calculation that produced the displayed outcome, including atomic decisions, candidate statuses, and state deltas. It is historical evidence and is never recomputed from the final plan. + +**Counterfactual date analysis**: +A hypothetical comparison that keeps the calculation snapshot and all unrelated placements fixed while testing one selected placement across every date in the half-open planning horizon. It neither mutates the plan nor rewrites the original trace. + **Incomplete calculation**: A calculation stopped before it completed the planning objective order. It may expose its best valid plan but never implies that the obligations conflict. diff --git a/README.md b/README.md index 1c13d43..81246ec 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ The planner answers four distinct questions: - Can all mandatory rules be satisfied together? - What is the best plan found by the ordered heuristic? - If calculation stops early, what valid incumbent is available? +- Why did each displayed placement survive the objective order? +- Which alternatives were considered, and what would another date change? ## Architecture @@ -18,7 +20,7 @@ flowchart LR subgraph Browser UI["Angular standalone components"] State["Signals and localStorage-backed settings"] - Facade["planLeave(request)
async public boundary"] + Facade["Planning calculation context
immutable snapshot + async artifacts"] Worker["Leave-planner Web Worker"] Core["Synchronous planner core"] Holidays["date-holidays
French public calendar"] @@ -28,7 +30,7 @@ flowchart LR Facade --> Worker Worker --> Core Core --> Holidays - Worker -- "progress, checkpoints, outcome" --> Facade + Worker -- "progress, checkpoints, outcome, then artifacts" --> Facade Facade --> UI end ``` @@ -38,12 +40,12 @@ Leave calculation stays in the browser. The production Express process serves th ### Main execution path 1. `CalendarYear` takes a snapshot of leave balances and ordered rules. -2. `CalendarService` supplies the Europe/Paris planning horizon and exposes the single `planLeave` operation. +2. `CalendarService` supplies the Europe/Paris planning horizon and opens one immutable planning calculation context. 3. `leave-planner.service.ts` copies the request into a canonical transport shape and starts the worker. -4. `leave-planner.worker.ts` runs the framework-free core and sends progress, valid checkpoints, and the final outcome. +4. `leave-planner.worker.ts` runs the framework-free core, sends progress and valid checkpoints, publishes the outcome first, then delivers its stable explanation and original trace. 5. `CalendarYear` ignores stale calculations, renders the selected dates, and reports remaining balances or errors. -The public boundary has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is `PlanningIncomplete` with that incumbent; it is not reported as a constraint conflict. +Planning work has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is `PlanningIncomplete` with that incumbent; it is not reported as a constraint conflict. Explanation and trace delivery happen after the outcome and do not consume that planning budget. ## Planning model @@ -193,7 +195,7 @@ Open . The development server reloads when source files c ## Performance benchmark -Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, and a loaded year. It reports preparation time, calculation time, total wall time, outcome, last completed stage, placement count, and determinism. +Build or start the application, then open `/benchmark`. The isolated harness covers a representative year, typed obligations, chained earned entitlements, a deliberate conflict, and a loaded year. It reports preparation time, planning latency, separate trace-delivery latency, total wall time, outcome, last completed stage, placement count, determinism, and trace size/status. The release protocol and reference-phone thresholds are documented in [docs/benchmark/README.md](docs/benchmark/README.md). Phone timings are release evidence, not CI assertions. @@ -222,3 +224,4 @@ GitLab CI runs independent validation, test, build, security, and image stages: - [ADR 0001](docs/adr/0001-ordered-heuristic-optimization.md) records the ordered heuristic decision. - [ADR 0002](docs/adr/0002-personal-client-side-planner-for-now.md) records the client-side scope. - [ADR 0003](docs/adr/0003-constraint-first-lexicographic-planning.md) records the constraint-first planner architecture. +- [ADR 0004](docs/adr/0004-stable-explanation-original-trace-counterfactual.md) separates the outcome, stable explanation, original trace, and hypothetical analysis. diff --git a/docs/adr/0004-stable-explanation-original-trace-counterfactual.md b/docs/adr/0004-stable-explanation-original-trace-counterfactual.md new file mode 100644 index 0000000..84e9159 --- /dev/null +++ b/docs/adr/0004-stable-explanation-original-trace-counterfactual.md @@ -0,0 +1,9 @@ +# Stable Explanation, Original Trace, and Counterfactuals + +VacOptimizer exposes one client-side calculation context. Its immutable submitted snapshot and planning outcome are independent from three explanation concerns: + +- the stable plan explanation is derived from the final displayed plan and remains useful for complete or incumbent plans; +- the calculation trace is captured while the producing run executes, grouped by phase and atomic decision, and may become ready after the outcome; +- counterfactual date analysis is an explicitly hypothetical operation that removes only the selected final placement, or starts from a selected trace decision's exact pre-decision state, and never rewrites the plan or historical trace. + +The public planning outcome is delivered as soon as planning work finishes. Trace capture and delivery have separate readiness/failure state, remain local to the latest calculation, and cannot turn a valid outcome into an explanation error. Counterfactual work is cancellable or ignored when its calculation identity is stale. The existing deterministic constraint-first planner and its planning-work deadline remain unchanged. diff --git a/docs/benchmark/README.md b/docs/benchmark/README.md index a93d986..c68b6c9 100644 --- a/docs/benchmark/README.md +++ b/docs/benchmark/README.md @@ -4,10 +4,11 @@ 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. +The harness measures preparation, planning-to-outcome latency, later trace +delivery, and total wall time separately. It records the outcome, last +completed quality stage, placement count, determinism, trace status, and trace +candidate count. `PlanningIncomplete` is not a conflict: its last completed +stage and incumbent are retained. ## Protocol @@ -24,5 +25,7 @@ not a conflict: its last completed stage and incumbent are retained. 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. +The browser page uses the same `createPlanningCalculation(request)` context as +the calendar and adds no solver or backend dependency. The ten-second release +ceiling applies to planning latency; trace delivery is reported separately and +cannot replace a valid planning outcome. diff --git a/src/app/benchmark/leave-planner-benchmark.spec.ts b/src/app/benchmark/leave-planner-benchmark.spec.ts index 21922ef..f525709 100644 --- a/src/app/benchmark/leave-planner-benchmark.spec.ts +++ b/src/app/benchmark/leave-planner-benchmark.spec.ts @@ -29,7 +29,13 @@ describe('leave planner benchmark protocol', () => { expect(chained?.placements).toBeGreaterThanOrEqual(3); expect(loaded?.outcome).toBe('Plan'); expect(loaded?.deterministic).toBe(true); - expect(benchmarkReport(measurements).split('\n')).toHaveLength(6); + expect(loaded?.planningMs).toBeGreaterThanOrEqual(0); + expect(loaded?.traceDeliveryMs).toBeGreaterThanOrEqual(0); + expect(loaded?.traceStatus).toBe('ready'); + expect(loaded?.traceCandidates).toBeGreaterThan(0); + const report = benchmarkReport(measurements); + expect(report.split('\n')).toHaveLength(6); + expect(report).toContain('planning_ms\ttrace_delivery_ms'); } finally { vi.stubGlobal('window', browserWindow); } diff --git a/src/app/benchmark/leave-planner-benchmark.ts b/src/app/benchmark/leave-planner-benchmark.ts index 6a870c6..b71b336 100644 --- a/src/app/benchmark/leave-planner-benchmark.ts +++ b/src/app/benchmark/leave-planner-benchmark.ts @@ -1,4 +1,9 @@ -import { planLeave, PlanningOutcome, PlanningRequest } from '../services/leave-planner.service'; +import { + createPlanningCalculation, + PlanningOutcome, + PlanningRequest, + PlanningTrace, +} from '../services/leave-planner.service'; export interface BenchmarkScenario { name: string; @@ -9,12 +14,15 @@ export interface BenchmarkMeasurement { scenario: string; repetition: number; preparationMs: number; - calculationMs: number; + planningMs: number; + traceDeliveryMs: number; wallMs: number; outcome: PlanningOutcome['kind']; lastCompletedStage?: string; placements: number; deterministic: boolean; + traceStatus: PlanningTrace['status'] | 'not-produced'; + traceCandidates: number; } const balance = ( @@ -166,18 +174,35 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise + total + + phase.decisions.reduce( + (decisionTotal, decision) => + decisionTotal + decision.candidates.length, + 0, + ), + 0, + ) ?? 0, }); previous = stable; } @@ -187,7 +212,7 @@ export async function runLeavePlannerBenchmark(repetitions = 1): Promise @@ -195,12 +220,15 @@ export function benchmarkReport(measurements: BenchmarkMeasurement[]): string { item.scenario, item.repetition, item.preparationMs.toFixed(1), - item.calculationMs.toFixed(1), + item.planningMs.toFixed(1), + item.traceDeliveryMs.toFixed(1), item.wallMs.toFixed(1), item.outcome, item.lastCompletedStage ?? '', item.placements, item.deterministic, + item.traceStatus, + item.traceCandidates, ].join('\t'), ), ].join('\n'); diff --git a/src/app/components/calendar-month/calendar-month.html b/src/app/components/calendar-month/calendar-month.html index 4af8566..d8bd4c8 100644 --- a/src/app/components/calendar-month/calendar-month.html +++ b/src/app/components/calendar-month/calendar-month.html @@ -26,21 +26,45 @@

} @else { @let dayTooltip = getDayTooltip(day); -

- {{ day.getDate() }} -

+ {{ day.getDate() }} + @if (status) { + + } + } } diff --git a/src/app/components/calendar-month/calendar-month.scss b/src/app/components/calendar-month/calendar-month.scss index e163de5..7848abe 100644 --- a/src/app/components/calendar-month/calendar-month.scss +++ b/src/app/components/calendar-month/calendar-month.scss @@ -22,6 +22,7 @@ } .day { + position: relative; text-align: center; display: flex; align-items: center; @@ -32,7 +33,10 @@ margin-top: 2px; margin-bottom: 2px; border-radius: 30%; + border: 1px solid transparent; color: var(--number-color); + font: inherit; + padding: 0; &.empty { visibility: hidden; @@ -41,7 +45,17 @@ &.number:hover { background-color: var(--number-bg-hover); color: var(--number-color-hover); - cursor: default; + cursor: pointer; + } + + &.number:focus-visible { + outline: 3px solid var(--today-border-color, #1976d2); + outline-offset: 2px; + } + + &.context-only { + opacity: 0.38; + cursor: not-allowed; } &.past { @@ -49,8 +63,66 @@ } } + .status-marker { + position: absolute; + inset-block-start: -0.35rem; + inset-inline-end: -0.2rem; + min-width: 0.85rem; + font-size: 0.65rem; + font-weight: 800; + line-height: 1; + background: var(--ui-card-bg, #fff); + border-radius: 999px; + } + + .current { + outline: 3px double var(--today-border-color, #1976d2); + outline-offset: 1px; + } + + .eligible, + .chosen { + border-block-end: 3px solid var(--ui-success-text, #174d26); + } + + .ineligible, + .evaluated-rejected { + text-decoration: line-through; + text-decoration-thickness: 2px; + } + + .pruned { + border: 2px dotted currentColor; + } + + .not-examined { + border: 2px dashed currentColor; + } + + .change-added, + .change-moved-to { + box-shadow: inset 0 -3px var(--ui-success-text, #174d26); + } + + .change-moved-from, + .change-removed { + box-shadow: inset 0 3px var(--ui-error-text, #8b1a1a); + } + + .change-shared { + box-shadow: inset 3px 0 var(--today-border-color, #1976d2); + } + + .change-unchanged { + box-shadow: inset 0 0 0 1px currentColor; + } + .selected { - cursor: help !important; + cursor: pointer; + } + + .context-only.selected { + cursor: not-allowed; } .selected:not(.start):not(.end) { diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts index d668d30..e247219 100644 --- a/src/app/components/calendar-month/calendar-month.ts +++ b/src/app/components/calendar-month/calendar-month.ts @@ -1,10 +1,19 @@ -import { Component, Input, OnInit, inject } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; import { CalendarService, Month, SelectedDates } from '../../services/calendar-service'; import { MatTooltip } from '@angular/material/tooltip'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { DateCacheService } from '../../services/date-cache.service'; import { TranslationService } from '../../services/translation-service'; +export interface CalendarDayStatus { + kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined'; + label: string; + reason?: string; + historicalLabel?: string; + change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged'; + changeLabel?: string; +} + @Component({ selector: 'app-calendar-month', imports: [MatTooltip], @@ -38,6 +47,89 @@ export class CalendarMonth implements OnInit { @Input({ required: true }) selectedDates!: SelectedDates; + @Input() + horizonStart?: string; + + @Input() + horizonEnd?: string; + + @Input() + currentDate: string | null = null; + + @Input() + dateStatuses: Readonly> = {}; + + @Output() + readonly dateSelected = new EventEmitter(); + + getDateKey(day: Date): string { + return [ + day.getFullYear(), + String(day.getMonth() + 1).padStart(2, '0'), + String(day.getDate()).padStart(2, '0'), + ].join('-'); + } + + isInHorizon(day: Date): boolean { + const date = this.getDateKey(day); + return ( + (!this.horizonStart || date >= this.horizonStart) && + (!this.horizonEnd || date < this.horizonEnd) + ); + } + + getStatus(day: Date): CalendarDayStatus | undefined { + return this.dateStatuses[this.getDateKey(day)]; + } + + getStatusMarker(day: Date): string { + switch (this.getStatus(day)?.change) { + case 'added': + return '+'; + case 'moved-from': + return '←'; + case 'moved-to': + return '→'; + case 'removed': + return '−'; + case 'shared': + return '≈'; + case 'unchanged': + return '='; + } + switch (this.getStatus(day)?.kind) { + case 'chosen': + case 'eligible': + return '✓'; + case 'ineligible': + return '×'; + case 'evaluated-rejected': + return '−'; + case 'pruned': + return '⌁'; + case 'not-examined': + return '?'; + default: + return ''; + } + } + + getDayAriaLabel(day: Date): string { + const parts = [ + day.toLocaleDateString(this.translationService.locale(), { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }), + ]; + const tooltip = this.getDayTooltip(day); + if (tooltip) parts.push(tooltip); + if (!this.isInHorizon(day)) + parts.push(this.translationService.tr().planning.outsideHorizon); + return parts.join('. '); + } + getMonthName(): string { const locale = this.translationService.locale(); const tempDate = new Date(this.year, this.monthIndex, 1); @@ -69,9 +161,19 @@ export class CalendarMonth implements OnInit { } getDayTooltip(day: Date): string | null { - const tooltips = this.selectedDates.datesSelected + const tooltips: string[] = this.selectedDates.datesSelected .filter((dateSelected) => dateSelected.isSelected(day) && dateSelected.type) .map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!)); + const status = this.getStatus(day); + if (status) { + tooltips.push(status.label); + if (status.changeLabel) tooltips.push(status.changeLabel); + if (status.historicalLabel) + tooltips.push( + `${this.translationService.tr().planning.historical}: ${status.historicalLabel}`, + ); + if (status.reason) tooltips.push(status.reason); + } return tooltips.length > 0 ? tooltips.join(' + ') : null; } diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index 5d8749c..8864263 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -2,15 +2,26 @@ 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 { + CalendarService, + DayType, + SelectedDate, + SelectedDates, + SelectableDayType, +} from '../../services/calendar-service'; +import { + CounterfactualAnalysis, PlanOutcome, + PlanningCalculationContext, PlanningConflictOutcome, + PlanningExplanation, PlanningIncompleteOutcome, InvalidInputOutcome, PlanningOutcome, + PlanningTrace, } from '../../services/leave-planner.service'; import { UserInputService } from '../../services/user-input-service'; +import { DateRange } from '@angular/material/datepicker'; const invalid: InvalidInputOutcome = { kind: 'InvalidInput', @@ -64,18 +75,120 @@ const plan: PlanOutcome = { lastCompletedStage: 'dates', }; +const objectiveVector = { + objectives: [{ id: 'non-working-day-gain' as const, direction: 'maximize' as const, value: 4 }], +}; + +const explanation: PlanningExplanation = { + kind: 'PlanExplanation', + placements: plan.placements.map((placement, index) => ({ + placement, + balance: { + id: 'cp', + name: 'Paid leave', + type: 'cp', + quantity: 2, + expiryDate: '2024-12-31', + }, + ruleIds: placement.ruleIds, + supporting: false, + remainingQuantity: 1 - index, + contributions: [{ kind: 'entitlement', code: 'balance', label: 'Paid leave', value: 1 }], + finalMetrics: plan.metrics, + objectiveVector, + comparison: { + before: { objectives: [{ ...objectiveVector.objectives[0], value: 3 }] }, + after: objectiveVector, + delta: [ + { + id: 'non-working-day-gain', + direction: 'maximize', + value: 1, + }, + ], + firstDecisiveObjective: 'non-working-day-gain', + }, + firstDecisiveObjective: 'non-working-day-gain', + })), +}; + +function selectedDatesForPlan(value: PlanOutcome): SelectedDates { + const selected = new SelectedDates(); + for (const placement of value.placements) { + const date = new Date(`${placement.date}T00:00:00`); + selected.push(new SelectedDate(DayType.CP, new DateRange(date, date))); + } + return selected; +} + +function calculationFor( + outcome: PlanningOutcome, + stableExplanation?: PlanningExplanation, + trace?: PlanningTrace, +): PlanningCalculationContext { + const counterfactual: CounterfactualAnalysis = { + kind: 'Counterfactual', + calculationId: 'test', + placement: plan.placements[0], + hypothetical: true, + baseline: objectiveVector, + results: [ + { + date: '2024-05-03', + status: 'eligible', + comparison: + stableExplanation?.kind === 'PlanExplanation' + ? stableExplanation.placements[0].comparison + : undefined, + }, + { + date: '2024-05-04', + status: 'ineligible', + reason: { code: 'non-working', message: 'Weekend' }, + }, + ], + }; + return { + id: 'test', + snapshot: { + horizon: plan.horizon, + balances: [], + rules: [], + identities: {}, + labels: {}, + }, + outcome: Promise.resolve(outcome), + explanation: Promise.resolve(stableExplanation), + explanationError: Promise.resolve(undefined), + trace: Promise.resolve(trace), + analyzeCounterfactual: vi.fn(async () => counterfactual), + }; +} + +function calendarFor( + outcome: PlanningOutcome, + stableExplanation?: PlanningExplanation, + trace?: PlanningTrace, +) { + const calculation = calculationFor(outcome, stableExplanation, trace); + return { + calculation, + calendar: { + getSelectedDatesForYear: vi.fn(() => new SelectedDates()), + getDefaultPlanningHorizon: vi.fn(() => plan.horizon), + createPlanningCalculation: vi.fn(() => calculation), + selectedDatesForPlan: vi.fn(selectedDatesForPlan), + }, + }; +} + 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()), - }; + const { calendar } = calendarFor(outcome); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ @@ -100,12 +213,7 @@ describe('CalendarYear planning outcome boundary', () => { }); it('keeps and summarizes a returned result', 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()), - }; + const { calendar } = calendarFor(plan, explanation); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ @@ -127,13 +235,215 @@ describe('CalendarYear planning outcome boundary', () => { expect(summaryValues).toEqual(['2', '4', '6']); }); + it('keeps an incomplete incumbent visible with its interrupted-stage explanation', async () => { + const outcome: PlanningIncompleteOutcome = { + kind: 'PlanningIncomplete', + incumbent: plan, + completedStages: ['at-risk'], + lastCompletedStage: 'at-risk', + reasons: [{ code: 'deadline', message: 'Planning stopped at the deadline.' }], + }; + const incumbentExplanation: PlanningExplanation = { + ...explanation, + incomplete: { + kind: 'IncompleteExplanation', + reason: outcome.reasons[0], + completedStages: ['at-risk'], + uncompletedStages: ['holiday-week-value'], + }, + }; + const { calendar } = calendarFor(outcome, incumbentExplanation); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.incomplete-incumbent').textContent).toContain( + 'holiday-week-value', + ); + expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); + }); + + it('shows the stable placement explanation by default and keeps the full plan visible', async () => { + const { calendar, calculation } = calendarFor(plan, explanation); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.placement-explanation')).not.toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Paid leave'); + expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); + expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]); + + const next = fixture.nativeElement.querySelector( + 'button[data-step="next"]', + ) as HTMLButtonElement; + next.click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe( + '2024-05-06', + ); + }); + + it('renders conflict causes and replays an atomic trace decision', async () => { + const conflictExplanation: PlanningExplanation = { + kind: 'ConflictExplanation', + conflictingRules: [{ id: 'rule', label: 'Required spring leave' }], + bottleneck: { code: 'typed-capacity', message: 'No CP entitlement is available.' }, + }; + let setup = calendarFor(conflict, conflictExplanation); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: setup.calendar }, + ], + }).compileComponents(); + let fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain( + 'Required spring leave', + ); + expect(fixture.nativeElement.querySelector('.conflict-explanation').textContent).toContain( + 'No CP entitlement is available.', + ); + + TestBed.resetTestingModule(); + const trace: PlanningTrace = { + status: 'ready', + initialState: { + horizon: plan.horizon, + balances: [ + { + id: 'cp', + type: 'cp', + quantity: 2, + remaining: 2, + availableFrom: '2024-01-01', + expiryDate: '2024-12-31', + earned: false, + }, + ], + placements: [plan.placements[1]], + earnedActivations: [], + }, + phases: [ + { + id: 'phase:1', + name: 'at-risk', + completed: true, + status: 'completed', + decisions: [ + { + id: 'decision:1', + action: 'add', + dates: ['2024-05-03'], + candidates: [ + { + status: 'chosen', + date: '2024-05-03', + placement: plan.placements[0], + }, + ], + deltas: [ + { + kind: 'placement-added', + date: '2024-05-03', + balanceId: 'cp', + placement: plan.placements[0], + }, + ], + }, + ], + }, + { + id: 'phase:2', + name: 'dates', + completed: true, + status: 'completed', + decisions: [], + }, + ], + completedStages: ['at-risk'], + }; + setup = calendarFor(plan, explanation, trace); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: setup.calendar }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + await fixture.whenStable(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + const lastPhase = fixture.nativeElement.querySelector( + 'button[data-phase-step="last"]', + ) as HTMLButtonElement; + lastPhase.click(); + fixture.detectChanges(); + expect(fixture.componentInstance['tracePhaseIndex']()).toBe(1); + const firstPhase = fixture.nativeElement.querySelector( + 'button[data-phase-step="first"]', + ) as HTMLButtonElement; + firstPhase.click(); + fixture.detectChanges(); + const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0]; + beforeButton.click(); + fixture.detectChanges(); + expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1); + expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged'); + expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added'); + const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1]; + afterButton.click(); + fixture.detectChanges(); + expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(2); + const compareButton = fixture.nativeElement.querySelector( + '.candidate-list button', + ) as HTMLButtonElement; + compareButton.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + expect(fixture.componentInstance['dateStatuses']()['2024-05-04'].kind).toBe('ineligible'); + const alternativeDate = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-04"]', + ) as HTMLButtonElement; + alternativeDate.click(); + fixture.detectChanges(); + expect(alternativeDate.classList).toContain('current'); + expect(alternativeDate.getAttribute('aria-current')).toBe('date'); + }); + 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'); - }), + createPlanningCalculation: vi.fn(() => ({ + ...calculationFor(plan), + outcome: Promise.reject(new Error('planner exploded')), + })), selectedDatesForPlan: vi.fn(() => new SelectedDates()), }; await TestBed.configureTestingModule({ diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 695c331..9e14ddb 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -1,4 +1,6 @@
+

{{ liveMessage() }}

+ @if (isLoading()) {
@@ -50,6 +52,73 @@ + @if (planningOutcome(); as outcome) { + @if (outcome.kind === 'InvalidInput') { + + } + } + + @if (planningExplanation(); as outcomeExplanation) { + @if (outcomeExplanation.kind === 'ConflictExplanation') { +
+

{{ tr().planning.conflictDetails }}

+

+ {{ tr().planning.conflictingRules }}: +

+
    + @for (rule of outcomeExplanation.conflictingRules; track rule.id) { +
  • + {{ rule.label }} {{ rule.id }} +
  • + } +
+

+ {{ tr().planning.bottleneck }}: + {{ outcomeExplanation.bottleneck.message }} +

+
+ } @else if (outcomeExplanation.kind === 'IncompleteExplanation') { +
+

{{ tr().planning.incompleteDetails }}

+

{{ outcomeExplanation.reason.message }}

+
+
+
{{ tr().planning.completedStages }}
+
+ {{ + outcomeExplanation.completedStages.length + ? outcomeExplanation.completedStages.join(', ') + : tr().planning.none + }} +
+
+
+
{{ tr().planning.uncompletedStages }}
+
+ {{ + outcomeExplanation.uncompletedStages.length + ? outcomeExplanation.uncompletedStages.join(', ') + : tr().planning.none + }} +
+
+
+
+ } + } + @if (displayedPlan(); as plan) {
@@ -67,6 +136,329 @@
} + @if (selectedExplanation(); as selected) { +
+
+
+

{{ tr().planning.whyPlacement }}

+

+ {{ selected.placement.date }} · {{ balanceLabel(selected) }} +

+
+ +
+ @if (planExplanation()!.incomplete; as interrupted) { +
+ {{ tr().planning.incumbentExplanation }} +

{{ interrupted.reason.message }}

+

+ {{ tr().planning.completedStages }}: + {{ + interrupted.completedStages.length + ? interrupted.completedStages.join(', ') + : tr().planning.none + }} +

+

+ {{ tr().planning.uncompletedStages }}: + {{ + interrupted.uncompletedStages.length + ? interrupted.uncompletedStages.join(', ') + : tr().planning.none + }} +

+
+ } + +
+
+
{{ tr().planning.balance }}
+
+ {{ balanceLabel(selected) }} {{ selected.placement.balanceId }} + @if (selected.balance) { +
{{ planningDate(selected.balance.availableFrom) }} – + {{ planningDate(selected.balance.expiryDate) }} + } +
+
+
+
{{ tr().planning.rules }}
+
+ @if (selected.ruleIds.length) { + @for (ruleId of selected.ruleIds; track ruleId; let last = $last) { + {{ ruleLabel(ruleId) }} {{ ruleId }}{{ last ? '' : ', ' }} + } + } @else { + {{ tr().planning.noRules }} + } +
+
+
+
{{ tr().planning.supporting }}
+
{{ selected.supporting ? tr().planning.yes : tr().planning.no }}
+
+
+
{{ tr().planning.remaining }}
+
+ {{ selected.remainingQuantity }} + @if (selected.remainingBalance) { +
+ {{ formatIdentifier(selected.remainingBalance.classification) }} · + {{ selected.remainingBalance.reason.message }} + + } +
+
+ @if (selected.timeOffPeriod; as period) { +
+
{{ tr().planning.timeOffPeriod }}
+
{{ period.start }} – {{ period.end }} ({{ period.duration }})
+
+ } + @if (selected.firstDecisiveObjective; as decisive) { +
+
{{ tr().planning.firstDecisive }}
+
{{ formatIdentifier(decisive) }}
+
+ } +
+ +
+
+

{{ tr().planning.contributions }}

+
    + @for ( + contribution of selected.contributions; + track contribution.kind + contribution.code + ) { +
  • + {{ contribution.label }} + @if (contribution.value !== undefined) { + · {{ contribution.value }} + } + @if (contribution.dates?.length) { + · {{ contribution.dates!.join(', ') }} + } +
  • + } +
+
+
+

{{ tr().planning.objectiveVector }}

+
    + @for ( + objective of selected.objectiveVector.objectives; + track objective.id + ) { +
  1. + {{ objectiveLabel(objective) }} + {{ objective.value }} + {{ formatIdentifier(objective.direction) }} +
  2. + } +
+
+
+

{{ tr().planning.finalMetrics }}

+
+
+
{{ tr().planning.unplacedAtRisk }}
+
{{ selected.finalMetrics.unplacedAtRisk }}
+
+
+
{{ tr().planning.holidayWeekValue }}
+
{{ selected.finalMetrics.holidayWeekValue }}
+
+
+
{{ tr().planning.daysGained }}
+
{{ selected.finalMetrics.nonWorkingDayGain }}
+
+
+
{{ tr().planning.longestWorkSequence }}
+
{{ selected.finalMetrics.longestWorkSequence }}
+
+
+
{{ tr().planning.shortSequenceDeficit }}
+
{{ selected.finalMetrics.shortSequenceDeficit }}
+
+
+
{{ tr().planning.supportingPlacements }}
+
{{ selected.finalMetrics.supportingPlacements }}
+
+
+
{{ tr().planning.preferenceSatisfaction }}
+
+ {{ + selected.finalMetrics.preferenceSatisfaction.join(', ') || + tr().planning.none + }} +
+
+
+
+
+ +
+ {{ tr().planning.removedRestoredComparison }} +

{{ tr().planning.comparisonHelp }}

+
    + @for (delta of selected.comparison.delta; track delta.id) { +
  • + {{ formatIdentifier(delta.id) }}: {{ delta.value }} +
  • + } +
+
+ +
+

{{ tr().planning.counterfactual }}

+

{{ tr().planning.hypothetical }}

+ @if (counterfactual(); as analysis) { +

+ {{ analysis.placement.date }} · + {{ analysis.placement.balanceId }} +

+ } + @if (counterfactualLoading()) { +

{{ tr().planning.counterfactualLoading }}

+ } @else if (counterfactualError()) { + + } @else if (selectedCounterfactualResult(); as result) { +
+
{{ result.date }}
+

+ {{ + result.status === 'eligible' + ? tr().planning.eligible + : tr().planning.ineligible + }} +

+ @if (result.historicalStatus) { +

+ {{ tr().planning.historical }}: + {{ traceStatusLabel(result.historicalStatus) }} +

+ } + @if (result.reason) { +

+ {{ tr().planning.reason }}: + {{ result.reason.message }} +

+ } + @if (result.comparison; as comparison) { +

+ {{ tr().planning.firstDecisive }}: + {{ + comparison.firstDecisiveObjective + ? formatIdentifier(comparison.firstDecisiveObjective) + : tr().planning.none + }} +

+
    + @for (delta of comparison.delta; track delta.id) { +
  • + {{ formatIdentifier(delta.id) }}: + {{ delta.value }} +
  • + } +
+
+ {{ tr().planning.fullComparison }} +
+
+ {{ tr().planning.before }} +
    + @for ( + objective of comparison.before.objectives; + track objective.id + ) { +
  1. + {{ objectiveLabel(objective) }}: + {{ objective.value }} +
  2. + } +
+
+
+ {{ tr().planning.after }} +
    + @for ( + objective of comparison.after.objectives; + track objective.id + ) { +
  1. + {{ objectiveLabel(objective) }}: + {{ objective.value }} +
  2. + } +
+
+
+
+ } +
+ } +
+
+ } @else if (explanationError()) { + + } +
{{ tr().planning.legend }}
    @@ -90,20 +482,406 @@ {{ tr().dayTypes.today }} +
  • + {{ tr().planning.currentPlacement }} +
  • +
  • + {{ tr().planning.eligible }} +
  • +
  • + {{ tr().planning.ineligible }} +
- @for (month of months; track $index) { + @for (month of months(); track month.year + '-' + month.monthIndex) { }
+ + @if (planningOutcome() && planningOutcome()?.kind !== 'InvalidInput') { +
+ {{ tr().planning.traceTitle }} + @if (traceLoading()) { +

{{ tr().planning.traceLoading }}

+ } @else if (planningTrace(); as trace) { + @if (trace.error) { + + } +
+ + + @if (selectedTracePhase(); as phase) { +
+

{{ formatIdentifier(phase.name) }}

+ @if (phase.decisions.length) { + + + @if (selectedTraceDecision(); as decision) { +
+ + + +
+

+ {{ tr().planning.dates }}: + {{ + decision.dates.length + ? decision.dates.join(', ') + : tr().planning.none + }} +

+ @if (decision.reason) { +

+ {{ tr().planning.reason }}: + {{ decision.reason.message }} +

+ } + +

{{ tr().planning.candidates }}

+ @if (decision.candidates.length > 50) { + + } +
+ @for (candidate of visibleTraceCandidates(); track $index) { +
+ + + {{ traceStatusLabel(candidate.status) }} · + @if ( + candidate.sourceDate && + candidate.sourceDate !== candidate.date + ) { + {{ candidate.sourceDate }} → + } + {{ + candidate.dates?.join(', ') || + candidate.date || + tr().planning.none + }} + + @if (candidate.reason) { +

{{ candidate.reason.message }}

+ } + @if (candidate.comparison?.firstDecisiveObjective) { +

+ {{ + tr().planning.firstDecisive + }}: + {{ + formatIdentifier( + candidate.comparison! + .firstDecisiveObjective! + ) + }} +

+ } + @if (candidate.comparison; as comparison) { + @if (comparison.tieBreak) { +

+ {{ + tr().planning.tieBreak + }}: + {{ + formatIdentifier( + comparison.tieBreak + ) + }} +

+ } +
    + @for ( + delta of comparison.delta; + track delta.id + ) { +
  • + {{ + formatIdentifier(delta.id) + }}: + {{ delta.value }} +
  • + } +
+
+
+ {{ + tr().planning.before + }} +
    + @for ( + objective of comparison.before + .objectives; + track objective.id + ) { +
  1. + {{ + objectiveLabel( + objective + ) + }}: {{ objective.value }} +
  2. + } +
+
+
+ {{ + tr().planning.after + }} +
    + @for ( + objective of comparison.after + .objectives; + track objective.id + ) { +
  1. + {{ + objectiveLabel( + objective + ) + }}: {{ objective.value }} +
  2. + } +
+
+
+ } + @if (candidate.placement) { + + } +
+ } +
+ +

{{ tr().planning.deltas }}

+
    + @for (delta of decision.deltas; track $index) { +
  • + {{ formatIdentifier(delta.kind) }} + @if (delta.fromDate || delta.date) { + · {{ delta.fromDate || delta.date }} + } + @if (delta.toDate) { + → {{ delta.toDate }} + } + @if (delta.balanceId) { + · {{ delta.balanceId }} + } + @if (delta.remainingAfter !== undefined) { + · {{ delta.remainingBefore }} → + {{ delta.remainingAfter }} + } +
  • + } +
+ @if (traceState(); as state) { +

+ {{ + traceMoment() === 'before' + ? tr().planning.before + : tr().planning.after + }}: {{ state.placements.length }} + {{ tr().planning.placements }}, + {{ state.earnedActivations.length }} + {{ tr().planning.activations }} +

+ } + } + } @else { +

{{ tr().planning.noDecisions }}

+ } +
+ } +
+ } @else if (traceResolved()) { +

{{ tr().planning.traceUnavailable }}

+ } +
+ }
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index 1ac06c6..824c8fe 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -3,6 +3,18 @@ min-width: 0; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .calendar-year { display: grid; container: calendar-year / inline-size; @@ -146,6 +158,261 @@ } } +.reason-list, +.outcome-explanation, +.placement-explanation, +.trace-explorer { + margin: 0; + padding: 1rem; + border: 1px solid var(--ui-border, #c7c7d0); + border-radius: var(--ui-radius-md, 0.75rem); + background: var(--ui-card-bg, #fff); +} + +.outcome-explanation, +.placement-explanation { + display: grid; + gap: 1rem; + + h3, + h4, + h5, + p, + ul, + ol { + margin-block: 0; + } +} + +.conflict-explanation { + border-inline-start: 0.35rem solid var(--ui-error-text, #751b1b); +} + +.incomplete-explanation { + border-inline-start: 0.35rem solid var(--ui-warning-text, #4f3800); +} + +.incomplete-incumbent { + display: grid; + gap: 0.35rem; + padding: 0.75rem 1rem; + border-inline-start: 0.35rem solid var(--ui-warning-text, #4f3800); + background: var(--ui-warning-bg, #fff4d6); + + p { + margin: 0; + } +} + +.explanation-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.eyebrow { + color: var(--ui-text-secondary, #45464f); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.placement-stepper, +.trace-toolbar, +.trace-decisions nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + + button, + output { + min-height: 2.5rem; + padding: 0.45rem 0.7rem; + border: 1px solid var(--ui-border, #777984); + border-radius: var(--ui-radius-sm, 0.5rem); + background: var(--ui-card-bg, #fff); + color: inherit; + font: inherit; + } + + button { + cursor: pointer; + + &:hover:not(:disabled), + &[aria-pressed='true'] { + background: var(--ui-panel-bg, #ececf2); + } + + &:focus-visible { + outline: 3px solid var(--today-border-color, #1976d2); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + } + + output { + display: inline-flex; + align-items: center; + font-variant-numeric: tabular-nums; + } +} + +.compact-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr)); + gap: 0.75rem; + margin: 0; + + div { + min-width: 0; + padding: 0.75rem; + border-radius: var(--ui-radius-sm, 0.5rem); + background: var(--ui-panel-bg, #f7f7fa); + } + + dt { + margin-block-end: 0.25rem; + color: var(--ui-text-secondary, #45464f); + font-size: 0.8rem; + font-weight: 700; + } + + dd { + margin: 0; + line-height: 1.5; + overflow-wrap: anywhere; + } +} + +code { + font-size: 0.8em; + overflow-wrap: anywhere; +} + +.explanation-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); + gap: 1rem; + + section { + display: grid; + align-content: start; + gap: 0.75rem; + padding: 1rem; + border-radius: var(--ui-radius-sm, 0.5rem); + background: var(--ui-panel-bg, #f7f7fa); + } +} + +.objective-vector { + display: grid; + gap: 0.4rem; + padding-inline-start: 1.5rem; + + li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0 0.75rem; + } + + small { + grid-column: 1 / -1; + color: var(--ui-text-secondary, #45464f); + } +} + +.metric-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.4rem 1rem; + margin: 0; + + div { + display: flex; + justify-content: space-between; + gap: 0.5rem; + } + + dt { + color: var(--ui-text-secondary, #45464f); + } + + dd { + margin: 0; + font-weight: 700; + font-variant-numeric: tabular-nums; + } +} + +.comparison-details, +.counterfactual-panel { + padding: 0.75rem 1rem; + border-radius: var(--ui-radius-sm, 0.5rem); + background: var(--ui-panel-bg, #f7f7fa); +} + +.comparison-details summary, +.trace-explorer > summary, +.candidate-list summary { + cursor: pointer; + font-weight: 700; +} + +.counterfactual-result { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem 1rem; +} + +.comparison-vectors { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: 0.75rem; + margin-block-start: 0.5rem; + + div { + min-width: 0; + } + + ol { + margin-block: 0.35rem 0; + padding-inline-start: 1.25rem; + } + + li { + overflow-wrap: anywhere; + } +} + +.status-badge { + padding: 0.2rem 0.5rem; + border: 2px solid var(--ui-success-text, #174d26); + border-radius: 999px; + font-weight: 700; + + &.ineligible { + border-color: var(--ui-error-text, #751b1b); + text-decoration: line-through; + } +} + +.inline-error { + margin: 0; + padding: 0.75rem; + border-inline-start: 0.35rem solid var(--ui-error-text, #751b1b); + background: var(--ui-error-bg, #ffe0e0); + color: var(--ui-error-text, #751b1b); +} + .calendar-legend { display: flex; flex-wrap: wrap; @@ -212,15 +479,124 @@ } } +.legend-symbol { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + font-weight: 800; +} + .month-grid { - display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-evenly; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr)); + gap: 1rem; width: 100%; max-width: 100%; } .month-card { - margin: 16px; + min-width: 0; +} + +.trace-explorer { + summary { + font-size: 1.05rem; + } +} + +.trace-layout { + display: grid; + grid-template-columns: minmax(12rem, 0.35fr) minmax(0, 1fr); + gap: 1rem; + margin-block-start: 1rem; +} + +.trace-phases { + display: grid; + align-content: start; + gap: 0.35rem; + + button { + display: flex; + justify-content: space-between; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--ui-border, #c7c7d0); + border-radius: var(--ui-radius-sm, 0.5rem); + background: transparent; + color: inherit; + font: inherit; + text-align: start; + cursor: pointer; + + &.active { + border-inline-start: 0.35rem solid var(--today-border-color, #1976d2); + background: var(--ui-panel-bg, #f7f7fa); + } + + small { + color: var(--ui-text-secondary, #45464f); + } + } +} + +.trace-phase-stepper { + display: flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + + output { + min-width: 4rem; + text-align: center; + } +} + +.trace-decisions { + display: grid; + align-content: start; + gap: 0.85rem; + min-width: 0; + + h3, + h4, + p, + ul { + margin-block: 0; + } +} + +.candidate-list { + display: grid; + gap: 0.4rem; + + details { + padding: 0.55rem 0.75rem; + border: 1px solid var(--ui-border, #c7c7d0); + border-radius: var(--ui-radius-sm, 0.5rem); + } +} + +.status-symbol { + display: inline-block; + min-width: 1rem; + font-weight: 800; +} + +.trace-state-summary { + padding: 0.75rem; + border: 2px solid var(--today-border-color, #1976d2); + border-radius: var(--ui-radius-sm, 0.5rem); +} + +@media (max-width: 52rem) { + .trace-layout { + grid-template-columns: 1fr; + } + + .trace-phases { + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + } } diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index e41abdc..5978157 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -1,16 +1,37 @@ -import { Component, computed, effect, signal, inject, untracked } from '@angular/core'; -import { UserInputService } from '../../services/user-input-service'; +import { Component, computed, effect, inject, signal, untracked } from '@angular/core'; import { MatCard, MatCardContent } from '@angular/material/card'; -import { CalendarMonth } from '../calendar-month/calendar-month'; -import { CalendarService, SelectedDates } from '../../services/calendar-service'; +import { MatIcon } from '@angular/material/icon'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; -import { DateCacheService } from '../../services/date-cache.service'; +import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month'; +import { CalendarSettings } from '../calendar-settings/calendar-settings'; +import { + CalendarService, + SelectedDates, + SelectableDayType, + VacDay, +} from '../../services/calendar-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'; -import { MatIcon } from '@angular/material/icon'; -import { CalendarSettings } from '../calendar-settings/calendar-settings'; +import { UserInputService } from '../../services/user-input-service'; +import { + CounterfactualAnalysis, + CounterfactualSelection, + LeavePlacement, + PlanExplanation, + PlanOutcome, + PlanningCalculationContext, + PlanningExplanation, + PlanningObjective, + PlanningOutcome, + PlanningReason, + PlanningTrace, + PlanningTraceCandidate, + PlanningTraceDecision, + PlanningTraceState, +} from '../../services/leave-planner.service'; + +type PlacementExplanation = PlanExplanation['placements'][number]; +type TraceMoment = 'before' | 'after'; @Component({ selector: 'app-calendar-year', @@ -26,21 +47,42 @@ import { CalendarSettings } from '../calendar-settings/calendar-settings'; styleUrl: './calendar-year.scss', }) export class CalendarYear { - private dateCache = inject(DateCacheService); - private userInput = inject(UserInputService); - private calendarService = inject(CalendarService); - private ruleService = inject(RuleService); + private readonly userInput = inject(UserInputService); + private readonly calendarService = inject(CalendarService); + private readonly ruleService = inject(RuleService); protected readonly translationService = inject(TranslationService); + protected readonly isLoading = signal(true); + protected readonly loadingProgress = signal(0); + protected readonly planningOutcome = signal(null); + protected readonly planningExplanation = signal(null); + protected readonly explanationError = signal(null); + protected readonly planningTrace = signal(null); + protected readonly traceLoading = signal(false); + protected readonly traceResolved = signal(false); + protected readonly selectedPlacementIndex = signal(0); + protected readonly counterfactual = signal(null); + protected readonly selectedCounterfactualDate = signal(null); + protected readonly counterfactualLoading = signal(false); + protected readonly counterfactualError = signal(null); + protected readonly tracePhaseIndex = signal(0); + protected readonly traceDecisionIndex = signal(0); + protected readonly traceCandidatePage = signal(0); + protected readonly traceMoment = signal('before'); + protected readonly traceReplay = signal(false); + protected readonly liveMessage = signal(''); + protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null); + protected readonly selectedDates = signal(new SelectedDates()); + + private calculationVersion = 0; + private counterfactualVersion = 0; + private activeCalculation: PlanningCalculationContext | null = null; + get tr() { return this.translationService.tr; } - protected isLoading = signal(true); - - protected loadingProgress = signal(0); - protected planningOutcome = signal(null); - protected displayedPlan = computed(() => { + protected readonly displayedPlan = computed(() => { const outcome = this.planningOutcome(); return outcome?.kind === 'Plan' ? outcome @@ -48,7 +90,557 @@ export class CalendarYear { ? outcome.incumbent : undefined; }); - private calculationVersion = 0; + + protected readonly planExplanation = computed(() => { + const explanation = this.planningExplanation(); + return explanation?.kind === 'PlanExplanation' ? explanation : undefined; + }); + + protected readonly selectedExplanation = computed( + () => this.planExplanation()?.placements[this.selectedPlacementIndex()], + ); + + protected readonly selectedTracePhase = computed( + () => this.planningTrace()?.phases[this.tracePhaseIndex()], + ); + + protected readonly selectedTraceDecision = computed( + () => this.selectedTracePhase()?.decisions[this.traceDecisionIndex()], + ); + + protected readonly visibleTraceCandidates = computed(() => { + const start = this.traceCandidatePage() * 50; + return this.selectedTraceDecision()?.candidates.slice(start, start + 50) ?? []; + }); + + protected readonly traceCandidatePages = computed(() => + Math.max(1, Math.ceil((this.selectedTraceDecision()?.candidates.length ?? 0) / 50)), + ); + + protected readonly selectedCounterfactualResult = computed(() => + this.counterfactual()?.results.find( + (result) => result.date === this.selectedCounterfactualDate(), + ), + ); + + protected readonly traceState = computed(() => { + if (!this.traceReplay()) return undefined; + return this.replayTraceState( + this.planningTrace(), + this.selectedTraceDecision()?.id, + this.traceMoment(), + ); + }); + + protected readonly calendarSelectedDates = computed(() => { + const state = this.traceState(); + return state + ? this.calendarService.selectedDatesForPlan({ + placements: [...state.placements], + horizon: { ...state.horizon }, + }) + : this.selectedDates(); + }); + + protected readonly currentPlacementDate = computed(() => { + if (this.counterfactual()) + return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date; + if (this.traceReplay()) return this.selectedTraceDecision()?.dates[0] ?? null; + return this.selectedExplanation()?.placement.date ?? null; + }); + + protected readonly dateStatuses = computed>>(() => { + const statuses: Record = {}; + const trace = this.planningTrace(); + if (!this.counterfactual() && this.traceReplay() && trace) { + const priority = new Map([ + ['ineligible', 0], + ['not-examined', 1], + ['pruned', 2], + ['evaluated-rejected', 3], + ['chosen', 4], + ]); + for ( + let timestamp = Date.parse(`${trace.initialState.horizon.start}T00:00:00Z`), + end = Date.parse(`${trace.initialState.horizon.end}T00:00:00Z`); + timestamp < end; + timestamp += 86_400_000 + ) { + const date = new Date(timestamp).toISOString().slice(0, 10); + statuses[date] = { + kind: 'not-examined', + label: this.traceStatusLabel('not-examined'), + }; + } + for (const candidate of trace.phases.flatMap((phase) => + phase.decisions.flatMap((decision) => decision.candidates), + )) { + if (candidate.status !== 'ineligible' || candidate.reason?.code !== 'non-working') + continue; + for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) + statuses[date] = { + kind: candidate.status, + label: this.traceStatusLabel(candidate.status), + reason: candidate.reason.message, + }; + } + const explicitPriorities = new Map(); + for (const candidate of this.selectedTraceDecision()?.candidates ?? []) { + for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) { + const candidatePriority = priority.get(candidate.status)!; + if (candidatePriority > (explicitPriorities.get(date) ?? -1)) { + statuses[date] = { + kind: candidate.status, + label: this.traceStatusLabel(candidate.status), + reason: candidate.reason?.message, + }; + explicitPriorities.set(date, candidatePriority); + } + } + } + const decision = this.selectedTraceDecision(); + if (decision) { + const changedDates = new Set(); + const setChange = ( + date: string | undefined, + change: NonNullable, + ) => { + if (!date) return; + changedDates.add(date); + statuses[date] = { + ...(statuses[date] ?? { + kind: 'not-examined', + label: this.traceStatusLabel('not-examined'), + }), + change, + changeLabel: this.traceChangeLabel(change), + }; + }; + for (const delta of decision.deltas) { + if (delta.kind === 'placement-added') setChange(delta.date, 'added'); + else if (delta.kind === 'placement-removed') setChange(delta.date, 'removed'); + else if (delta.kind === 'placement-moved') { + setChange(delta.fromDate, 'moved-from'); + setChange(delta.toDate, 'moved-to'); + } else if (delta.kind === 'placement-updated') setChange(delta.date, 'shared'); + } + const before = this.replayTraceState(trace, decision.id, 'before'); + const after = this.replayTraceState(trace, decision.id, 'after'); + for (const placement of [ + ...(before?.placements ?? []), + ...(after?.placements ?? []), + ]) { + if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged'); + } + } + return statuses; + } + for (const result of this.counterfactual()?.results ?? []) + statuses[result.date] = { + kind: result.status, + label: + result.status === 'eligible' + ? this.tr().planning.eligible + : this.tr().planning.ineligible, + reason: result.reason?.message, + historicalLabel: result.historicalStatus + ? this.traceStatusLabel(result.historicalStatus) + : undefined, + }; + return statuses; + }); + + protected readonly months = computed(() => { + const horizon = this.planningHorizon(); + if (!horizon) return []; + const start = new Date(`${horizon.start}T00:00:00`); + const last = new Date(`${horizon.end}T00:00:00`); + last.setDate(last.getDate() - 1); + const result: { monthIndex: number; year: number }[] = []; + const cursor = new Date(start.getFullYear(), start.getMonth(), 1); + while (cursor <= last) { + result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); + cursor.setMonth(cursor.getMonth() + 1); + } + return result; + }); + + constructor() { + effect(() => { + this.userInput.computeTriggerSignal(); + this.resetCalculationState(); + const vacationData = untracked(() => this.userInput.vacationNumberSignal()); + const currentRules = untracked(() => this.ruleService.rules); + const horizon = this.calendarService.getDefaultPlanningHorizon(); + this.planningHorizon.set(horizon); + const version = ++this.calculationVersion; + this.isLoading.set(true); + this.loadingProgress.set(0); + queueMicrotask(() => { + let calculation: PlanningCalculationContext; + try { + calculation = this.calendarService.createPlanningCalculation({ + horizon, + balances: vacationData.map((balance) => ({ + id: balance.id, + type: balance.type, + quantity: balance.numberOfDays, + label: this.translationService.getDayTypeLabel(balance.type), + availableFrom: balance.availableDate, + expiryDate: balance.expiryDate, + })), + rules: currentRules, + labels: Object.fromEntries( + currentRules.map((rule) => [rule.id, rule.name ?? rule.id]), + ), + progress: (stage) => { + if (version === this.calculationVersion) + this.loadingProgress.set(this.stageProgress(stage)); + }, + }); + } catch (error) { + this.failCalculation(error, version); + return; + } + this.activeCalculation = calculation; + this.loadArtifacts(calculation, version); + void calculation.outcome + .then((outcome) => { + if (version === this.calculationVersion) this.applyOutcome(outcome); + }) + .catch((error: unknown) => { + 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) return; + this.loadingProgress.set(100); + this.isLoading.set(false); + }); + }); + }); + } + + private failCalculation(error: unknown, version: number): void { + if (version !== this.calculationVersion) return; + const reason = { + code: 'planner-error', + message: error instanceof Error ? error.message : 'The planner failed unexpectedly.', + }; + this.applyOutcome({ kind: 'PlanningIncomplete', completedStages: [], reasons: [reason] }); + this.explanationError.set(reason); + this.traceLoading.set(false); + this.traceResolved.set(true); + this.loadingProgress.set(100); + this.isLoading.set(false); + } + + private resetCalculationState(): void { + this.activeCalculation = null; + this.planningOutcome.set(null); + this.planningExplanation.set(null); + this.explanationError.set(null); + this.planningTrace.set(null); + this.traceLoading.set(true); + this.traceResolved.set(false); + this.selectedPlacementIndex.set(0); + this.clearCounterfactual(); + this.tracePhaseIndex.set(0); + this.traceDecisionIndex.set(0); + this.traceCandidatePage.set(0); + this.traceMoment.set('before'); + this.traceReplay.set(false); + this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); + this.userInput.remainingVacationDaysSignal.set([]); + } + + private loadArtifacts(calculation: PlanningCalculationContext, version: number): void { + void calculation.explanation.then((explanation) => { + if (version !== this.calculationVersion) return; + this.planningExplanation.set(explanation ?? null); + if (explanation?.kind === 'PlanExplanation' && explanation.placements.length) + this.selectPlacement(0); + }); + void calculation.explanationError.then((reason) => { + if (version === this.calculationVersion) this.explanationError.set(reason ?? null); + }); + void calculation.trace + .then((trace) => { + if (version !== this.calculationVersion) return; + this.planningTrace.set(trace ?? null); + this.traceResolved.set(true); + }) + .catch((error: unknown) => { + if (version !== this.calculationVersion) return; + this.planningTrace.set({ + status: 'failed', + initialState: { + horizon: calculation.snapshot.horizon, + balances: [], + placements: [], + earnedActivations: [], + }, + phases: [], + completedStages: [], + error: { + code: 'trace-error', + message: error instanceof Error ? error.message : String(error), + }, + }); + this.traceResolved.set(true); + }) + .finally(() => { + if (version === this.calculationVersion) this.traceLoading.set(false); + }); + } + + private applyOutcome(outcome: PlanningOutcome): void { + this.planningOutcome.set(outcome); + this.liveMessage.set(this.outcomeLabel(outcome)); + const plan = this.displayedPlan(); + if (plan) { + this.planningHorizon.set(plan.horizon); + this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan)); + this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan)); + return; + } + this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); + this.userInput.remainingVacationDaysSignal.set([]); + } + + protected selectPlacement(index: number): void { + const explanation = this.planExplanation(); + if (!explanation?.placements.length) return; + const selected = Math.max(0, Math.min(index, explanation.placements.length - 1)); + this.selectedPlacementIndex.set(selected); + this.traceReplay.set(false); + this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date); + this.liveMessage.set( + `${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`, + ); + void this.analyzeSelection(explanation.placements[selected].placement); + } + + protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void { + const last = (this.planExplanation()?.placements.length ?? 1) - 1; + const current = this.selectedPlacementIndex(); + this.selectPlacement( + step === 'first' + ? 0 + : step === 'last' + ? last + : step === 'previous' + ? current - 1 + : current + 1, + ); + } + + protected selectCalendarDate(date: string): void { + if (!this.traceReplay()) { + const index = this.planExplanation()?.placements.findIndex( + (explanation) => explanation.placement.date === date, + ); + if (index !== undefined && index >= 0) { + this.selectPlacement(index); + return; + } + } + if (this.counterfactual()?.results.some((result) => result.date === date)) { + this.selectedCounterfactualDate.set(date); + this.liveMessage.set(`${date}: ${this.dateStatuses()[date]?.label ?? ''}`); + } + } + + protected selectTracePhase(index: number): void { + const last = (this.planningTrace()?.phases.length ?? 1) - 1; + index = Math.max(0, Math.min(index, last)); + this.tracePhaseIndex.set(index); + this.traceDecisionIndex.set(0); + this.traceCandidatePage.set(0); + this.traceMoment.set('before'); + this.traceReplay.set(false); + this.clearCounterfactual(); + this.liveMessage.set( + `${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`, + ); + } + + protected stepTracePhase(step: 'first' | 'previous' | 'next' | 'last'): void { + const last = (this.planningTrace()?.phases.length ?? 1) - 1; + const current = this.tracePhaseIndex(); + this.selectTracePhase( + step === 'first' + ? 0 + : step === 'last' + ? last + : step === 'previous' + ? current - 1 + : current + 1, + ); + } + + protected selectTraceDecision(index: number): void { + const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1; + index = Math.max(0, Math.min(index, last)); + this.traceDecisionIndex.set(index); + this.traceCandidatePage.set(0); + this.traceMoment.set('before'); + this.traceReplay.set(true); + this.clearCounterfactual(); + this.liveMessage.set( + `${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, + ); + } + + protected stepTraceDecision(step: 'first' | 'previous' | 'next' | 'last'): void { + const last = (this.selectedTracePhase()?.decisions.length ?? 1) - 1; + const current = this.traceDecisionIndex(); + this.selectTraceDecision( + step === 'first' + ? 0 + : step === 'last' + ? last + : step === 'previous' + ? current - 1 + : current + 1, + ); + } + + protected stepTraceCandidatePage(direction: -1 | 1): void { + this.traceCandidatePage.update((page) => + Math.max(0, Math.min(page + direction, this.traceCandidatePages() - 1)), + ); + } + + protected showTraceMoment(moment: TraceMoment): void { + this.clearCounterfactual(); + this.traceMoment.set(moment); + this.traceReplay.set(true); + } + + protected showFinalPlan(): void { + this.traceReplay.set(false); + this.clearCounterfactual(); + } + + protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void { + const decision = this.selectedTraceDecision(); + if (!candidate.placement || !decision) return; + this.traceMoment.set('before'); + this.traceReplay.set(true); + void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement }); + } + + private async analyzeSelection(selection: CounterfactualSelection): Promise { + const calculation = this.activeCalculation; + if (!calculation) return; + const version = ++this.counterfactualVersion; + this.counterfactual.set(null); + this.counterfactualError.set(null); + this.counterfactualLoading.set(true); + try { + const result = await calculation.analyzeCounterfactual(selection); + if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) + return; + if (result.kind === 'Counterfactual') { + this.counterfactual.set(result); + this.selectedCounterfactualDate.set(result.placement.date); + this.liveMessage.set(this.tr().planning.counterfactualReady); + } + } catch (error) { + if (version !== this.counterfactualVersion || calculation !== this.activeCalculation) + return; + this.counterfactualError.set(error instanceof Error ? error.message : String(error)); + } finally { + if (version === this.counterfactualVersion) this.counterfactualLoading.set(false); + } + } + + private clearCounterfactual(): void { + this.counterfactualVersion++; + this.counterfactual.set(null); + this.selectedCounterfactualDate.set(null); + this.counterfactualLoading.set(false); + this.counterfactualError.set(null); + } + + private replayTraceState( + trace: PlanningTrace | null, + decisionId: string | undefined, + moment: TraceMoment, + ): PlanningTraceState | undefined { + if (!trace || !decisionId) return undefined; + const balances = trace.initialState.balances.map((balance) => ({ ...balance })); + const placements = new Map( + trace.initialState.placements.map((placement) => [placement.date, { ...placement }]), + ); + const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })); + const snapshot = (): PlanningTraceState => ({ + horizon: { ...trace.initialState.horizon }, + balances, + placements: [...placements.values()], + earnedActivations, + }); + for (const decision of trace.phases.flatMap((phase) => phase.decisions)) { + if (decision.id === decisionId && moment === 'before') return snapshot(); + this.applyTraceDecision(decision, placements, balances, earnedActivations); + if (decision.id === decisionId) return snapshot(); + } + return undefined; + } + + private applyTraceDecision( + decision: PlanningTraceDecision, + placements: Map, + balances: PlanningTraceState['balances'][number][], + earnedActivations: Array<{ + id: string; + ruleId: string; + occurrence: string; + dates: string[]; + }>, + ): void { + for (const delta of decision.deltas) { + if (delta.previousPlacement) placements.delete(delta.previousPlacement.date); + if (delta.placement) + placements.set(delta.placement.date, { + ...delta.placement, + ruleIds: [...delta.placement.ruleIds], + }); + const balanceIndex = balances.findIndex( + (candidate) => candidate.id === delta.balanceId, + ); + if (balanceIndex >= 0 && delta.remainingAfter !== undefined) + balances[balanceIndex] = { + ...balances[balanceIndex], + remaining: delta.remainingAfter, + }; + else if (balanceIndex < 0 && delta.balance) balances.push({ ...delta.balance }); + if ( + delta.activation && + !earnedActivations.some((activation) => activation.id === delta.activation!.id) + ) + earnedActivations.push({ + ...delta.activation, + dates: [...delta.activation.dates], + }); + } + } protected hasPlannerError(): boolean { const outcome = this.planningOutcome(); @@ -57,6 +649,53 @@ export class CalendarYear { ); } + protected ruleLabel(ruleId: string): string { + const rule = this.activeCalculation?.snapshot.rules.find( + (candidate) => candidate.id === ruleId, + ); + return rule?.name ?? this.activeCalculation?.snapshot.labels[ruleId] ?? ruleId; + } + + protected balanceLabel(explanation: PlacementExplanation): string { + return ( + explanation.balance?.name ?? + explanation.balance?.label ?? + explanation.placement.balanceId + ); + } + + protected objectiveLabel(objective: PlanningObjective): string { + return objective.label ?? objective.id; + } + + protected planningDate(value: string | Date | undefined): string { + if (!value) return ''; + return value instanceof Date ? value.toISOString().slice(0, 10) : value.slice(0, 10); + } + + protected traceStatusLabel(status: PlanningTraceCandidate['status']): string { + return this.tr().planning.traceStatuses[status]; + } + + protected tracePhaseStatusLabel(status: 'completed' | 'interrupted' | 'not-run'): string { + return this.tr().planning.tracePhaseStatuses[status]; + } + + protected traceChangeLabel(change: NonNullable): string { + return this.tr().planning.traceChanges[change]; + } + + protected formatIdentifier(value: string): string { + return value.replaceAll('-', ' ').replaceAll(':', ': '); + } + + private outcomeLabel(outcome: PlanningOutcome): string { + if (outcome.kind === 'Plan') return this.tr().planning.complete; + if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput; + if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict; + return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete; + } + private stageProgress(stage: string): number { if (stage === 'complete') return 100; if (stage === 'validation') return 5; @@ -73,87 +712,6 @@ export class CalendarYear { return this.loadingProgress(); } - // Use DateCacheService instead of creating Date objects - private get currentMonth() { - return this.dateCache.getCurrentMonth(); - } - - private get currentYear() { - return this.dateCache.getCurrentYear(); - } - - constructor() { - effect(() => { - 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); - - this.isLoading.set(true); - this.loadingProgress.set(0); - const version = ++this.calculationVersion; - // Defer execution to allow UI to update - queueMicrotask(() => { - void (async () => { - 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)); - }, - }); - 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); - } - } - })(); - }); - }); - } - - private applyOutcome(outcome: PlanningOutcome): void { - this.planningOutcome.set(outcome); - const plan = this.displayedPlan(); - 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, @@ -165,14 +723,4 @@ export class CalendarYear { reason: balance.reason, })); } - - protected selectedDates: SelectedDates = new SelectedDates(); - - getYear(monthIndex: number): number { - const offset = this.currentMonth <= monthIndex ? 0 : 1; - return this.currentYear + offset; - } - - // months starting from actual date - cache this array - protected months: number[] = Array.from({ length: 12 }, (_, i) => (this.currentMonth + i) % 12); } diff --git a/src/app/services/calendar-service.spec.ts b/src/app/services/calendar-service.spec.ts index a0378ae..0dd2f94 100644 --- a/src/app/services/calendar-service.spec.ts +++ b/src/app/services/calendar-service.spec.ts @@ -105,6 +105,16 @@ describe('CalendarService', () => { ); expect(hasToday).toBe(true); }); + + it('includes the final partial month of a half-open one-year horizon', () => { + const selectedDates = service.getSelectedDatesForHorizon({ + start: '2024-01-15', + end: '2025-01-15', + }); + expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain( + DayType.WEEKEND, + ); + }); }); describe('setWeekendSelection', () => { diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index aa2e8a9..8790dd6 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -6,6 +6,7 @@ import weekOfYear from 'dayjs/plugin/weekOfYear'; import isoWeek from 'dayjs/plugin/isoWeek'; import { DateCacheService } from './date-cache.service'; import { + createPlanningCalculation as createPlanningCalculationOperation, planLeave as planLeaveOperation, LeaveType, PlanOutcome, @@ -30,14 +31,19 @@ export class CalendarService { private dateCache = inject(DateCacheService); readonly planLeave = planLeaveOperation; + readonly createPlanningCalculation = createPlanningCalculationOperation; 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(); + selectedDatesForPlan( + plan: Pick & { horizon?: { start: string; end: string } }, + ): SelectedDates { + const selected = plan.horizon + ? this.getSelectedDatesForHorizon(plan.horizon) + : this.getSelectedDatesForYear(); for (const placement of plan.placements) { selected.push( new SelectedDate( @@ -65,8 +71,22 @@ export class CalendarService { } getSelectedDatesForYear(): SelectedDates { - const holidays = this.getHolidays(); - const monthInAYear = this.monthsInAYearFromNow(); + return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon()); + } + + getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates { + const start = new Date(`${horizon.start}T00:00:00`); + const last = new Date(`${horizon.end}T00:00:00`); + last.setDate(last.getDate() - 1); + const holidays = this.getHolidays(start.getFullYear()); + const monthInHorizon: Year = []; + const cursor = new Date(start.getFullYear(), start.getMonth(), 1); + while (cursor <= last) { + monthInHorizon.push( + CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()), + ); + cursor.setMonth(cursor.getMonth() + 1); + } const selectedDates: SelectedDates = new SelectedDates(); // Use DateCacheService to get today's date components @@ -79,7 +99,7 @@ export class CalendarService { // Create a Set of holiday timestamps for O(1) lookup instead of O(n) const holidayTimestamps = new Set(holidays.map((h) => h.getTime())); - for (const weeksInAMonth of monthInAYear) { + for (const weeksInAMonth of monthInHorizon) { for (const daysInAWeek of weeksInAMonth) { for (const day of daysInAWeek) { if (day) { @@ -131,10 +151,9 @@ export class CalendarService { } } - getHolidays(): Date[] { + getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] { // Cache holidays to avoid recalculation - const currentYear = this.dateCache.getCurrentYear(); - if (this.holidayCacheYear === currentYear && this.holidayTimestampsCache) { + if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) { // Return timestamps directly for efficient lookup return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts)); } @@ -142,10 +161,10 @@ export class CalendarService { // Logic to return holidays for the given year const hd = new Holidays('FR', allTypesOptions); const holidays = hd - .getHolidays(currentYear, lang) + .getHolidays(startYear, lang) .filter((holiday) => holiday.type === 'public'); const holidaysPlusOne = hd - .getHolidays(currentYear + 1, lang) + .getHolidays(startYear + 1, lang) .filter((holiday) => holiday.type === 'public'); holidays.push(...holidaysPlusOne); const holidaysDate: Date[] = holidays.map((holiday) => { @@ -155,7 +174,7 @@ export class CalendarService { // Update cache this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime())); - this.holidayCacheYear = currentYear; + this.holidayCacheYear = startYear; return holidaysDate; } diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 9191591..1bb70a3 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -19,6 +19,8 @@ export interface LeaveBalanceInput { quantity: number; availableFrom?: PlanningDate; expiryDate: PlanningDate; + name?: string; + label?: string; } export interface TimeWindowPlanningRule { @@ -53,6 +55,10 @@ export interface PlanningRequest { horizon: PlanningHorizon; balances: readonly LeaveBalanceInput[]; rules: readonly PlanningRule[]; + /** Optional caller-owned display identities copied into a calculation snapshot. */ + identities?: unknown; + /** Optional caller-owned human-readable labels copied into a calculation snapshot. */ + labels?: unknown; /** A testable deadline in milliseconds. The production default is ten seconds. */ deadlineMs?: number; progress?: (stage: string) => void; @@ -114,6 +120,235 @@ export interface PreferenceResult { reason?: PlanningReason; } +export type PlanningObjectiveId = + | 'unplaced-at-risk' + | `preference:${string}` + | 'holiday-week-value' + | 'non-working-day-gain' + | 'longest-work-sequence' + | 'short-sequence-deficit' + | 'supporting-placements' + | 'balance-expiry' + | 'placement-date'; + +export interface PlanningObjective { + id: PlanningObjectiveId; + direction: 'minimize' | 'maximize'; + value: number | string; + label?: string; +} + +/** The real ordered objective values. It deliberately has no scalar score. */ +export interface PlanningObjectiveVector { + objectives: readonly PlanningObjective[]; +} + +export interface PlanningObjectiveDelta { + id: PlanningObjectiveId; + direction: 'minimize' | 'maximize'; + value: number | string | null; + label?: string; +} + +export interface PlanningObjectiveComparison { + before: PlanningObjectiveVector; + after: PlanningObjectiveVector; + delta: readonly PlanningObjectiveDelta[]; + firstDecisiveObjective?: PlanningObjectiveId; + tieBreak?: 'balance-expiry' | 'placement-date'; +} + +export interface PlacementContribution { + kind: 'entitlement' | 'rule' | 'at-risk' | 'supporting' | 'time-off-period' | 'metric'; + code: string; + label: string; + ruleId?: string; + value?: number; + dates?: readonly string[]; +} + +export interface PlacementExplanation { + placement: LeavePlacement; + balance?: Readonly; + ruleIds: readonly string[]; + supporting: boolean; + timeOffPeriod?: TimeOffPeriod; + remainingBalance?: RemainingBalance; + remainingQuantity: number; + contributions: readonly PlacementContribution[]; + finalMetrics: PlanningMetrics; + objectiveVector: PlanningObjectiveVector; + /** The final-plan comparison with this placement removed and restored here. */ + comparison: PlanningObjectiveComparison; + firstDecisiveObjective?: PlanningObjectiveId; +} + +export interface PlanExplanation { + kind: 'PlanExplanation'; + placements: readonly PlacementExplanation[]; + /** Present when the explained plan is the incumbent of an interrupted calculation. */ + incomplete?: IncompleteExplanation; +} + +export interface ConflictExplanation { + kind: 'ConflictExplanation'; + conflictingRules: readonly Readonly<{ id: string; label: string }>[]; + bottleneck: PlanningReason; +} + +export interface IncompleteExplanation { + kind: 'IncompleteExplanation'; + reason: PlanningReason; + completedStages: readonly string[]; + uncompletedStages: readonly string[]; +} + +export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation; + +export interface PlanningSnapshot { + horizon: Readonly<{ start: string; end: string }>; + balances: readonly Readonly[]; + rules: readonly Readonly[]; + identities: Readonly>; + labels: Readonly>; +} + +export type TraceCandidateStatus = + | 'chosen' + | 'evaluated-rejected' + | 'pruned' + | 'not-examined' + | 'ineligible'; + +export interface PlanningTraceState { + horizon: Readonly<{ start: string; end: string }>; + balances: readonly Readonly<{ + id: string; + type: LeaveType; + quantity: number; + remaining: number; + availableFrom: string; + expiryDate: string; + earned: boolean; + sourceRuleId?: string; + }>[]; + placements: readonly LeavePlacement[]; + earnedActivations: readonly Readonly<{ + id: string; + ruleId: string; + occurrence: string; + dates: readonly string[]; + }>[]; +} + +export interface PlanningTraceDelta { + kind: + | 'placement-added' + | 'placement-removed' + | 'placement-moved' + | 'placement-updated' + | 'balance-changed' + | 'earned-activated'; + date?: string; + fromDate?: string; + toDate?: string; + balanceId?: string; + placement?: LeavePlacement; + previousPlacement?: LeavePlacement; + remainingBefore?: number; + remainingAfter?: number; + /** Full metadata when a generated entitlement first enters the traced state. */ + balance?: PlanningTraceState['balances'][number]; + activation?: Readonly<{ + id: string; + ruleId: string; + occurrence: string; + dates: readonly string[]; + }>; +} + +export interface PlanningTraceCandidate { + status: TraceCandidateStatus; + sourceDate?: string; + date?: string; + dates?: readonly string[]; + reason?: PlanningReason; + comparison?: PlanningObjectiveComparison; + placement?: LeavePlacement; +} + +export interface PlanningTraceDecision { + id: string; + action: 'validate' | 'choose' | 'share' | 'add' | 'move' | 'remove' | 'activate' | 'reject'; + dates: readonly string[]; + candidates: readonly PlanningTraceCandidate[]; + deltas: readonly PlanningTraceDelta[]; + reason?: PlanningReason; +} + +export interface PlanningTracePhase { + id: string; + name: string; + completed: boolean; + status: 'completed' | 'interrupted' | 'not-run'; + decisions: readonly PlanningTraceDecision[]; +} + +export interface PlanningTrace { + status: 'ready' | 'incomplete' | 'failed'; + initialState: PlanningTraceState; + phases: readonly PlanningTracePhase[]; + completedStages: readonly string[]; + error?: PlanningReason; +} + +export type CounterfactualReasonCode = + | 'non-working' + | 'occupied' + | 'unavailable' + | 'expired' + | 'type-incompatible' + | 'rule-invalid'; + +export interface CounterfactualDateResult { + date: string; + status: 'eligible' | 'ineligible'; + historicalStatus?: TraceCandidateStatus; + reason?: PlanningReason & { code: CounterfactualReasonCode }; + comparison?: PlanningObjectiveComparison; +} + +export interface CounterfactualAnalysis { + kind: 'Counterfactual'; + calculationId: string; + placement: LeavePlacement; + hypothetical: true; + baseline: PlanningObjectiveVector; + results: readonly CounterfactualDateResult[]; +} + +export interface StaleCounterfactualAnalysis { + kind: 'CounterfactualStale'; + calculationId: string; + placement: LeavePlacement; + hypothetical: true; + results: readonly []; +} + +export interface TraceCounterfactualSelection { + decisionId: string; + placement: LeavePlacement; +} + +export type CounterfactualSelection = LeavePlacement | string | TraceCounterfactualSelection; + +export interface CounterfactualBaselineOptions { + /** The placement released before testing alternatives; null means an additive decision. */ + removeDate: string | null; + /** Preserve the exact pre-decision state as the comparison's before vector. */ + compareOriginalState: boolean; +} + export interface PlanningMetrics { unplacedAtRisk: number; holidayWeekValue: number; @@ -205,8 +440,11 @@ interface RuntimePlanningRequest { horizon?: unknown; balances?: unknown; rules?: unknown; + identities?: unknown; + labels?: unknown; deadlineMs?: unknown; progress?: unknown; + traceProgress?: unknown; } interface RuntimeRecord { @@ -215,12 +453,16 @@ interface RuntimeRecord { end?: unknown; balances?: unknown; rules?: unknown; + identities?: unknown; + labels?: unknown; deadlineMs?: unknown; progress?: unknown; + traceProgress?: unknown; id?: unknown; name?: unknown; type?: unknown; quantity?: unknown; + label?: unknown; availableFrom?: unknown; expiryDate?: unknown; enabled?: unknown; @@ -244,6 +486,347 @@ interface Requirement { candidates?: string[]; } +type MutableTraceCandidate = PlanningTraceCandidate & { candidates?: never }; +type MutableTraceDecision = { + id: string; + action: PlanningTraceDecision['action']; + dates: string[]; + candidates: MutableTraceCandidate[]; + deltas: PlanningTraceDelta[]; + reason?: PlanningReason; +}; +type MutableTracePhase = { + id: string; + name: string; + completed: boolean; + status: PlanningTracePhase['status']; + decisions: MutableTraceDecision[]; +}; + +/** + * Records observations while the core is producing a result. The recorder is + * intentionally write-only from the search code; it never participates in a + * candidate sort, deadline check, or state mutation. + */ +class TraceRecorder { + private readonly phases: MutableTracePhase[] = []; + private phase: MutableTracePhase | undefined; + private sequence = 0; + private previousPlacements = new Map(); + private previousEarned = new Set(); + private previousBalances: ReadonlyMap; + + constructor( + private readonly initialState: PlanningTraceState, + private readonly plannedPhases: readonly string[], + private readonly workingDays: ReadonlySet, + ) { + this.previousBalances = new Map( + initialState.balances.map((balance) => [balance.id, balance.remaining]), + ); + } + + startPhase(name: string): void { + if (this.phase?.name === name) return; + if (this.phase) { + this.phase.completed = true; + this.phase.status = 'completed'; + } + this.phase = { + id: `phase:${this.phases.length + 1}`, + name, + completed: false, + status: 'interrupted', + decisions: [], + }; + this.phases.push(this.phase); + } + + private decision( + action: MutableTraceDecision['action'], + dates: readonly string[] = [], + ): MutableTraceDecision { + if (!this.phase) this.startPhase('validation'); + const current = this.phase!; + const decision: MutableTraceDecision = { + id: `decision:${++this.sequence}`, + action, + dates: [...dates], + candidates: [], + deltas: [], + }; + current.decisions.push(decision); + return decision; + } + + private latestDecision( + action: MutableTraceDecision['action'] = 'choose', + ): MutableTraceDecision { + const current = this.phase; + const latest = current?.decisions[current.decisions.length - 1]; + return latest ?? this.decision(action); + } + + private latestComparison(placement: LeavePlacement): PlanningObjectiveComparison | undefined { + for (const decision of [...(this.phase?.decisions ?? [])].reverse()) { + const candidate = [...decision.candidates] + .reverse() + .find( + (item) => + item.placement?.date === placement.date && + item.placement.balanceId === placement.balanceId && + item.comparison, + ); + if (candidate?.comparison) return candidate.comparison; + } + return undefined; + } + + recordCandidate(candidate: PlanningTraceCandidate): void { + this.latestDecision().candidates.push({ + ...candidate, + dates: candidate.dates ? [...candidate.dates] : undefined, + reason: candidate.reason ? { ...candidate.reason } : undefined, + placement: candidate.placement + ? { ...candidate.placement, ruleIds: [...candidate.placement.ruleIds] } + : undefined, + }); + } + + recordDecision( + action: MutableTraceDecision['action'], + dates: readonly string[], + candidate?: PlanningTraceCandidate, + ): void { + const decision = this.decision(action, dates); + if (candidate) decision.candidates.push(candidate); + } + + recordDelta(delta: PlanningTraceDelta): void { + this.latestDecision().deltas.push({ ...delta }); + } + + recordPlan( + placements: readonly LeavePlacement[], + balances: PlanningTraceState['balances'], + earnedActivations: PlanningTraceState['earnedActivations'], + ): void { + const current = new Map(placements.map((placement) => [placement.date, placement])); + const removed = [...this.previousPlacements.values()].filter( + (placement) => !current.has(placement.date), + ); + const added = placements.filter( + (placement) => !this.previousPlacements.has(placement.date), + ); + const updated = [...current].filter(([date, placement]) => { + const previous = this.previousPlacements.get(date); + return previous && JSON.stringify(previous) !== JSON.stringify(placement); + }); + const balancesChanged = balances.some( + (balance) => this.previousBalances.get(balance.id) !== balance.remaining, + ); + const earnedChanged = balances.some( + (balance) => balance.earned && !this.previousEarned.has(balance.id), + ); + if ( + !removed.length && + !added.length && + !updated.length && + !balancesChanged && + !earnedChanged + ) + return; + this.decision( + removed.length && added.length + ? 'move' + : added.length + ? 'add' + : removed.length + ? 'remove' + : updated.length + ? 'share' + : 'activate', + [ + ...removed.map((placement) => placement.date), + ...added.map((placement) => placement.date), + ...updated.map(([date]) => date), + ], + ); + const movedAdded = new Set(); + const movedRemoved = new Set(); + for (const previous of removed) { + const placement = added.find( + (candidate) => + !movedAdded.has(candidate.date) && + candidate.balanceId === previous.balanceId && + candidate.type === previous.type, + ); + if (!placement) continue; + movedAdded.add(placement.date); + movedRemoved.add(previous.date); + this.recordDelta({ + kind: 'placement-moved', + fromDate: previous.date, + toDate: placement.date, + balanceId: placement.balanceId, + previousPlacement: previous, + placement, + }); + this.recordCandidate({ + status: 'chosen', + date: placement.date, + placement, + comparison: this.latestComparison(placement), + }); + } + for (const placement of removed) { + if (movedRemoved.has(placement.date)) continue; + this.recordDelta({ + kind: 'placement-removed', + date: placement.date, + balanceId: placement.balanceId, + previousPlacement: placement, + }); + } + for (const placement of added) { + if (movedAdded.has(placement.date)) continue; + this.recordDelta({ + kind: 'placement-added', + date: placement.date, + balanceId: placement.balanceId, + placement, + }); + this.recordCandidate({ + status: 'chosen', + date: placement.date, + placement, + comparison: this.latestComparison(placement), + }); + } + for (const [date, placement] of updated) { + const previous = this.previousPlacements.get(date)!; + this.recordDelta({ + kind: 'placement-updated', + date, + balanceId: placement.balanceId, + previousPlacement: previous, + placement, + }); + this.recordCandidate({ + status: 'chosen', + date, + placement, + comparison: this.latestComparison(placement), + }); + } + for (const balance of balances) { + const previousRemaining = this.previousBalances.get(balance.id); + if (previousRemaining !== undefined && previousRemaining !== balance.remaining) + this.recordDelta({ + kind: 'balance-changed', + balanceId: balance.id, + remainingBefore: previousRemaining, + remainingAfter: balance.remaining, + }); + if (previousRemaining === undefined) + this.recordDelta({ + kind: 'balance-changed', + balanceId: balance.id, + remainingAfter: balance.remaining, + balance: { ...balance }, + }); + if (balance.earned && !this.previousEarned.has(balance.id)) { + const activation = earnedActivations.find( + (candidate) => candidate.id === balance.id, + ); + this.recordDelta({ + kind: 'earned-activated', + balanceId: balance.id, + activation: activation + ? { ...activation, dates: [...activation.dates] } + : undefined, + }); + } + } + this.previousPlacements = current; + this.previousBalances = new Map(balances.map((balance) => [balance.id, balance.remaining])); + this.previousEarned = new Set( + balances.filter((balance) => balance.earned).map((balance) => balance.id), + ); + } + + finish( + status: PlanningTrace['status'], + completedStages: readonly string[], + error?: PlanningReason, + ): PlanningTrace { + const seenDates = new Set( + this.phases.flatMap((phase) => + phase.decisions.flatMap((decision) => + decision.candidates.flatMap( + (candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []), + ), + ), + ), + ); + if (status !== 'failed') { + const decision = this.decision('reject'); + for (const date of dateRange( + this.initialState.horizon.start, + this.initialState.horizon.end, + )) { + if (seenDates.has(date)) continue; + decision.candidates.push( + this.workingDays.has(date) + ? { status: 'not-examined', date } + : { + status: 'ineligible', + date, + reason: reason( + 'non-working', + 'The date is a weekend or public holiday.', + ), + }, + ); + } + } + if (this.phase && status === 'ready') { + this.phase.completed = true; + this.phase.status = 'completed'; + } + if (status === 'incomplete') { + const existing = new Set(this.phases.map((phase) => phase.name)); + for (const name of this.plannedPhases) { + if (existing.has(name)) continue; + this.phases.push({ + id: `phase:${this.phases.length + 1}`, + name, + completed: false, + status: 'not-run', + decisions: [], + }); + } + } + return { + status, + initialState: this.initialState, + phases: this.phases.map((phase) => ({ + ...phase, + decisions: phase.decisions.map((decision) => ({ + ...decision, + dates: [...decision.dates], + candidates: decision.candidates.map((candidate) => ({ + ...candidate, + })), + deltas: decision.deltas.map((delta) => ({ ...delta })), + })), + })), + completedStages: [...completedStages], + error, + }; + } +} + interface SearchContext { horizon: { start: string; end: string }; holidays: Set; @@ -265,6 +848,64 @@ interface SearchContext { protectedTypesByDate: Map>; preferenceDemandByDate: Map; anyPreferenceDemandByDate: Map; + trace?: TraceRecorder; + traceTime: number; + traceReportedTime: number; + traceError?: PlanningReason; +} + +function now(): number { + return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); +} + +function observeTrace(context: SearchContext, observe: (trace: TraceRecorder) => void): void { + if (!context.trace) return; + const started = now(); + try { + observe(context.trace); + } catch (error) { + context.traceError = reason( + 'trace-error', + error instanceof Error ? error.message : String(error), + ); + context.trace = undefined; + } finally { + context.traceTime += now() - started; + if ( + context.trace && + context.traceTime - context.traceReportedTime >= 25 && + typeof context.request.traceProgress === 'function' + ) { + try { + context.request.traceProgress(context.traceTime); + } catch (error) { + context.traceError = reason( + 'trace-error', + error instanceof Error ? error.message : String(error), + ); + context.trace = undefined; + } + context.traceReportedTime = context.traceTime; + } + } +} + +function recordTraceCandidate(context: SearchContext, candidate: PlanningTraceCandidate): void { + observeTrace(context, (trace) => trace.recordCandidate(candidate)); +} + +function recordTraceIneligible( + context: SearchContext, + date: string, + code: string, + message: string, + ruleIds?: string[], +): void { + recordTraceCandidate(context, { + status: 'ineligible', + date, + reason: reason(code, message, ruleIds), + }); } class DeadlineReached extends Error {} @@ -307,8 +948,11 @@ function runtimeRequest(value: unknown): RuntimePlanningRequest { horizon: source.horizon, balances: source.balances, rules: source.rules, + identities: source.identities, + labels: source.labels, deadlineMs: source.deadlineMs, progress: source.progress, + traceProgress: source.traceProgress, }; } @@ -448,6 +1092,34 @@ function cloneState(state: State): State { }; } +function planningTraceState( + state: State, + horizon: { start: string; end: string }, +): PlanningTraceState { + return { + horizon, + balances: state.balances.map((balance) => ({ ...balance })), + placements: [...state.placements.values()] + .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId)) + .map((placement) => ({ + ...placement, + ruleIds: [...placement.ruleIds].sort(), + })), + earnedActivations: state.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + }; +} + +function recordTraceState(context: SearchContext, state: State): void { + if (!context.trace) return; + observeTrace(context, (trace) => { + const snapshot = planningTraceState(state, context.horizon); + trace.recordPlan(snapshot.placements, snapshot.balances, snapshot.earnedActivations); + }); +} + function normalizeType(value: unknown): LeaveType | null { const normalized = String(value ?? '').toLowerCase(); return VALID_TYPES.has(normalized as LeaveType) ? (normalized as LeaveType) : null; @@ -974,17 +1646,43 @@ function assignDateVariants( context.check(); const existing = state.placements.get(date); if (existing) { - if (!selectorMatches(selector, existing.type)) return []; + if (!selectorMatches(selector, existing.type)) { + recordTraceCandidate(context, { + status: 'ineligible', + date, + reason: reason( + 'type-incompatible', + 'The existing placement has an incompatible leave 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) - ) + ) { + recordTraceCandidate(context, { + status: 'ineligible', + date, + reason: reason('rule-invalid', 'The date does not satisfy the planning rule.', [ + ruleId, + ]), + }); return []; + } } const result = cloneState(state); if (ruleId) result.placements.get(date)!.ruleIds.add(ruleId); + recordTraceCandidate(context, { + status: 'evaluated-rejected', + date, + placement: { + ...result.placements.get(date)!, + ruleIds: [...result.placements.get(date)!.ruleIds], + }, + }); return [result]; } const balances = state.balances @@ -1017,6 +1715,15 @@ function assignDateVariants( } return compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id); }); + if (!balances.length) + recordTraceCandidate(context, { + status: 'evaluated-rejected', + date, + reason: reason( + 'balance-unavailable', + 'No compatible leave balance can fund this candidate date.', + ), + }); return balances.map((balance) => { const result = cloneState(state); const target = result.balances.find((item) => item.id === balance.id)!; @@ -1028,6 +1735,14 @@ function assignDateVariants( ruleIds: ruleId ? new Set([ruleId]) : new Set(), supporting: false, }); + recordTraceCandidate(context, { + status: 'evaluated-rejected', + date, + placement: { + ...result.placements.get(date)!, + ruleIds: [...result.placements.get(date)!.ruleIds], + }, + }); return result; }); } @@ -1040,15 +1755,31 @@ function assignDateToBalance( ruleIds = new Set(), ): State | null { context.check(); - if (state.placements.has(date)) return null; + if (state.placements.has(date)) { + recordTraceCandidate(context, { + status: 'ineligible', + date, + reason: reason('occupied', 'Another placement already uses this 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 - ) + ) { + recordTraceCandidate(context, { + status: 'evaluated-rejected', + date, + reason: reason( + 'balance-unavailable', + 'The selected leave balance cannot fund this date.', + ), + }); return null; + } const result = cloneState(state); const target = result.balances.find((item) => item.id === balanceId)!; target.remaining--; @@ -1059,6 +1790,14 @@ function assignDateToBalance( ruleIds: new Set(ruleIds), supporting: false, }); + recordTraceCandidate(context, { + status: 'evaluated-rejected', + date, + placement: { + ...result.placements.get(date)!, + ruleIds: [...result.placements.get(date)!.ruleIds], + }, + }); return result; } @@ -1086,6 +1825,14 @@ function assignDateToBalanceInPlace( ruleIds: new Set(), supporting: false, }); + recordTraceCandidate(context, { + status: 'evaluated-rejected', + date, + placement: { + ...state.placements.get(date)!, + ruleIds: [], + }, + }); return true; } @@ -1226,6 +1973,12 @@ function satisfyObligations( const requirement = requirements[index]; if (requirement.patterns) { for (const pattern of requirement.patterns) { + observeTrace(context, (trace) => + trace.recordDecision('choose', pattern, { + status: 'evaluated-rejected', + dates: pattern, + }), + ); for (const assigned of assignDatesVariants(state, pattern, requirement.rule, context)) { const candidate = addEarnedBalance( assigned, @@ -1243,8 +1996,16 @@ function satisfyObligations( solutionLimit, ); if (complete && !solutions) return complete; - if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { + recordTraceCandidate(context, { + status: 'pruned', + reason: reason( + 'obligation-solution-limit', + 'Further obligation branches were not examined after the solution limit.', + ), + }); return null; + } } } return null; @@ -1254,6 +2015,12 @@ function satisfyObligations( const choose = (position: number, picked: string[], current: State): State | null => { context.check(); if (picked.length === needed) { + observeTrace(context, (trace) => + trace.recordDecision('choose', picked, { + status: 'evaluated-rejected', + dates: picked, + }), + ); const complete = satisfyObligations( requirements, current, @@ -1265,7 +2032,18 @@ function satisfyObligations( if (complete && !solutions) return complete; return null; } - if (candidates.length - position < needed - picked.length) return null; + if (candidates.length - position < needed - picked.length) { + recordTraceCandidate(context, { + status: 'pruned', + dates: picked, + reason: reason( + 'obligation-capacity-pruned', + 'Too few candidate dates remain to complete this obligation branch.', + [requirement.rule.id], + ), + }); + return null; + } for (let i = position; i < candidates.length; i++) { for (const next of assignDateVariants( current, @@ -1276,8 +2054,16 @@ function satisfyObligations( )) { const complete = choose(i + 1, [...picked, candidates[i]], next); if (complete && !solutions) return complete; - if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) + if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { + recordTraceCandidate(context, { + status: 'pruned', + reason: reason( + 'obligation-solution-limit', + 'Further obligation branches were not examined after the solution limit.', + ), + }); return null; + } } } return null; @@ -1727,6 +2513,35 @@ function compareObligationPreviews( return compareText(placementTieKey(a.state), placementTieKey(b.state)); } +function recordTraceEvaluation( + context: SearchContext, + beforeState: State, + afterState: State, + preferences: readonly PreferenceResult[], + beforeMetrics: PlanningMetrics, + afterMetrics: PlanningMetrics, + dates: readonly string[], + sourceDate?: string, +): void { + observeTrace(context, (trace) => { + const date = dates[dates.length - 1]; + const placement = date ? afterState.placements.get(date) : undefined; + trace.recordCandidate({ + status: 'evaluated-rejected', + sourceDate, + date, + dates: [...dates], + comparison: objectiveComparison( + objectiveVector(beforeMetrics, preferences, beforeState), + objectiveVector(afterMetrics, preferences, afterState), + ), + placement: placement + ? { ...placement, ruleIds: [...placement.ruleIds].sort() } + : undefined, + }); + }); +} + function optimizeMovablePlacements( state: State, context: SearchContext, @@ -1771,6 +2586,20 @@ function optimizeMovablePlacements( !isWorking(addDays(date, 1), context.holidays); return touchesNonWorking || index % 7 === 0; }); + if (!exhaustive) { + const sampled = new Set(candidateDates); + for (const date of context.workingDays) { + if (sampled.has(date)) continue; + recordTraceCandidate(context, { + status: 'not-examined', + date, + reason: reason( + 'loaded-plan-sampling', + 'This date was not examined because of loaded-plan quality sampling.', + ), + }); + } + } for (const original of [...result.placements.values()] .filter( (placement) => placement.ruleIds.size === 0 || isMovableTimeWindow(placement, context), @@ -1798,7 +2627,16 @@ function optimizeMovablePlacements( : candidateDates; for (const date of placementCandidates) { if (date === original.date) continue; - if (!compatibleWithRules(date, current.type, current.ruleIds, context)) continue; + if (!compatibleWithRules(date, current.type, current.ruleIds, context)) { + recordTraceIneligible( + context, + date, + 'rule-invalid', + 'The date does not preserve the placement rules.', + [...current.ruleIds], + ); + continue; + } const candidate = assignDateToBalance( without, date, @@ -1811,6 +2649,16 @@ function optimizeMovablePlacements( if (!feasibleCandidate) continue; feasibleCandidate.placements.get(date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + baseline, + metrics, + [date], + current.date, + ); if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue; if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue; if ( @@ -1845,6 +2693,7 @@ function optimizeMovablePlacements( bestDate = date; } } + if (best !== result) recordTraceState(context, best); result = best; } return result; @@ -1889,7 +2738,16 @@ function completeHolidayWeek( compareDate(a.date, b.date), ); for (const source of movable) { - if (!compatibleWithRules(date, source.type, source.ruleIds, context)) continue; + if (!compatibleWithRules(date, source.type, source.ruleIds, context)) { + recordTraceIneligible( + context, + date, + 'rule-invalid', + 'The date does not preserve the placement rules.', + [...source.ruleIds], + ); + continue; + } const moved = cloneState(current); moved.placements.delete(source.date); const released = moved.balances.find((item) => item.id === source.balanceId); @@ -1945,6 +2803,15 @@ function optimizeHolidayWeeks( const complete = completeHolidayWeek(result, candidate, context, protectedDates); if (!complete) continue; const metrics = qualityMetrics(complete, context, preferences); + recordTraceEvaluation( + context, + result, + complete, + preferences, + baseline, + metrics, + candidate.dates, + ); if ( metrics.unplacedAtRisk > baseline.unplacedAtRisk || comparePreferenceVectors( @@ -1965,6 +2832,7 @@ function optimizeHolidayWeeks( } } if (!best) return result; + recordTraceState(context, best); result = best; } } @@ -2027,12 +2895,23 @@ function optimizeExpiryPlacements( if (!feasibleCandidate) continue; feasibleCandidate.placements.get(current.date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + baseline, + metrics, + [current.date], + current.date, + ); const expiry = expiryTieKey(feasibleCandidate); if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) { best = feasibleCandidate; bestExpiry = expiry; } } + if (best !== result) recordTraceState(context, best); result = best; } return result; @@ -2121,7 +3000,7 @@ function periodData(state: State, context: SearchContext): TimeOffPeriod[] { function qualityMetrics( state: State, context: SearchContext, - preferences: PreferenceResult[], + preferences: readonly PreferenceResult[], ): PlanningMetrics { const periods = periodData(state, context); const placed = new Set(state.placements.keys()); @@ -2207,6 +3086,228 @@ function remainingBalances(state: State, context: SearchContext): RemainingBalan }); } +function objectiveVector( + metrics: PlanningMetrics, + preferences: readonly PreferenceResult[], + state: State, +): PlanningObjectiveVector { + const objectives: PlanningObjective[] = [ + { + id: 'unplaced-at-risk', + direction: 'minimize', + value: metrics.unplacedAtRisk, + label: 'Unplaced at-risk leave', + }, + ...preferences.map((preference) => ({ + id: `preference:${preference.ruleId}` as const, + direction: 'maximize' as const, + value: preference.satisfied, + label: `Preference ${preference.ruleId}`, + })), + { + id: 'holiday-week-value', + direction: 'maximize', + value: metrics.holidayWeekValue, + label: 'Public-holiday week value', + }, + { + id: 'non-working-day-gain', + direction: 'maximize', + value: metrics.nonWorkingDayGain, + label: 'Non-working-day gain', + }, + { + id: 'longest-work-sequence', + direction: 'minimize', + value: metrics.longestWorkSequence, + label: 'Longest work sequence', + }, + { + id: 'short-sequence-deficit', + direction: 'minimize', + value: metrics.shortSequenceDeficit, + label: 'Short work-sequence deficit', + }, + { + id: 'supporting-placements', + direction: 'minimize', + value: metrics.supportingPlacements, + label: 'Supporting placements', + }, + { + id: 'balance-expiry', + direction: 'minimize', + value: expiryTieKey(state), + label: 'Balance expiry tie-break', + }, + { + id: 'placement-date', + direction: 'minimize', + value: placementTieKey(state), + label: 'Placement date tie-break', + }, + ]; + return { objectives }; +} + +function displayLabel(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim() ? value : fallback; +} + +function planExplanation( + state: State, + context: SearchContext, + placements: readonly LeavePlacement[], + periods: readonly TimeOffPeriod[], + preferences: readonly PreferenceResult[], + remaining: readonly RemainingBalance[], + metrics: PlanningMetrics, +): PlanExplanation { + const source = context.request; + const rawBalances = Array.isArray(source.balances) ? source.balances : []; + const rawRules = Array.isArray(source.rules) ? source.rules : []; + const labels = recordValue(source.labels) as Record | undefined; + const identities = recordValue(source.identities) as Record | undefined; + const vector = objectiveVector(metrics, preferences, state); + return { + kind: 'PlanExplanation', + placements: placements.map((placement) => { + const balance = rawBalances + .map(recordValue) + .find((candidate) => candidate?.id === placement.balanceId); + const normalizedBalance = state.balances.find( + (candidate) => candidate.id === placement.balanceId, + ); + const period = periods.find((candidate) => + candidate.placementDates.includes(placement.date), + ); + const sourceRules = placement.ruleIds.map((id) => { + const rule = context.rules.find((candidate) => candidate.id === id); + const rawRule = rawRules.map(recordValue).find((candidate) => candidate?.id === id); + const label = displayLabel( + labels?.[id] ?? identities?.[id] ?? rawRule?.name ?? rule?.name, + id, + ); + return { id, label }; + }); + const atRisk = normalizedBalance + ? compareDate(normalizedBalance.expiryDate, context.horizon.start) >= 0 && + compareDate(normalizedBalance.expiryDate, context.horizon.end) <= 0 + : false; + const withoutPlacement = cloneState(state); + withoutPlacement.placements.delete(placement.date); + const released = withoutPlacement.balances.find( + (candidate) => candidate.id === placement.balanceId, + ); + if (released) released.remaining++; + const withoutPreferences = preferenceResultsForState(withoutPlacement, context); + const withoutMetrics = qualityMetrics(withoutPlacement, context, withoutPreferences); + const comparison = objectiveComparison( + objectiveVector(withoutMetrics, withoutPreferences, withoutPlacement), + vector, + ); + const contributions: PlacementContribution[] = [ + { + kind: 'entitlement', + code: 'balance', + label: displayLabel( + labels?.[placement.balanceId] ?? + identities?.[placement.balanceId] ?? + balance?.label ?? + balance?.name ?? + balance?.id, + placement.balanceId, + ), + }, + ...sourceRules.map((rule) => ({ + kind: 'rule' as const, + code: 'rule', + label: rule.label, + ruleId: rule.id, + })), + ]; + if (atRisk) + contributions.push({ + kind: 'at-risk', + code: 'at-risk', + label: 'Protects leave at risk of expiry.', + }); + if (placement.supporting) + contributions.push({ + kind: 'supporting', + code: 'supporting', + label: 'Supports the surrounding time-off plan.', + }); + if (period) + contributions.push({ + kind: 'time-off-period', + code: 'time-off-period', + label: `Contributes to the time-off period ${period.start}–${period.end}.`, + dates: period.nonWorkingDates, + }); + for (const delta of comparison.delta) { + if (typeof delta.value !== 'number' || delta.value === 0) continue; + const objective = vector.objectives.find((candidate) => candidate.id === delta.id)!; + contributions.push({ + kind: 'metric', + code: objective.id, + label: objective.label ?? objective.id, + value: delta.value, + }); + } + const balanceSnapshot: Readonly | undefined = balance + ? { + id: typeof balance.id === 'string' ? balance.id : placement.balanceId, + type: typeof balance.type === 'string' ? balance.type : placement.type, + quantity: typeof balance.quantity === 'number' ? balance.quantity : 0, + name: typeof balance.name === 'string' ? balance.name : undefined, + label: typeof balance.label === 'string' ? balance.label : undefined, + availableFrom: + typeof balance.availableFrom === 'string' || + balance.availableFrom instanceof Date + ? balance.availableFrom + : normalizedBalance?.availableFrom, + expiryDate: + typeof balance.expiryDate === 'string' || + balance.expiryDate instanceof Date + ? balance.expiryDate + : (normalizedBalance?.expiryDate ?? ''), + } + : normalizedBalance + ? { + id: normalizedBalance.id, + type: normalizedBalance.type, + quantity: normalizedBalance.quantity, + name: normalizedBalance.sourceRuleId + ? `Earned from ${displayLabel( + labels?.[normalizedBalance.sourceRuleId], + normalizedBalance.sourceRuleId, + )}` + : undefined, + availableFrom: normalizedBalance.availableFrom, + expiryDate: normalizedBalance.expiryDate, + } + : undefined; + return { + placement: { ...placement, ruleIds: [...placement.ruleIds] }, + balance: balanceSnapshot, + ruleIds: [...placement.ruleIds], + supporting: placement.supporting, + timeOffPeriod: period, + remainingBalance: remaining.find( + (candidate) => candidate.id === placement.balanceId, + ), + remainingQuantity: normalizedBalance?.remaining ?? 0, + contributions, + finalMetrics: metrics, + objectiveVector: vector, + comparison, + firstDecisiveObjective: comparison.firstDecisiveObjective, + }; + }), + }; +} + function makePlan( state: State, context: SearchContext, @@ -2225,7 +3326,7 @@ function makePlan( supporting: placement.supporting, })); const remaining = remainingBalances(state, context); - return { + const plan: PlanOutcome = { kind: 'Plan', horizon: context.horizon, placements, @@ -2236,12 +3337,128 @@ function makePlan( completedStages: [...stages], lastCompletedStage: stages[stages.length - 1] ?? 'validation', }; + recordTraceState(context, state); + return plan; } function markProgress(context: SearchContext, stage: string): void { + if (context.trace && stage !== 'complete') { + observeTrace(context, (trace) => { + trace.startPhase(stage); + trace.recordDecision(stage === 'validation' ? 'validate' : 'choose', []); + }); + } if (typeof context.request.progress === 'function') context.request.progress(stage); } +function planningStages(context: SearchContext): string[] { + return [ + 'validation', + 'obligations', + 'at-risk', + ...context.rules + .filter((rule) => rule.enabled && rule.force === 'preference') + .map((rule) => `preference:${rule.id}`), + 'holiday-week-value', + 'non-working-day-gain', + 'longest-work-sequence', + 'short-sequence-deficit', + 'supporting-placements', + 'expiry', + 'dates', + ]; +} + +function conflictExplanation( + outcome: PlanningConflictOutcome, + context: SearchContext, +): ConflictExplanation { + const labels = recordValue(context.request.labels) as Record | undefined; + return { + kind: 'ConflictExplanation', + conflictingRules: outcome.conflictingRuleIds.map((id) => ({ + id, + label: displayLabel( + labels?.[id] ?? context.rules.find((rule) => rule.id === id)?.name, + id, + ), + })), + bottleneck: outcome.reasons[0], + }; +} + +function obligationConflict( + requirements: readonly Requirement[], + context: SearchContext, +): PlanningConflictOutcome { + const ruleIds = [...new Set(requirements.map((requirement) => requirement.rule.id))]; + const generatedTypes = new Set( + context.rules + .filter((rule) => rule.enabled && (rule.rewardDays ?? 0) > 0 && rule.rewardType) + .map((rule) => rule.rewardType!), + ); + for (const requirement of requirements) { + if (requirement.rule.placeType === 'any') continue; + const required = requirement.patterns?.[0]?.length ?? requirement.rule.placeDays; + const capacity = context.initialBalances + .filter((balance) => balance.type === requirement.rule.placeType) + .reduce((sum, balance) => sum + balance.quantity, 0); + if (capacity < required && !generatedTypes.has(requirement.rule.placeType)) + return { + kind: 'PlanningConflict', + conflictingRuleIds: ruleIds, + reasons: [ + reason( + 'typed-capacity', + `The obligations require more ${requirement.rule.placeType.toUpperCase()} leave than is available.`, + ruleIds, + ), + ], + }; + } + if ( + requirements.some( + (requirement) => + (requirement.patterns !== undefined && requirement.patterns.length === 0) || + (requirement.candidates !== undefined && requirement.candidates.length === 0), + ) + ) + return { + kind: 'PlanningConflict', + conflictingRuleIds: ruleIds, + reasons: [ + reason( + 'horizon-bottleneck', + 'The planning horizon contains no eligible dates for at least one obligation.', + ruleIds, + ), + ], + }; + if (generatedTypes.size) + return { + kind: 'PlanningConflict', + conflictingRuleIds: ruleIds, + reasons: [ + reason( + 'earned-entitlement-bottleneck', + 'An earned leave entitlement is not available early enough to satisfy every obligation.', + ruleIds, + ), + ], + }; + return { + kind: 'PlanningConflict', + conflictingRuleIds: ruleIds, + reasons: [ + reason( + 'joint-date-conflict', + 'The obligation date patterns cannot be satisfied together.', + ruleIds, + ), + ], + }; +} + function supportingPlacements( state: State, context: SearchContext, @@ -2273,6 +3490,15 @@ function supportingPlacements( const feasibleCandidate = finalizeEarnedState(candidate, context); if (!feasibleCandidate) continue; const metrics = qualityMetrics(feasibleCandidate, context, preferences); + recordTraceEvaluation( + context, + result, + feasibleCandidate, + preferences, + before, + metrics, + [date], + ); const preservesHigherMetrics = metrics.unplacedAtRisk === before.unplacedAtRisk && metrics.preferenceSatisfaction.length === @@ -2298,6 +3524,7 @@ function supportingPlacements( } } if (!best) return result; + recordTraceState(context, best); result = best; } } @@ -2320,6 +3547,16 @@ function pruneRedundantSupportingPlacements( const balance = candidate.balances.find((item) => item.id === placement.balanceId); if (balance) balance.remaining++; const metrics = qualityMetrics(candidate, context, preferences); + recordTraceEvaluation( + context, + result, + candidate, + preferences, + baseline, + metrics, + [placement.date], + placement.date, + ); if ( metrics.unplacedAtRisk === baseline.unplacedAtRisk && metrics.holidayWeekValue >= baseline.holidayWeekValue && @@ -2327,6 +3564,7 @@ function pruneRedundantSupportingPlacements( metrics.longestWorkSequence <= baseline.longestWorkSequence && metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit ) { + recordTraceState(context, candidate); result = candidate; removed = true; break; @@ -2349,16 +3587,29 @@ function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number { } export type PlanningCheckpoint = (plan: PlanOutcome) => void; +export interface PlanningArtifacts { + explanation?: PlanningExplanation; + trace?: PlanningTrace; + finalState?: PlanningTraceState; + explanationError?: PlanningReason; +} +export type PlanningArtifactsCallback = (artifacts: PlanningArtifacts) => void; +export type PlanningOutcomeCallback = (outcome: PlanningOutcome) => void; -/** @internal Worker-only synchronous core. Application code must use planLeave. */ +/** @internal Worker-only synchronous core. Application code uses the public calculation context. */ export function calculatePlanningInternal( request: unknown, checkpointCallback?: PlanningCheckpoint, + artifactsCallback?: PlanningArtifactsCallback, + outcomeCallback?: PlanningOutcomeCallback, ): PlanningOutcome { const source = runtimeRequest(request); const normalized = normalizeRequest(source); if (normalized.reasons.length) { - return invalidInputFor(source)!; + const outcome = invalidInputFor(source)!; + outcomeCallback?.(outcome); + artifactsCallback?.({}); + return outcome; } const horizon = normalized.horizon!; const holidays = holidaySet(horizon); @@ -2404,16 +3655,33 @@ export function calculatePlanningInternal( protectedTypesByDate: new Map(), preferenceDemandByDate: new Map(), anyPreferenceDemandByDate: new Map(), + traceTime: 0, + traceReportedTime: 0, } as SearchContext; + const traceStarted = now(); + const traceRecorder = artifactsCallback + ? new TraceRecorder( + { + horizon, + balances: normalized.balances.map((balance) => ({ + ...balance, + remaining: balance.quantity, + })), + placements: [], + earnedActivations: [], + }, + planningStages(context), + new Set(workingDays), + ) + : undefined; + if (traceRecorder) context.traceTime += now() - traceStarted; + context.trace = traceRecorder; context.holidayWeeks = holidayWeekCandidates(context); 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(); + if (now() - context.started - context.traceTime >= context.deadline) + throw new DeadlineReached(); } }; const initialState: State = { @@ -2428,6 +3696,116 @@ export function calculatePlanningInternal( let state = initialState; const preferences: PreferenceResult[] = []; let checkpoint: PlanOutcome | undefined; + let checkpointState: State | undefined; + const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => { + outcomeCallback?.(outcome); + // Explanation materialization is post-outcome work and must not consume or + // inherit the planning deadline. + context.check = () => undefined; + let explanation: PlanningExplanation | undefined; + let explanationError: PlanningReason | undefined; + try { + if (outcome.kind === 'Plan' && explanationState) + explanation = planExplanation( + explanationState, + context, + outcome.placements, + outcome.timeOffPeriods, + outcome.preferences, + outcome.remainingBalances, + outcome.metrics, + ); + else if (outcome.kind === 'PlanningIncomplete') { + if (outcome.incumbent && explanationState) { + const incumbentExplanation = planExplanation( + explanationState, + context, + outcome.incumbent.placements, + outcome.incumbent.timeOffPeriods, + outcome.incumbent.preferences, + outcome.incumbent.remainingBalances, + outcome.incumbent.metrics, + ); + explanation = { + ...incumbentExplanation, + incomplete: { + kind: 'IncompleteExplanation', + reason: outcome.reasons[0], + completedStages: [...outcome.completedStages], + uncompletedStages: planningStages(context).filter( + (stage) => !outcome.completedStages.includes(stage), + ), + }, + }; + } else + explanation = { + kind: 'IncompleteExplanation', + reason: outcome.reasons[0], + completedStages: [...outcome.completedStages], + uncompletedStages: planningStages(context).filter( + (stage) => !outcome.completedStages.includes(stage), + ), + }; + } else if (outcome.kind === 'PlanningConflict') + explanation = conflictExplanation(outcome, context); + } catch (error) { + explanationError = reason( + 'explanation-error', + error instanceof Error ? error.message : String(error), + ); + } + let trace: PlanningTrace | undefined; + try { + trace = traceRecorder?.finish( + context.traceError + ? 'failed' + : outcome.kind === 'PlanningIncomplete' + ? 'incomplete' + : 'ready', + outcome.kind === 'Plan' + ? outcome.completedStages + : outcome.kind === 'PlanningIncomplete' + ? outcome.completedStages + : [], + context.traceError ?? + (outcome.kind === 'PlanningIncomplete' ? outcome.reasons[0] : undefined), + ); + } catch (error) { + const traceError = reason( + 'trace-error', + error instanceof Error ? error.message : String(error), + ); + trace = { + status: 'failed', + initialState: { + horizon, + balances: normalized.balances.map((balance) => ({ + ...balance, + remaining: balance.quantity, + })), + placements: [], + earnedActivations: [], + }, + phases: [], + completedStages: [], + error: traceError, + }; + } + artifactsCallback?.({ + explanation, + trace, + finalState: explanationState + ? planningTraceState(explanationState, context.horizon) + : undefined, + explanationError, + }); + return outcome; + }; + const publishCheckpoint = (): void => { + checkpoint = makePlan(state, context, preferences, stages); + checkpointState = cloneState(state); + checkpointCallback?.(checkpoint); + }; try { markProgress(context, 'validation'); // At-risk quantity is the first objective, but equivalent placements can @@ -2470,20 +3848,7 @@ export function calculatePlanningInternal( 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, - ), - ], - }; + return finish(obligationConflict(obligationRequirements, context)); } const preferenceRules = normalized.rules.filter( (item) => item.enabled && item.force === 'preference', @@ -2510,25 +3875,11 @@ export function calculatePlanningInternal( 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, - ), - ], - }; + return finish(obligationConflict(obligationRequirements, context)); } state = bestPreview.obligationState; stages.push('obligations'); - checkpoint = makePlan(state, context, preferences, stages); - checkpointCallback?.(checkpoint); + publishCheckpoint(); markProgress(context, 'at-risk'); context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); context.reservedTypes = new Set( @@ -2538,8 +3889,7 @@ export function calculatePlanningInternal( ); state = placeAtRisk(state, context); stages.push('at-risk'); - checkpoint = makePlan(state, context, preferences, stages); - checkpointCallback?.(checkpoint); + publishCheckpoint(); for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { const rule = preferenceRules[preferenceIndex]; markProgress(context, `preference:${rule.id}`); @@ -2551,52 +3901,45 @@ export function calculatePlanningInternal( context, ); stages.push(`preference:${rule.id}`); - checkpoint = makePlan(state, context, preferences, stages); - checkpointCallback?.(checkpoint); + publishCheckpoint(); } markProgress(context, 'holiday-week-value'); context.check(); state = optimizeHolidayWeeks(state, context, preferences); stages.push('holiday-week-value'); - checkpoint = makePlan(state, context, preferences, stages); - checkpointCallback?.(checkpoint); + publishCheckpoint(); 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); + publishCheckpoint(); 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); + publishCheckpoint(); 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); + publishCheckpoint(); 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); + publishCheckpoint(); markProgress(context, 'expiry'); context.check(); state = optimizeExpiryPlacements(state, context, preferences); stages.push('expiry'); - checkpoint = makePlan(state, context, preferences, stages); - checkpointCallback?.(checkpoint); + publishCheckpoint(); markProgress(context, 'dates'); context.check(); state = optimizeMovablePlacements(state, context, preferences, 'dates'); stages.push('dates'); const finalized = finalizeEarnedState(state, context); if (!finalized) { - return { + return finish({ kind: 'PlanningConflict', conflictingRuleIds: state.earnedActivations.map((activation) => activation.ruleId), reasons: [ @@ -2605,13 +3948,12 @@ export function calculatePlanningInternal( 'Generated leave became available only after its final merged time-off period.', ), ], - }; + }); } state = finalized; - checkpoint = makePlan(state, context, preferences, stages); - checkpointCallback?.(checkpoint); + publishCheckpoint(); markProgress(context, 'complete'); - return checkpoint; + return finish(checkpoint!, state); } catch (error) { if (!(error instanceof DeadlineReached)) throw error; const reasons = [ @@ -2620,12 +3962,317 @@ export function calculatePlanningInternal( `Planning stopped after ${context.deadline}ms before all quality stages completed.`, ), ]; - return { - kind: 'PlanningIncomplete', - incumbent: checkpoint, - completedStages: checkpoint?.completedStages ?? [], - lastCompletedStage: checkpoint?.lastCompletedStage, - reasons, - }; + return finish( + { + kind: 'PlanningIncomplete', + incumbent: checkpoint, + completedStages: checkpoint?.completedStages ?? [], + lastCompletedStage: checkpoint?.lastCompletedStage, + reasons, + }, + checkpointState, + ); } } + +function counterfactualReason( + code: CounterfactualReasonCode, + message: string, +): PlanningReason & { code: CounterfactualReasonCode } { + return { code, message }; +} + +function sameObjectiveValue(a: number | string, b: number | string): boolean { + return a === b; +} + +function objectiveComparison( + before: PlanningObjectiveVector, + after: PlanningObjectiveVector, +): PlanningObjectiveComparison { + const delta: PlanningObjectiveDelta[] = after.objectives.map((objective, index) => { + const previous = before.objectives[index]; + const value = + typeof objective.value === 'number' && typeof previous?.value === 'number' + ? objective.value - previous.value + : sameObjectiveValue(objective.value, previous?.value ?? objective.value) + ? 0 + : null; + return { + id: objective.id, + direction: objective.direction, + value, + label: objective.label, + }; + }); + const first = after.objectives.findIndex( + (objective, index) => + !sameObjectiveValue( + objective.value, + before.objectives[index]?.value ?? objective.value, + ), + ); + const decisive = first >= 0 ? after.objectives[first].id : undefined; + return { + before, + after, + delta, + firstDecisiveObjective: decisive, + tieBreak: + decisive === 'balance-expiry' + ? 'balance-expiry' + : decisive === 'placement-date' + ? 'placement-date' + : undefined, + }; +} + +function counterfactualRuleValid( + date: string, + type: LeaveType, + ruleIds: readonly string[], + context: SearchContext, + baseline: State, +): boolean { + for (const ruleId of ruleIds) { + const rule = context.rules.find((candidate) => candidate.id === ruleId); + if (!rule) return false; + if (rule.type === 'time_window') { + if ( + !selectorMatches(rule.placeType, type) || + compareDate(date, rule.startDate!) < 0 || + compareDate(date, rule.endDate!) > 0 + ) + return false; + continue; + } + const matchesPattern = occurrences(rule, context).some((occurrence) => + patternsFor(rule, occurrence, context).some( + (pattern) => + pattern.includes(date) && + pattern.every( + (candidateDate) => + candidateDate === date || + baseline.placements.get(candidateDate)?.ruleIds.has(rule.id) === true, + ), + ), + ); + if (!matchesPattern || !selectorMatches(rule.placeType, type)) return false; + } + return true; +} + +function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] { + return context.rules + .filter((rule) => rule.enabled && rule.force === 'preference') + .map((rule) => { + const requested = + rule.type === 'time_window' ? rule.placeDays : occurrences(rule, context).length; + const satisfied = + rule.type === 'time_window' + ? [...state.placements.values()].filter((placement) => + placement.ruleIds.has(rule.id), + ).length + : occurrences(rule, context).filter((occurrence) => + patternsFor(rule, occurrence, context).some((pattern) => + pattern.every( + (date) => + state.placements.get(date)?.ruleIds.has(rule.id) === true, + ), + ), + ).length; + return { + ruleId: rule.id, + requested, + satisfied, + completeOccurrences: rule.type === 'if_then' ? satisfied : 0, + }; + }); +} + +/** Analyze only the selected placement against the final-plan baseline. */ +export function analyzeCounterfactualInternal( + request: unknown, + plan: PlanOutcome, + selected: LeavePlacement, + finalStateSnapshot?: PlanningTraceState, + options?: CounterfactualBaselineOptions, +): CounterfactualAnalysis { + const source = runtimeRequest(request); + const normalized = normalizeRequest(source); + const horizon = normalized.horizon ?? plan.horizon; + const holidays = holidaySet(horizon); + 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 context = { + horizon, + holidays, + holidayWeeks: [], + calendarDays, + calendarIndex, + workingCalendar, + workingPrefix, + workingDays: dateRange(horizon.start, horizon.end).filter((date) => + isWorking(date, holidays), + ), + initialBalances: normalized.balances, + rules: normalized.rules, + deadline: Number.POSITIVE_INFINITY, + started: 0, + request: source, + nodes: 0, + check: () => undefined, + reservedTypes: new Set(), + sharedTypesByDate: new Map>(), + protectedTypesByDate: new Map>(), + preferenceDemandByDate: new Map(), + anyPreferenceDemandByDate: new Map(), + traceTime: 0, + traceReportedTime: 0, + } as SearchContext; + context.holidayWeeks = holidayWeekCandidates(context); + const finalState: State = finalStateSnapshot + ? { + balances: finalStateSnapshot.balances.map((balance) => ({ ...balance })), + placements: new Map( + finalStateSnapshot.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: new Set(placement.ruleIds) }, + ]), + ), + earnedActivations: finalStateSnapshot.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })), + } + : (() => { + const balances = normalized.balances.map((balance) => ({ + ...balance, + remaining: balance.quantity, + })); + for (const placement of plan.placements) { + let balance = balances.find( + (candidate) => candidate.id === placement.balanceId, + ); + if (!balance) { + const remaining = plan.remainingBalances.find( + (candidate) => candidate.id === placement.balanceId, + ); + balance = { + id: placement.balanceId, + type: placement.type, + quantity: 0, + remaining: 0, + availableFrom: remaining?.availableFrom ?? horizon.start, + expiryDate: remaining?.expiryDate ?? horizon.end, + earned: remaining?.earned ?? true, + sourceRuleId: remaining?.sourceRuleId, + }; + balances.push(balance); + } + balance.remaining = Math.max(0, balance.remaining - 1); + } + return { + balances, + placements: new Map( + plan.placements.map((placement) => [ + placement.date, + { ...placement, ruleIds: new Set(placement.ruleIds) }, + ]), + ), + earnedActivations: [], + }; + })(); + const comparisonState = cloneState(finalState); + const baseline = cloneState(finalState); + const removeDate = options ? options.removeDate : selected.date; + const removed = removeDate ? baseline.placements.get(removeDate) : undefined; + if (removeDate) baseline.placements.delete(removeDate); + const released = baseline.balances.find((balance) => balance.id === selected.balanceId); + if (released && removed?.balanceId === selected.balanceId) released.remaining++; + const beforeState = options?.compareOriginalState ? comparisonState : baseline; + const beforePreferences = preferenceResultsForState(beforeState, context); + const beforeMetrics = qualityMetrics(beforeState, context, beforePreferences); + const before = objectiveVector(beforeMetrics, beforePreferences, beforeState); + const results: CounterfactualDateResult[] = []; + for (const date of dateRange(horizon.start, horizon.end)) { + let ineligible: (PlanningReason & { code: CounterfactualReasonCode }) | undefined; + if (!isWorking(date, holidays)) + ineligible = counterfactualReason( + 'non-working', + 'The date is a weekend or public holiday.', + ); + else if (baseline.placements.has(date)) + ineligible = counterfactualReason( + 'occupied', + 'Another final placement already uses this date.', + ); + else if (!released) + ineligible = counterfactualReason( + 'unavailable', + 'The selected leave entitlement is unavailable.', + ); + else if (compareDate(released.availableFrom, date) > 0) + ineligible = counterfactualReason( + 'unavailable', + 'The selected entitlement is not available yet.', + ); + else if (compareDate(date, released.expiryDate) > 0) + ineligible = counterfactualReason( + 'expired', + 'The selected entitlement has expired by this date.', + ); + else if (released.type !== selected.type) + ineligible = counterfactualReason( + 'type-incompatible', + 'The date cannot host this leave type.', + ); + else if (!counterfactualRuleValid(date, selected.type, selected.ruleIds, context, baseline)) + ineligible = counterfactualReason( + 'rule-invalid', + 'The date does not preserve the placement rules.', + ); + if (ineligible) { + results.push({ date, status: 'ineligible', reason: ineligible }); + continue; + } + const candidate = assignDateToBalance( + baseline, + date, + selected.balanceId, + context, + new Set(selected.ruleIds), + ); + if (!candidate) { + results.push({ + date, + status: 'ineligible', + reason: counterfactualReason( + 'unavailable', + 'The selected entitlement cannot be placed on this date.', + ), + }); + continue; + } + candidate.placements.get(date)!.supporting = selected.supporting; + const afterPreferences = preferenceResultsForState(candidate, context); + const afterMetrics = qualityMetrics(candidate, context, afterPreferences); + const after = objectiveVector(afterMetrics, afterPreferences, candidate); + const comparison = objectiveComparison(before, after); + results.push({ date, status: 'eligible', comparison }); + } + return { + kind: 'Counterfactual', + calculationId: '', + placement: { ...selected, ruleIds: [...selected.ruleIds] }, + hypothetical: true, + baseline: before, + results, + }; +} diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts index e873f3d..5182802 100644 --- a/src/app/services/leave-planner.service.spec.ts +++ b/src/app/services/leave-planner.service.spec.ts @@ -1,5 +1,10 @@ import { vi } from 'vitest'; -import { PlanningOutcome, PlanningRequest, planLeave } from './leave-planner.service'; +import { + PlanningOutcome, + PlanningRequest, + createPlanningCalculation, + planLeave, +} from './leave-planner.service'; const horizon = { start: '2024-01-01', end: '2025-01-01' }; const balance = ( @@ -1113,6 +1118,43 @@ describe('planLeave transport', () => { } }); + it('does not charge reported trace instrumentation time to the planning deadline', async () => { + const expected = isPlan( + await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), + ); + class TraceAwareWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(): void { + this.onmessage?.({ data: { type: 'trace-overhead', totalMs: 30 } } as MessageEvent); + setTimeout(() => { + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts', artifacts: {} }, + } as MessageEvent); + }, 15); + } + + terminate(): void {} + } + vi.stubGlobal('Worker', TraceAwareWorker); + try { + expect( + await planLeave({ + horizon, + balances: [balance('cp', 'cp', 1)], + rules: [], + deadlineMs: 10, + }), + ).toEqual(expected); + } 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 = { @@ -1197,3 +1239,602 @@ describe('planLeave transport', () => { } }); }); + +describe('planning calculation context', () => { + it('keeps the planning outcome identical when trace instrumentation is enabled', async () => { + const core = await import('./leave-planner.core'); + const request = { + horizon: { start: '2024-01-01', end: '2024-02-01' }, + balances: [balance('cp', 'cp', 3, '2024-01-31')], + rules: [], + }; + const withoutTrace = core.calculatePlanningInternal(request); + let traceStatus: string | undefined; + const withTrace = core.calculatePlanningInternal(request, undefined, (artifacts) => { + traceStatus = artifacts.trace?.status; + }); + + expect(withTrace).toEqual(withoutTrace); + expect(traceStatus).toBe('ready'); + }); + + it('keeps an immutable submitted snapshot beside the outcome', async () => { + const request = { + horizon, + balances: [balance('cp', 'cp', 1)], + rules: [], + labels: { cp: 'Paid leave' }, + }; + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation(request); + request.balances[0].quantity = 99; + request.labels.cp = 'Changed later'; + + expect(calculation.snapshot.balances[0].quantity).toBe(1); + expect(calculation.snapshot.labels['cp']).toBe('Paid leave'); + expect((await calculation.outcome).kind).toBe('Plan'); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('materializes a chronological explanation after the unchanged outcome', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-15' }, + balances: [ + { + ...balance('cp', 'cp', 1, '2026-01-01'), + name: 'Paid leave entitlement', + }, + ], + rules: [ + { + id: 'winter', + name: 'Winter break', + type: 'time_window', + startDate: '2024-01-02', + endDate: '2024-01-12', + placeDays: 1, + placeType: 'cp', + }, + ], + labels: { winter: 'Winter break' }, + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + expect('explanation' in outcome).toBe(false); + const explanation = await calculation.explanation; + expect(explanation?.kind).toBe('PlanExplanation'); + if (explanation?.kind !== 'PlanExplanation') return; + expect(explanation.placements).toHaveLength(outcome.placements.length); + expect(explanation.placements[0].balance?.name).toBe('Paid leave entitlement'); + expect(explanation.placements[0].ruleIds).toContain('winter'); + expect(explanation.placements[0].objectiveVector.objectives.length).toBeGreaterThan(1); + expect('score' in explanation.placements[0].objectiveVector).toBe(false); + expect(explanation.placements[0].firstDecisiveObjective).toBe( + explanation.placements[0].comparison.firstDecisiveObjective, + ); + expect(explanation.placements[0].firstDecisiveObjective).toBe('preference:winter'); + const alternatives = await calculation.analyzeCounterfactual(outcome.placements[0]); + expect(alternatives.kind).toBe('Counterfactual'); + if (alternatives.kind === 'Counterfactual') + expect( + alternatives.results.find( + (result) => result.date === outcome.placements[0].date, + )?.comparison?.firstDecisiveObjective, + ).toBe('preference:winter'); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('reports every rule contribution when one placement is shared', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-06' }, + balances: [balance('cp', 'cp', 1)], + rules: [ + { + id: 'wide', + name: 'Wide window', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-02', + endDate: '2024-01-05', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'exact', + name: 'Exact day', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-05', + endDate: '2024-01-05', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + const explanation = await calculation.explanation; + expect(explanation?.kind).toBe('PlanExplanation'); + if (explanation?.kind !== 'PlanExplanation') return; + expect( + explanation.placements[0].contributions + .filter((contribution) => contribution.kind === 'rule') + .map((contribution) => contribution.ruleId), + ).toEqual(['exact', 'wide']); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('compares the selected placement across the half-open horizon without changing the plan', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1, '2024-12-31')], + rules: [], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + const before = structuredClone(outcome); + const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect(analysis.results).toHaveLength(9); + expect(analysis.results.some((result) => result.reason?.code === 'non-working')).toBe( + true, + ); + expect( + analysis.results.some( + (result) => result.status === 'eligible' && result.comparison, + ), + ).toBe(true); + expect(outcome).toEqual(before); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('preserves supporting provenance in counterfactual placements', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-05', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1, '2025-01-01')], + rules: [], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + const selected = outcome.placements.find((placement) => placement.supporting); + expect(selected).toBeDefined(); + if (!selected) return; + + const analysis = await calculation.analyzeCounterfactual(selected); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + const restored = analysis.results.find((result) => result.date === selected.date); + expect( + restored?.comparison?.after.objectives.find( + (objective) => objective.id === 'supporting-placements', + )?.value, + ).toBe(1); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('discards counterfactual work from an older calculation', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const older = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }); + const olderOutcome = await older.outcome; + expect(olderOutcome.kind).toBe('Plan'); + if (olderOutcome.kind !== 'Plan') return; + createPlanningCalculation({ horizon, balances: [], rules: [] }); + + const analysis = await older.analyzeCounterfactual(olderOutcome.placements[0]); + expect(analysis.kind).toBe('CounterfactualStale'); + expect(analysis.results).toEqual([]); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('keeps earned-entitlement availability and provenance in counterfactuals', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + 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, + }, + ], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + const earned = outcome.placements.find((placement) => + placement.balanceId.startsWith('earned:'), + ); + expect(earned).toBeDefined(); + if (!earned) return; + const analysis = await calculation.analyzeCounterfactual(earned); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect(analysis.placement).toEqual(earned); + expect( + analysis.results.find((result) => result.date === '2024-04-25')?.reason?.code, + ).toBe('unavailable'); + const trace = await calculation.trace; + const earnedBalanceDelta = trace?.phases + .flatMap((phase) => phase.decisions) + .flatMap((decision) => decision.deltas) + .find((delta) => delta.balance?.id === earned.balanceId); + expect(earnedBalanceDelta?.balance).toMatchObject({ + id: earned.balanceId, + type: earned.type, + earned: true, + }); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('replays a trace decision from its pre-decision state', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-02-01' }, + balances: [balance('cp', 'cp', 2, '2024-01-31')], + rules: [ + { + id: 'wide', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-01', + endDate: '2024-01-31', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + const decision = trace.phases + .flatMap((phase) => phase.decisions) + .find((candidate) => + candidate.deltas.some((delta) => delta.kind === 'placement-added'), + ); + const placement = decision?.deltas.find( + (delta) => delta.kind === 'placement-added', + )?.placement; + expect(decision).toBeDefined(); + expect(placement).toBeDefined(); + if (!decision || !placement) return; + const laterPlacement = outcome.placements.find( + (candidate) => candidate.date !== placement.date, + ); + expect(laterPlacement).toBeDefined(); + if (!laterPlacement) return; + + const analysis = await calculation.analyzeCounterfactual({ + decisionId: decision.id, + placement, + }); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect( + analysis.results.find((result) => result.date === laterPlacement.date)?.status, + ).toBe('eligible'); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('reconstructs the returned plan from the original trace deltas', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-02-01' }, + balances: [balance('cp', 'cp', 4, '2024-01-31')], + rules: [], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + expect(trace?.status).toBe('ready'); + if (outcome.kind !== 'Plan' || !trace) return; + + const placements = new Map( + trace.initialState.placements.map((placement) => [placement.date, placement]), + ); + for (const delta of trace.phases.flatMap((phase) => + phase.decisions.flatMap((decision) => decision.deltas), + )) { + if (delta.previousPlacement) placements.delete(delta.previousPlacement.date); + if (delta.placement) placements.set(delta.placement.date, delta.placement); + } + expect([...placements.values()].sort((a, b) => a.date.localeCompare(b.date))).toEqual( + outcome.placements, + ); + expect(trace.phases.map((phase) => phase.name)).toEqual( + expect.arrayContaining([ + 'obligations', + 'at-risk', + 'holiday-week-value', + 'non-working-day-gain', + 'longest-work-sequence', + 'short-sequence-deficit', + 'supporting-placements', + 'expiry', + 'dates', + ]), + ); + expect( + trace.phases + .flatMap((phase) => phase.decisions) + .flatMap((decision) => decision.candidates).length, + ).toBeGreaterThan(20); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('records loaded-plan pruning and full quality comparisons without truncation', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-04-01' }, + balances: [balance('cp', 'cp', 25, '2024-03-31')], + rules: [], + }); + expect((await calculation.outcome).kind).toBe('Plan'); + const trace = await calculation.trace; + const candidates = + trace?.phases.flatMap((phase) => + phase.decisions.flatMap((decision) => decision.candidates), + ) ?? []; + expect( + candidates.some( + (candidate) => + candidate.status === 'not-examined' && + candidate.reason?.code === 'loaded-plan-sampling', + ), + ).toBe(true); + const comparison = candidates.find((candidate) => candidate.comparison)?.comparison; + expect(comparison?.before.objectives.length).toBeGreaterThan(1); + expect(comparison?.after.objectives.length).toBe(comparison?.before.objectives.length); + expect( + candidates.some((candidate) => candidate.comparison && candidate.sourceDate), + ).toBe(true); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('offers no explanation trace for invalid input and explains conflicts', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const invalidCalculation = createPlanningCalculation({ + horizon: { start: '2025-01-01', end: '2024-01-01' }, + balances: [], + rules: [], + }); + expect((await invalidCalculation.outcome).kind).toBe('InvalidInput'); + expect(await invalidCalculation.explanation).toBeUndefined(); + expect(await invalidCalculation.trace).toBeUndefined(); + + const conflictCalculation = createPlanningCalculation({ + horizon, + balances: [balance('rtt', 'rtt', 1)], + rules: [ + { + id: 'cp-only', + name: 'Paid leave only', + type: 'time_window', + force: 'obligation', + startDate: '2024-04-01', + endDate: '2024-04-05', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + expect((await conflictCalculation.outcome).kind).toBe('PlanningConflict'); + const explanation = await conflictCalculation.explanation; + expect(explanation?.kind).toBe('ConflictExplanation'); + if (explanation?.kind === 'ConflictExplanation') + expect(explanation.conflictingRules).toEqual([ + { id: 'cp-only', label: 'Paid leave only' }, + ]); + const trace = await conflictCalculation.trace; + expect(trace?.phases.some((phase) => phase.name === 'holiday-week-value')).toBe(false); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('marks interrupted and unexecuted phases when no incumbent exists', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon, + balances: [balance('cp', 'cp', 5)], + rules: [], + deadlineMs: 0, + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('PlanningIncomplete'); + const explanation = await calculation.explanation; + expect(explanation?.kind).toBe('IncompleteExplanation'); + const trace = await calculation.trace; + expect(trace?.phases.some((phase) => phase.status === 'interrupted')).toBe(true); + expect(trace?.phases.some((phase) => phase.status === 'not-run')).toBe(true); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('explains an incomplete incumbent without reusing the expired planning deadline', async () => { + const core = await import('./leave-planner.core'); + let artifacts: import('./leave-planner.core').PlanningArtifacts | undefined; + const outcome = core.calculatePlanningInternal( + { + horizon, + balances: Array.from({ length: 12 }, (_, index) => + balance(`balance-${index}`, index % 2 ? 'rtt' : 'cp', 12), + ), + rules: [], + deadlineMs: 20, + }, + undefined, + (value) => (artifacts = value), + ); + expect(outcome.kind).toBe('PlanningIncomplete'); + if (outcome.kind !== 'PlanningIncomplete') return; + expect(outcome.incumbent).toBeDefined(); + expect(artifacts?.explanation?.kind).toBe('PlanExplanation'); + if (artifacts?.explanation?.kind !== 'PlanExplanation') return; + expect(artifacts.explanation.incomplete?.reason.code).toBe('deadline'); + expect(artifacts.explanation.placements).toHaveLength( + outcome.incumbent?.placements.length ?? 0, + ); + }); + + it('publishes the worker outcome before delayed explanation artifacts', async () => { + const expected = isPlan( + await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), + ); + const trace = { + status: 'ready' as const, + initialState: { + horizon, + balances: [], + placements: [], + earnedActivations: [], + }, + phases: [], + completedStages: [], + }; + class DelayedArtifactsWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(): void { + queueMicrotask(() => + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent), + ); + setTimeout( + () => + this.onmessage?.({ + data: { type: 'artifacts', artifacts: { trace } }, + } as MessageEvent), + 20, + ); + } + + terminate(): void {} + } + vi.stubGlobal('Worker', DelayedArtifactsWorker); + try { + const calculation = createPlanningCalculation({ + horizon, + balances: [balance('cp', 'cp', 1)], + rules: [], + }); + let traceReady = false; + void calculation.trace.then(() => (traceReady = true)); + expect((await calculation.outcome).kind).toBe('Plan'); + expect(traceReady).toBe(false); + expect((await calculation.trace)?.status).toBe('ready'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('keeps a valid outcome when trace delivery fails afterward', async () => { + const expected = isPlan( + await runInNonBrowser({ horizon, balances: [balance('cp', 'cp', 1)], rules: [] }), + ); + class FailingArtifactsWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + + postMessage(): void { + queueMicrotask(() => { + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent); + queueMicrotask(() => this.onerror?.()); + }); + } + + terminate(): void {} + } + vi.stubGlobal('Worker', FailingArtifactsWorker); + try { + const calculation = createPlanningCalculation({ + horizon, + balances: [balance('cp', 'cp', 1)], + rules: [], + }); + expect(await calculation.outcome).toEqual(expected); + expect((await calculation.trace)?.status).toBe('failed'); + expect((await calculation.explanationError)?.code).toBe('worker-error'); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts index 8e3516e..4c39906 100644 --- a/src/app/services/leave-planner.service.ts +++ b/src/app/services/leave-planner.service.ts @@ -1,35 +1,71 @@ import type { + CounterfactualAnalysis, + CounterfactualBaselineOptions, + CounterfactualDateResult, + CounterfactualSelection, InvalidInputOutcome, + LeaveBalanceInput, + LeavePlacement, + LeaveType, PlanOutcome, + PlanningArtifacts, + PlanningExplanation, PlanningIncompleteOutcome, PlanningOutcome, PlanningRequest, + PlanningSnapshot, + PlanningTrace, + PlanningReason, + PlanningTraceState, + StaleCounterfactualAnalysis, + TraceCounterfactualSelection, } from './leave-planner.core'; export type { IfThenPlanningRule, + ConflictExplanation, + CounterfactualAnalysis, + CounterfactualSelection, + CounterfactualDateResult, + CounterfactualReasonCode, InvalidInputOutcome, + IncompleteExplanation, LeaveBalanceInput, LeavePlacement, LeaveSelector, LeaveType, PlanOutcome, + PlanExplanation, PlacementMode, PlanningConflictOutcome, PlanningDate, PlanningHorizon, PlanningIncompleteOutcome, + PlanningExplanation, PlanningMetrics, + PlanningObjective, + PlanningObjectiveComparison, + PlanningObjectiveDelta, + PlanningObjectiveId, + PlanningObjectiveVector, PlanningOutcome, PlanningReason, PlanningRequest, PlanningRule, + PlanningSnapshot, + PlanningTrace, + PlanningTraceCandidate, + PlanningTraceDecision, + PlanningTraceDelta, + PlanningTracePhase, + PlanningTraceState, PreferenceResult, RemainingBalance, RemainingClassification, RuleForce, TimeOffPeriod, TimeWindowPlanningRule, + TraceCounterfactualSelection, } from './leave-planner.core'; const MAX_PLANNING_MS = 10_000; @@ -40,12 +76,15 @@ interface UntrustedRecord { end?: unknown; balances?: unknown; rules?: unknown; + identities?: unknown; + labels?: unknown; deadlineMs?: unknown; progress?: unknown; id?: unknown; name?: unknown; type?: unknown; quantity?: unknown; + label?: unknown; availableFrom?: unknown; expiryDate?: unknown; enabled?: unknown; @@ -60,10 +99,12 @@ interface UntrustedRecord { rewardType?: unknown; } -interface PlanningSnapshot { +interface TransportSnapshot { horizon?: { start?: unknown; end?: unknown }; balances?: unknown; rules?: unknown; + identities?: unknown; + labels?: unknown; deadlineMs?: unknown; progress?: unknown; } @@ -76,7 +117,71 @@ function copyDate(value: unknown): unknown { return value instanceof Date ? new Date(value.getTime()) : value; } -function snapshotRequest(request: unknown): PlanningSnapshot { +function cloneUnknown(value: unknown, seen = new WeakMap()): unknown { + if (value instanceof Date) return new Date(value.getTime()); + if (typeof value !== 'object' || value === null) return value; + const previous = seen.get(value); + if (previous) return previous; + if (Array.isArray(value)) { + const result: unknown[] = []; + seen.set(value, result); + for (const item of value) result.push(cloneUnknown(item, seen)); + return result; + } + const result: Record = {}; + seen.set(value, result); + for (const [key, item] of Object.entries(value)) result[key] = cloneUnknown(item, seen); + return result; +} + +function snapshotValue(value: unknown): unknown { + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) return value.map(snapshotValue); + if (typeof value !== 'object' || value === null) return value; + const result: Record = {}; + for (const [key, item] of Object.entries(value)) result[key] = snapshotValue(item); + return result; +} + +function deepFreeze(value: T, seen = new WeakSet()): T { + if (typeof value !== 'object' || value === null || seen.has(value)) return value; + seen.add(value); + for (const child of Object.values(value as Record)) deepFreeze(child, seen); + return Object.freeze(value); +} + +function stringMap(value: unknown): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + typeof item === 'string' ? item : String(item), + ]), + ); +} + +function publicSnapshot(request: TransportSnapshot): PlanningSnapshot { + const horizon = request.horizon ?? {}; + const balances = Array.isArray(request.balances) + ? request.balances.map((value) => snapshotValue(value) as LeaveBalanceInput) + : []; + const rules = Array.isArray(request.rules) + ? request.rules.map((value) => snapshotValue(value) as PlanningRequest['rules'][number]) + : []; + const labels = stringMap(request.labels); + return deepFreeze({ + horizon: { + start: String(snapshotValue(horizon.start) ?? ''), + end: String(snapshotValue(horizon.end) ?? ''), + }, + balances, + rules, + identities: stringMap(request.identities), + labels, + }) as PlanningSnapshot; +} + +function snapshotRequest(request: unknown): TransportSnapshot { const source = asRecord(request) ?? {}; const horizon = asRecord(source.horizon); const balances = Array.isArray(source.balances) @@ -86,6 +191,8 @@ function snapshotRequest(request: unknown): PlanningSnapshot { id: value?.id, type: value?.type, quantity: value?.quantity, + name: value?.name, + label: value?.label, availableFrom: value?.availableFrom === undefined ? undefined @@ -133,17 +240,19 @@ function snapshotRequest(request: unknown): PlanningSnapshot { : undefined, balances, rules, + identities: cloneUnknown(source.identities), + labels: cloneUnknown(source.labels), deadlineMs: source.deadlineMs, progress: typeof source.progress === 'function' ? source.progress : undefined, }; } -function deadlineFor(request: PlanningSnapshot): number { +function deadlineFor(request: TransportSnapshot): 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 { +function invalidDeadline(request: TransportSnapshot): InvalidInputOutcome | undefined { const deadline = request.deadlineMs; if ( deadline !== undefined && @@ -163,10 +272,56 @@ function invalidDeadline(request: PlanningSnapshot): InvalidInputOutcome | undef type PlannerWorkerResponse = | { type: 'progress'; stage: string } + | { type: 'trace-overhead'; totalMs: number } | { type: 'checkpoint'; plan: PlanOutcome } | { type: 'result'; outcome: PlanningOutcome } + | { type: 'artifacts'; artifacts: PlanningArtifacts } | { type: 'error'; message: string }; +interface PlanningRun { + outcome: Promise; + artifacts: Promise; +} + +function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState { + const horizon = snapshot.horizon ?? {}; + const balances = Array.isArray(snapshot.balances) ? snapshot.balances : []; + return { + horizon: { + start: String(horizon.start ?? ''), + end: String(horizon.end ?? ''), + }, + balances: balances.map((item) => { + const balance = asRecord(item); + return { + id: String(balance?.id ?? ''), + type: String(balance?.type ?? 'other') as LeaveType, + quantity: typeof balance?.quantity === 'number' ? balance.quantity : 0, + remaining: typeof balance?.quantity === 'number' ? balance.quantity : 0, + availableFrom: String(balance?.availableFrom ?? horizon.start ?? ''), + expiryDate: String(balance?.expiryDate ?? ''), + earned: false, + }; + }), + placements: [], + earnedActivations: [], + }; +} + +function fallbackTrace( + snapshot: TransportSnapshot, + status: PlanningTrace['status'], + error?: PlanningReason, +): PlanningTrace { + return { + status, + initialState: traceStateFor(snapshot), + phases: [], + completedStages: [], + error, + }; +} + function interruptedOutcome( checkpoint: PlanOutcome | undefined, deadline: number, @@ -182,40 +337,71 @@ function interruptedOutcome( }; } -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; - } +function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun { + let resolveOutcome!: (outcome: PlanningOutcome) => void; + let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; + const outcome = new Promise((resolve) => { + resolveOutcome = resolve; + }); + const artifacts = new Promise((resolve) => { + resolveArtifacts = resolve; + }); + try { + const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { + type: 'module', + }); - let settled = false; + let outcomeSettled = false; + let artifactsSettled = false; let checkpoint: PlanOutcome | undefined; - const finish = (outcome: PlanningOutcome) => { - if (settled) return; - settled = true; + let traceAllowance = 0; + let timeout: ReturnType; + const timeoutStarted = performance.now(); + const terminate = () => { clearTimeout(timeout); worker.onmessage = null; worker.onerror = null; worker.terminate(); - resolve(outcome); }; - const timeout = setTimeout( - () => finish(interruptedOutcome(checkpoint, deadline)), - Math.max(0, deadline - 1), - ); + const finishOutcome = (result: PlanningOutcome) => { + if (!outcomeSettled) { + outcomeSettled = true; + clearTimeout(timeout); + resolveOutcome(result); + } + }; + const finishArtifacts = (result: PlanningArtifacts) => { + if (!artifactsSettled) { + artifactsSettled = true; + resolveArtifacts(result); + terminate(); + } + }; + const fail = (code: string, message: string) => { + const reason = { code, message }; + if (!outcomeSettled) + finishOutcome(interruptedOutcome(checkpoint, deadline, code, message)); + finishArtifacts({ + trace: fallbackTrace( + snapshot, + code === 'deadline' ? 'incomplete' : 'failed', + reason, + ), + explanationError: reason, + }); + }; + const scheduleTimeout = () => { + clearTimeout(timeout); + timeout = setTimeout( + () => + fail( + 'deadline', + `Planning stopped after ${deadline}ms before all quality stages completed.`, + ), + Math.max(0, deadline + traceAllowance - (performance.now() - timeoutStarted)), + ); + }; + scheduleTimeout(); worker.onmessage = (event: MessageEvent) => { const message = event.data; @@ -224,65 +410,489 @@ function planInWorker(snapshot: PlanningSnapshot, deadline: number): Promise - finish( - interruptedOutcome( - checkpoint, - deadline, - 'worker-error', - 'Planning worker failed before returning an outcome.', - ), - ); + fail('worker-error', 'Planning worker failed before returning explanation data.'); 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.', - ), - ); + fail('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( + } catch { + const result = interruptedOutcome( undefined, deadline, 'worker-unavailable', 'Planning could not start because isolated execution is unavailable.', ); + resolveOutcome(result); + resolveArtifacts({ + trace: fallbackTrace(snapshot, 'failed', result.reasons[0]), + explanationError: result.reasons[0], + }); } - await new Promise((resolve) => setTimeout(resolve, 0)); + return { outcome, artifacts }; +} + +let calculationSequence = 0; +let latestCalculationId = ''; + +function selectionFor( + plan: PlanOutcome, + selection: CounterfactualSelection, +): LeavePlacement | undefined { + if (typeof selection === 'object' && 'decisionId' in selection) return selection.placement; + if (typeof selection !== 'string') + return plan.placements.find( + (candidate) => + candidate.date === selection.date && candidate.balanceId === selection.balanceId, + ); + return plan.placements.find( + (candidate) => candidate.date === selection || candidate.balanceId === selection, + ); +} + +function traceSelection( + selection: CounterfactualSelection, +): selection is TraceCounterfactualSelection { + return typeof selection === 'object' && 'decisionId' in selection; +} + +function stateBeforeDecision( + trace: PlanningTrace, + decisionId: string, + finalState?: PlanningTraceState, +): PlanningTraceState | undefined { + const balances = trace.initialState.balances.map((balance) => ({ ...balance })); + const placements = new Map( + trace.initialState.placements.map((placement) => [placement.date, { ...placement }]), + ); + const earnedActivations = trace.initialState.earnedActivations.map((activation) => ({ + ...activation, + dates: [...activation.dates], + })); + for (const decision of trace.phases.flatMap((phase) => phase.decisions)) { + if (decision.id === decisionId) + return { + horizon: { ...trace.initialState.horizon }, + balances, + placements: [...placements.values()], + earnedActivations, + }; + for (const delta of decision.deltas) { + if (delta.previousPlacement) placements.delete(delta.previousPlacement.date); + if (delta.placement) + placements.set(delta.placement.date, { + ...delta.placement, + ruleIds: [...delta.placement.ruleIds], + }); + if (delta.kind === 'balance-changed' && delta.balanceId) { + let balance = balances.find((candidate) => candidate.id === delta.balanceId); + if (!balance && delta.balance) { + balance = { ...delta.balance }; + balances.push(balance); + } + if (!balance) { + const source = finalState?.balances.find( + (candidate) => candidate.id === delta.balanceId, + ); + if (source) { + balance = { ...source }; + balances.push(balance); + } + } + if (balance && delta.remainingAfter !== undefined) + balance.remaining = delta.remainingAfter; + } + if ( + delta.kind === 'earned-activated' && + delta.activation && + !earnedActivations.some((activation) => activation.id === delta.activation!.id) + ) + earnedActivations.push({ + ...delta.activation, + dates: [...delta.activation.dates], + }); + } + } + return undefined; +} + +function decisionBaseline( + trace: PlanningTrace, + selection: TraceCounterfactualSelection, + finalState?: PlanningTraceState, +): { state: PlanningTraceState; options: CounterfactualBaselineOptions } | undefined { + const decision = trace.phases + .flatMap((phase) => phase.decisions) + .find((candidate) => candidate.id === selection.decisionId); + const state = stateBeforeDecision(trace, selection.decisionId, finalState); + if (!decision || !state) return undefined; + const replacement = decision.deltas.find( + (delta) => + (delta.kind === 'placement-moved' || delta.kind === 'placement-updated') && + delta.placement?.date === selection.placement.date && + delta.placement.balanceId === selection.placement.balanceId, + ); + return { + state, + options: { + removeDate: + replacement?.kind === 'placement-moved' + ? (replacement.fromDate ?? null) + : (replacement?.date ?? null), + compareOriginalState: true, + }, + }; +} + +function withHistoricalStatuses( + analysis: CounterfactualAnalysis, + trace?: PlanningTrace, + decisionId?: string, +): CounterfactualAnalysis { + if (!trace) return analysis; + const priority = new Map([ + ['ineligible', 0], + ['not-examined', 1], + ['pruned', 2], + ['evaluated-rejected', 3], + ['chosen', 4], + ]); + const statuses = new Map(); + for (const candidate of trace.phases.flatMap((phase) => + phase.decisions + .filter((decision) => !decisionId || decision.id === decisionId) + .flatMap((decision) => decision.candidates), + )) { + for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) { + const previous = statuses.get(date); + if (!previous || priority.get(candidate.status)! > priority.get(previous)!) + statuses.set(date, candidate.status); + } + } + return { + ...analysis, + results: analysis.results.map((result) => ({ + ...result, + historicalStatus: + statuses.get(result.date) ?? (decisionId ? 'not-examined' : undefined), + })), + }; +} + +async function runCounterfactual( + context: PlanningCalculationContext, + selection: CounterfactualSelection, + artifacts: Promise, +): Promise { + if (context.id !== latestCalculationId) { + const plan = await context.outcome; + const finalPlan = + plan.kind === 'Plan' + ? plan + : plan.kind === 'PlanningIncomplete' + ? plan.incumbent + : undefined; + const placement = finalPlan ? selectionFor(finalPlan, selection) : undefined; + if (!placement) + throw new Error('The selected placement is not present in the calculation.'); + return { + kind: 'CounterfactualStale', + calculationId: context.id, + placement, + hypothetical: true, + results: [], + }; + } + const outcome = await context.outcome; + if (context.id !== latestCalculationId) { + const plan = + outcome.kind === 'Plan' + ? outcome + : outcome.kind === 'PlanningIncomplete' + ? outcome.incumbent + : undefined; + const placement = plan ? selectionFor(plan, selection) : undefined; + if (!placement) + throw new Error('The selected placement is not present in the calculation.'); + return { + kind: 'CounterfactualStale', + calculationId: context.id, + placement, + hypothetical: true, + results: [], + }; + } + const plan = + outcome.kind === 'Plan' + ? outcome + : outcome.kind === 'PlanningIncomplete' + ? outcome.incumbent + : undefined; + if (!plan) throw new Error('Counterfactual analysis requires a plan outcome.'); + const placement = selectionFor(plan, selection); + if (!placement) throw new Error('The selected placement is not present in the calculation.'); + const completedArtifacts = await artifacts; + const selectedDecision = traceSelection(selection) + ? completedArtifacts.trace && + decisionBaseline(completedArtifacts.trace, selection, completedArtifacts.finalState) + : undefined; + if (traceSelection(selection) && !selectedDecision) + throw new Error('The selected trace decision is unavailable.'); + const analysisState = selectedDecision?.state ?? completedArtifacts.finalState; + const options = selectedDecision?.options; + if (context.id !== latestCalculationId) + return { + kind: 'CounterfactualStale', + calculationId: context.id, + placement, + hypothetical: true, + results: [], + }; + if (typeof window !== 'undefined' && typeof Worker !== 'undefined') { + const result = await counterfactualInWorker( + context.snapshot, + plan, + placement, + analysisState, + options, + ); + return context.id === latestCalculationId + ? withHistoricalStatuses( + { ...result, calculationId: context.id }, + completedArtifacts.trace, + traceSelection(selection) ? selection.decisionId : undefined, + ) + : { + kind: 'CounterfactualStale', + calculationId: context.id, + placement, + hypothetical: true, + results: [], + }; + } const core = await import('./leave-planner.core'); - return core.calculatePlanningInternal(snapshot); + const result = core.analyzeCounterfactualInternal( + context.snapshot, + plan, + placement, + analysisState, + options, + ); + return context.id === latestCalculationId + ? withHistoricalStatuses( + { ...result, calculationId: context.id }, + completedArtifacts.trace, + traceSelection(selection) ? selection.decisionId : undefined, + ) + : { + kind: 'CounterfactualStale', + calculationId: context.id, + placement, + hypothetical: true, + results: [], + }; +} + +function counterfactualInWorker( + snapshot: PlanningSnapshot, + plan: PlanOutcome, + placement: LeavePlacement, + state?: PlanningTraceState, + options?: CounterfactualBaselineOptions, +): Promise { + return new Promise((resolve, reject) => { + let worker: Worker; + try { + worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { + type: 'module', + }); + } catch (error) { + reject(error); + return; + } + const finish = (callback: () => void) => { + worker.onmessage = null; + worker.onerror = null; + worker.terminate(); + callback(); + }; + worker.onmessage = ( + event: MessageEvent<{ + type: string; + analysis?: CounterfactualAnalysis; + message?: string; + }>, + ) => { + if (event.data?.type === 'counterfactual-result' && event.data.analysis) + finish(() => resolve(event.data.analysis!)); + else if (event.data?.type === 'error') + finish(() => + reject(new Error(event.data.message ?? 'Counterfactual analysis failed.')), + ); + }; + worker.onerror = () => finish(() => reject(new Error('Counterfactual worker failed.'))); + try { + worker.postMessage({ + type: 'counterfactual', + request: snapshot, + plan, + placement, + state, + options, + }); + } catch (error) { + finish(() => reject(error)); + } + }); +} + +export interface PlanningCalculationContext { + readonly id: string; + readonly snapshot: PlanningSnapshot; + readonly outcome: Promise; + readonly explanation: Promise; + readonly explanationError: Promise; + readonly trace: Promise; + readonly analyzeCounterfactual: ( + selection: CounterfactualSelection, + ) => Promise; +} + +export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext { + const transport = snapshotRequest(request); + const snapshot = publicSnapshot(transport); + const id = `calculation:${++calculationSequence}`; + latestCalculationId = id; + const deadlineError = invalidDeadline(transport); + let run: PlanningRun; + if (deadlineError) { + run = { + outcome: new Promise((resolve) => queueMicrotask(() => resolve(deadlineError))), + artifacts: Promise.resolve({}), + }; + } else { + const deadline = deadlineFor(transport); + if (typeof window !== 'undefined') { + if (typeof Worker !== 'undefined') { + run = planInWorker(transport, deadline); + } else { + let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; + const artifacts = new Promise( + (resolve) => (resolveArtifacts = resolve), + ); + const outcome = new Promise((resolve) => { + queueMicrotask(async () => { + const core = await import('./leave-planner.core'); + const validation = core.invalidInputFor(transport); + if (validation) { + resolve(validation); + resolveArtifacts({}); + return; + } + const result = interruptedOutcome( + undefined, + deadline, + 'worker-unavailable', + 'Planning could not start because isolated execution is unavailable.', + ); + resolve(result); + resolveArtifacts({ + explanation: { + kind: 'IncompleteExplanation', + reason: result.reasons[0], + completedStages: [], + uncompletedStages: [], + }, + trace: fallbackTrace(transport, 'failed', result.reasons[0]), + }); + }); + }); + run = { outcome, artifacts }; + } + } else { + let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; + const artifacts = new Promise( + (resolve) => (resolveArtifacts = resolve), + ); + const outcome = new Promise((resolve) => { + queueMicrotask(async () => { + try { + const core = await import('./leave-planner.core'); + let completedArtifacts: PlanningArtifacts = {}; + const result = core.calculatePlanningInternal( + transport, + undefined, + (value) => (completedArtifacts = value), + ); + resolve(result); + queueMicrotask(() => resolveArtifacts(completedArtifacts)); + } catch (error) { + const reason: PlanningReason = { + code: 'planner-error', + message: error instanceof Error ? error.message : String(error), + }; + resolve({ + kind: 'PlanningIncomplete', + completedStages: [], + reasons: [reason], + }); + resolveArtifacts({ + trace: fallbackTrace(transport, 'failed', reason), + explanationError: reason, + }); + } + }); + }); + run = { outcome, artifacts }; + } + } + const retainedArtifacts = run.artifacts.then((artifacts) => ({ + ...artifacts, + trace: + id === latestCalculationId + ? artifacts.trace + : fallbackTrace(transport, 'failed', { + code: 'stale-calculation', + message: 'This trace belongs to an older calculation and was discarded.', + }), + })); + const context: PlanningCalculationContext = { + id, + snapshot, + outcome: run.outcome, + explanation: retainedArtifacts.then((artifacts) => artifacts.explanation), + explanationError: retainedArtifacts.then((artifacts) => artifacts.explanationError), + trace: retainedArtifacts.then((artifacts) => artifacts.trace), + analyzeCounterfactual: (selection) => + runCounterfactual(context, selection, retainedArtifacts), + }; + return context; +} + +export async function planLeave(request: PlanningRequest): Promise { + return createPlanningCalculation(request).outcome; } diff --git a/src/app/services/leave-planner.worker.ts b/src/app/services/leave-planner.worker.ts index 51a3218..f0d36a1 100644 --- a/src/app/services/leave-planner.worker.ts +++ b/src/app/services/leave-planner.worker.ts @@ -1,41 +1,92 @@ /// -import type { PlanningOutcome, PlanOutcome } from './leave-planner.service'; -import { calculatePlanningInternal } from './leave-planner.core'; +import type { + CounterfactualAnalysis, + LeavePlacement, + PlanOutcome, + PlanningOutcome, + PlanningSnapshot, + PlanningTraceState, +} from './leave-planner.service'; +import { + analyzeCounterfactualInternal, + calculatePlanningInternal, + type CounterfactualBaselineOptions, + type PlanningArtifacts, +} from './leave-planner.core'; type WorkerPlanningRequest = { horizon?: { start?: unknown; end?: unknown }; balances?: unknown; rules?: unknown; + identities?: unknown; + labels?: unknown; deadlineMs?: unknown; progress?: unknown; }; -type WorkerRequest = { - type: 'plan'; - request: WorkerPlanningRequest; -}; +type WorkerRequest = + | { + type: 'plan'; + request: WorkerPlanningRequest; + } + | { + type: 'counterfactual'; + request: PlanningSnapshot; + plan: PlanOutcome; + placement: LeavePlacement; + state?: PlanningTraceState; + options?: CounterfactualBaselineOptions; + }; type WorkerResponse = | { type: 'progress'; stage: string } + | { type: 'trace-overhead'; totalMs: number } | { type: 'checkpoint'; plan: PlanOutcome } | { type: 'result'; outcome: PlanningOutcome } + | { type: 'artifacts'; artifacts: PlanningArtifacts } + | { type: 'counterfactual-result'; analysis: CounterfactualAnalysis } | { 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), + if (event.data?.type === 'plan') { + let artifacts: PlanningArtifacts = {}; + let outcomePosted = false; + const outcome = calculatePlanningInternal( + { + ...event.data.request, + progress: (stage: string) => + worker.postMessage({ type: 'progress', stage } satisfies WorkerResponse), + traceProgress: (totalMs: number) => + worker.postMessage({ + type: 'trace-overhead', + totalMs, + } satisfies WorkerResponse), + }, + (plan) => worker.postMessage({ type: 'checkpoint', plan } satisfies WorkerResponse), + (value) => (artifacts = value), + (value) => { + outcomePosted = true; + worker.postMessage({ type: 'result', outcome: value } satisfies WorkerResponse); + }, + ); + if (!outcomePosted) + worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse); + worker.postMessage({ type: 'artifacts', artifacts } satisfies WorkerResponse); + return; + } + if (event.data?.type !== 'counterfactual') return; + const analysis = analyzeCounterfactualInternal( + event.data.request, + event.data.plan, + event.data.placement, + event.data.state, + event.data.options, ); - worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse); + worker.postMessage({ type: 'counterfactual-result', analysis } satisfies WorkerResponse); } catch (error) { worker.postMessage({ type: 'error', diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index 331d066..97c1bf2 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -109,6 +109,103 @@ export interface AppTranslations { incomplete: string; error: string; complete: string; + whyPlacement: string; + placementNavigation: string; + placement: string; + placements: string; + decision: string; + of: string; + first: string; + previous: string; + next: string; + last: string; + balance: string; + rules: string; + noRules: string; + supporting: string; + yes: string; + no: string; + remaining: string; + timeOffPeriod: string; + firstDecisive: string; + contributions: string; + objectiveVector: string; + finalMetrics: string; + unplacedAtRisk: string; + holidayWeekValue: string; + shortSequenceDeficit: string; + supportingPlacements: string; + preferenceSatisfaction: string; + removedRestoredComparison: string; + fullComparison: string; + comparisonHelp: string; + counterfactual: string; + hypothetical: string; + counterfactualLoading: string; + counterfactualReady: string; + counterfactualError: string; + eligible: string; + ineligible: string; + historical: string; + reason: string; + explanationError: string; + conflictDetails: string; + conflictingRules: string; + bottleneck: string; + incompleteDetails: string; + incumbentExplanation: string; + completedStages: string; + uncompletedStages: string; + none: string; + currentPlacement: string; + outsideHorizon: string; + traceTitle: string; + traceLoading: string; + traceUnavailable: string; + phases: string; + firstPhase: string; + previousPhase: string; + nextPhase: string; + lastPhase: string; + decisions: string; + firstDecision: string; + previousDecision: string; + nextDecision: string; + lastDecision: string; + replay: string; + before: string; + after: string; + finalPlan: string; + dates: string; + candidates: string; + previousCandidates: string; + nextCandidates: string; + page: string; + compareAlternatives: string; + tieBreak: string; + deltas: string; + activations: string; + noDecisions: string; + traceStatuses: { + chosen: string; + 'evaluated-rejected': string; + pruned: string; + 'not-examined': string; + ineligible: string; + }; + tracePhaseStatuses: { + completed: string; + interrupted: string; + 'not-run': string; + }; + traceChanges: { + added: string; + 'moved-from': string; + 'moved-to': string; + removed: string; + shared: string; + unchanged: string; + }; }; benchmark: { title: string; diff --git a/src/i18n/de.json b/src/i18n/de.json index 3973fcf..5d85b0d 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -104,7 +104,104 @@ "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." + "complete": "Plan berechnet.", + "whyPlacement": "Warum diese Platzierung", + "placementNavigation": "Navigation der Platzierungserklärungen", + "placement": "Platzierung", + "placements": "Platzierungen", + "decision": "Entscheidung", + "of": "von", + "first": "Erste Platzierung", + "previous": "Vorherige Platzierung", + "next": "Nächste Platzierung", + "last": "Letzte Platzierung", + "balance": "Anspruch", + "rules": "Regeln", + "noRules": "Keine Regel", + "supporting": "Unterstützende Platzierung", + "yes": "Ja", + "no": "Nein", + "remaining": "Verbleibender Anspruch nach der Platzierung", + "timeOffPeriod": "Freizeitzeitraum", + "firstDecisive": "Erstes entscheidendes Ziel", + "contributions": "Beiträge", + "objectiveVector": "Vollständiger Zielvektor", + "finalMetrics": "Kennzahlen des endgültigen Plans", + "unplacedAtRisk": "Nicht platzierter gefährdeter Urlaub", + "holidayWeekValue": "Wert der Feiertagswochen", + "shortSequenceDeficit": "Defizit kurzer Arbeitsfolgen", + "supportingPlacements": "Unterstützende Platzierungen", + "preferenceSatisfaction": "Erfüllung der Präferenzen", + "removedRestoredComparison": "Platzierung entfernt und wiederhergestellt", + "fullComparison": "Vollständige Zielvektoren vorher und nachher", + "comparisonHelp": "Vergleich des endgültigen Plans ohne diese Platzierung und nach ihrer Wiederherstellung.", + "counterfactual": "Alternative Termine", + "hypothetical": "Nur hypothetisch: Der optimierte Plan bleibt unverändert. Wählen Sie ein Datum im Horizont aus.", + "counterfactualLoading": "Alle Daten im Horizont werden analysiert…", + "counterfactualReady": "Alternative Termine sind bereit.", + "counterfactualError": "Alternative Termine konnten nicht berechnet werden.", + "eligible": "Geeignet", + "ineligible": "Nicht geeignet", + "historical": "Status der ursprünglichen Suche", + "reason": "Grund", + "explanationError": "Die Erklärung konnte nicht erstellt werden.", + "conflictDetails": "Warum kein Plan die Verpflichtungen erfüllt", + "conflictingRules": "Konfligierende Regeln", + "bottleneck": "Engpass", + "incompleteDetails": "Vor dem Abbruch abgeschlossene Schritte", + "incumbentExplanation": "Dies erklärt den besten gültigen Zwischenstand; spätere Qualitätsstufen wurden nicht abgeschlossen.", + "completedStages": "Abgeschlossene Schritte", + "uncompletedStages": "Unterbrochene oder nicht gestartete Schritte", + "none": "Keine", + "currentPlacement": "Aktuell erklärte Platzierung", + "outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts", + "traceTitle": "Erweitert: ursprüngliche Entscheidungsspur", + "traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…", + "traceUnavailable": "Für dieses Ergebnis ist keine Entscheidungsspur verfügbar.", + "phases": "Spurphasen", + "firstPhase": "Erste Phase", + "previousPhase": "Vorherige Phase", + "nextPhase": "Nächste Phase", + "lastPhase": "Letzte Phase", + "decisions": "Atomare Entscheidungen", + "firstDecision": "Erste Entscheidung", + "previousDecision": "Vorherige Entscheidung", + "nextDecision": "Nächste Entscheidung", + "lastDecision": "Letzte Entscheidung", + "replay": "Entscheidungszustand", + "before": "Vorher", + "after": "Nachher", + "finalPlan": "Endgültiger Plan", + "dates": "Daten", + "candidates": "Kandidaten", + "previousCandidates": "Vorherige Kandidatenseite", + "nextCandidates": "Nächste Kandidatenseite", + "page": "Seite", + "compareAlternatives": "Diesen Kandidaten im gesamten Horizont vergleichen", + "tieBreak": "Entscheidungskriterium", + "deltas": "Zustandsänderungen", + "activations": "erworbene Aktivierungen", + "noDecisions": "Diese Phase enthält keine atomare Entscheidung.", + "traceStatuses": { + "chosen": "Ausgewählt", + "evaluated-rejected": "Bewertet und abgelehnt", + "pruned": "Verworfen", + "not-examined": "Nicht untersucht", + "ineligible": "Nicht geeignet" + }, + "tracePhaseStatuses": { + "completed": "Abgeschlossen", + "interrupted": "Unterbrochen", + "not-run": "Nicht ausgeführt" + }, + "traceChanges": { + "added": "Durch diese Entscheidung hinzugefügt", + "moved-from": "Von diesem Datum verschoben", + "moved-to": "Auf dieses Datum verschoben", + "removed": "Durch diese Entscheidung entfernt", + "shared": "Durch diese Entscheidung geteilt", + "unchanged": "Durch diese Entscheidung unverändert" + } }, "benchmark": { "title": "Benchmark der Urlaubsplanung", diff --git a/src/i18n/en.json b/src/i18n/en.json index a592641..540a1c4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -104,7 +104,104 @@ "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." + "complete": "Plan calculated.", + "whyPlacement": "Why this placement", + "placementNavigation": "Placement explanation navigation", + "placement": "Placement", + "placements": "placements", + "decision": "Decision", + "of": "of", + "first": "First placement", + "previous": "Previous placement", + "next": "Next placement", + "last": "Last placement", + "balance": "Entitlement", + "rules": "Rules", + "noRules": "No rule", + "supporting": "Supporting placement", + "yes": "Yes", + "no": "No", + "remaining": "Remaining entitlement after placement", + "timeOffPeriod": "Time-off period", + "firstDecisive": "First decisive objective", + "contributions": "Contributions", + "objectiveVector": "Full objective vector", + "finalMetrics": "Final plan metrics", + "unplacedAtRisk": "Unplaced at-risk leave", + "holidayWeekValue": "Public-holiday week value", + "shortSequenceDeficit": "Short work-sequence deficit", + "supportingPlacements": "Supporting placements", + "preferenceSatisfaction": "Preference satisfaction", + "removedRestoredComparison": "Placement removed and restored", + "fullComparison": "Full before-and-after objective vectors", + "comparisonHelp": "This compares the final plan without this placement with the final plan after restoring it.", + "counterfactual": "Date alternatives", + "hypothetical": "Hypothetical only: the optimized plan is unchanged. Select any date in the horizon to inspect it.", + "counterfactualLoading": "Analyzing every date in the horizon…", + "counterfactualReady": "Date alternatives are ready.", + "counterfactualError": "Date alternatives could not be calculated.", + "eligible": "Eligible", + "ineligible": "Ineligible", + "historical": "Original search status", + "reason": "Reason", + "explanationError": "The explanation could not be produced.", + "conflictDetails": "Why no plan can satisfy the obligations", + "conflictingRules": "Conflicting rules", + "bottleneck": "Bottleneck", + "incompleteDetails": "What completed before calculation stopped", + "incumbentExplanation": "This explains the best valid incumbent; later quality stages did not complete.", + "completedStages": "Completed stages", + "uncompletedStages": "Interrupted or unstarted stages", + "none": "None", + "currentPlacement": "Current explained placement", + "outsideHorizon": "Context date outside the planning horizon", + "traceTitle": "Advanced: original decision trace", + "traceLoading": "The plan is ready; its decision trace is still loading…", + "traceUnavailable": "No decision trace is available for this result.", + "phases": "Trace phases", + "firstPhase": "First phase", + "previousPhase": "Previous phase", + "nextPhase": "Next phase", + "lastPhase": "Last phase", + "decisions": "Atomic decisions", + "firstDecision": "First decision", + "previousDecision": "Previous decision", + "nextDecision": "Next decision", + "lastDecision": "Last decision", + "replay": "Decision state", + "before": "Before", + "after": "After", + "finalPlan": "Final plan", + "dates": "Dates", + "candidates": "Candidates", + "previousCandidates": "Previous candidate page", + "nextCandidates": "Next candidate page", + "page": "Page", + "compareAlternatives": "Compare this candidate across the horizon", + "tieBreak": "Tie-break", + "deltas": "State changes", + "activations": "earned activations", + "noDecisions": "This phase recorded no atomic decision.", + "traceStatuses": { + "chosen": "Chosen", + "evaluated-rejected": "Evaluated and rejected", + "pruned": "Pruned", + "not-examined": "Not examined", + "ineligible": "Ineligible" + }, + "tracePhaseStatuses": { + "completed": "Completed", + "interrupted": "Interrupted", + "not-run": "Not run" + }, + "traceChanges": { + "added": "Added by this decision", + "moved-from": "Moved from this date", + "moved-to": "Moved to this date", + "removed": "Removed by this decision", + "shared": "Shared by this decision", + "unchanged": "Unchanged by this decision" + } }, "benchmark": { "title": "Leave planner benchmark", diff --git a/src/i18n/es.json b/src/i18n/es.json index fd06727..1680cd6 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -104,7 +104,104 @@ "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." + "complete": "Plan calculado.", + "whyPlacement": "Por qué esta asignación", + "placementNavigation": "Navegación por las explicaciones de las asignaciones", + "placement": "Asignación", + "placements": "asignaciones", + "decision": "Decisión", + "of": "de", + "first": "Primera asignación", + "previous": "Asignación anterior", + "next": "Asignación siguiente", + "last": "Última asignación", + "balance": "Derecho", + "rules": "Reglas", + "noRules": "Sin regla", + "supporting": "Asignación de apoyo", + "yes": "Sí", + "no": "No", + "remaining": "Derecho restante tras la asignación", + "timeOffPeriod": "Período de descanso", + "firstDecisive": "Primer objetivo decisivo", + "contributions": "Contribuciones", + "objectiveVector": "Vector completo de objetivos", + "finalMetrics": "Métricas del plan final", + "unplacedAtRisk": "Vacaciones en riesgo sin asignar", + "holidayWeekValue": "Valor de semanas con festivos", + "shortSequenceDeficit": "Déficit de secuencias cortas de trabajo", + "supportingPlacements": "Asignaciones de apoyo", + "preferenceSatisfaction": "Satisfacción de preferencias", + "removedRestoredComparison": "Asignación eliminada y restaurada", + "fullComparison": "Vectores de objetivos completos antes y después", + "comparisonHelp": "Compara el plan final sin esta asignación y después de restaurarla.", + "counterfactual": "Fechas alternativas", + "hypothetical": "Solo hipotético: el plan optimizado no cambia. Selecciona una fecha del horizonte para inspeccionarla.", + "counterfactualLoading": "Analizando cada fecha del horizonte…", + "counterfactualReady": "Las fechas alternativas están listas.", + "counterfactualError": "No se pudieron calcular las fechas alternativas.", + "eligible": "Válida", + "ineligible": "No válida", + "historical": "Estado de la búsqueda original", + "reason": "Motivo", + "explanationError": "No se pudo generar la explicación.", + "conflictDetails": "Por qué ningún plan satisface las obligaciones", + "conflictingRules": "Reglas en conflicto", + "bottleneck": "Cuello de botella", + "incompleteDetails": "Lo completado antes de detener el cálculo", + "incumbentExplanation": "Esto explica el mejor plan válido encontrado; las etapas de calidad posteriores no se completaron.", + "completedStages": "Etapas completadas", + "uncompletedStages": "Etapas interrumpidas o no iniciadas", + "none": "Ninguno", + "currentPlacement": "Asignación explicada actualmente", + "outsideHorizon": "Fecha de contexto fuera del horizonte de planificación", + "traceTitle": "Avanzado: traza de decisiones original", + "traceLoading": "El plan está listo; su traza de decisiones aún se está cargando…", + "traceUnavailable": "No hay una traza de decisiones disponible para este resultado.", + "phases": "Fases de la traza", + "firstPhase": "Primera fase", + "previousPhase": "Fase anterior", + "nextPhase": "Fase siguiente", + "lastPhase": "Última fase", + "decisions": "Decisiones atómicas", + "firstDecision": "Primera decisión", + "previousDecision": "Decisión anterior", + "nextDecision": "Decisión siguiente", + "lastDecision": "Última decisión", + "replay": "Estado de la decisión", + "before": "Antes", + "after": "Después", + "finalPlan": "Plan final", + "dates": "Fechas", + "candidates": "Candidatos", + "previousCandidates": "Página de candidatos anterior", + "nextCandidates": "Página de candidatos siguiente", + "page": "Página", + "compareAlternatives": "Comparar este candidato en todo el horizonte", + "tieBreak": "Desempate", + "deltas": "Cambios de estado", + "activations": "activaciones obtenidas", + "noDecisions": "Esta fase no registró ninguna decisión atómica.", + "traceStatuses": { + "chosen": "Elegido", + "evaluated-rejected": "Evaluado y rechazado", + "pruned": "Podado", + "not-examined": "No examinado", + "ineligible": "No válido" + }, + "tracePhaseStatuses": { + "completed": "Completada", + "interrupted": "Interrumpida", + "not-run": "No ejecutada" + }, + "traceChanges": { + "added": "Añadido por esta decisión", + "moved-from": "Movido desde esta fecha", + "moved-to": "Movido a esta fecha", + "removed": "Eliminado por esta decisión", + "shared": "Compartido por esta decisión", + "unchanged": "Sin cambios por esta decisión" + } }, "benchmark": { "title": "Prueba del planificador de vacaciones", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 3c60b5e..a50e845 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -104,7 +104,104 @@ "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é." + "complete": "Plan calculé.", + "whyPlacement": "Pourquoi ce placement", + "placementNavigation": "Navigation entre les explications des placements", + "placement": "Placement", + "placements": "placements", + "decision": "Décision", + "of": "sur", + "first": "Premier placement", + "previous": "Placement précédent", + "next": "Placement suivant", + "last": "Dernier placement", + "balance": "Droit", + "rules": "Règles", + "noRules": "Aucune règle", + "supporting": "Placement de soutien", + "yes": "Oui", + "no": "Non", + "remaining": "Droit restant après placement", + "timeOffPeriod": "Période de repos", + "firstDecisive": "Premier objectif décisif", + "contributions": "Contributions", + "objectiveVector": "Vecteur d’objectifs complet", + "finalMetrics": "Métriques du plan final", + "unplacedAtRisk": "Congés à risque non placés", + "holidayWeekValue": "Valeur des semaines fériées", + "shortSequenceDeficit": "Déficit des courtes séquences travaillées", + "supportingPlacements": "Placements de soutien", + "preferenceSatisfaction": "Satisfaction des préférences", + "removedRestoredComparison": "Placement retiré puis rétabli", + "fullComparison": "Vecteurs d’objectifs complets avant et après", + "comparisonHelp": "Comparaison du plan final sans ce placement puis après son rétablissement.", + "counterfactual": "Dates alternatives", + "hypothetical": "Hypothèse uniquement : le plan optimisé ne change pas. Sélectionnez une date de l’horizon pour l’inspecter.", + "counterfactualLoading": "Analyse de chaque date de l’horizon…", + "counterfactualReady": "Les dates alternatives sont prêtes.", + "counterfactualError": "Les dates alternatives n’ont pas pu être calculées.", + "eligible": "Éligible", + "ineligible": "Inéligible", + "historical": "Statut de la recherche originale", + "reason": "Raison", + "explanationError": "L’explication n’a pas pu être produite.", + "conflictDetails": "Pourquoi aucun plan ne satisfait les obligations", + "conflictingRules": "Règles en conflit", + "bottleneck": "Goulot d’étranglement", + "incompleteDetails": "Étapes terminées avant l’arrêt du calcul", + "incumbentExplanation": "Cette explication concerne le meilleur plan valide trouvé ; les étapes de qualité suivantes ne sont pas terminées.", + "completedStages": "Étapes terminées", + "uncompletedStages": "Étapes interrompues ou non démarrées", + "none": "Aucun", + "currentPlacement": "Placement expliqué actuellement", + "outsideHorizon": "Date de contexte hors de l’horizon de planification", + "traceTitle": "Avancé : trace des décisions originales", + "traceLoading": "Le plan est prêt ; sa trace de décision est encore en cours de chargement…", + "traceUnavailable": "Aucune trace de décision n’est disponible pour ce résultat.", + "phases": "Phases de la trace", + "firstPhase": "Première phase", + "previousPhase": "Phase précédente", + "nextPhase": "Phase suivante", + "lastPhase": "Dernière phase", + "decisions": "Décisions atomiques", + "firstDecision": "Première décision", + "previousDecision": "Décision précédente", + "nextDecision": "Décision suivante", + "lastDecision": "Dernière décision", + "replay": "État de la décision", + "before": "Avant", + "after": "Après", + "finalPlan": "Plan final", + "dates": "Dates", + "candidates": "Candidats", + "previousCandidates": "Page de candidats précédente", + "nextCandidates": "Page de candidats suivante", + "page": "Page", + "compareAlternatives": "Comparer ce candidat sur tout l’horizon", + "tieBreak": "Critère de départage", + "deltas": "Changements d’état", + "activations": "activations acquises", + "noDecisions": "Cette phase n’a enregistré aucune décision atomique.", + "traceStatuses": { + "chosen": "Choisi", + "evaluated-rejected": "Évalué et rejeté", + "pruned": "Élagué", + "not-examined": "Non examiné", + "ineligible": "Inéligible" + }, + "tracePhaseStatuses": { + "completed": "Terminée", + "interrupted": "Interrompue", + "not-run": "Non exécutée" + }, + "traceChanges": { + "added": "Ajouté par cette décision", + "moved-from": "Déplacé depuis cette date", + "moved-to": "Déplacé vers cette date", + "removed": "Retiré par cette décision", + "shared": "Partagé par cette décision", + "unchanged": "Inchangé par cette décision" + } }, "benchmark": { "title": "Benchmark du planificateur de congés",