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