feat(stats): open calculated plan statistics (#65)
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user