feat(stats): open calculated plan statistics (#65)

This commit is contained in:
nessar committed 2026-10-07 22:12:56 +02:00
1 parent 3dc601982a
commit d9f3f8d34a
9 files changed
+323 -1

No files matched your search

@@ -407,6 +407,203 @@ describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it.each([
{ partial: false, placements: 2 },
{ partial: true, placements: 2 },
{ partial: false, placements: 0 },
{ partial: true, placements: 0 },
])(
'opens leave-usage 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' }],
};
const outcome: PlanningOutcome = partial
? {
...incomplete,
incumbent: validPlan,
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
}
: validPlan;
const { calendar, calculation } = calendarFor(
outcome,
undefined,
undefined,
pendingTrace.promise,
);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
TestBed.inject(TranslationService).setLanguage('en');
const input = TestBed.inject(UserInputService);
const rules = TestBed.inject(RuleService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const before = {
balances: structuredClone(input.vacationNumberSignal()),
remaining: structuredClone(input.remainingVacationDaysSignal()),
declarations: structuredClone(input.declaredDaysSignal()),
rules: structuredClone(rules.rules),
};
const opener = fixture.nativeElement.querySelector(
'.statistics-button',
) as HTMLButtonElement;
expect(opener).not.toBeNull();
expect(opener.textContent).toContain('Statistics');
activateWithKeyboard(opener);
await fixture.whenStable();
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));
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(input.vacationNumberSignal()).toEqual(before.balances);
expect(input.remainingVacationDaysSignal()).toEqual(before.remaining);
expect(input.declaredDaysSignal()).toEqual(before.declarations);
expect(rules.rules).toEqual(before.rules);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
pendingTrace.reject(new Error('Trace delivery failed'));
await fixture.whenStable();
fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe(
String(placements),
);
input.updateBalances(() => []);
input.toggleDeclaredDay('2024-05-10', 'other');
rules.deleteRule(rules.rules[0].id);
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-05-14', '2021-05-14');
input.declarationEntrySignal.set(true);
fixture.detectChanges();
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe(
String(placements),
);
expect(input.planningOutcomeSignal()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
},
);
it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => {
const { calendar } = calendarFor(plan);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const translation = TestBed.inject(TranslationService);
translation.setLanguage('en');
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const opener = fixture.nativeElement.querySelector(
'.statistics-button',
) as HTMLButtonElement;
activateWithKeyboard(opener, ' ');
await fixture.whenStable();
const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
expect(opener.getAttribute('aria-haspopup')).toBe('dialog');
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();
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');
dialog.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }),
);
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(document.activeElement).toBe(opener);
activateWithKeyboard(opener);
await fixture.whenStable();
fixture.destroy();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
});
it.each([
['a new valid plan', { ...plan, placements: [plan.placements[0]] }, false],
['invalid input', invalid, false],
['a planning conflict', conflict, false],
['an incomplete calculation without a retained plan', incomplete, false],
['a conflict after focus has moved to another control', conflict, true],
] satisfies [string, PlanningOutcome, boolean][])(
'replaces open statistics after %s and ignores an older valid outcome',
async (_, replacement: PlanningOutcome, focusMoved) => {
const oldOutcome = deferred<PlanningOutcome>();
const oldTrace = deferred<PlanningTrace | undefined>();
const { calendar } = calendarFor(plan);
calendar.createPlanningCalculation
.mockReturnValueOnce(calculationFor(plan))
.mockReturnValueOnce({
...calculationFor(plan, undefined, undefined, oldTrace.promise),
outcome: oldOutcome.promise,
})
.mockReturnValueOnce(calculationFor(replacement));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const input = TestBed.inject(UserInputService);
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
input.requestComputation();
fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
input.requestComputation();
fixture.detectChanges();
const otherControl = document.createElement('button');
otherControl.textContent = 'Another control';
document.body.appendChild(otherControl);
if (focusMoved) otherControl.focus();
await fixture.whenStable();
fixture.detectChanges();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
oldOutcome.resolve(plan);
oldTrace.resolve(jointTrace());
await fixture.whenStable();
fixture.detectChanges();
expect(input.planningOutcomeSignal()).toBe(replacement);
const opener = root.querySelector<HTMLButtonElement>('.statistics-button');
if (replacement.kind === 'Plan') {
expect(opener).not.toBeNull();
opener!.click();
await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('1');
} else {
expect(opener).toBeNull();
expect(document.activeElement).toBe(
focusMoved ? otherControl : root.querySelector('#planning-results-title'),
);
}
otherControl.remove();
},
);
it.each([false, true])(
'shows fixed rule credits separately from balance-consuming placements (partial: %s)',
async (partial) => {
@@ -2612,6 +2809,12 @@ describe('CalendarYear planning outcome boundary', () => {
expect(root.querySelector('.trace-state-summary')?.textContent).toContain(
'cp: 1 remaining',
);
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');
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
fixture.detectChanges();
expect(afterButton.getAttribute('aria-pressed')).toBe('true');
@@ -62,6 +62,17 @@
</p>
}
</div>
@if (displayedPlan()) {
<button
type="button"
class="statistics-button"
aria-haspopup="dialog"
(click)="openStatistics()"
>
<mat-icon aria-hidden="true">bar_chart</mat-icon>
{{ tr().planning.statistics.open }}
</button>
}
</header>
@if (planningOutcome(); as outcome) {
@@ -1233,6 +1244,27 @@
</details>
</ng-template>
<ng-template #planStatisticsDialog>
<h2 mat-dialog-title id="plan-statistics-title">{{ tr().planning.statistics.title }}</h2>
<mat-dialog-content class="statistics-content">
@if (displayedPlan(); as plan) {
@if (planningOutcome()?.kind === 'PlanningIncomplete') {
<p class="incomplete-incumbent">{{ tr().planning.statistics.partialPlan }}</p>
}
<p id="plan-statistics-help">{{ tr().planning.statistics.help }}</p>
<dl class="plan-summary statistics-summary">
<div>
<dt>{{ tr().planning.statistics.leaveDays }}</dt>
<dd>{{ plan.placements.length }}</dd>
</div>
</dl>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-dialog-close>{{ tr().settings.close }}</button>
</mat-dialog-actions>
</ng-template>
<ng-template #dateComparisonDialog>
<section class="counterfactual-panel">
<header class="comparison-heading">
@@ -221,6 +221,14 @@ summary:focus-visible {
}
}
.statistics-content {
display: grid;
gap: 1rem;
}
.statistics-summary {
grid-template-columns: minmax(0, 1fr);
}
.placement-explanation,
.outcome-explanation,
.trace-decisions {
@@ -22,7 +22,14 @@ import { MatInputModule } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import {
MatDialog,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import {
CalendarMonth,
CalendarDayStatus,
@@ -72,7 +79,10 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
imports: [
JsonPipe,
NgTemplateOutlet,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
@@ -99,6 +109,9 @@ export class CalendarYear {
private readonly comparisonTemplate =
viewChild.required<TemplateRef<unknown>>('dateComparisonDialog');
private comparisonDialog: MatDialogRef<unknown> | null = null;
private readonly statisticsTemplate =
viewChild.required<TemplateRef<unknown>>('planStatisticsDialog');
private statisticsDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(false);
@@ -496,6 +509,7 @@ export class CalendarYear {
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
this.destroyRef.onDestroy(() => {
this.closeStatistics();
this.clearCounterfactual();
this.cancelDeclarationRange();
});
@@ -630,6 +644,7 @@ export class CalendarYear {
}
private resetCalculationState(horizon: { start: string; end: string }): void {
this.closeStatistics();
this.activeCalculation = null;
this.userInput.planningSnapshotSignal.set(null);
this.planningOutcome.set(null);
@@ -747,6 +762,35 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set([]);
}
protected openStatistics(): void {
if (!this.displayedPlan() || this.statisticsDialog) return;
const dialog = this.dialog.open(this.statisticsTemplate(), {
width: '40rem',
maxWidth: 'calc(100vw - 2rem)',
maxHeight: 'calc(100dvh - 2rem)',
autoFocus: 'first-heading',
ariaLabelledBy: 'plan-statistics-title',
ariaDescribedBy: 'plan-statistics-help',
});
this.statisticsDialog = dialog;
dialog.beforeClosed().subscribe(() => {
if (this.statisticsDialog === dialog) this.statisticsDialog = null;
});
dialog.afterClosed().subscribe(() => {
if (
!this.destroyRef.destroyed &&
!this.displayedPlan() &&
document.activeElement === document.body
)
this.resultsTitle()?.nativeElement.focus();
});
}
private closeStatistics(): void {
this.statisticsDialog?.close();
this.statisticsDialog = null;
}
protected selectPlacement(index: number, announce = true): void {
const explanation = this.planExplanation();
if (!explanation?.placements.length) return;