feat(stats): count holidays connected to weekends (#68)
This commit is contained in:
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 ?? []) {
|
||||
|
||||
@@ -221,6 +221,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||
root.querySelector<HTMLButtonElement>('.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<HTMLButtonElement>('.statistics-button')!.click();
|
||||
await fixture.whenStable();
|
||||
expect(connectedHolidays()).toBe('1');
|
||||
fixture.destroy();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface AppTranslations {
|
||||
averageWorkSequence: string;
|
||||
averageTimeOffPeriod: string;
|
||||
longestTimeOffPeriod: string;
|
||||
weekendConnectedHolidays: string;
|
||||
earnedLeave: string;
|
||||
earnedLeaveByRule: string;
|
||||
leaveDaysUnit: string;
|
||||
|
||||
@@ -143,6 +143,7 @@
|
||||
"averageWorkSequence": "Durchschnittliche Arbeitsphase",
|
||||
"averageTimeOffPeriod": "Durchschnittliche Ruhephase",
|
||||
"longestTimeOffPeriod": "Längste Ruhephase",
|
||||
"weekendConnectedHolidays": "Feiertage mit Wochenendanschluss",
|
||||
"workingDays": "Arbeitstage",
|
||||
"calendarDays": "Kalendertage",
|
||||
"partialPlan": "Teilplan",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user