From 06adeb12d6abbb14f6584a4cb9c3b8250e677ca2 Mon Sep 17 00:00:00 2001 From: TO Date: Wed, 7 Oct 2026 23:12:06 +0200 Subject: [PATCH] feat(stats): count holidays connected to weekends (#68) --- .../calendar-year-planning.spec.ts | 188 +++++++++++++++++- .../calendar-year/calendar-year.html | 8 + .../components/calendar-year/calendar-year.ts | 14 ++ src/app/services/leave-planner.worker.spec.ts | 12 ++ src/app/services/translation-service.ts | 1 + src/i18n/de.json | 1 + src/i18n/en.json | 1 + src/i18n/es.json | 1 + src/i18n/fr.json | 1 + 9 files changed, 218 insertions(+), 9 deletions(-) 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 6e982a5..e6d3f2f 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -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('[role="dialog"] button')!.click(); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); activateWithKeyboard(afterButton); diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 31356fe..b77d7b1 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -1295,6 +1295,14 @@
{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}
+
+
+ {{ tr().planning.statistics.weekendConnectedHolidays }} ({{ + tr().planning.statistics.calendarDays + }}) +
+
{{ formatStatistic(weekendConnectedHolidays()) }}
+
{{ tr().planning.statistics.earnedLeave }} ({{ diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index c3b4dcc..384d5fb 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -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(); for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) { diff --git a/src/app/services/leave-planner.worker.spec.ts b/src/app/services/leave-planner.worker.spec.ts index 7656952..7945af9 100644 --- a/src/app/services/leave-planner.worker.spec.ts +++ b/src/app/services/leave-planner.worker.spec.ts @@ -221,6 +221,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); root.querySelector('.statistics-button')!.click(); await fixture.whenStable(); 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 = () => [ dialog .querySelector('.statistics-summary > div:last-child dt') @@ -258,6 +265,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); const trace = await calculation.trace; await fixture.whenStable(); fixture.detectChanges(); + expect(connectedHolidays()).toBe('1'); expect(earnedStatistics()).toEqual([ 'Congés acquis avant consommation (jours)', '1', @@ -292,6 +300,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data })); expect(plan.placements).toEqual(direct.placements); expect(plan.ifThenContributions).toEqual(direct.ifThenContributions); 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('CP autour du férié'); 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', declaredDays: originalDeclarations, }); + root.querySelector('.statistics-button')!.click(); + await fixture.whenStable(); + expect(connectedHolidays()).toBe('1'); fixture.destroy(); }, ); diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index ca6fbe1..92ff8a6 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -134,6 +134,7 @@ export interface AppTranslations { averageWorkSequence: string; averageTimeOffPeriod: string; longestTimeOffPeriod: string; + weekendConnectedHolidays: string; earnedLeave: string; earnedLeaveByRule: string; leaveDaysUnit: string; diff --git a/src/i18n/de.json b/src/i18n/de.json index ec28422..4f08b9f 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -143,6 +143,7 @@ "averageWorkSequence": "Durchschnittliche Arbeitsphase", "averageTimeOffPeriod": "Durchschnittliche Ruhephase", "longestTimeOffPeriod": "Längste Ruhephase", + "weekendConnectedHolidays": "Feiertage mit Wochenendanschluss", "workingDays": "Arbeitstage", "calendarDays": "Kalendertage", "partialPlan": "Teilplan", diff --git a/src/i18n/en.json b/src/i18n/en.json index 124872f..432d629 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -143,6 +143,7 @@ "averageWorkSequence": "Average work sequence", "averageTimeOffPeriod": "Average time-off period", "longestTimeOffPeriod": "Longest time-off period", + "weekendConnectedHolidays": "Public holidays connected to a weekend", "workingDays": "working days", "calendarDays": "calendar days", "partialPlan": "Partial plan", diff --git a/src/i18n/es.json b/src/i18n/es.json index e54274a..04ad197 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -143,6 +143,7 @@ "averageWorkSequence": "Secuencia de trabajo media", "averageTimeOffPeriod": "Período de descanso medio", "longestTimeOffPeriod": "Período de descanso más largo", + "weekendConnectedHolidays": "Festivos conectados con un fin de semana", "workingDays": "días laborables", "calendarDays": "días naturales", "partialPlan": "Plan parcial", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index a8c7700..599a00e 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -143,6 +143,7 @@ "averageWorkSequence": "Séquence de travail moyenne", "averageTimeOffPeriod": "Période de repos moyenne", "longestTimeOffPeriod": "Plus longue période de repos", + "weekendConnectedHolidays": "Jours fériés reliés à un week-end", "workingDays": "jours ouvrés", "calendarDays": "jours calendaires", "partialPlan": "Planning partiel",