feat(stats): count holidays connected to weekends (#68)

This commit is contained in:
nessar committed 2026-10-07 23:12:06 +02:00
1 parent da08ee054d
commit 06adeb12d6
9 files changed
+218 -9

No files matched your search

@@ -414,6 +414,160 @@ describe('CalendarYear planning outcome boundary', () => {
vi.useRealTimers();
});
it.each([
{
label: 'a Monday holiday directly beside a weekend',
start: '2024-05-21',
end: '2024-05-22',
placed: ['2024-05-21'],
expected: 1,
},
{
label: 'a Friday holiday directly beside a weekend',
start: '2024-10-31',
end: '2024-11-01',
placed: ['2024-10-31'],
expected: 1,
},
{
label: 'a Tuesday holiday bridged by placed Monday leave',
start: '2023-08-14',
end: '2023-08-16',
placed: ['2023-08-14'],
expected: 1,
},
{
label: 'a Tuesday holiday bridged only by a declared Monday',
start: '2023-08-14',
end: '2023-08-16',
declared: ['2023-08-14'],
expected: 1,
},
{
label: 'one Wednesday holiday in a completed public-holiday week valued at three',
start: '2024-04-29',
end: '2024-05-04',
declared: ['2024-04-29', '2024-04-30', '2024-05-02', '2024-05-03'],
expected: 1,
weighted: 3,
},
{
label: 'a weekday-only break without a weekend',
start: '2024-04-30',
end: '2024-05-02',
declared: ['2024-04-30'],
expected: 0,
},
{
label: 'a natural long weekend without any placement or declaration',
start: '2024-11-01',
end: '2024-11-04',
expected: 0,
},
{
label: 'a public holiday on Saturday',
start: '2021-04-30',
end: '2021-05-01',
placed: ['2021-04-30'],
expected: 0,
},
{
label: 'a public holiday on Sunday',
start: '2022-04-29',
end: '2022-04-30',
placed: ['2022-04-29'],
expected: 0,
},
{
label: 'a declaration beside a weekend without a public holiday',
start: '2024-06-14',
end: '2024-06-15',
declared: ['2024-06-14'],
expected: 0,
},
{
label: 'a Monday holiday beyond the horizon after Friday leave and its weekend',
start: '2024-05-17',
end: '2024-05-18',
placed: ['2024-05-17'],
expected: 1,
},
{
label: 'a Friday holiday before the horizon beside declared Monday leave',
start: '2024-11-04',
end: '2024-11-05',
declared: ['2024-11-04'],
expected: 1,
},
{
label: 'two distinct weekday holidays within one break',
start: '2024-05-10',
end: '2024-05-11',
declared: ['2024-05-10'],
expected: 2,
},
{
label: 'duplicate holiday date contributions',
start: '2024-05-10',
end: '2024-05-11',
declared: ['2024-05-10'],
expected: 2,
duplicate: true,
},
])(
'counts public holidays connected to weekends: $label',
async ({ start, end, placed = [], declared = [], expected, weighted, duplicate }) => {
const core = await import('../../services/leave-planner.core');
const outcome = core.calculatePlanningInternal({
calculationDate: '2020-01-01',
horizon: { start, end },
balances: placed.length
? [{ id: 'cp', type: 'cp', quantity: placed.length, expiryDate: '2025-12-31' }]
: [],
rules: placed.map((date) => ({
id: date,
type: 'time_window',
force: 'obligation',
startDate: date,
endDate: date,
placeDays: 1,
placeType: 'cp',
})),
declaredDays: declared.map((date) => ({ date, type: 'other' })),
});
if (outcome.kind !== 'Plan') throw new Error('Expected a valid plan.');
expect(outcome.placements.map(({ date }) => date)).toEqual(placed);
if (weighted !== undefined) expect(outcome.metrics.holidayWeekValue).toBe(weighted);
if (duplicate)
outcome.timeOffPeriods = outcome.timeOffPeriods.map((period) => ({
...period,
nonWorkingDates: [...period.nonWorkingDates, ...period.nonWorkingDates],
}));
const { calendar } = calendarFor(outcome);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
fixture.nativeElement.querySelector('.statistics-button').click();
await fixture.whenStable();
const holidayLabel = Array.from(document.querySelectorAll('[role="dialog"] dt')).find(
(label) => label.textContent?.includes('Public holidays connected to a weekend'),
);
expect(holidayLabel?.textContent?.replace(/\s+/g, ' ').trim()).toBe(
'Public holidays connected to a weekend (calendar days)',
);
expect(holidayLabel?.nextElementSibling?.textContent?.trim()).toBe(String(expected));
},
);
it.each([
{ partial: false, placements: 2 },
{ partial: true, placements: 2 },
@@ -428,7 +582,21 @@ describe('CalendarYear planning outcome boundary', () => {
...plan,
calculationDate: '2024-05-01',
placements: placements ? plan.placements : [],
declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
declaredDays: [
{ date: '2024-05-07', type: 'rtt' },
{ date: '2024-05-10', type: 'other' },
],
timeOffPeriods: [
{
start: '2024-05-08',
end: '2024-05-12',
duration: 5,
placementDates: [],
declaredDates: ['2024-05-10'],
nonWorkingDates: ['2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12'],
nonWorkingDayGain: 4,
},
],
ifThenContributions: [
{
ruleId: 'original-rule',
@@ -509,6 +677,7 @@ describe('CalendarYear planning outcome boundary', () => {
['Longest work sequence (working days)', '6'],
['Average time-off period (calendar days)', '2.5'],
['Longest time-off period (calendar days)', '4'],
['Public holidays connected to a weekend (calendar days)', '2'],
['Earned leave before use (days)', '3'],
['Original generating rule', '3'],
];
@@ -993,8 +1162,8 @@ describe('CalendarYear planning outcome boundary', () => {
),
).toEqual(
allDaysOff
? ['0', '4', '—', '0', '9', '9', '0']
: ['0', '0', '5', '5', '—', '—', '0'],
? ['0', '4', '—', '0', '9', '9', '0', '0']
: ['0', '0', '5', '5', '—', '—', '0', '0'],
);
},
);
@@ -1032,18 +1201,19 @@ describe('CalendarYear planning outcome boundary', () => {
expect(dialog.getAttribute('aria-labelledby')).toBe('plan-statistics-title');
expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help');
await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2')));
for (const [language, workUnit, calendarUnit] of [
['de', 'Arbeitstage', 'Kalendertage'],
['es', 'días laborables', 'días naturales'],
['fr', 'jours ouvrés', 'jours calendaires'],
for (const [language, workUnit, calendarUnit, holidayLabel] of [
['de', 'Arbeitstage', 'Kalendertage', 'Feiertage mit Wochenendanschluss'],
['es', 'días laborables', 'días naturales', 'Festivos conectados con un fin de semana'],
['fr', 'jours ouvrés', 'jours calendaires', 'Jours fériés reliés à un week-end'],
]) {
translation.setLanguage(language);
fixture.detectChanges();
expect(dialog.textContent).toContain(workUnit);
expect(dialog.textContent).toContain(calendarUnit);
expect(dialog.textContent).toContain(holidayLabel);
expect(
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0']);
).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0', '0']);
}
expect(dialog.textContent).toContain('Statistiques du planning');
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
@@ -3339,7 +3509,7 @@ describe('CalendarYear planning outcome boundary', () => {
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
).toEqual(['2', '4', '6', '6', '—', '—', '0']);
).toEqual(['2', '4', '6', '6', '—', '—', '0', '0']);
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
@@ -1295,6 +1295,14 @@
</dt>
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.weekendConnectedHolidays }} ({{
tr().planning.statistics.calendarDays
}})
</dt>
<dd>{{ formatStatistic(weekendConnectedHolidays()) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.earnedLeave }} ({{
@@ -202,6 +202,20 @@ export class CalendarYear {
}
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
protected readonly weekendConnectedHolidays = computed(() => {
const isWeekend = (date: string) => {
const day = new Date(`${date}T00:00:00Z`).getUTCDay();
return day === 0 || day === 6;
};
// Weekday natural non-working dates are public holidays; declarations are separate.
return new Set(
(this.displayedPlan()?.timeOffPeriods ?? []).flatMap(({ nonWorkingDates }) =>
nonWorkingDates.some(isWeekend)
? nonWorkingDates.filter((date) => !isWeekend(date))
: [],
),
).size;
});
protected readonly earnedLeave = computed(() => {
const byRule = new Map<string, { ruleId: string; label: string; quantity: number }>();
for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) {