From da08ee054dcebf2d4e5a716e664fe3b868e90c39 Mon Sep 17 00:00:00 2001
From: TO
Date: Wed, 7 Oct 2026 22:58:15 +0200
Subject: [PATCH] feat(stats): show gross earned leave by rule (#67)
---
.../calendar-year-planning.spec.ts | 456 +++++++++++++++++-
.../calendar-year/calendar-year.html | 28 ++
.../calendar-year/calendar-year.scss | 19 +
.../components/calendar-year/calendar-year.ts | 19 +
src/app/services/leave-planner.worker.spec.ts | 35 +-
src/app/services/translation-service.ts | 3 +
src/i18n/de.json | 5 +-
src/i18n/en.json | 5 +-
src/i18n/es.json | 5 +-
src/i18n/fr.json | 5 +-
10 files changed, 571 insertions(+), 9 deletions(-)
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 5c66b48..6e982a5 100644
--- a/src/app/components/calendar-year/calendar-year-planning.spec.ts
+++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts
@@ -25,6 +25,7 @@ import {
PlanningOutcome,
PlanningObjectiveVector,
PlanningTrace,
+ PlanningTraceUpdate,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
@@ -408,7 +409,10 @@ function jointTrace(): PlanningTrace {
describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear());
- afterEach(() => localStorage.clear());
+ afterEach(() => {
+ localStorage.clear();
+ vi.useRealTimers();
+ });
it.each([
{ partial: false, placements: 2 },
@@ -418,11 +422,31 @@ describe('CalendarYear planning outcome boundary', () => {
])(
'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)',
async ({ partial, placements }) => {
+ vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
const pendingTrace = deferred();
const validPlan: PlanOutcome = {
...plan,
+ calculationDate: '2024-05-01',
placements: placements ? plan.placements : [],
declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
+ ifThenContributions: [
+ {
+ ruleId: 'original-rule',
+ label: 'Original generating rule',
+ occurrence: '2024-05-08',
+ requested: 1,
+ declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
+ placementDates: [],
+ earnedEntitlement: {
+ id: 'generated-entitlement',
+ type: 'cp',
+ quantity: 3,
+ availableFrom: '2024-05-09',
+ expiryDate: '2025-05-09',
+ added: true,
+ },
+ },
+ ],
metrics: {
...plan.metrics,
averageWorkSequence: 10 / 3,
@@ -485,6 +509,8 @@ describe('CalendarYear planning outcome boundary', () => {
['Longest work sequence (working days)', '6'],
['Average time-off period (calendar days)', '2.5'],
['Longest time-off period (calendar days)', '4'],
+ ['Earned leave before use (days)', '3'],
+ ['Original generating rule', '3'],
];
expect(statistics()).toEqual(expectedStatistics);
expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
@@ -504,6 +530,7 @@ describe('CalendarYear planning outcome boundary', () => {
rules.deleteRule(rules.rules[0].id);
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-05-14', '2021-05-14');
input.declarationEntrySignal.set(true);
+ vi.setSystemTime(new Date('2024-05-10T12:00:00Z'));
fixture.detectChanges();
await fixture.whenStable();
expect(statistics()).toEqual(expectedStatistics);
@@ -512,6 +539,423 @@ describe('CalendarYear planning outcome boundary', () => {
},
);
+ it.each([2, 3])('shows 3 gross earned days after consuming %s', async (used) => {
+ vi.setSystemTime(new Date('2024-05-01T21:59:59Z'));
+ try {
+ const core = await import('../../services/leave-planner.core');
+ const outcome = core.calculatePlanningInternal({
+ horizon: { start: '2024-04-29', end: used === 2 ? '2024-05-07' : '2024-05-09' },
+ balances: [],
+ declaredDays: ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' })),
+ rules: [
+ {
+ id: 'samedi',
+ name: 'Samedi Malin',
+ type: 'if_then',
+ force: 'preference',
+ dayOfWeek: 3,
+ placement: 'before',
+ placeDays: 2,
+ placeType: 'cp',
+ rewardDays: 3,
+ rewardType: 'other',
+ },
+ {
+ id: 'use',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-02',
+ endDate: '2024-05-08',
+ placeDays: used,
+ placeType: 'other',
+ },
+ ],
+ });
+ if (outcome.kind !== 'Plan') throw new Error('Expected an earned-leave plan.');
+ expect(outcome.placements).toHaveLength(used);
+ expect(outcome.remainingBalances).toMatchObject(used === 2 ? [{ quantity: 1 }] : []);
+ const { calendar } = calendarFor(outcome);
+ await TestBed.configureTestingModule({
+ imports: [CalendarYear],
+ providers: [
+ provideZonelessChangeDetection(),
+ { provide: CalendarService, useValue: calendar },
+ ],
+ }).compileComponents();
+ TestBed.inject(TranslationService).setLanguage('en');
+ const fixture = TestBed.createComponent(CalendarYear);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ vi.setSystemTime(new Date('2024-05-02T12:00:00Z'));
+ fixture.nativeElement.querySelector('.statistics-button').click();
+ await fixture.whenStable();
+ const dialog = document.querySelector('[role="dialog"]')!;
+ expect(
+ dialog
+ .querySelector('.statistics-summary > div:last-child dd')
+ ?.textContent?.trim(),
+ ).toBe('3');
+ expect(
+ Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
+ label.textContent?.trim(),
+ label.nextElementSibling?.textContent?.trim(),
+ ]),
+ ).toEqual([['Samedi Malin', '3']]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ describe('gross earned leave from finalized occurrences', () => {
+ const producer = {
+ id: 'samedi',
+ name: 'Samedi Malin',
+ type: 'if_then',
+ force: 'preference',
+ dayOfWeek: 3,
+ placement: 'before',
+ placeDays: 2,
+ placeType: 'cp',
+ rewardDays: 2,
+ rewardType: 'other',
+ };
+ const declaredDays = ['2024-04-29', '2024-04-30'].map((date) => ({ date, type: 'cp' }));
+ const submitted = {
+ horizon: { start: '2024-04-29', end: '2024-05-07' },
+ balances: [],
+ rules: [producer],
+ declaredDays,
+ };
+ it.each([
+ {
+ scenario: 'availability after the calculation date',
+ today: '2024-05-01',
+ request: submitted,
+ total: 2,
+ availableFrom: '2024-05-02',
+ rows: [['Samedi Malin', '2']],
+ },
+ {
+ scenario: 'availability on the calculation date',
+ today: '2024-05-02',
+ request: submitted,
+ total: 0,
+ availableFrom: '2024-05-02',
+ rows: [],
+ },
+ {
+ scenario: 'availability before the calculation date',
+ today: '2024-05-03',
+ request: submitted,
+ total: 0,
+ availableFrom: '2024-05-02',
+ rows: [],
+ },
+ {
+ scenario: 'future earnings after the horizon without consumption',
+ today: '2024-05-01',
+ request: { ...submitted, horizon: { start: '2024-04-29', end: '2024-05-02' } },
+ total: 2,
+ availableFrom: '2024-05-02',
+ rows: [['Samedi Malin', '2']],
+ },
+ {
+ scenario: 'a later extension makes a provisional historical right future',
+ today: '2024-05-03',
+ request: {
+ ...submitted,
+ horizon: { start: '2024-04-29', end: '2024-05-04' },
+ declaredDays: [...declaredDays, { date: '2024-05-03', type: 'other' }],
+ balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }],
+ rules: [
+ producer,
+ {
+ id: 'extend',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-02',
+ endDate: '2024-05-02',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ ],
+ },
+ total: 2,
+ availableFrom: '2024-05-06',
+ rows: [['Samedi Malin', '2']],
+ },
+ {
+ scenario: 'repeated shared occurrences attributed to distinct same-name rules',
+ today: '2024-05-01',
+ request: {
+ ...submitted,
+ horizon: { start: '2024-04-29', end: '2024-05-09' },
+ declaredDays: ['2024-04-30', '2024-05-07'].map((date) => ({
+ date,
+ type: 'cp',
+ })),
+ rules: [
+ {
+ ...producer,
+ id: 'first',
+ name: 'Same name',
+ placeDays: 1,
+ rewardDays: 1,
+ },
+ {
+ ...producer,
+ id: 'second',
+ name: 'Same name',
+ placeDays: 1,
+ rewardDays: 2,
+ },
+ { ...producer, placeDays: 1, rewardDays: 3 },
+ { ...producer, id: 'disabled', enabled: false, rewardDays: 99 },
+ { ...producer, id: 'zero', rewardDays: 0, placeDays: 1 },
+ ],
+ },
+ total: 12,
+ availableFrom: '2024-05-02',
+ rows: [
+ ['Same name', '2'],
+ ['Same name', '4'],
+ ['Samedi Malin', '6'],
+ ],
+ },
+ ])('renders $scenario', async ({ today, request, total, availableFrom, rows }) => {
+ vi.setSystemTime(new Date(`${today}T12:00:00Z`));
+ try {
+ const core = await import('../../services/leave-planner.core');
+ const outcome = core.calculatePlanningInternal(request);
+ if (outcome.kind !== 'Plan') throw new Error('Expected a valid generating plan.');
+ expect(outcome.ifThenContributions?.[0].earnedEntitlement?.availableFrom).toBe(
+ availableFrom,
+ );
+ const { calendar } = calendarFor(outcome);
+ await TestBed.configureTestingModule({
+ imports: [CalendarYear],
+ providers: [
+ provideZonelessChangeDetection(),
+ { provide: CalendarService, useValue: calendar },
+ ],
+ }).compileComponents();
+ TestBed.inject(TranslationService).setLanguage('en');
+ const fixture = TestBed.createComponent(CalendarYear);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ vi.setSystemTime(new Date('2025-01-01T12:00:00Z'));
+ fixture.nativeElement.querySelector('.statistics-button').click();
+ await fixture.whenStable();
+ const dialog = document.querySelector('[role="dialog"]')!;
+ expect(
+ dialog
+ .querySelector('.statistics-summary > div:last-child dd')
+ ?.textContent?.trim(),
+ ).toBe(String(total));
+ expect(
+ Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
+ label.textContent?.trim(),
+ label.nextElementSibling?.textContent?.trim(),
+ ]),
+ ).toEqual(rows);
+ expect(!!dialog.querySelector('#earned-leave-title')).toBe(total > 0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it.each(['declarations', 'placements', 'both'])(
+ 'renders an occurrence funded by %s',
+ async (source) => {
+ vi.setSystemTime(new Date('2024-05-01T12:00:00Z'));
+ try {
+ const core = await import('../../services/leave-planner.core');
+ const fixed = source === 'declarations' ? 2 : source === 'both' ? 1 : 0;
+ const outcome = core.calculatePlanningInternal({
+ ...submitted,
+ declaredDays: declaredDays.slice(0, fixed),
+ balances:
+ fixed === 2
+ ? []
+ : [
+ {
+ id: 'cp',
+ type: 'cp',
+ quantity: 2 - fixed,
+ expiryDate: '2025-12-31',
+ },
+ ],
+ });
+ if (outcome.kind !== 'Plan')
+ throw new Error('Expected a valid generating plan.');
+ expect(outcome.ifThenContributions?.[0].declaredDays).toHaveLength(fixed);
+ expect(outcome.ifThenContributions?.[0].placementDates).toHaveLength(2 - fixed);
+ const { calendar } = calendarFor(outcome);
+ await TestBed.configureTestingModule({
+ imports: [CalendarYear],
+ providers: [
+ provideZonelessChangeDetection(),
+ { provide: CalendarService, useValue: calendar },
+ ],
+ }).compileComponents();
+ TestBed.inject(TranslationService).setLanguage('en');
+ const fixture = TestBed.createComponent(CalendarYear);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ fixture.nativeElement.querySelector('.statistics-button').click();
+ await fixture.whenStable();
+ expect(
+ document
+ .querySelector(
+ '[role="dialog"] .statistics-summary > div:last-child dd',
+ )
+ ?.textContent?.trim(),
+ ).toBe('2');
+ expect(
+ Array.from(
+ document.querySelectorAll('[role="dialog"] .earned-leave-breakdown dt'),
+ (label) => [
+ label.textContent?.trim(),
+ label.nextElementSibling?.textContent?.trim(),
+ ],
+ ),
+ ).toEqual([['Samedi Malin', '2']]);
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+ );
+ });
+
+ it('shows the retained earnings when cancellation interrupts a different generating candidate', async () => {
+ vi.setSystemTime(new Date('2024-04-28T12:00:00Z'));
+ // Flush the original trace deterministically so cancellation occurs at the observed branch.
+ let elapsed = 0;
+ const clock = vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 30));
+ let checkpoint: PlanOutcome | undefined;
+ let interrupted = false;
+ let latestGenerator: string | undefined;
+ let artifacts: import('../../services/leave-planner.core').PlanningArtifacts = {};
+ try {
+ const core = await import('../../services/leave-planner.core');
+ const outcome = await core.calculatePlanningAsyncInternal(
+ {
+ horizon: { start: '2024-04-29', end: '2024-05-11' },
+ declaredDays: [
+ { date: '2024-04-29', type: 'cp' },
+ { date: '2024-05-03', type: 'cp' },
+ { date: '2024-05-06', type: 'other' },
+ ],
+ balances: [{ id: 'cp', type: 'cp', quantity: 1, expiryDate: '2025-12-31' }],
+ rules: [
+ {
+ id: 'consumer',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-10',
+ endDate: '2024-05-10',
+ placeDays: 1,
+ placeType: 'other',
+ },
+ ...['before', 'after'].map((placement, index) => ({
+ id: placement,
+ name: placement,
+ type: 'if_then',
+ force: 'preference',
+ dayOfWeek: 3,
+ placement,
+ placeDays: 2,
+ placeType: 'cp',
+ rewardDays: index + 1,
+ rewardType: 'other',
+ })),
+ ],
+ traceProgress: (_: number, update: PlanningTraceUpdate) => {
+ if (interrupted) return;
+ for (const operation of update.operations) {
+ const deltas =
+ operation.kind === 'delta'
+ ? [operation.delta]
+ : operation.kind === 'decision'
+ ? (operation.decision.branch?.deltas ?? [])
+ : [];
+ for (const delta of deltas) {
+ if (delta.kind !== 'earned-activated') continue;
+ latestGenerator = delta.activation?.ruleId;
+ if (checkpoint && latestGenerator === 'before') interrupted = true;
+ }
+ }
+ },
+ },
+ (value) => (checkpoint = value),
+ (value) => (artifacts = value),
+ undefined,
+ undefined,
+ () => interrupted,
+ );
+ clock.mockRestore();
+ if (outcome.kind !== 'PlanningIncomplete' || !outcome.incumbent)
+ throw new Error('Expected a real cancellation with a retained valid plan.');
+ expect(interrupted).toBe(true);
+ expect(latestGenerator).toBe('before');
+ expect(outcome.incumbent.ifThenContributions).toMatchObject([
+ { ruleId: 'after', earnedEntitlement: { quantity: 2, added: true } },
+ ]);
+ expect(outcome.incumbent.ifThenContributions).toHaveLength(1);
+ expect(outcome.incumbent.placements.map(({ date }) => date)).toEqual([
+ '2024-05-02',
+ '2024-05-10',
+ ]);
+ const latestBranch = artifacts.trace?.phases
+ .flatMap(({ decisions }) => decisions)
+ .filter(({ branch }) => branch)
+ .at(-1);
+ expect(latestBranch?.candidates).toMatchObject([
+ { status: 'evaluated-rejected', dates: ['2024-04-29', '2024-04-30'] },
+ ]);
+ expect(latestBranch?.branch?.deltas).toContainEqual(
+ expect.objectContaining({
+ kind: 'earned-activated',
+ activation: expect.objectContaining({ ruleId: 'before' }),
+ }),
+ );
+ const { calendar } = calendarFor(outcome, artifacts.explanation, artifacts.trace);
+ await TestBed.configureTestingModule({
+ imports: [CalendarYear],
+ providers: [
+ provideZonelessChangeDetection(),
+ { provide: CalendarService, useValue: calendar },
+ ],
+ }).compileComponents();
+ TestBed.inject(TranslationService).setLanguage('en');
+ const fixture = TestBed.createComponent(CalendarYear);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ fixture.nativeElement.querySelector('.statistics-button').click();
+ await fixture.whenStable();
+ const dialog = document.querySelector('[role="dialog"]')!;
+ expect(dialog.textContent).toContain('Partial plan');
+ expect(
+ dialog
+ .querySelector('.statistics-summary > div:last-child dd')
+ ?.textContent?.trim(),
+ ).toBe('2');
+ expect(
+ Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
+ label.textContent?.trim(),
+ label.nextElementSibling?.textContent?.trim(),
+ ]),
+ ).toEqual([['after', '2']]);
+ } finally {
+ clock.mockRestore();
+ vi.useRealTimers();
+ }
+ });
+
it.each([false, true])(
'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)',
async (allDaysOff) => {
@@ -547,7 +991,11 @@ describe('CalendarYear planning outcome boundary', () => {
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
- ).toEqual(allDaysOff ? ['0', '4', '—', '0', '9', '9'] : ['0', '0', '5', '5', '—', '—']);
+ ).toEqual(
+ allDaysOff
+ ? ['0', '4', '—', '0', '9', '9', '0']
+ : ['0', '0', '5', '5', '—', '—', '0'],
+ );
},
);
@@ -595,7 +1043,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(dialog.textContent).toContain(calendarUnit);
expect(
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
- ).toEqual(['2', '4', '3,333', '6', '2,5', '4']);
+ ).toEqual(['2', '4', '3,333', '6', '2,5', '4', '0']);
}
expect(dialog.textContent).toContain('Statistiques du planning');
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
@@ -2891,7 +3339,7 @@ describe('CalendarYear planning outcome boundary', () => {
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
- ).toEqual(['2', '4', '6', '6', '—', '—']);
+ ).toEqual(['2', '4', '6', '6', '—', '—', '0']);
document.querySelector('[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 2b8ceb0..31356fe 100644
--- a/src/app/components/calendar-year/calendar-year.html
+++ b/src/app/components/calendar-year/calendar-year.html
@@ -1295,7 +1295,35 @@
{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}
+
+
+ {{ tr().planning.statistics.earnedLeave }} ({{
+ tr().planning.statistics.leaveDaysUnit
+ }})
+
+ {{ formatStatistic(earnedLeave().total) }}
+
+ @if (earnedLeave().contributions.length) {
+
+
+ {{ tr().planning.statistics.earnedLeaveByRule }} ({{
+ tr().planning.statistics.leaveDaysUnit
+ }})
+
+
+ @for (
+ contribution of earnedLeave().contributions;
+ track contribution.ruleId
+ ) {
+
+
- {{ contribution.label }}
+ - {{ formatStatistic(contribution.quantity) }}
+
+ }
+
+
+ }
}
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss
index f866c15..292825e 100644
--- a/src/app/components/calendar-year/calendar-year.scss
+++ b/src/app/components/calendar-year/calendar-year.scss
@@ -228,6 +228,25 @@ summary:focus-visible {
.statistics-summary {
grid-template-columns: minmax(0, 1fr);
}
+.earned-leave-breakdown {
+ display: grid;
+ gap: 0.75rem;
+ margin: 0;
+ div {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ }
+ dt {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ }
+ dd {
+ margin: 0;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ }
+}
.placement-explanation,
.outcome-explanation,
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 439a469..c3b4dcc 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -202,6 +202,25 @@ export class CalendarYear {
}
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
+ protected readonly earnedLeave = computed(() => {
+ const byRule = new Map();
+ for (const contribution of this.displayedPlan()?.ifThenContributions ?? []) {
+ const entitlement = contribution.earnedEntitlement;
+ if (!entitlement?.added || entitlement.quantity <= 0) continue;
+ const entry = byRule.get(contribution.ruleId) ?? {
+ ruleId: contribution.ruleId,
+ label: contribution.label,
+ quantity: 0,
+ };
+ entry.quantity += entitlement.quantity;
+ byRule.set(entry.ruleId, entry);
+ }
+ const contributions = [...byRule.values()];
+ return {
+ total: contributions.reduce((total, contribution) => total + contribution.quantity, 0),
+ contributions,
+ };
+ });
protected readonly planExplanation = computed(() => {
const explanation = this.planningExplanation();
diff --git a/src/app/services/leave-planner.worker.spec.ts b/src/app/services/leave-planner.worker.spec.ts
index 6313e70..7656952 100644
--- a/src/app/services/leave-planner.worker.spec.ts
+++ b/src/app/services/leave-planner.worker.spec.ts
@@ -214,9 +214,36 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
]);
if (outcome.kind === 'PlanningIncomplete')
expect(outcome.reasons[0].code).toBe('user-cancelled');
+ expect(plan.remainingBalances).toEqual([]);
+ await fixture.whenStable();
+ fixture.detectChanges();
+ const root: HTMLElement = fixture.nativeElement;
+ root.querySelector('.statistics-button')!.click();
+ await fixture.whenStable();
+ const dialog = document.querySelector('[role="dialog"]')!;
+ const earnedStatistics = () => [
+ dialog
+ .querySelector('.statistics-summary > div:last-child dt')
+ ?.textContent?.replace(/\s+/g, ' ')
+ .trim(),
+ dialog
+ .querySelector('.statistics-summary > div:last-child dd')
+ ?.textContent?.trim(),
+ ...Array.from(dialog.querySelectorAll('.earned-leave-breakdown dt'), (label) => [
+ label.textContent?.trim(),
+ label.nextElementSibling?.textContent?.trim(),
+ ]),
+ ];
+ expect(earnedStatistics()).toEqual([
+ 'Congés acquis avant consommation (jours)',
+ '1',
+ ['CP autour du férié', '1'],
+ ]);
+ expect(dialog.textContent?.includes('Planning partiel')).toBe(cancelled);
const originalDeclarations = structuredClone(calculation.snapshot.declaredDays);
input.toggleDeclaredDay('2024-04-30', 'rtt');
rules.updateRule({ ...rules.rules[0], name: 'Règle modifiée après calcul' });
+ rules.deleteRule('producer');
input.updateBalances(() => []);
if (worker) {
await vi.waitFor(() => expect(heldArtifactMessages.length).toBeGreaterThan(0));
@@ -231,6 +258,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
const trace = await calculation.trace;
await fixture.whenStable();
fixture.detectChanges();
+ expect(earnedStatistics()).toEqual([
+ 'Congés acquis avant consommation (jours)',
+ '1',
+ ['CP autour du férié', '1'],
+ ]);
+ dialog.querySelector('button')!.click();
+ await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(explanation).toMatchObject({
kind: 'PlanExplanation',
calculationDate: '2024-05-01',
@@ -258,7 +292,6 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
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é');
expect(root.textContent).not.toContain('Règle modifiée après calcul');
diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts
index c76a33b..ca6fbe1 100644
--- a/src/app/services/translation-service.ts
+++ b/src/app/services/translation-service.ts
@@ -134,6 +134,9 @@ export interface AppTranslations {
averageWorkSequence: string;
averageTimeOffPeriod: string;
longestTimeOffPeriod: string;
+ earnedLeave: string;
+ earnedLeaveByRule: string;
+ leaveDaysUnit: string;
workingDays: string;
calendarDays: string;
partialPlan: string;
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 2045d23..ec28422 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -146,7 +146,10 @@
"workingDays": "Arbeitstage",
"calendarDays": "Kalendertage",
"partialPlan": "Teilplan",
- "help": "Statistik des berechneten Plans. Bereits gebuchte Tage prägen Ruhe- und Arbeitsphasen, ohne das verbleibende Guthaben erneut zu verbrauchen."
+ "help": "Statistik des berechneten Plans. Bereits gebuchte Tage prägen Ruhe- und Arbeitsphasen, ohne das verbleibende Guthaben erneut zu verbrauchen.",
+ "earnedLeave": "Erworbener Urlaub vor Verbrauch",
+ "leaveDaysUnit": "Tage",
+ "earnedLeaveByRule": "Erworbener Urlaub nach Regel"
},
"calculationDate": "Berechnungsdatum",
"futureEntitlement": "Erzeugter künftiger Anspruch",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 5ed0729..124872f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -146,7 +146,10 @@
"workingDays": "working days",
"calendarDays": "calendar days",
"partialPlan": "Partial plan",
- "help": "Statistics for the calculated plan. Already booked days shape breaks and working stretches without consuming remaining balances."
+ "help": "Statistics for the calculated plan. Already booked days shape breaks and working stretches without consuming remaining balances.",
+ "earnedLeave": "Earned leave before use",
+ "leaveDaysUnit": "days",
+ "earnedLeaveByRule": "Earned leave by rule"
},
"calculationDate": "Calculation date",
"futureEntitlement": "Future entitlement generated",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index ac60500..e54274a 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -146,7 +146,10 @@
"workingDays": "días laborables",
"calendarDays": "días naturales",
"partialPlan": "Plan parcial",
- "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."
+ "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.",
+ "earnedLeave": "Permisos adquiridos antes de su uso",
+ "leaveDaysUnit": "días",
+ "earnedLeaveByRule": "Permisos adquiridos por regla"
},
"calculationDate": "Fecha del cálculo",
"futureEntitlement": "Derecho futuro generado",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index df1ff0e..a8c7700 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -146,7 +146,10 @@
"workingDays": "jours ouvrés",
"calendarDays": "jours calendaires",
"partialPlan": "Planning partiel",
- "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."
+ "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.",
+ "earnedLeave": "Congés acquis avant consommation",
+ "leaveDaysUnit": "jours",
+ "earnedLeaveByRule": "Congés acquis par règle"
},
"calculationDate": "Date du calcul",
"futureEntitlement": "Droit futur généré",