- 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:
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 }
|
||||
|
||||
Reference in new issue
Block a user