From d9f3f8d34a01e3b14118de4b7ca726750662f0fb Mon Sep 17 00:00:00 2001 From: TO Date: Wed, 7 Oct 2026 22:12:56 +0200 Subject: [PATCH] feat(stats): open calculated plan statistics (#65) --- .../calendar-year-planning.spec.ts | 203 ++++++++++++++++++ .../calendar-year/calendar-year.html | 32 +++ .../calendar-year/calendar-year.scss | 8 + .../components/calendar-year/calendar-year.ts | 46 +++- src/app/services/translation-service.ts | 7 + src/i18n/de.json | 7 + src/i18n/en.json | 7 + src/i18n/es.json | 7 + src/i18n/fr.json | 7 + 9 files changed, 323 insertions(+), 1 deletion(-) 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",