feat(stats): show work and time-off lengths (#66)

This commit is contained in:
nessar committed 2026-10-07 22:39:05 +02:00
1 parent d9f3f8d34a
commit 3fbf9c3172
12 files changed
+373 -20

No files matched your search

@@ -77,6 +77,9 @@ const plan: PlanOutcome = {
holidayWeekValue: 0,
nonWorkingDayGain: 4,
longestWorkSequence: 6,
averageWorkSequence: 6,
averageTimeOffPeriod: null,
longestTimeOffPeriod: null,
shortSequenceDeficit: 0,
supportingPlacements: 0,
preferenceSatisfaction: [],
@@ -413,13 +416,19 @@ describe('CalendarYear planning outcome boundary', () => {
{ partial: false, placements: 0 },
{ partial: true, placements: 0 },
])(
'opens leave-usage statistics independently of the trace (partial: $partial, placements: $placements)',
'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)',
async ({ partial, placements }) => {
const pendingTrace = deferred<PlanningTrace | undefined>();
const validPlan: PlanOutcome = {
...plan,
placements: placements ? plan.placements : [],
declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
metrics: {
...plan.metrics,
averageWorkSequence: 10 / 3,
averageTimeOffPeriod: 2.5,
longestTimeOffPeriod: 4,
},
};
const outcome: PlanningOutcome = partial
? {
@@ -464,8 +473,20 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog.textContent).toContain('Plan statistics');
expect(dialog.querySelector('dt')?.textContent).toContain('New leave days');
expect(dialog.querySelector('dd')?.textContent?.trim()).toBe(String(placements));
const statistics = () =>
Array.from(dialog.querySelectorAll('dt'), (label) => [
label.textContent?.replace(/\s+/g, ' ').trim(),
label.nextElementSibling?.textContent?.trim(),
]);
const expectedStatistics = [
['New leave days', String(placements)],
['Non-working days gained (calendar days)', '4'],
['Average work sequence (working days)', '3.333'],
['Longest work sequence (working days)', '6'],
['Average time-off period (calendar days)', '2.5'],
['Longest time-off period (calendar days)', '4'],
];
expect(statistics()).toEqual(expectedStatistics);
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(input.vacationNumberSignal()).toEqual(before.balances);
@@ -477,9 +498,7 @@ describe('CalendarYear planning outcome boundary', () => {
pendingTrace.reject(new Error('Trace delivery failed'));
await fixture.whenStable();
fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe(
String(placements),
);
expect(statistics()).toEqual(expectedStatistics);
input.updateBalances(() => []);
input.toggleDeclaredDay('2024-05-10', 'other');
rules.deleteRule(rules.rules[0].id);
@@ -487,16 +506,61 @@ describe('CalendarYear planning outcome boundary', () => {
input.declarationEntrySignal.set(true);
fixture.detectChanges();
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe(
String(placements),
);
expect(statistics()).toEqual(expectedStatistics);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
},
);
it.each([false, true])(
'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)',
async (allDaysOff) => {
const core = await import('../../services/leave-planner.core');
const validPlan = core.calculatePlanningInternal({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [],
rules: [],
declaredDays: allDaysOff
? ['08', '09', '10', '11', '12'].map((day) => ({
date: `2024-01-${day}`,
type: 'other',
}))
: [],
});
if (validPlan.kind !== 'Plan') throw new Error('Expected a valid plan.');
const { calendar } = calendarFor(validPlan);
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();
expect(
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
).toEqual(allDaysOff ? ['0', '4', '—', '0', '9', '9'] : ['0', '0', '5', '5', '—', '—']);
},
);
it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => {
const { calendar } = calendarFor(plan);
const { calendar } = calendarFor({
...plan,
metrics: {
...plan.metrics,
averageWorkSequence: 10 / 3,
averageTimeOffPeriod: 2.5,
longestTimeOffPeriod: 4,
},
});
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -520,8 +584,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')));
translation.setLanguage('fr');
fixture.detectChanges();
for (const [language, workUnit, calendarUnit] of [
['de', 'Arbeitstage', 'Kalendertage'],
['es', 'días laborables', 'días naturales'],
['fr', 'jours ouvrés', 'jours calendaires'],
]) {
translation.setLanguage(language);
fixture.detectChanges();
expect(dialog.textContent).toContain(workUnit);
expect(dialog.textContent).toContain(calendarUnit);
expect(
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
).toEqual(['2', '4', '3,333', '6', '2,5', '4']);
}
expect(dialog.textContent).toContain('Statistiques du planning');
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
expect(dialog.querySelector('button')?.textContent?.trim()).toBe('Fermer');
@@ -2812,7 +2887,11 @@ describe('CalendarYear planning outcome boundary', () => {
expect(root.querySelector('.trace-state-summary')?.textContent).toContain('1 placements');
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
expect(
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
).toEqual(['2', '4', '6', '6', '—', '—']);
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
@@ -1257,6 +1257,44 @@
<dt>{{ tr().planning.statistics.leaveDays }}</dt>
<dd>{{ plan.placements.length }}</dd>
</div>
<div>
<dt>
{{ tr().planning.daysGained }} ({{ tr().planning.statistics.calendarDays }})
</dt>
<dd>{{ formatStatistic(plan.metrics.nonWorkingDayGain) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.averageWorkSequence }} ({{
tr().planning.statistics.workingDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.averageWorkSequence) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.longestWorkSequence }} ({{
tr().planning.statistics.workingDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.longestWorkSequence) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.averageTimeOffPeriod }} ({{
tr().planning.statistics.calendarDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.averageTimeOffPeriod) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.longestTimeOffPeriod }} ({{
tr().planning.statistics.calendarDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
</div>
</dl>
}
</mat-dialog-content>
@@ -762,6 +762,10 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set([]);
}
protected formatStatistic(value: number | null): string {
return value === null ? '—' : value.toLocaleString(this.translationService.locale());
}
protected openStatistics(): void {
if (!this.displayedPlan() || this.statisticsDialog) return;
const dialog = this.dialog.open(this.statisticsTemplate(), {