feat(stats): show gross earned leave by rule (#67)
This commit is contained in:
1 parent
3fbf9c3172
commit
da08ee054d
10 files changed
+571
-9
No files matched your search
@@ -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<PlanningTrace | undefined>();
|
||||
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<HTMLButtonElement>('[role="dialog"] button')!.click();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||||
activateWithKeyboard(afterButton);
|
||||
|
||||
@@ -1295,7 +1295,35 @@
|
||||
</dt>
|
||||
<dd>{{ formatStatistic(plan.metrics.longestTimeOffPeriod) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{{ tr().planning.statistics.earnedLeave }} ({{
|
||||
tr().planning.statistics.leaveDaysUnit
|
||||
}})
|
||||
</dt>
|
||||
<dd>{{ formatStatistic(earnedLeave().total) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@if (earnedLeave().contributions.length) {
|
||||
<section aria-labelledby="earned-leave-title">
|
||||
<h3 id="earned-leave-title">
|
||||
{{ tr().planning.statistics.earnedLeaveByRule }} ({{
|
||||
tr().planning.statistics.leaveDaysUnit
|
||||
}})
|
||||
</h3>
|
||||
<dl class="earned-leave-breakdown">
|
||||
@for (
|
||||
contribution of earnedLeave().contributions;
|
||||
track contribution.ruleId
|
||||
) {
|
||||
<div>
|
||||
<dt>{{ contribution.label }}</dt>
|
||||
<dd>{{ formatStatistic(contribution.quantity) }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -202,6 +202,25 @@ export class CalendarYear {
|
||||
}
|
||||
|
||||
protected readonly displayedPlan = this.userInput.displayedPlanSignal;
|
||||
protected readonly earnedLeave = computed(() => {
|
||||
const byRule = new Map<string, { ruleId: string; label: string; quantity: number }>();
|
||||
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();
|
||||
|
||||
@@ -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<HTMLButtonElement>('.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<HTMLButtonElement>('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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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é",
|
||||
|
||||
Reference in new issue
Block a user