feat(stats): show work and time-off lengths (#66)
This commit is contained in:
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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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é');
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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é",
|
||||||
|
|||||||
Reference in new issue
Block a user