('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton);
diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html
index 8136aec..2b8ceb0 100644
--- a/src/app/components/calendar-year/calendar-year.html
+++ b/src/app/components/calendar-year/calendar-year.html
@@ -1257,6 +1257,44 @@
{{ tr().planning.statistics.leaveDays }}
{{ plan.placements.length }}
+
+
+ {{ tr().planning.daysGained }} ({{ tr().planning.statistics.calendarDays }})
+
+ {{ formatStatistic(plan.metrics.nonWorkingDayGain) }}
+
+
+
+ {{ tr().planning.statistics.averageWorkSequence }} ({{
+ tr().planning.statistics.workingDays
+ }})
+
+ {{ formatStatistic(plan.metrics.averageWorkSequence) }}
+
+
+
+ {{ tr().planning.longestWorkSequence }} ({{
+ tr().planning.statistics.workingDays
+ }})
+
+ {{ formatStatistic(plan.metrics.longestWorkSequence) }}
+
+
+
+ {{ tr().planning.statistics.averageTimeOffPeriod }} ({{
+ tr().planning.statistics.calendarDays
+ }})
+
+ {{ formatStatistic(plan.metrics.averageTimeOffPeriod) }}
+
+
+
+ {{ tr().planning.statistics.longestTimeOffPeriod }} ({{
+ tr().planning.statistics.calendarDays
+ }})
+
+ {{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}
+
}
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 954f837..439a469 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -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(), {
diff --git a/src/app/services/leave-planner-cancellation.spec.ts b/src/app/services/leave-planner-cancellation.spec.ts
index 783cc4a..3d1f7ca 100644
--- a/src/app/services/leave-planner-cancellation.spec.ts
+++ b/src/app/services/leave-planner-cancellation.spec.ts
@@ -79,6 +79,13 @@ describe('user cancellation', () => {
expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.incumbent.timeOffPeriods).toEqual(completed.timeOffPeriods);
expect(outcome.incumbent.metrics).toEqual(completed.metrics);
+ expect(outcome.incumbent.metrics).toMatchObject({
+ nonWorkingDayGain: 2,
+ averageWorkSequence: 2,
+ longestWorkSequence: 3,
+ averageTimeOffPeriod: 3,
+ longestTimeOffPeriod: 3,
+ });
expect(artifacts.explanation).toMatchObject({
kind: 'PlanExplanation',
declaredDays: submitted.declaredDays,
@@ -344,6 +351,36 @@ describe('user cancellation', () => {
expect(await calculation.trace).toEqual(trace);
});
+ it.each([false, true])(
+ 'preserves calculated statistics from a retained checkpoint after worker failure (declared period: %s)',
+ async (withPeriod) => {
+ const core = await import('./leave-planner.core');
+ const submitted = {
+ horizon: { start: '2024-01-02', end: '2024-01-09' },
+ balances: [],
+ rules: [],
+ declaredDays: withPeriod ? [{ date: '2024-01-05', type: 'other' as const }] : [],
+ };
+ const checkpoint = core.calculatePlanningInternal(submitted);
+ if (checkpoint.kind !== 'Plan') throw new Error('Expected a valid checkpoint.');
+ const calculation = createPlanningCalculation(submitted);
+ currentWorker().emit({ type: 'checkpoint', plan: structuredClone(checkpoint) });
+ currentWorker().onerror?.();
+ const outcome = await calculation.outcome;
+ if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent)
+ throw new Error('Expected a retained checkpoint.');
+ expect(outcome.reasons[0].code).toBe('worker-error');
+ expect(outcome.incumbent.metrics).toEqual(checkpoint.metrics);
+ expect(outcome.incumbent.metrics).toMatchObject({
+ nonWorkingDayGain: withPeriod ? 2 : 0,
+ averageWorkSequence: withPeriod ? 2 : 5,
+ longestWorkSequence: withPeriod ? 3 : 5,
+ averageTimeOffPeriod: withPeriod ? 3 : null,
+ longestTimeOffPeriod: withPeriod ? 3 : null,
+ });
+ },
+ );
+
it('reports worker failure distinctly after a cancellation request', async () => {
const calculation = createPlanningCalculation(request);
calculation.cancel();
diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts
index a6484ea..e4a777f 100644
--- a/src/app/services/leave-planner.core.ts
+++ b/src/app/services/leave-planner.core.ts
@@ -460,6 +460,9 @@ export interface PlanningMetrics {
holidayWeekValue: number;
nonWorkingDayGain: number;
longestWorkSequence: number;
+ averageWorkSequence: number | null;
+ averageTimeOffPeriod: number | null;
+ longestTimeOffPeriod: number | null;
shortSequenceDeficit: number;
supportingPlacements: number;
preferenceSatisfaction: number[];
@@ -5026,6 +5029,7 @@ function qualityMetrics(
} else current++;
}
if (context.workingDays.length) sequences.push(current);
+ const nonEmptySequences = sequences.filter((length) => length > 0);
const internal = hasTimeOff ? sequences.slice(1, -1) : [];
const shortSequenceDeficit = internal.reduce(
(sum, length) => sum + (length > 0 && length < 5 ? 5 - length : 0),
@@ -5036,6 +5040,15 @@ function qualityMetrics(
holidayWeekValue: holidayWeekValue(state, context),
nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0),
longestWorkSequence: sequences.length ? Math.max(...sequences) : 0,
+ averageWorkSequence: nonEmptySequences.length
+ ? nonEmptySequences.reduce((sum, length) => sum + length, 0) / nonEmptySequences.length
+ : null,
+ averageTimeOffPeriod: periods.length
+ ? periods.reduce((sum, period) => sum + period.duration, 0) / periods.length
+ : null,
+ longestTimeOffPeriod: periods.length
+ ? Math.max(...periods.map((period) => period.duration))
+ : null,
shortSequenceDeficit,
supportingPlacements: [...state.placements.values()].filter(
(placement) => placement.supporting,
diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts
index 04dd06c..5a5c20c 100644
--- a/src/app/services/leave-planner.service.spec.ts
+++ b/src/app/services/leave-planner.service.spec.ts
@@ -48,6 +48,129 @@ async function runInNonBrowser(request: Parameters[0]) {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
+describe('calculated work-sequence and time-off-period statistics', () => {
+ it('keeps fractional work means and merges consecutive declared days across a weekend', async () => {
+ const result = isPlan(
+ await runInNonBrowser({
+ horizon: { start: '2024-01-02', end: '2024-01-11' },
+ balances: [],
+ rules: [],
+ declaredDays: ['2024-01-05', '2024-01-08'].map((date) => ({
+ date,
+ type: 'other',
+ })),
+ }),
+ );
+ expect(result.metrics).toMatchObject({
+ nonWorkingDayGain: 2,
+ averageWorkSequence: 2.5,
+ longestWorkSequence: 3,
+ averageTimeOffPeriod: 4,
+ longestTimeOffPeriod: 4,
+ });
+ });
+
+ it.each([
+ {
+ scenario: 'averages the 10, 5 and 15 working days including both horizon edges',
+ horizon: { start: '2024-01-08', end: '2024-02-21' },
+ declaredDates: ['2024-01-22', '2024-01-30'],
+ metrics: {
+ averageWorkSequence: 10,
+ longestWorkSequence: 15,
+ averageTimeOffPeriod: 2,
+ longestTimeOffPeriod: 3,
+ nonWorkingDayGain: 2,
+ },
+ },
+ {
+ scenario: 'keeps the unrounded mean of work sequences of 3, 1 and 6 days',
+ horizon: { start: '2024-01-02', end: '2024-01-18' },
+ declaredDates: ['2024-01-05', '2024-01-09'],
+ metrics: {
+ averageWorkSequence: 10 / 3,
+ longestWorkSequence: 6,
+ averageTimeOffPeriod: 2,
+ longestTimeOffPeriod: 3,
+ nonWorkingDayGain: 2,
+ },
+ },
+ {
+ scenario: 'averages one-day and two-day breaks without adding zero-length work samples',
+ horizon: { start: '2024-01-02', end: '2024-01-12' },
+ declaredDates: ['2024-01-03', '2024-01-09', '2024-01-10'],
+ metrics: {
+ averageWorkSequence: 5 / 3,
+ longestWorkSequence: 3,
+ averageTimeOffPeriod: 1.5,
+ longestTimeOffPeriod: 2,
+ nonWorkingDayGain: 0,
+ },
+ },
+ {
+ scenario: 'does not count or split work on intervening holidays and weekends',
+ horizon: { start: '2024-05-06', end: '2024-05-15' },
+ declaredDates: [],
+ metrics: {
+ averageWorkSequence: 5,
+ longestWorkSequence: 5,
+ averageTimeOffPeriod: null,
+ longestTimeOffPeriod: null,
+ nonWorkingDayGain: 0,
+ },
+ },
+ {
+ scenario:
+ 'retains an empty work average and a measured zero when every workday is declared',
+ horizon: { start: '2024-01-08', end: '2024-01-13' },
+ declaredDates: ['2024-01-08', '2024-01-09', '2024-01-10', '2024-01-11', '2024-01-12'],
+ metrics: {
+ averageWorkSequence: null,
+ longestWorkSequence: 0,
+ averageTimeOffPeriod: 9,
+ longestTimeOffPeriod: 9,
+ nonWorkingDayGain: 4,
+ },
+ },
+ {
+ scenario: 'distinguishes no samples from zero gain in a weekend-only horizon',
+ horizon: { start: '2024-01-06', end: '2024-01-08' },
+ declaredDates: [],
+ metrics: {
+ averageWorkSequence: null,
+ longestWorkSequence: 0,
+ averageTimeOffPeriod: null,
+ longestTimeOffPeriod: null,
+ nonWorkingDayGain: 0,
+ },
+ },
+ {
+ scenario:
+ 'counts Christmas on a Saturday once alongside its Sunday in a declared break',
+ horizon: { start: '2021-12-20', end: '2021-12-28' },
+ declaredDates: ['2021-12-24'],
+ metrics: {
+ averageWorkSequence: 2.5,
+ longestWorkSequence: 4,
+ averageTimeOffPeriod: 3,
+ longestTimeOffPeriod: 3,
+ nonWorkingDayGain: 2,
+ },
+ },
+ ])('$scenario', async ({ horizon, declaredDates, metrics }) => {
+ const result = isPlan(
+ await runInNonBrowser({
+ horizon,
+ balances: [],
+ rules: [],
+ declaredDays: declaredDates.map((date) => ({ date, type: 'other' })),
+ }),
+ );
+ expect(result.placements).toEqual([]);
+ expect(result.metrics).toMatchObject(metrics);
+ });
+});
+
describe('declared holiday patterns and captured entitlement date', () => {
const producer = {
id: 'producer',
@@ -1214,7 +1337,13 @@ describe('planLeave', () => {
nonWorkingDayGain: 2,
},
]);
- expect(result.metrics).toMatchObject({ nonWorkingDayGain: 2, longestWorkSequence: 3 });
+ expect(result.metrics).toMatchObject({
+ nonWorkingDayGain: 2,
+ averageWorkSequence: 2,
+ longestWorkSequence: 3,
+ averageTimeOffPeriod: 3,
+ longestTimeOffPeriod: 3,
+ });
});
it('merges declarations, placements, weekends and national holidays without counting dates twice', async () => {
@@ -1254,7 +1383,14 @@ describe('planLeave', () => {
nonWorkingDayGain: 6,
},
]);
- expect(result.metrics).toMatchObject({ nonWorkingDayGain: 6, holidayWeekValue: 5 });
+ expect(result.metrics).toMatchObject({
+ nonWorkingDayGain: 6,
+ holidayWeekValue: 5,
+ averageWorkSequence: null,
+ longestWorkSequence: 0,
+ averageTimeOffPeriod: 10,
+ longestTimeOffPeriod: 10,
+ });
});
it('uses declared days to split work sequences while weekends and holidays alone never split them', async () => {
@@ -1319,6 +1455,13 @@ describe('planLeave', () => {
declaredDates: ['2024-01-02', '2024-01-03', '2024-01-05'],
nonWorkingDayGain: 5,
});
+ expect(result.metrics).toMatchObject({
+ nonWorkingDayGain: 5,
+ averageWorkSequence: null,
+ longestWorkSequence: 0,
+ averageTimeOffPeriod: 9,
+ longestTimeOffPeriod: 9,
+ });
const unanchored = isPlan(
await runInNonBrowser({
horizon: selectedHorizon,
@@ -1328,7 +1471,13 @@ describe('planLeave', () => {
}),
);
expect(unanchored.timeOffPeriods).toEqual([]);
- expect(unanchored.metrics.nonWorkingDayGain).toBe(0);
+ expect(unanchored.metrics).toMatchObject({
+ nonWorkingDayGain: 0,
+ averageWorkSequence: 2,
+ longestWorkSequence: 2,
+ averageTimeOffPeriod: null,
+ longestTimeOffPeriod: null,
+ });
});
it.each([
diff --git a/src/app/services/leave-planner.worker.spec.ts b/src/app/services/leave-planner.worker.spec.ts
index d6a0531..6313e70 100644
--- a/src/app/services/leave-planner.worker.spec.ts
+++ b/src/app/services/leave-planner.worker.spec.ts
@@ -197,6 +197,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
: undefined;
if (!plan) throw new Error('Expected a valid earned-chain plan.');
expect(plan.calculationDate).toBe('2024-05-01');
+ expect(plan.metrics).toMatchObject({
+ nonWorkingDayGain: 5,
+ averageWorkSequence: 1,
+ longestWorkSequence: 1,
+ averageTimeOffPeriod: 4.5,
+ longestTimeOffPeriod: 5,
+ });
expect(plan.placements).toMatchObject([
{
date: '2024-05-03',
@@ -250,6 +257,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
throw new Error(`Expected the same direct plan: ${JSON.stringify(direct)}`);
expect(plan.placements).toEqual(direct.placements);
expect(plan.ifThenContributions).toEqual(direct.ifThenContributions);
+ expect(plan.metrics).toEqual(direct.metrics);
const root: HTMLElement = fixture.nativeElement;
expect(root.textContent).toContain('Droit futur généré');
expect(root.textContent).toContain('CP autour du férié');
diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts
index 56ad18c..c76a33b 100644
--- a/src/app/services/translation-service.ts
+++ b/src/app/services/translation-service.ts
@@ -131,6 +131,11 @@ export interface AppTranslations {
open: string;
title: string;
leaveDays: string;
+ averageWorkSequence: string;
+ averageTimeOffPeriod: string;
+ longestTimeOffPeriod: string;
+ workingDays: string;
+ calendarDays: string;
partialPlan: string;
help: string;
};
diff --git a/src/i18n/de.json b/src/i18n/de.json
index ae1d373..2045d23 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -140,8 +140,13 @@
"open": "Statistik",
"title": "Planstatistik",
"leaveDays": "Neu geplante Urlaubstage",
+ "averageWorkSequence": "Durchschnittliche Arbeitsphase",
+ "averageTimeOffPeriod": "Durchschnittliche Ruhephase",
+ "longestTimeOffPeriod": "Längste Ruhephase",
+ "workingDays": "Arbeitstage",
+ "calendarDays": "Kalendertage",
"partialPlan": "Teilplan",
- "help": "Urlaubsverbrauch des berechneten Plans. Bereits gebuchte Tage sind ausgeschlossen."
+ "help": "Statistik des berechneten Plans. Bereits gebuchte Tage prägen Ruhe- und Arbeitsphasen, ohne das verbleibende Guthaben erneut zu verbrauchen."
},
"calculationDate": "Berechnungsdatum",
"futureEntitlement": "Erzeugter künftiger Anspruch",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 4d91329..5ed0729 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -140,8 +140,13 @@
"open": "Statistics",
"title": "Plan statistics",
"leaveDays": "New leave days",
+ "averageWorkSequence": "Average work sequence",
+ "averageTimeOffPeriod": "Average time-off period",
+ "longestTimeOffPeriod": "Longest time-off period",
+ "workingDays": "working days",
+ "calendarDays": "calendar days",
"partialPlan": "Partial plan",
- "help": "Leave usage for the calculated plan. Already booked days are excluded."
+ "help": "Statistics for the calculated plan. Already booked days shape breaks and working stretches without consuming remaining balances."
},
"calculationDate": "Calculation date",
"futureEntitlement": "Future entitlement generated",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 9f6dcad..ac60500 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -140,8 +140,13 @@
"open": "Estadísticas",
"title": "Estadísticas del plan",
"leaveDays": "Nuevos días de permiso",
+ "averageWorkSequence": "Secuencia de trabajo media",
+ "averageTimeOffPeriod": "Período de descanso medio",
+ "longestTimeOffPeriod": "Período de descanso más largo",
+ "workingDays": "días laborables",
+ "calendarDays": "días naturales",
"partialPlan": "Plan parcial",
- "help": "Permisos utilizados por el plan calculado. Se excluyen los días ya reservados."
+ "help": "Estadísticas del plan calculado. Los días ya reservados definen los períodos de descanso y trabajo sin consumir de nuevo los saldos restantes."
},
"calculationDate": "Fecha del cálculo",
"futureEntitlement": "Derecho futuro generado",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 5957aec..df1ff0e 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -140,8 +140,13 @@
"open": "Statistiques",
"title": "Statistiques du planning",
"leaveDays": "Nouveaux jours de congé posés",
+ "averageWorkSequence": "Séquence de travail moyenne",
+ "averageTimeOffPeriod": "Période de repos moyenne",
+ "longestTimeOffPeriod": "Plus longue période de repos",
+ "workingDays": "jours ouvrés",
+ "calendarDays": "jours calendaires",
"partialPlan": "Planning partiel",
- "help": "Congés utilisés par le planning calculé. Les jours déjà posés sont exclus."
+ "help": "Statistiques du planning calculé. Les jours déjà posés contribuent aux périodes de repos et de travail sans consommer à nouveau les soldes."
},
"calculationDate": "Date du calcul",
"futureEntitlement": "Droit futur généré",