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 01dcbb1..2a80854 100644
--- a/src/app/components/calendar-year/calendar-year-planning.spec.ts
+++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts
@@ -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();
+ 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();
+ const oldTrace = deferred();
+ 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('.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('.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('.statistics-button')!);
+ await fixture.whenStable();
+ expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2');
+ document.querySelector('[role="dialog"] button')!.click();
+ await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
fixture.detectChanges();
expect(afterButton.getAttribute('aria-pressed')).toBe('true');
diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html
index ebe9a32..8136aec 100644
--- a/src/app/components/calendar-year/calendar-year.html
+++ b/src/app/components/calendar-year/calendar-year.html
@@ -62,6 +62,17 @@
}
+ @if (displayedPlan()) {
+
+ }
@if (planningOutcome(); as outcome) {
@@ -1233,6 +1244,27 @@
+
+ {{ tr().planning.statistics.title }}
+
+ @if (displayedPlan(); as plan) {
+ @if (planningOutcome()?.kind === 'PlanningIncomplete') {
+ {{ tr().planning.statistics.partialPlan }}
+ }
+ {{ tr().planning.statistics.help }}
+
+
+
- {{ tr().planning.statistics.leaveDays }}
+ - {{ plan.placements.length }}
+
+
+ }
+
+
+
+
+
+
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss
index a657e36..f866c15 100644
--- a/src/app/components/calendar-year/calendar-year.scss
+++ b/src/app/components/calendar-year/calendar-year.scss
@@ -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 {
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 8d58856..954f837 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -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>('dateComparisonDialog');
private comparisonDialog: MatDialogRef | null = null;
+ private readonly statisticsTemplate =
+ viewChild.required>('planStatisticsDialog');
+ private statisticsDialog: MatDialogRef | 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;
diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts
index 8c5b567..56ad18c 100644
--- a/src/app/services/translation-service.ts
+++ b/src/app/services/translation-service.ts
@@ -127,6 +127,13 @@ export interface AppTranslations {
};
};
planning: {
+ statistics: {
+ open: string;
+ title: string;
+ leaveDays: string;
+ partialPlan: string;
+ help: string;
+ };
calculationDate: string;
futureEntitlement: string;
includedEntitlement: string;
diff --git a/src/i18n/de.json b/src/i18n/de.json
index a962429..ae1d373 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -136,6 +136,13 @@
}
},
"planning": {
+ "statistics": {
+ "open": "Statistik",
+ "title": "Planstatistik",
+ "leaveDays": "Neu geplante Urlaubstage",
+ "partialPlan": "Teilplan",
+ "help": "Urlaubsverbrauch des berechneten Plans. Bereits gebuchte Tage sind ausgeschlossen."
+ },
"calculationDate": "Berechnungsdatum",
"futureEntitlement": "Erzeugter künftiger Anspruch",
"includedEntitlement": "Anspruch bereits in den gemeldeten Guthaben enthalten",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 4080749..4d91329 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -136,6 +136,13 @@
}
},
"planning": {
+ "statistics": {
+ "open": "Statistics",
+ "title": "Plan statistics",
+ "leaveDays": "New leave days",
+ "partialPlan": "Partial plan",
+ "help": "Leave usage for the calculated plan. Already booked days are excluded."
+ },
"calculationDate": "Calculation date",
"futureEntitlement": "Future entitlement generated",
"includedEntitlement": "Entitlement already included in reported balances",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 321dc2a..9f6dcad 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -136,6 +136,13 @@
}
},
"planning": {
+ "statistics": {
+ "open": "Estadísticas",
+ "title": "Estadísticas del plan",
+ "leaveDays": "Nuevos días de permiso",
+ "partialPlan": "Plan parcial",
+ "help": "Permisos utilizados por el plan calculado. Se excluyen los días ya reservados."
+ },
"calculationDate": "Fecha del cálculo",
"futureEntitlement": "Derecho futuro generado",
"includedEntitlement": "Derecho ya incluido en los saldos declarados",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index d828305..5957aec 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -136,6 +136,13 @@
}
},
"planning": {
+ "statistics": {
+ "open": "Statistiques",
+ "title": "Statistiques du planning",
+ "leaveDays": "Nouveaux jours de congé posés",
+ "partialPlan": "Planning partiel",
+ "help": "Congés utilisés par le planning calculé. Les jours déjà posés sont exclus."
+ },
"calculationDate": "Date du calcul",
"futureEntitlement": "Droit futur généré",
"includedEntitlement": "Droit déjà inclus dans les soldes reportés",