feat(planning): #62 #63 #64 enhance leave planner with calculation date and entitlement details

- Introduced calculationDate in various service interfaces and snapshots to track the date of planning calculations.
- Added futureEntitlement and includedEntitlement fields to the translation service and respective language files for better user feedback.
- Updated leave-planner.worker to handle calculationDate and declaredDays in requests.
- Enhanced tests to ensure proper handling of calculation dates and artifact retention during planning processes.
- Improved snapshot and explanation outputs to include calculation date for clarity in planning results.
This commit is contained in:
nessar committed 2026-10-07 19:22:46 +02:00
1 parent 036a8c0e1e
commit 9851edf906
18 files changed
+3347 -229

No files matched your search

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPlanningCalculation } from './leave-planner.service';
import { createPlanningCalculation, applyReplayDeltas } from './leave-planner.service';
const balance = (
id: string,
@@ -12,7 +12,245 @@ const balance = (
beforeEach(() => vi.stubGlobal('window', undefined));
afterEach(() => vi.unstubAllGlobals());
beforeEach(() => vi.setSystemTime(new Date('2024-01-01T12:00:00Z')));
afterEach(() => vi.useRealTimers());
describe('planner explanation and trace seam', () => {
it('replays a reported occurrence without borrowing its future balance from the final plan', async () => {
vi.setSystemTime(new Date('2024-05-03T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-04-29', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
{ date: '2024-05-03', type: 'other' },
],
balances: [balance('cp', 'cp', 1, '2025-12-31')],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'obligation',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays: 1,
rewardType: 'other',
},
{
id: 'extend',
type: 'time_window',
force: 'obligation',
startDate: '2024-05-02',
endDate: '2024-05-02',
placeDays: 1,
placeType: 'cp',
},
{
id: 'consumer',
type: 'time_window',
force: 'obligation',
startDate: '2024-04-29',
endDate: '2024-05-07',
placeDays: 2,
placeType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
throw new Error('Expected the extended generator to finance a traced plan.');
const replay = {
balances: artifacts.trace.initialState.balances.map((item) => ({ ...item })),
placements: new Map(
artifacts.trace.initialState.placements.map((item) => [item.date, item]),
),
earnedActivations: [...artifacts.trace.initialState.earnedActivations],
};
const earnedId = 'earned:producer:2024-05-01';
let recognized = false;
for (const decision of artifacts.trace.phases.flatMap(({ decisions }) => decisions)) {
applyReplayDeltas(replay, decision.deltas, artifacts.finalState);
if (
decision.deltas.some(
({ kind, balanceId }) => kind === 'earned-activated' && balanceId === earnedId,
)
) {
recognized = true;
expect(replay.earnedActivations.map(({ id }) => id)).toContain(earnedId);
expect(replay.balances.map(({ id }) => id)).not.toContain(earnedId);
}
}
expect(recognized).toBe(true);
expect(replay.balances).toEqual(artifacts.finalState.balances);
expect([...replay.placements.values()]).toEqual(artifacts.finalState.placements);
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
expect(replay.balances.find(({ id }) => id === earnedId)).toMatchObject({
remaining: 0,
availableFrom: '2024-05-06',
expiryDate: '2025-05-06',
});
});
it.each([5, 8])(
'keeps %s unrelated obligation branches linear when a declared entitlement is already reported',
async (count) => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
const dates = [
'2024-04-17',
'2024-04-18',
'2024-04-19',
'2024-04-22',
'2024-04-23',
'2024-04-24',
'2024-04-25',
'2024-04-26',
].slice(0, count);
const branchCounts: number[] = [];
for (const rewardDays of [0, 1]) {
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-04-17', end: '2024-05-08' },
declaredDays: [
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
],
balances: [
{
id: 'other',
type: 'other',
quantity: dates.length,
availableFrom: '2024-04-17',
expiryDate: '2025-12-31',
},
],
rules: [
...dates.map((date) => ({
id: date,
type: 'time_window' as const,
force: 'obligation' as const,
startDate: date,
endDate: date,
placeDays: 1,
placeType: 'other' as const,
})),
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placement: 'before',
placeDays: 2,
placeType: 'cp',
rewardDays,
rewardType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace)
throw new Error('Expected a traced completed plan.');
expect(outcome.placements.map(({ date }) => date)).toEqual(dates);
expect(outcome.remainingBalances).toEqual([]);
branchCounts.push(
artifacts.trace.phases
.find(({ name }) => name === 'obligations')
?.decisions.filter(({ deltas }) =>
deltas.some(({ placement }) => placement !== undefined),
).length ?? 0,
);
if (rewardDays)
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-02',
added: false,
});
}
expect(branchCounts[0]).toBeGreaterThan(0);
expect(branchCounts[1]).toBe(branchCounts[0]);
},
);
it('removes an entitlement from the original replay when a real quality move shortens its generator into today', async () => {
vi.setSystemTime(new Date('2024-05-06T12:00:00Z'));
const core = await import('./leave-planner.core');
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
const outcome = core.calculatePlanningInternal(
{
horizon: { start: '2024-05-01', end: '2024-05-11' },
balances: [
{
id: 'cp',
type: 'cp',
quantity: 1,
availableFrom: '2024-05-06',
expiryDate: '2024-05-07',
},
],
declaredDays: [
{ date: '2024-05-02', type: 'cp' },
{ date: '2024-05-03', type: 'cp' },
],
rules: [
{
id: 'producer',
type: 'if_then',
force: 'preference',
dayOfWeek: 3,
placeDays: 2,
placeType: 'cp',
placement: 'after',
rewardDays: 1,
rewardType: 'other',
},
],
},
undefined,
(value) => (artifacts = value),
);
if (outcome.kind !== 'Plan' || !artifacts.trace || !artifacts.finalState)
throw new Error('Expected a traced completed plan.');
expect(outcome.placements).toMatchObject([{ date: '2024-05-07', balanceId: 'cp' }]);
expect(outcome.remainingBalances).toEqual([]);
expect(outcome.ifThenContributions?.[0].earnedEntitlement).toMatchObject({
availableFrom: '2024-05-06',
added: false,
});
const decisions = artifacts.trace.phases.flatMap(({ decisions }) => decisions);
expect(decisions.flatMap(({ deltas }) => deltas)).toContainEqual({
kind: 'balance-removed',
balanceId: 'earned:producer:2024-05-01',
remainingBefore: 1,
});
const initial = artifacts.trace.initialState;
const replay = {
balances: initial.balances.map((balance) => ({ ...balance })),
placements: new Map(
initial.placements.map((placement) => [
placement.date,
{ ...placement, ruleIds: [...placement.ruleIds] },
]),
),
earnedActivations: initial.earnedActivations.map((activation) => ({
...activation,
dates: [...activation.dates],
})),
};
for (const decision of decisions) applyReplayDeltas(replay, decision.deltas);
expect(replay.balances).toEqual(artifacts.finalState.balances);
expect(replay.earnedActivations).toEqual(artifacts.finalState.earnedActivations);
expect([...replay.placements.values()]).toEqual(outcome.placements);
});
it('records only actual evaluations and publishes non-working dates as metadata', async () => {
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2024-01-07' },