feat(stats): show work and time-off lengths (#66)
This commit is contained in:
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(), {
|
||||
|
||||
Reference in new issue
Block a user