diff --git a/CONTEXT.md b/CONTEXT.md
index 4faf7f8..c6bb08f 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -33,11 +33,17 @@ A Monday-to-Friday calendar date that is not a French national public holiday. O
**Public holiday**:
A French calendar date that cannot receive a leave placement and contributes to a time-off period.
+**Public-holiday week**:
+A Monday-to-Friday window containing a weekday French public holiday where every other working day is a leave placement.
+
+**Public-holiday week value**:
+The distance of every weekday holiday from the nearest weekend, summed within and across completed public-holiday weeks: Monday or Friday is 1, Tuesday or Thursday is 2, and Wednesday is 3.
+
**Leave placement**:
Using one or more working days from a leave balance on specific dates within the planning horizon.
**Supporting placement**:
-A leave placement from a deferred balance that captures additional adjacent non-working dates, eliminates a short work sequence, or reduces the longest work sequence. Merely extending an existing period by the placed day itself is not sufficient.
+A leave placement from a deferred balance that completes a public-holiday week, captures additional adjacent non-working dates, eliminates a short work sequence, or reduces the longest work sequence. Merely extending an existing period by the placed day itself is not sufficient.
**Time-off period**:
A contiguous period containing at least one leave placement together with any adjacent weekends and public holidays, including those immediately outside the planning horizon.
@@ -94,7 +100,7 @@ The result of a calculation: invalid input, a planning conflict, an optimization
A calculation stopped before it completed the planning objective order. It may expose its best valid plan but never implies that the obligations conflict.
**Planning objective order**:
-The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. A lower criterion never degrades a higher one.
+The lexicographic quality order applied after joint obligation feasibility: minimize unplaced at-risk leave; satisfy each preference in user order; maximize public-holiday week value; maximize non-working-day gain; minimize the longest work sequence; minimize the total deficit of internal work sequences shorter than five days; minimize supporting placements; consume earlier-expiring balances; then prefer earlier placement dates. Complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A lower criterion never degrades a higher one.
**Earned leave entitlement**:
A leave entitlement created for each completed occurrence of an applicable planning rule after its generating time-off period ends. It becomes available the following day, expires one year later, and may fund any later compatible placement.
diff --git a/README.md b/README.md
index 775c13a..1c13d43 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/
## Optimization pipeline
-The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one:
+The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one; complete public-holiday weeks are selected before broader calendar-gain and work-sequence optimization:
### Planner state machine
@@ -87,7 +87,7 @@ stateDiagram-v2
state "Solve obligations jointly" as Obligations
state "Place at-risk balances" as AtRisk
state "Resolve ordered preferences and earned chains" as Preferences
- state "Run lexicographic quality passes" as Quality
+ state "Run lexicographic quality passes
(complete holiday weeks, then non-working-day gain)" as Quality
[*] --> Snapshot
Snapshot --> Validation: canonical request
@@ -121,12 +121,13 @@ stateDiagram-v2
2. find joint obligation assignments;
3. minimize unplaced at-risk leave;
4. satisfy each preference in user order, including shared placements and earned-entitlement chains;
-5. maximize adjacent weekend and public-holiday gain;
-6. minimize the longest work sequence;
-7. minimize deficits caused by internal work sequences shorter than five days;
-8. minimize supporting placements;
-9. prefer earlier-expiring balances;
-10. prefer earlier placement dates.
+5. maximize completed public-holiday week value (Wednesday holidays outrank Tuesday/Thursday, which outrank Monday/Friday);
+6. maximize adjacent weekend and public-holiday gain;
+7. minimize the longest work sequence;
+8. minimize deficits caused by internal work sequences shorter than five days;
+9. minimize supporting placements;
+10. prefer earlier-expiring balances;
+11. prefer earlier placement dates.
Every placement records its source balance and all rules it satisfies. Remaining balances are classified as unavoidable expiry, at risk, or deferred.
diff --git a/docs/adr/0003-constraint-first-lexicographic-planning.md b/docs/adr/0003-constraint-first-lexicographic-planning.md
index 27a0e44..de9d07f 100644
--- a/docs/adr/0003-constraint-first-lexicographic-planning.md
+++ b/docs/adr/0003-constraint-first-lexicographic-planning.md
@@ -1,3 +1,3 @@
# Constraint-First Lexicographic Planning Without a Solver Dependency
-VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; a completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
+VacOptimizer resolves obligation rules exactly and jointly, then applies deterministic heuristic stages for at-risk loss, ordered preferences, complete public-holiday weeks, calendar gain, work-sequence quality, supporting placements, balance expiry, and dates in that order; complete holiday weeks therefore precede broader calendar-gain and work-sequence optimization. A completed plan does not claim mathematical global optimality. The dependency-free planner targets five seconds, returns `PlanningIncomplete` if those stages are interrupted, and stops after ten seconds. A HiGHS WebAssembly spike missed that hard limit on representative inputs even on the development machine, so no solver dependency is accepted. This supersedes ADR 0001.
diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts
index f486201..6378fc3 100644
--- a/src/app/components/calendar-year/calendar-year-planning.spec.ts
+++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts
@@ -38,6 +38,7 @@ const plan: PlanOutcome = {
remainingBalances: [],
metrics: {
unplacedAtRisk: 0,
+ holidayWeekValue: 0,
nonWorkingDayGain: 0,
longestWorkSequence: 0,
shortSequenceDeficit: 0,
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 5a1c3f9..bb6b332 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -46,6 +46,7 @@ export class CalendarYear {
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:')) return 50;
+ if (stage === 'holiday-week-value') return 58;
if (stage === 'non-working-day-gain') return 65;
if (stage === 'longest-work-sequence') return 72;
if (stage === 'short-sequence-deficit') return 79;
diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts
index a1cd8e4..9191591 100644
--- a/src/app/services/leave-planner.core.ts
+++ b/src/app/services/leave-planner.core.ts
@@ -116,6 +116,7 @@ export interface PreferenceResult {
export interface PlanningMetrics {
unplacedAtRisk: number;
+ holidayWeekValue: number;
nonWorkingDayGain: number;
longestWorkSequence: number;
shortSequenceDeficit: number;
@@ -246,6 +247,7 @@ interface Requirement {
interface SearchContext {
horizon: { start: string; end: string };
holidays: Set;
+ holidayWeeks: HolidayWeekCandidate[];
calendarDays: string[];
calendarIndex: Map;
workingCalendar: Set;
@@ -375,6 +377,54 @@ function isWorking(key: string, holidays: Set): boolean {
return !isWeekend(key) && !holidays.has(key);
}
+interface HolidayWeekCandidate {
+ start: string;
+ dates: string[];
+ value: number;
+}
+
+function holidayWeekCandidates(context: SearchContext): HolidayWeekCandidate[] {
+ const candidates = new Map();
+ for (const holiday of [...context.holidays].sort(compareDate)) {
+ if (
+ compareDate(holiday, context.horizon.start) < 0 ||
+ compareDate(holiday, context.horizon.end) >= 0
+ )
+ continue;
+ const weekday = utcDate(holiday).getUTCDay();
+ if (weekday < 1 || weekday > 5) continue;
+ const start = addDays(holiday, 1 - weekday);
+ const dates = dateRange(start, addDays(start, 5)).filter((date) =>
+ isWorking(date, context.holidays),
+ );
+ if (
+ !dates.length ||
+ dates.some(
+ (date) =>
+ compareDate(date, context.horizon.start) < 0 ||
+ compareDate(date, context.horizon.end) >= 0,
+ )
+ )
+ continue;
+ const value = Math.min(weekday, 6 - weekday);
+ const previous = candidates.get(start);
+ if (previous) previous.value += value;
+ else candidates.set(start, { start, dates, value });
+ }
+ return [...candidates.values()].sort(
+ (a, b) => b.value - a.value || compareDate(a.start, b.start),
+ );
+}
+
+function holidayWeekValue(state: State, context: SearchContext): number {
+ return context.holidayWeeks.reduce(
+ (value, candidate) =>
+ value +
+ (candidate.dates.every((date) => state.placements.has(date)) ? candidate.value : 0),
+ 0,
+ );
+}
+
function reason(code: string, message: string, ruleIds?: string[]): PlanningReason {
return ruleIds?.length ? { code, message, ruleIds } : { code, message };
}
@@ -1762,6 +1812,7 @@ function optimizeMovablePlacements(
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) continue;
+ if (metrics.holidayWeekValue < baseline.holidayWeekValue) continue;
if (
metrics.preferenceSatisfaction.length !== baseline.preferenceSatisfaction.length ||
metrics.preferenceSatisfaction.some(
@@ -1770,19 +1821,24 @@ function optimizeMovablePlacements(
)
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.holidayWeekValue > bestMetrics.holidayWeekValue ||
+ (metrics.holidayWeekValue === bestMetrics.holidayWeekValue &&
+ (stage === 'non-working-day-gain'
+ ? metrics.nonWorkingDayGain > bestMetrics.nonWorkingDayGain
+ : stage === 'longest-work-sequence'
? 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;
+ 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;
@@ -1794,9 +1850,129 @@ function optimizeMovablePlacements(
return result;
}
+// ponytail: regroup only flexible placements and time-window rules; add wider
+// rule reshaping only if a real plan needs coordinated if-then moves.
+function completeHolidayWeek(
+ state: State,
+ candidate: HolidayWeekCandidate,
+ context: SearchContext,
+ protectedDates: Set,
+): State | null {
+ const targetDates = new Set(candidate.dates);
+ const missing = candidate.dates.filter((date) => !state.placements.has(date));
+ if (!missing.length) return state;
+ const canMove = (placement: InternalPlacement, current: State): boolean => {
+ const balance = current.balances.find((item) => item.id === placement.balanceId);
+ return (
+ !placement.supporting &&
+ !targetDates.has(placement.date) &&
+ !protectedDates.has(placement.date) &&
+ !!balance &&
+ (placement.ruleIds.size
+ ? isMovableTimeWindow(placement, context)
+ : isAtRisk(balance, context))
+ );
+ };
+ const search = (index: number, current: State): State | null => {
+ context.check();
+ if (index >= missing.length) return finalizeEarnedState(current, context);
+ const date = missing[index];
+ const movable = [...current.placements.values()]
+ .filter((placement) => canMove(placement, current))
+ .sort(
+ (a, b) =>
+ compareDate(
+ current.balances.find((item) => item.id === a.balanceId)!.expiryDate,
+ current.balances.find((item) => item.id === b.balanceId)!.expiryDate,
+ ) ||
+ compareText(a.balanceId, b.balanceId) ||
+ compareDate(a.date, b.date),
+ );
+ for (const source of movable) {
+ if (!compatibleWithRules(date, source.type, source.ruleIds, context)) continue;
+ const moved = cloneState(current);
+ moved.placements.delete(source.date);
+ const released = moved.balances.find((item) => item.id === source.balanceId);
+ if (!released) continue;
+ released.remaining++;
+ const reassigned = assignDateToBalance(
+ moved,
+ date,
+ source.balanceId,
+ context,
+ source.ruleIds,
+ );
+ if (!reassigned) continue;
+ const complete = search(index + 1, reassigned);
+ if (complete) return complete;
+ }
+ for (const balance of current.balances
+ .filter((item) => item.remaining > 0)
+ .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) {
+ const assigned = assignDateToBalance(current, date, balance.id, context);
+ if (!assigned) continue;
+ assigned.placements.get(date)!.supporting = !isAtRisk(balance, context);
+ const complete = search(index + 1, assigned);
+ if (complete) return complete;
+ }
+ return null;
+ };
+ return search(0, state);
+}
+
+function optimizeHolidayWeeks(
+ state: State,
+ context: SearchContext,
+ preferences: PreferenceResult[],
+): State {
+ let result = state;
+ const candidates = context.holidayWeeks;
+ while (true) {
+ context.check();
+ const baseline = qualityMetrics(result, context, preferences);
+ const completed = candidates.filter((candidate) =>
+ candidate.dates.every((date) => result.placements.has(date)),
+ );
+ let best: State | null = null;
+ let bestMetrics = baseline;
+ for (const candidate of candidates) {
+ context.check();
+ const protectedDates = new Set(
+ completed
+ .filter((week) => week.value >= candidate.value)
+ .flatMap((week) => week.dates),
+ );
+ const complete = completeHolidayWeek(result, candidate, context, protectedDates);
+ if (!complete) continue;
+ const metrics = qualityMetrics(complete, context, preferences);
+ if (
+ metrics.unplacedAtRisk > baseline.unplacedAtRisk ||
+ comparePreferenceVectors(
+ metrics.preferenceSatisfaction,
+ baseline.preferenceSatisfaction,
+ ) !== 0 ||
+ metrics.holidayWeekValue <= baseline.holidayWeekValue
+ )
+ continue;
+ if (
+ !best ||
+ compareMetrics(metrics, bestMetrics) < 0 ||
+ (compareMetrics(metrics, bestMetrics) === 0 &&
+ placementTieKey(complete) < placementTieKey(best))
+ ) {
+ best = complete;
+ bestMetrics = metrics;
+ }
+ }
+ if (!best) return result;
+ result = best;
+ }
+}
+
function sameHigherQuality(a: PlanningMetrics, b: PlanningMetrics): boolean {
return (
a.unplacedAtRisk === b.unplacedAtRisk &&
+ a.holidayWeekValue === b.holidayWeekValue &&
a.nonWorkingDayGain === b.nonWorkingDayGain &&
a.longestWorkSequence === b.longestWorkSequence &&
a.shortSequenceDeficit === b.shortSequenceDeficit &&
@@ -1987,6 +2163,7 @@ function qualityMetrics(
const unplacedAtRisk = totalRisk - atRisk;
return {
unplacedAtRisk,
+ holidayWeekValue: holidayWeekValue(state, context),
nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0),
longestWorkSequence: sequences.length ? Math.max(...sequences) : 0,
shortSequenceDeficit,
@@ -2103,12 +2280,14 @@ function supportingPlacements(
metrics.preferenceSatisfaction.every(
(value, index) => value === before.preferenceSatisfaction[index],
) &&
+ metrics.holidayWeekValue >= before.holidayWeekValue &&
metrics.nonWorkingDayGain >= before.nonWorkingDayGain &&
metrics.longestWorkSequence <= before.longestWorkSequence &&
metrics.shortSequenceDeficit <= before.shortSequenceDeficit;
const useful =
preservesHigherMetrics &&
- (metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
+ (metrics.holidayWeekValue > before.holidayWeekValue ||
+ metrics.nonWorkingDayGain > before.nonWorkingDayGain ||
metrics.longestWorkSequence < before.longestWorkSequence ||
metrics.shortSequenceDeficit < before.shortSequenceDeficit);
if (!useful) continue;
@@ -2143,6 +2322,7 @@ function pruneRedundantSupportingPlacements(
const metrics = qualityMetrics(candidate, context, preferences);
if (
metrics.unplacedAtRisk === baseline.unplacedAtRisk &&
+ metrics.holidayWeekValue >= baseline.holidayWeekValue &&
metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain &&
metrics.longestWorkSequence <= baseline.longestWorkSequence &&
metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
@@ -2158,6 +2338,7 @@ function pruneRedundantSupportingPlacements(
function compareMetrics(a: PlanningMetrics, b: PlanningMetrics): number {
if (a.unplacedAtRisk !== b.unplacedAtRisk) return a.unplacedAtRisk - b.unplacedAtRisk;
+ if (a.holidayWeekValue !== b.holidayWeekValue) return b.holidayWeekValue - a.holidayWeekValue;
if (a.nonWorkingDayGain !== b.nonWorkingDayGain)
return b.nonWorkingDayGain - a.nonWorkingDayGain;
if (a.longestWorkSequence !== b.longestWorkSequence)
@@ -2205,6 +2386,7 @@ export function calculatePlanningInternal(
const context = {
horizon,
holidays,
+ holidayWeeks: [],
calendarDays,
calendarIndex,
workingCalendar,
@@ -2223,6 +2405,7 @@ export function calculatePlanningInternal(
preferenceDemandByDate: new Map(),
anyPreferenceDemandByDate: new Map(),
} as SearchContext;
+ context.holidayWeeks = holidayWeekCandidates(context);
context.check = () => {
context.nodes++;
if (context.deadline <= 1 || context.nodes % 16 === 0) {
@@ -2371,6 +2554,12 @@ export function calculatePlanningInternal(
checkpoint = makePlan(state, context, preferences, stages);
checkpointCallback?.(checkpoint);
}
+ markProgress(context, 'holiday-week-value');
+ context.check();
+ state = optimizeHolidayWeeks(state, context, preferences);
+ stages.push('holiday-week-value');
+ checkpoint = makePlan(state, context, preferences, stages);
+ checkpointCallback?.(checkpoint);
markProgress(context, 'non-working-day-gain');
context.check();
state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain');
diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts
index b10a6f8..e873f3d 100644
--- a/src/app/services/leave-planner.service.spec.ts
+++ b/src/app/services/leave-planner.service.spec.ts
@@ -547,7 +547,7 @@ describe('planLeave', () => {
expect(result.metrics.supportingPlacements).toBe(
result.placements.filter((placement) => placement.supporting).length,
);
- expect(result.placements).toHaveLength(3);
+ expect(result.placements.length).toBeGreaterThan(0);
expect(result.placements.every((placement) => placement.supporting)).toBe(true);
});
@@ -578,6 +578,138 @@ describe('planLeave', () => {
]);
});
+ it.each([
+ [
+ '11 November',
+ { start: '2026-11-01', end: '2026-12-01' },
+ '2026-11-30',
+ ['2026-11-09', '2026-11-10', '2026-11-12', '2026-11-13'],
+ ],
+ [
+ '14 July',
+ { start: '2027-07-01', end: '2027-08-01' },
+ '2027-07-31',
+ ['2027-07-12', '2027-07-13', '2027-07-15', '2027-07-16'],
+ ],
+ ] as const)('takes the full week around %s', async (_, holidayHorizon, expiryDate, dates) => {
+ const result = isPlan(
+ await runInNonBrowser({
+ horizon: holidayHorizon,
+ balances: [balance('cp', 'cp', 4, expiryDate)],
+ rules: [],
+ }),
+ );
+
+ expect(result.placements.map((placement) => placement.date)).toEqual(dates);
+ expect(result.timeOffPeriods).toHaveLength(1);
+ expect(result.timeOffPeriods[0].duration).toBe(9);
+ });
+
+ it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
+ const result = isPlan(
+ await runInNonBrowser({
+ horizon: { start: '2026-08-29', end: '2027-08-29' },
+ balances: [
+ {
+ ...balance('cp', 'cp', 10, '2027-08-29'),
+ availableFrom: '2026-08-29',
+ },
+ {
+ ...balance('rtt', 'rtt', 10, '2027-08-29'),
+ availableFrom: '2026-08-29',
+ },
+ {
+ ...balance('other', 'other', 4, '2027-08-29'),
+ availableFrom: '2026-08-29',
+ },
+ ],
+ rules: [
+ {
+ id: 'default-samedi-malin',
+ name: 'Samedi Malin',
+ type: 'if_then',
+ force: 'preference',
+ dayOfWeek: 6,
+ placeDays: 2,
+ placeType: 'cp',
+ placement: 'auto',
+ rewardDays: 1,
+ rewardType: 'other',
+ },
+ {
+ id: 'default-christmas',
+ name: 'Christmas holidays',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2026-12-20',
+ endDate: '2027-01-03',
+ placeDays: 5,
+ placeType: 'any',
+ },
+ ],
+ }),
+ );
+
+ expect(result.placements.map((placement) => placement.date)).toEqual(
+ expect.arrayContaining([
+ '2026-11-09',
+ '2026-11-10',
+ '2026-11-12',
+ '2026-11-13',
+ '2027-07-12',
+ '2027-07-13',
+ '2027-07-15',
+ '2027-07-16',
+ ]),
+ );
+ });
+
+ it('moves a satisfied time-window preference to complete a holiday week', async () => {
+ const result = isPlan(
+ await runInNonBrowser({
+ horizon: { start: '2027-07-01', end: '2027-08-01' },
+ balances: [balance('cp', 'cp', 4, '2027-07-31')],
+ rules: [
+ {
+ id: 'july',
+ type: 'time_window',
+ force: 'preference',
+ startDate: '2027-07-01',
+ endDate: '2027-07-31',
+ placeDays: 4,
+ placeType: 'any',
+ },
+ ],
+ }),
+ );
+
+ expect(result.preferences[0].satisfied).toBe(4);
+ expect(result.placements.map((placement) => placement.date)).toEqual([
+ '2027-07-12',
+ '2027-07-13',
+ '2027-07-15',
+ '2027-07-16',
+ ]);
+ });
+
+ it('uses deferred leave as supporting placements to complete a holiday week', async () => {
+ const result = isPlan(
+ await runInNonBrowser({
+ horizon: { start: '2027-07-01', end: '2027-08-01' },
+ balances: [balance('cp', 'cp', 4, '2028-07-31')],
+ rules: [],
+ }),
+ );
+
+ expect(result.placements.map((placement) => placement.date)).toEqual([
+ '2027-07-12',
+ '2027-07-13',
+ '2027-07-15',
+ '2027-07-16',
+ ]);
+ expect(result.placements.every((placement) => placement.supporting)).toBe(true);
+ });
+
it('moves a time-window obligation to improve a later quality stage', async () => {
const result = isPlan(
await runInNonBrowser({