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(); 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([ it.each([
{ partial: false, placements: 2 }, { partial: false, placements: 2 },
{ partial: true, placements: 2 }, { partial: true, placements: 2 },
@@ -428,7 +582,21 @@ describe('CalendarYear planning outcome boundary', () => {
...plan, ...plan,
calculationDate: '2024-05-01', calculationDate: '2024-05-01',
placements: placements ? plan.placements : [], 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: [ ifThenContributions: [
{ {
ruleId: 'original-rule', ruleId: 'original-rule',
@@ -509,6 +677,7 @@ describe('CalendarYear planning outcome boundary', () => {
['Longest work sequence (working days)', '6'], ['Longest work sequence (working days)', '6'],
['Average time-off period (calendar days)', '2.5'], ['Average time-off period (calendar days)', '2.5'],
['Longest time-off period (calendar days)', '4'], ['Longest time-off period (calendar days)', '4'],
['Public holidays connected to a weekend (calendar days)', '2'],
['Earned leave before use (days)', '3'], ['Earned leave before use (days)', '3'],
['Original generating rule', '3'], ['Original generating rule', '3'],
]; ];
@@ -993,8 +1162,8 @@ describe('CalendarYear planning outcome boundary', () => {
), ),
).toEqual( ).toEqual(
allDaysOff allDaysOff
? ['0', '4', '—', '0', '9', '9', '0'] ? ['0', '4', '—', '0', '9', '9', '0', '0']
: ['0', '0', '5', '5', '—', '—', '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-labelledby')).toBe('plan-statistics-title');
expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help'); expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help');
await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2'))); await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2')));
for (const [language, workUnit, calendarUnit] of [ for (const [language, workUnit, calendarUnit, holidayLabel] of [
['de', 'Arbeitstage', 'Kalendertage'], ['de', 'Arbeitstage', 'Kalendertage', 'Feiertage mit Wochenendanschluss'],
['es', 'días laborables', 'días naturales'], ['es', 'días laborables', 'días naturales', 'Festivos conectados con un fin de semana'],
['fr', 'jours ouvrés', 'jours calendaires'], ['fr', 'jours ouvrés', 'jours calendaires', 'Jours fériés reliés à un week-end'],
]) { ]) {
translation.setLanguage(language); translation.setLanguage(language);
fixture.detectChanges(); fixture.detectChanges();
expect(dialog.textContent).toContain(workUnit); expect(dialog.textContent).toContain(workUnit);
expect(dialog.textContent).toContain(calendarUnit); expect(dialog.textContent).toContain(calendarUnit);
expect(dialog.textContent).toContain(holidayLabel);
expect( expect(
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()), 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.textContent).toContain('Statistiques du planning');
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés'); 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) => Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(), value.textContent?.trim(),
), ),
).toEqual(['2', '4', '6', '6', '—', '—', '0']); ).toEqual(['2', '4', '6', '6', '—', '—', '0', '0']);
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click(); document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton); activateWithKeyboard(afterButton);
@@ -1295,6 +1295,14 @@
</dt> </dt>
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd> <dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
</div> </div>
<div>
<dt>
{{ tr().planning.statistics.weekendConnectedHolidays }} ({{
tr().planning.statistics.calendarDays
}})
</dt>
<dd>{{ formatStatistic(weekendConnectedHolidays()) }}</dd>
</div>
<div> <div>
<dt> <dt>
{{ tr().planning.statistics.earnedLeave }} ({{ {{ tr().planning.statistics.earnedLeave }} ({{
@@ -202,6 +202,20 @@ export class CalendarYear {
} }
protected readonly displayedPlan = this.userInput.displayedPlanSignal; 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(() => { protected readonly earnedLeave = computed(() => {
const byRule = new Map<string, { ruleId: string; label: string; quantity: number }>(); const byRule = new Map<string, { ruleId: string; label: string; quantity: number }>();
for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) { for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) {
@@ -221,6 +221,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
root.querySelector<HTMLButtonElement>('.statistics-button')!.click(); root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
await fixture.whenStable(); await fixture.whenStable();
const dialog = document.querySelector('[role="dialog"]')!; const dialog = document.querySelector('[role="dialog"]')!;
const connectedHolidays = () =>
Array.from(document.querySelectorAll('[role="dialog"] .statistics-summary dt'))
.find((label) =>
label.textContent?.includes('Jours fériés reliés à un week-end'),
)
?.nextElementSibling?.textContent?.trim();
expect(connectedHolidays()).toBe('1');
const earnedStatistics = () => [ const earnedStatistics = () => [
dialog dialog
.querySelector('.statistics-summary > div:last-child dt') .querySelector('.statistics-summary > div:last-child dt')
@@ -258,6 +265,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const trace = await calculation.trace; const trace = await calculation.trace;
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
expect(connectedHolidays()).toBe('1');
expect(earnedStatistics()).toEqual([ expect(earnedStatistics()).toEqual([
'Congés acquis avant consommation (jours)', 'Congés acquis avant consommation (jours)',
'1', '1',
@@ -292,6 +300,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(plan.placements).toEqual(direct.placements); expect(plan.placements).toEqual(direct.placements);
expect(plan.ifThenContributions).toEqual(direct.ifThenContributions); expect(plan.ifThenContributions).toEqual(direct.ifThenContributions);
expect(plan.metrics).toEqual(direct.metrics); expect(plan.metrics).toEqual(direct.metrics);
expect(plan.timeOffPeriods).toEqual(direct.timeOffPeriods);
expect(root.textContent).toContain('Droit futur généré'); expect(root.textContent).toContain('Droit futur généré');
expect(root.textContent).toContain('CP autour du férié'); expect(root.textContent).toContain('CP autour du férié');
expect(root.textContent).not.toContain('Règle modifiée après calcul'); expect(root.textContent).not.toContain('Règle modifiée après calcul');
@@ -331,6 +340,9 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
calculationDate: '2024-05-01', calculationDate: '2024-05-01',
declaredDays: originalDeclarations, declaredDays: originalDeclarations,
}); });
root.querySelector<HTMLButtonElement>('.statistics-button')!.click();
await fixture.whenStable();
expect(connectedHolidays()).toBe('1');
fixture.destroy(); fixture.destroy();
}, },
); );
+1
View File
@@ -134,6 +134,7 @@ export interface AppTranslations {
averageWorkSequence: string; averageWorkSequence: string;
averageTimeOffPeriod: string; averageTimeOffPeriod: string;
longestTimeOffPeriod: string; longestTimeOffPeriod: string;
weekendConnectedHolidays: string;
earnedLeave: string; earnedLeave: string;
earnedLeaveByRule: string; earnedLeaveByRule: string;
leaveDaysUnit: string; leaveDaysUnit: string;
+1
View File
@@ -143,6 +143,7 @@
"averageWorkSequence": "Durchschnittliche Arbeitsphase", "averageWorkSequence": "Durchschnittliche Arbeitsphase",
"averageTimeOffPeriod": "Durchschnittliche Ruhephase", "averageTimeOffPeriod": "Durchschnittliche Ruhephase",
"longestTimeOffPeriod": "Längste Ruhephase", "longestTimeOffPeriod": "Längste Ruhephase",
"weekendConnectedHolidays": "Feiertage mit Wochenendanschluss",
"workingDays": "Arbeitstage", "workingDays": "Arbeitstage",
"calendarDays": "Kalendertage", "calendarDays": "Kalendertage",
"partialPlan": "Teilplan", "partialPlan": "Teilplan",
+1
View File
@@ -143,6 +143,7 @@
"averageWorkSequence": "Average work sequence", "averageWorkSequence": "Average work sequence",
"averageTimeOffPeriod": "Average time-off period", "averageTimeOffPeriod": "Average time-off period",
"longestTimeOffPeriod": "Longest time-off period", "longestTimeOffPeriod": "Longest time-off period",
"weekendConnectedHolidays": "Public holidays connected to a weekend",
"workingDays": "working days", "workingDays": "working days",
"calendarDays": "calendar days", "calendarDays": "calendar days",
"partialPlan": "Partial plan", "partialPlan": "Partial plan",
+1
View File
@@ -143,6 +143,7 @@
"averageWorkSequence": "Secuencia de trabajo media", "averageWorkSequence": "Secuencia de trabajo media",
"averageTimeOffPeriod": "Período de descanso medio", "averageTimeOffPeriod": "Período de descanso medio",
"longestTimeOffPeriod": "Período de descanso más largo", "longestTimeOffPeriod": "Período de descanso más largo",
"weekendConnectedHolidays": "Festivos conectados con un fin de semana",
"workingDays": "días laborables", "workingDays": "días laborables",
"calendarDays": "días naturales", "calendarDays": "días naturales",
"partialPlan": "Plan parcial", "partialPlan": "Plan parcial",
+1
View File
@@ -143,6 +143,7 @@
"averageWorkSequence": "Séquence de travail moyenne", "averageWorkSequence": "Séquence de travail moyenne",
"averageTimeOffPeriod": "Période de repos moyenne", "averageTimeOffPeriod": "Période de repos moyenne",
"longestTimeOffPeriod": "Plus longue période de repos", "longestTimeOffPeriod": "Plus longue période de repos",
"weekendConnectedHolidays": "Jours fériés reliés à un week-end",
"workingDays": "jours ouvrés", "workingDays": "jours ouvrés",
"calendarDays": "jours calendaires", "calendarDays": "jours calendaires",
"partialPlan": "Planning partiel", "partialPlan": "Planning partiel",