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