feat(planning): #62 #63 #64 enhance leave planner with calculation date and entitlement details

- Introduced calculationDate in various service interfaces and snapshots to track the date of planning calculations.
- Added futureEntitlement and includedEntitlement fields to the translation service and respective language files for better user feedback.
- Updated leave-planner.worker to handle calculationDate and declaredDays in requests.
- Enhanced tests to ensure proper handling of calculation dates and artifact retention during planning processes.
- Improved snapshot and explanation outputs to include calculation date for clarity in planning results.
This commit is contained in:
nessar committed 2026-10-07 19:22:46 +02:00
1 parent 036a8c0e1e
commit 9851edf906
18 files changed
+3347 -229

No files matched your search

@@ -407,6 +407,288 @@ describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it.each([false, true])(
'shows fixed rule credits separately from balance-consuming placements (partial: %s)',
async (partial) => {
const core = await import('../../services/leave-planner.core');
const declaredDays = partial
? [{ date: '2024-01-10', type: 'cp' as const }]
: ['08', '09', '10'].map((day) => ({
date: `2024-01-${day}`,
type: 'cp' as const,
}));
const submitted = {
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: partial
? [
{
id: 'cp',
type: 'cp' as const,
quantity: 1,
availableFrom: '2024-01-12',
expiryDate: '2024-01-12',
},
]
: [],
declaredDays,
labels: { 'two-cp': 'Two CP including booked leave' },
rules: [
{
id: 'two-cp',
type: 'time_window',
force: 'obligation',
startDate: '2024-01-08',
endDate: '2024-01-12',
placeDays: 2,
placeType: 'cp',
},
],
};
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
submitted,
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue({
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
});
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-08', '2024-01-12');
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const summary =
root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ') ?? '';
expect(summary).toContain('Two CP including booked leave');
expect(summary).toContain(
partial
? '1 Already booked + 1 leave days used = 2 / 2'
: '2 Already booked + 0 leave days used = 2 / 2',
);
expect(summary).toContain('Paid Leave');
expect(summary).toContain('no remaining balance debit');
for (const day of declaredDays) {
const cell = root.querySelector<HTMLButtonElement>(
`button[data-date="${day.date}"]`,
)!;
expect(cell.classList).toContain('cp');
expect(cell.getAttribute('aria-label')).toContain('Already booked');
}
if (!partial)
expect(summary).not.toContain(
fixture.componentInstance['displayDate']('2024-01-10'),
);
expect(artifacts.explanation).toMatchObject({
timeWindowContributions: outcome.timeWindowContributions,
});
TestBed.inject(UserInputService).toggleDeclaredDay('2024-01-10', 'rtt');
settings.setPlanningDates('2025-01-01', '2025-01-31');
fixture.detectChanges();
expect(root.querySelector('.declared-rule')?.textContent?.replace(/\s+/g, ' ')).toBe(
summary,
);
},
);
it.each([
['2024-05-31', '2024-06-01', '2024-06-02', [], 3, 2],
[
'2024-07-31',
'2024-08-01',
'2024-08-01',
[{ date: '2024-08-01', type: 'cp' as const }],
2,
0,
],
] as const)(
'shows complete declared-only periods and context dates from %s',
async (start, end, contextDate, outsideDeclarations, duration, gain) => {
const core = await import('../../services/leave-planner.core');
const submitted = {
horizon: { start, end },
balances: [],
rules: [],
declaredDays: [{ date: start, type: 'other' as const }, ...outsideDeclarations],
};
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
submitted,
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calculation = {
...calculationFor(outcome),
explanation: Promise.resolve(artifacts.explanation),
trace: Promise.resolve(artifacts.trace),
};
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
);
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates(start, start);
const input = TestBed.inject(UserInputService);
input.toggleDeclaredDay(start, 'other');
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const summary =
root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ') ?? '';
expect(summary).toContain(`${duration} days off in a row`);
expect(summary).toContain(`${gain} weekend & holiday days`);
expect(summary).toContain('0 leave days used');
expect(summary).toContain('no remaining balance debit');
const declared = root.querySelector<HTMLButtonElement>(`button[data-date="${start}"]`)!;
expect(declared.classList).toContain('other');
expect(declared.getAttribute('aria-label')).toContain('Already booked');
const context = root.querySelector<HTMLButtonElement>(
`button[data-date="${contextDate}"]`,
)!;
expect(context.disabled).toBe(true);
expect(context.classList).toContain('context-only');
expect(context.classList).toContain('selected');
expect(context.getAttribute('aria-label')).toContain(
'Context date outside the planning horizon',
);
if (outsideDeclarations.length) {
expect(context.classList).toContain('cp');
expect(context.getAttribute('aria-label')).toContain('Already booked');
}
input.toggleDeclaredDay(start, 'cp');
settings.setPlanningDates('2025-01-01', '2025-01-31');
fixture.detectChanges();
expect(root.querySelector('.declared-period')?.textContent?.replace(/\s+/g, ' ')).toBe(
summary,
);
expect(declared.getAttribute('aria-label')).toContain('Other');
expect(fixture.componentInstance['displayedPlan']()?.metrics.nonWorkingDayGain).toBe(
gain,
);
expect(input.vacationNumberSignal()).toEqual([]);
},
);
it.each([false, true])(
'keeps adjacent context declarations attached to the historical anchors (fixed anchor: %s)',
async (fixedAnchor) => {
const core = await import('../../services/leave-planner.core');
const placementDate = fixedAnchor ? '2024-07-30' : '2024-07-31';
const submitted = {
horizon: { start: '2024-07-30', end: '2024-08-01' },
balances: [
{
id: 'rtt',
type: 'rtt' as const,
quantity: 1,
availableFrom: placementDate,
expiryDate: placementDate,
},
],
rules: [],
declaredDays: [
{ date: '2024-08-01', type: 'cp' as const },
...(fixedAnchor ? [{ date: '2024-07-31', type: 'other' as const }] : []),
],
};
let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
submitted,
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace)
throw new Error('Expected a plan and its original trace.');
const trace = artifacts.trace;
const phaseIndex = trace.phases.findIndex((phase) =>
phase.decisions.some((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
),
);
const decisionIndex = trace.phases[phaseIndex].decisions.findIndex((decision) =>
decision.deltas.some(
(delta) => delta.kind === 'placement-added' && delta.date === placementDate,
),
);
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calculation = calculationFor(outcome, artifacts.explanation, trace);
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
calculation,
);
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-07-30', '2024-07-31');
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-explorer summary')!);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
activateWithKeyboard(
root.querySelectorAll<HTMLElement>('.trace-phases > button')[phaseIndex],
);
fixture.detectChanges();
for (let index = 0; index < decisionIndex; index++) {
activateWithKeyboard(
root.querySelector<HTMLElement>('[aria-label="Next decision"]')!,
);
fixture.detectChanges();
}
activateWithKeyboard(root.querySelector<HTMLElement>('.trace-toolbar button')!);
fixture.detectChanges();
const before = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]');
if (fixedAnchor) {
expect(before?.classList).toContain('selected');
expect(before?.classList).toContain('cp');
} else expect(before).toBeNull();
activateWithKeyboard(root.querySelectorAll<HTMLElement>('.trace-toolbar button')[1]);
fixture.detectChanges();
const after = root.querySelector<HTMLButtonElement>('button[data-date="2024-08-01"]')!;
expect(after.classList).toContain('selected');
expect(after.classList).toContain('cp');
expect(after.classList).toContain('context-only');
expect(after.disabled).toBe(true);
expect(after.getAttribute('aria-label')).toContain('Already booked');
expect(fixture.componentInstance['traceState']()?.placements).toEqual(
outcome.placements,
);
TestBed.inject(UserInputService).toggleDeclaredDay('2024-08-01', 'rtt');
fixture.detectChanges();
expect(after.getAttribute('aria-label')).toContain('CP');
},
);
it('previews a growing and shrinking range across months, then saves it once on release', async () => {
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
@@ -146,6 +146,87 @@
<dd>{{ plan.metrics.longestWorkSequence }}</dd>
</div>
</dl>
@for (contribution of plan.timeWindowContributions ?? []; track contribution.ruleId) {
<section class="outcome-explanation declared-rule">
<h3>{{ contribution.label }}</h3>
<p>
{{ contribution.declaredDays.length }} {{ tr().bookedDays.booked }} +
{{ contribution.placementDates.length }}
{{ tr().planning.explanation.leaveDays }} =
<strong
>{{
contribution.declaredDays.length + contribution.placementDates.length
}}
/ {{ contribution.requested }}</strong
>
</p>
<p>
@for (day of contribution.declaredDays; track day.date) {
<span>{{ displayDate(day.date) }} · {{ tr().dayTypes[day.type] }} </span>
}
</p>
<p>{{ tr().bookedDays.fixed }}</p>
</section>
}
@if (plan.calculationDate) {
<p>{{ tr().planning.calculationDate }} : {{ displayDate(plan.calculationDate) }}</p>
}
@for (
contribution of plan.ifThenContributions ?? [];
track contribution.ruleId + contribution.occurrence
) {
<section class="outcome-explanation declared-rule">
<h3>{{ contribution.label }} · {{ displayDate(contribution.occurrence) }}</h3>
<p>
{{ contribution.declaredDays.length }} {{ tr().bookedDays.booked }} +
{{ contribution.placementDates.length }}
{{ tr().planning.explanation.leaveDays }} =
<strong>{{ contribution.requested }} / {{ contribution.requested }}</strong>
</p>
@for (day of contribution.declaredDays; track day.date) {
<span>{{ displayDate(day.date) }} · {{ tr().dayTypes[day.type] }} </span>
}
@if (contribution.declaredDays.length) {
<p>{{ tr().bookedDays.fixed }}</p>
}
@if (contribution.earnedEntitlement; as earned) {
<p>
{{
earned.added
? tr().planning.futureEntitlement
: tr().planning.includedEntitlement
}}
: {{ earned.quantity }} {{ tr().dayTypes[earned.type] }}
</p>
<p>
{{ tr().planning.details.available }}
{{ displayDate(earned.availableFrom) }} ·
{{ tr().planning.details.expires }} {{ displayDate(earned.expiryDate) }}
</p>
}
</section>
}
@for (period of plan.timeOffPeriods; track period.start) {
@if (period.declaredDates?.length) {
<section class="outcome-explanation declared-period">
<h3>
{{ tr().planning.explanation.breakTitle }} ·
{{ displayDate(period.start) }} – {{ displayDate(period.end) }}
</h3>
<p>
{{ period.declaredDates!.length }} {{ tr().bookedDays.booked }} +
{{ period.placementDates.length }}
{{ tr().planning.explanation.leaveDays }} +
{{ period.nonWorkingDates.length }}
{{ tr().planning.explanation.freeDays }} =
<strong
>{{ period.duration }} {{ tr().planning.explanation.daysOff }}</strong
>
</p>
<p>{{ tr().bookedDays.fixed }}</p>
</section>
}
}
}
<div class="plan-insights">
@@ -257,6 +338,13 @@
{{ displayDate(period.start) }} – {{ displayDate(period.end) }}
</h4>
<div class="break-equation">
@if (period.declaredDates?.length) {
<span
><strong>{{ period.declaredDates!.length }}</strong
>{{ tr().bookedDays.booked }}</span
>
<span>+</span>
}
<span
><strong>{{ period.placementDates.length }}</strong
>{{ tr().planning.explanation.leaveDays }}</span
@@ -32,6 +32,7 @@ import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import {
CalendarService,
calendarProjectionHorizon,
DayType,
SelectedDates,
SelectableDayType,
@@ -481,18 +482,14 @@ export class CalendarYear {
protected readonly months = computed(() => {
const horizon = this.calendarHorizon();
if (!horizon) return [];
let first = new Date(`${horizon.start}T00:00:00`);
let last = new Date(`${horizon.end}T00:00:00`);
const plan = this.traceState() ?? this.displayedPlan();
const projectionHorizon =
!this.declarationEntry() && plan
? (calendarProjectionHorizon(plan) ?? horizon)
: horizon;
const first = new Date(`${projectionHorizon.start}T00:00:00`);
const last = new Date(`${projectionHorizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
for (const period of this.declarationEntry()
? []
: (this.displayedPlan()?.timeOffPeriods ?? [])) {
for (const date of period.nonWorkingDates) {
const contextDate = new Date(`${date}T00:00:00`);
if (date < horizon.start && contextDate < first) first = contextDate;
else if (date >= horizon.end && contextDate > last) last = contextDate;
}
}
const result: { monthIndex: number; year: number }[] = [];
const cursor = new Date(first);
cursor.setDate(1);
@@ -711,6 +708,7 @@ export class CalendarYear {
this.planningTrace.set({
status: 'failed',
initialState: {
calculationDate: calculation.snapshot.calculationDate,
horizon: calculation.snapshot.horizon,
...(calculation.snapshot.declaredDays
? { declaredDays: calculation.snapshot.declaredDays }
@@ -1306,6 +1304,7 @@ export class CalendarYear {
})),
};
const snapshot = (): PlanningTraceState => ({
calculationDate: trace.initialState.calculationDate,
horizon: { ...trace.initialState.horizon },
...(trace.initialState.declaredDays
? { declaredDays: trace.initialState.declaredDays }