feat(stats): show work and time-off lengths (#66)

This commit is contained in:
nessar committed 2026-10-07 22:39:05 +02:00
1 parent d9f3f8d34a
commit 3fbf9c3172
12 files changed
+373 -20

No files matched your search

@@ -77,6 +77,9 @@ const plan: PlanOutcome = {
holidayWeekValue: 0, holidayWeekValue: 0,
nonWorkingDayGain: 4, nonWorkingDayGain: 4,
longestWorkSequence: 6, longestWorkSequence: 6,
averageWorkSequence: 6,
averageTimeOffPeriod: null,
longestTimeOffPeriod: null,
shortSequenceDeficit: 0, shortSequenceDeficit: 0,
supportingPlacements: 0, supportingPlacements: 0,
preferenceSatisfaction: [], preferenceSatisfaction: [],
@@ -413,13 +416,19 @@ describe('CalendarYear planning outcome boundary', () => {
{ partial: false, placements: 0 }, { partial: false, placements: 0 },
{ partial: true, placements: 0 }, { partial: true, placements: 0 },
])( ])(
'opens leave-usage statistics independently of the trace (partial: $partial, placements: $placements)', 'opens calculated statistics independently of the trace (partial: $partial, placements: $placements)',
async ({ partial, placements }) => { async ({ partial, placements }) => {
const pendingTrace = deferred<PlanningTrace | undefined>(); const pendingTrace = deferred<PlanningTrace | undefined>();
const validPlan: PlanOutcome = { const validPlan: PlanOutcome = {
...plan, ...plan,
placements: placements ? plan.placements : [], placements: placements ? plan.placements : [],
declaredDays: [{ date: '2024-05-07', type: 'rtt' }], declaredDays: [{ date: '2024-05-07', type: 'rtt' }],
metrics: {
...plan.metrics,
averageWorkSequence: 10 / 3,
averageTimeOffPeriod: 2.5,
longestTimeOffPeriod: 4,
},
}; };
const outcome: PlanningOutcome = partial const outcome: PlanningOutcome = partial
? { ? {
@@ -464,8 +473,20 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges(); fixture.detectChanges();
const dialog = document.querySelector('[role="dialog"]')!; const dialog = document.querySelector('[role="dialog"]')!;
expect(dialog.textContent).toContain('Plan statistics'); expect(dialog.textContent).toContain('Plan statistics');
expect(dialog.querySelector('dt')?.textContent).toContain('New leave days'); const statistics = () =>
expect(dialog.querySelector('dd')?.textContent?.trim()).toBe(String(placements)); Array.from(dialog.querySelectorAll('dt'), (label) => [
label.textContent?.replace(/\s+/g, ' ').trim(),
label.nextElementSibling?.textContent?.trim(),
]);
const expectedStatistics = [
['New leave days', String(placements)],
['Non-working days gained (calendar days)', '4'],
['Average work sequence (working days)', '3.333'],
['Longest work sequence (working days)', '6'],
['Average time-off period (calendar days)', '2.5'],
['Longest time-off period (calendar days)', '4'],
];
expect(statistics()).toEqual(expectedStatistics);
expect(dialog.textContent?.includes('Partial plan')).toBe(partial); expect(dialog.textContent?.includes('Partial plan')).toBe(partial);
expect(input.planningOutcomeSignal()).toBe(outcome); expect(input.planningOutcomeSignal()).toBe(outcome);
expect(input.vacationNumberSignal()).toEqual(before.balances); expect(input.vacationNumberSignal()).toEqual(before.balances);
@@ -477,9 +498,7 @@ describe('CalendarYear planning outcome boundary', () => {
pendingTrace.reject(new Error('Trace delivery failed')); pendingTrace.reject(new Error('Trace delivery failed'));
await fixture.whenStable(); await fixture.whenStable();
fixture.detectChanges(); fixture.detectChanges();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe( expect(statistics()).toEqual(expectedStatistics);
String(placements),
);
input.updateBalances(() => []); input.updateBalances(() => []);
input.toggleDeclaredDay('2024-05-10', 'other'); input.toggleDeclaredDay('2024-05-10', 'other');
rules.deleteRule(rules.rules[0].id); rules.deleteRule(rules.rules[0].id);
@@ -487,16 +506,61 @@ describe('CalendarYear planning outcome boundary', () => {
input.declarationEntrySignal.set(true); input.declarationEntrySignal.set(true);
fixture.detectChanges(); fixture.detectChanges();
await fixture.whenStable(); await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe( expect(statistics()).toEqual(expectedStatistics);
String(placements),
);
expect(input.planningOutcomeSignal()).toBe(outcome); expect(input.planningOutcomeSignal()).toBe(outcome);
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce(); expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
}, },
); );
it.each([false, true])(
'distinguishes empty averages from zero work and gain in a valid plan (all workdays off: %s)',
async (allDaysOff) => {
const core = await import('../../services/leave-planner.core');
const validPlan = core.calculatePlanningInternal({
horizon: { start: '2024-01-08', end: '2024-01-13' },
balances: [],
rules: [],
declaredDays: allDaysOff
? ['08', '09', '10', '11', '12'].map((day) => ({
date: `2024-01-${day}`,
type: 'other',
}))
: [],
});
if (validPlan.kind !== 'Plan') throw new Error('Expected a valid plan.');
const { calendar } = calendarFor(validPlan);
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(
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
).toEqual(allDaysOff ? ['0', '4', '—', '0', '9', '9'] : ['0', '0', '5', '5', '—', '—']);
},
);
it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => { it('localizes statistics and supports keyboard dismissal with focus restored to its opener', async () => {
const { calendar } = calendarFor(plan); const { calendar } = calendarFor({
...plan,
metrics: {
...plan.metrics,
averageWorkSequence: 10 / 3,
averageTimeOffPeriod: 2.5,
longestTimeOffPeriod: 4,
},
});
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CalendarYear], imports: [CalendarYear],
providers: [ providers: [
@@ -520,8 +584,19 @@ describe('CalendarYear planning outcome boundary', () => {
expect(dialog.getAttribute('aria-labelledby')).toBe('plan-statistics-title'); expect(dialog.getAttribute('aria-labelledby')).toBe('plan-statistics-title');
expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help'); expect(dialog.getAttribute('aria-describedby')).toBe('plan-statistics-help');
await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2'))); await vi.waitFor(() => expect(document.activeElement).toBe(dialog.querySelector('h2')));
translation.setLanguage('fr'); for (const [language, workUnit, calendarUnit] of [
fixture.detectChanges(); ['de', 'Arbeitstage', 'Kalendertage'],
['es', 'días laborables', 'días naturales'],
['fr', 'jours ouvrés', 'jours calendaires'],
]) {
translation.setLanguage(language);
fixture.detectChanges();
expect(dialog.textContent).toContain(workUnit);
expect(dialog.textContent).toContain(calendarUnit);
expect(
Array.from(dialog.querySelectorAll('dd'), (value) => value.textContent?.trim()),
).toEqual(['2', '4', '3,333', '6', '2,5', '4']);
}
expect(dialog.textContent).toContain('Statistiques du planning'); expect(dialog.textContent).toContain('Statistiques du planning');
expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés'); expect(dialog.querySelector('dt')?.textContent).toContain('Nouveaux jours de congé posés');
expect(dialog.querySelector('button')?.textContent?.trim()).toBe('Fermer'); expect(dialog.querySelector('button')?.textContent?.trim()).toBe('Fermer');
@@ -2812,7 +2887,11 @@ describe('CalendarYear planning outcome boundary', () => {
expect(root.querySelector('.trace-state-summary')?.textContent).toContain('1 placements'); expect(root.querySelector('.trace-state-summary')?.textContent).toContain('1 placements');
activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!); activateWithKeyboard(root.querySelector<HTMLButtonElement>('.statistics-button')!);
await fixture.whenStable(); await fixture.whenStable();
expect(document.querySelector('[role="dialog"] dd')?.textContent?.trim()).toBe('2'); expect(
Array.from(document.querySelectorAll('[role="dialog"] dd'), (value) =>
value.textContent?.trim(),
),
).toEqual(['2', '4', '6', '6', '—', '—']);
document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click(); document.querySelector<HTMLButtonElement>('[role="dialog"] button')!.click();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
activateWithKeyboard(afterButton); activateWithKeyboard(afterButton);
@@ -1257,6 +1257,44 @@
<dt>{{ tr().planning.statistics.leaveDays }}</dt> <dt>{{ tr().planning.statistics.leaveDays }}</dt>
<dd>{{ plan.placements.length }}</dd> <dd>{{ plan.placements.length }}</dd>
</div> </div>
<div>
<dt>
{{ tr().planning.daysGained }} ({{ tr().planning.statistics.calendarDays }})
</dt>
<dd>{{ formatStatistic(plan.metrics.nonWorkingDayGain) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.averageWorkSequence }} ({{
tr().planning.statistics.workingDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.averageWorkSequence) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.longestWorkSequence }} ({{
tr().planning.statistics.workingDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.longestWorkSequence) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.averageTimeOffPeriod }} ({{
tr().planning.statistics.calendarDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.averageTimeOffPeriod) }}</dd>
</div>
<div>
<dt>
{{ tr().planning.statistics.longestTimeOffPeriod }} ({{
tr().planning.statistics.calendarDays
}})
</dt>
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
</div>
</dl> </dl>
} }
</mat-dialog-content> </mat-dialog-content>
@@ -762,6 +762,10 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set([]); this.userInput.remainingVacationDaysSignal.set([]);
} }
protected formatStatistic(value: number | null): string {
return value === null ? '—' : value.toLocaleString(this.translationService.locale());
}
protected openStatistics(): void { protected openStatistics(): void {
if (!this.displayedPlan() || this.statisticsDialog) return; if (!this.displayedPlan() || this.statisticsDialog) return;
const dialog = this.dialog.open(this.statisticsTemplate(), { const dialog = this.dialog.open(this.statisticsTemplate(), {
@@ -79,6 +79,13 @@ describe('user cancellation', () => {
expect(outcome.reasons[0].code).toBe('user-cancelled'); expect(outcome.reasons[0].code).toBe('user-cancelled');
expect(outcome.incumbent.timeOffPeriods).toEqual(completed.timeOffPeriods); expect(outcome.incumbent.timeOffPeriods).toEqual(completed.timeOffPeriods);
expect(outcome.incumbent.metrics).toEqual(completed.metrics); 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({ expect(artifacts.explanation).toMatchObject({
kind: 'PlanExplanation', kind: 'PlanExplanation',
declaredDays: submitted.declaredDays, declaredDays: submitted.declaredDays,
@@ -344,6 +351,36 @@ describe('user cancellation', () => {
expect(await calculation.trace).toEqual(trace); 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 () => { it('reports worker failure distinctly after a cancellation request', async () => {
const calculation = createPlanningCalculation(request); const calculation = createPlanningCalculation(request);
calculation.cancel(); calculation.cancel();
+13
View File
@@ -460,6 +460,9 @@ export interface PlanningMetrics {
holidayWeekValue: number; holidayWeekValue: number;
nonWorkingDayGain: number; nonWorkingDayGain: number;
longestWorkSequence: number; longestWorkSequence: number;
averageWorkSequence: number | null;
averageTimeOffPeriod: number | null;
longestTimeOffPeriod: number | null;
shortSequenceDeficit: number; shortSequenceDeficit: number;
supportingPlacements: number; supportingPlacements: number;
preferenceSatisfaction: number[]; preferenceSatisfaction: number[];
@@ -5026,6 +5029,7 @@ function qualityMetrics(
} else current++; } else current++;
} }
if (context.workingDays.length) sequences.push(current); if (context.workingDays.length) sequences.push(current);
const nonEmptySequences = sequences.filter((length) => length > 0);
const internal = hasTimeOff ? sequences.slice(1, -1) : []; const internal = hasTimeOff ? sequences.slice(1, -1) : [];
const shortSequenceDeficit = internal.reduce( const shortSequenceDeficit = internal.reduce(
(sum, length) => sum + (length > 0 && length < 5 ? 5 - length : 0), (sum, length) => sum + (length > 0 && length < 5 ? 5 - length : 0),
@@ -5036,6 +5040,15 @@ function qualityMetrics(
holidayWeekValue: holidayWeekValue(state, context), holidayWeekValue: holidayWeekValue(state, context),
nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0), nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0),
longestWorkSequence: sequences.length ? Math.max(...sequences) : 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, shortSequenceDeficit,
supportingPlacements: [...state.placements.values()].filter( supportingPlacements: [...state.placements.values()].filter(
(placement) => placement.supporting, (placement) => placement.supporting,
+152 -3
View File
@@ -48,6 +48,129 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z'))); beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers()); 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', () => { describe('declared holiday patterns and captured entitlement date', () => {
const producer = { const producer = {
id: 'producer', id: 'producer',
@@ -1214,7 +1337,13 @@ describe('planLeave', () => {
nonWorkingDayGain: 2, 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 () => { it('merges declarations, placements, weekends and national holidays without counting dates twice', async () => {
@@ -1254,7 +1383,14 @@ describe('planLeave', () => {
nonWorkingDayGain: 6, 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 () => { 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'], declaredDates: ['2024-01-02', '2024-01-03', '2024-01-05'],
nonWorkingDayGain: 5, nonWorkingDayGain: 5,
}); });
expect(result.metrics).toMatchObject({
nonWorkingDayGain: 5,
averageWorkSequence: null,
longestWorkSequence: 0,
averageTimeOffPeriod: 9,
longestTimeOffPeriod: 9,
});
const unanchored = isPlan( const unanchored = isPlan(
await runInNonBrowser({ await runInNonBrowser({
horizon: selectedHorizon, horizon: selectedHorizon,
@@ -1328,7 +1471,13 @@ describe('planLeave', () => {
}), }),
); );
expect(unanchored.timeOffPeriods).toEqual([]); 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([ it.each([
@@ -197,6 +197,13 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
: undefined; : undefined;
if (!plan) throw new Error('Expected a valid earned-chain plan.'); if (!plan) throw new Error('Expected a valid earned-chain plan.');
expect(plan.calculationDate).toBe('2024-05-01'); 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([ expect(plan.placements).toMatchObject([
{ {
date: '2024-05-03', 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)}`); throw new Error(`Expected the same direct plan: ${JSON.stringify(direct)}`);
expect(plan.placements).toEqual(direct.placements); expect(plan.placements).toEqual(direct.placements);
expect(plan.ifThenContributions).toEqual(direct.ifThenContributions); expect(plan.ifThenContributions).toEqual(direct.ifThenContributions);
expect(plan.metrics).toEqual(direct.metrics);
const root: HTMLElement = fixture.nativeElement; const root: HTMLElement = fixture.nativeElement;
expect(root.textContent).toContain('Droit futur généré'); expect(root.textContent).toContain('Droit futur généré');
expect(root.textContent).toContain('CP autour du férié'); expect(root.textContent).toContain('CP autour du férié');
+5
View File
@@ -131,6 +131,11 @@ export interface AppTranslations {
open: string; open: string;
title: string; title: string;
leaveDays: string; leaveDays: string;
averageWorkSequence: string;
averageTimeOffPeriod: string;
longestTimeOffPeriod: string;
workingDays: string;
calendarDays: string;
partialPlan: string; partialPlan: string;
help: string; help: string;
}; };
+6 -1
View File
@@ -140,8 +140,13 @@
"open": "Statistik", "open": "Statistik",
"title": "Planstatistik", "title": "Planstatistik",
"leaveDays": "Neu geplante Urlaubstage", "leaveDays": "Neu geplante Urlaubstage",
"averageWorkSequence": "Durchschnittliche Arbeitsphase",
"averageTimeOffPeriod": "Durchschnittliche Ruhephase",
"longestTimeOffPeriod": "Längste Ruhephase",
"workingDays": "Arbeitstage",
"calendarDays": "Kalendertage",
"partialPlan": "Teilplan", "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", "calculationDate": "Berechnungsdatum",
"futureEntitlement": "Erzeugter künftiger Anspruch", "futureEntitlement": "Erzeugter künftiger Anspruch",
+6 -1
View File
@@ -140,8 +140,13 @@
"open": "Statistics", "open": "Statistics",
"title": "Plan statistics", "title": "Plan statistics",
"leaveDays": "New leave days", "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", "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", "calculationDate": "Calculation date",
"futureEntitlement": "Future entitlement generated", "futureEntitlement": "Future entitlement generated",
+6 -1
View File
@@ -140,8 +140,13 @@
"open": "Estadísticas", "open": "Estadísticas",
"title": "Estadísticas del plan", "title": "Estadísticas del plan",
"leaveDays": "Nuevos días de permiso", "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", "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", "calculationDate": "Fecha del cálculo",
"futureEntitlement": "Derecho futuro generado", "futureEntitlement": "Derecho futuro generado",
+6 -1
View File
@@ -140,8 +140,13 @@
"open": "Statistiques", "open": "Statistiques",
"title": "Statistiques du planning", "title": "Statistiques du planning",
"leaveDays": "Nouveaux jours de congé posés", "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", "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", "calculationDate": "Date du calcul",
"futureEntitlement": "Droit futur généré", "futureEntitlement": "Droit futur généré",