This commit is contained in:
nessar committed 2026-10-07 00:28:29 +02:00
1 parent 03841c6e3d
commit 036a8c0e1e
32 files changed
+1805 -89

No files matched your search

+12 -26
View File
@@ -1,6 +1,6 @@
import { Injectable, inject } from '@angular/core';
import { DateRange } from '@angular/material/datepicker';
import Holidays, { HolidaysTypes } from 'date-holidays';
import { frenchPublicHolidaysForYear } from './declared-non-working-days';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
@@ -18,12 +18,6 @@ import { addPlanningDays, addPlanningYears, planningDateInParis } from './planni
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
const lang = 'en';
const allTypesOptions: HolidaysTypes.Options = {
languages: [lang],
};
@Injectable({
providedIn: 'root',
})
@@ -39,7 +33,7 @@ export class CalendarService {
}
selectedDatesForPlan(
plan: Pick<PlanOutcome, 'placements'> & {
plan: Pick<PlanOutcome, 'placements' | 'declaredDays'> & {
horizon?: { start: string; end: string };
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
},
@@ -55,7 +49,14 @@ export class CalendarService {
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of plan.placements) {
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) =>
!plan.horizon ||
(day.date >= plan.horizon.start && day.date < plan.horizon.end),
),
]) {
selected.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
@@ -69,8 +70,6 @@ export class CalendarService {
return selected;
}
private readonly holidaysByYear = new Map<number, number[]>();
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
const _holidays = this.getHolidays();
@@ -156,21 +155,8 @@ export class CalendarService {
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
const dates: Date[] = [];
for (let year = startYear; year <= lastYear; year++) {
let timestamps = this.holidaysByYear.get(year);
if (!timestamps) {
const calendar = new Holidays('FR', allTypesOptions);
timestamps = calendar
.getHolidays(year, lang)
.filter((holiday) => holiday.type === 'public')
.map((holiday) => {
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
return (
dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date)
).getTime();
});
this.holidaysByYear.set(year, timestamps);
}
dates.push(...timestamps.map((timestamp) => new Date(timestamp)));
for (const date of frenchPublicHolidaysForYear(year))
dates.push(new Date(`${date}T00:00:00`));
}
return dates;
}
@@ -0,0 +1,50 @@
import Holidays from 'date-holidays';
import { isPlanningDate } from './planning-date';
import type { LeaveType } from './leave-planner.core';
export interface DeclaredNonWorkingDay {
date: string;
type: LeaveType;
}
const holidaysByYear = new Map<number, Set<string>>();
export function frenchPublicHolidaysForYear(year: number): ReadonlySet<string> {
let holidays = holidaysByYear.get(year);
if (!holidays) {
holidays = new Set(
new Holidays('FR', { languages: ['en'] })
.getHolidays(year, 'en')
.filter((holiday) => holiday.type === 'public')
.map((holiday) => String(holiday.date).slice(0, 10)),
);
holidaysByYear.set(year, holidays);
}
return holidays;
}
export function isWorkingPlanningDate(value: unknown): value is string {
if (!isPlanningDate(value)) return false;
const day = new Date(`${value}T00:00:00Z`).getUTCDay();
return (
day !== 0 && day !== 6 && !frenchPublicHolidaysForYear(Number(value.slice(0, 4))).has(value)
);
}
export function parseDeclaredDays(value: unknown): DeclaredNonWorkingDay[] {
if (!Array.isArray(value)) throw new Error('Declared non-working days must be a list.');
const dates = new Set<string>();
return value.map((item: unknown) => {
if (typeof item !== 'object' || item === null)
throw new Error('Each declared non-working day needs a date and leave type.');
const { date, type } = item as Record<string, unknown>;
if (!isWorkingPlanningDate(date) || dates.has(date))
throw new Error(
'Declared dates must be unique working days outside French public holidays.',
);
if (type !== 'cp' && type !== 'rtt' && type !== 'other')
throw new Error('Declared non-working days must have a CP, RTT, or Other type.');
dates.add(date);
return { date, type };
});
}
+102 -25
View File
@@ -1,5 +1,10 @@
import Holidays from 'date-holidays';
import {
DeclaredNonWorkingDay,
parseDeclaredDays,
frenchPublicHolidaysForYear,
} from './declared-non-working-days';
import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date';
export type { DeclaredNonWorkingDay } from './declared-non-working-days';
/** Date-only values are serialized as YYYY-MM-DD at the planning boundary. */
export type PlanningDate = string | Date;
@@ -55,6 +60,7 @@ export interface PlanningRequest {
horizon: PlanningHorizon;
balances: readonly LeaveBalanceInput[];
rules: readonly PlanningRule[];
declaredDays?: readonly DeclaredNonWorkingDay[];
/** Optional caller-owned display identities copied into a calculation snapshot. */
identities?: unknown;
/** Optional caller-owned human-readable labels copied into a calculation snapshot. */
@@ -186,6 +192,7 @@ export interface PlacementExplanation {
export interface PlanExplanation {
kind: 'PlanExplanation';
declaredDays?: readonly DeclaredNonWorkingDay[];
placements: readonly PlacementExplanation[];
/** Present when the explained plan is the incumbent of an interrupted calculation. */
incomplete?: IncompleteExplanation;
@@ -207,6 +214,7 @@ export interface IncompleteExplanation {
export type PlanningExplanation = PlanExplanation | ConflictExplanation | IncompleteExplanation;
export interface PlanningSnapshot {
declaredDays?: readonly DeclaredNonWorkingDay[];
horizon: Readonly<{ start: string; end: string }>;
balances: readonly Readonly<LeaveBalanceInput>[];
rules: readonly Readonly<PlanningRule>[];
@@ -222,6 +230,7 @@ export type TraceCandidateStatus =
| 'ineligible';
export interface PlanningTraceState {
declaredDays?: readonly DeclaredNonWorkingDay[];
horizon: Readonly<{ start: string; end: string }>;
balances: readonly Readonly<{
id: string;
@@ -361,6 +370,7 @@ export interface PlanningArtifactTimings {
export type CounterfactualReasonCode =
| 'non-working'
| 'declared-day'
| 'occupied'
| 'unavailable'
| 'expired'
@@ -424,6 +434,7 @@ export interface PlanningMetrics {
export interface PlanOutcome {
kind: 'Plan';
declaredDays?: readonly DeclaredNonWorkingDay[];
horizon: { start: string; end: string };
placements: LeavePlacement[];
timeOffPeriods: TimeOffPeriod[];
@@ -502,6 +513,7 @@ interface State {
interface RuntimePlanningRequest {
horizon?: unknown;
balances?: unknown;
declaredDays?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
@@ -515,6 +527,7 @@ interface RuntimeRecord {
start?: unknown;
end?: unknown;
balances?: unknown;
declaredDays?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
@@ -600,11 +613,17 @@ class TraceRecorder {
private readonly workingDays: ReadonlySet<string>,
private readonly streaming: boolean,
) {
const declaredDates = new Set(initialState.declaredDays?.map(({ date }) => date));
this.nonWorkingDates = dateRange(initialState.horizon.start, initialState.horizon.end)
.filter((date) => !workingDays.has(date))
.filter((date) => !workingDays.has(date) || declaredDates.has(date))
.map((date) => ({
date,
reason: reason('non-working', 'The date is a weekend or public holiday.'),
reason: declaredDates.has(date)
? reason(
'declared-day',
'The date is a declared non-working day fixed by the user.',
)
: reason('non-working', 'The date is a weekend or public holiday.'),
}));
this.previousBalances = new Map(
initialState.balances.map((balance) => [balance.id, { ...balance }]),
@@ -723,6 +742,13 @@ class TraceRecorder {
? undefined
: {
horizon: { ...this.initialState.horizon },
...(this.initialState.declaredDays
? {
declaredDays: this.initialState.declaredDays.map((day) => ({
...day,
})),
}
: {}),
balances: this.initialState.balances.map((balance) => ({ ...balance })),
placements: this.initialState.placements.map((placement) => ({
...placement,
@@ -1080,6 +1106,8 @@ class TraceRecorder {
}
interface SearchContext {
declaredDays: readonly DeclaredNonWorkingDay[];
declaredDates: ReadonlySet<string>;
horizon: { start: string; end: string };
holidays: Set<string>;
holidayWeeks: HolidayWeekCandidate[];
@@ -1243,7 +1271,6 @@ const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000;
const VALID_TYPES = new Set<LeaveType>(['cp', 'rtt', 'other']);
const VALID_SELECTORS = new Set<LeaveSelector>(['cp', 'rtt', 'other', 'any']);
const VALID_MODES = new Set<PlacementMode>(['before', 'after', 'around', 'auto']);
const holidaysByYear = new Map<number, Set<string>>();
function dateKey(value: unknown): string | null {
if (typeof value === 'string') {
@@ -1265,6 +1292,7 @@ function runtimeRequest(value: unknown): RuntimePlanningRequest {
return {
horizon: source.horizon,
balances: source.balances,
declaredDays: source.declaredDays,
rules: source.rules,
identities: source.identities,
labels: source.labels,
@@ -1306,26 +1334,12 @@ function defaultHorizon(now = new Date()): { start: string; end: string } {
return { start, end: addPlanningYears(start, 1) };
}
function holidaysForYear(year: number): Set<string> {
const cached = holidaysByYear.get(year);
if (cached) return new Set(cached);
const calendar = new Holidays('FR', { languages: ['en'] });
const holidays = new Set<string>();
for (const holiday of calendar.getHolidays(year, 'en')) {
if (holiday.type !== 'public') continue;
const key = dateKey(holiday.date);
if (key) holidays.add(key);
}
holidaysByYear.set(year, holidays);
return new Set(holidays);
}
function holidaySet(horizon: { start: string; end: string }): Set<string> {
const first = Number(horizon.start.slice(0, 4)) - 1;
const last = Number(horizon.end.slice(0, 4)) + 1;
const result = new Set<string>();
for (let year = first; year <= last; year++) {
for (const holiday of holidaysForYear(year)) result.add(holiday);
for (const holiday of frenchPublicHolidaysForYear(year)) result.add(holiday);
}
return result;
}
@@ -1413,9 +1427,11 @@ function cloneState(state: State): State {
function planningTraceState(
state: State,
horizon: { start: string; end: string },
declaredDays: readonly DeclaredNonWorkingDay[] = [],
): PlanningTraceState {
return {
horizon,
...(declaredDays.length ? { declaredDays } : {}),
balances: state.balances.map((balance) => ({ ...balance })),
placements: [...state.placements.values()]
.sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))
@@ -1634,7 +1650,7 @@ function retainAndRecordState(
acceptedCandidate?: MutableTraceCandidate,
): void {
observeTrace(context, (trace) => {
const snapshot = planningTraceState(state, context.horizon);
const snapshot = planningTraceState(state, context.horizon, context.declaredDays);
if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate);
trace.recordPlan(
snapshot.placements,
@@ -1893,6 +1909,7 @@ function normalizeRules(request: RuntimePlanningRequest): {
}
function normalizeRequest(request: unknown): {
declaredDays: DeclaredNonWorkingDay[];
horizon?: { start: string; end: string };
balances: NormalizedBalance[];
rules: NormalizedRule[];
@@ -1930,6 +1947,16 @@ function normalizeRequest(request: unknown): {
reason('deadline', 'The planning deadline must be a finite non-negative number.'),
);
}
let declaredDays: DeclaredNonWorkingDay[] = [];
try {
declaredDays = parseDeclaredDays(
source.declaredDays === undefined ? [] : source.declaredDays,
);
} catch (error) {
reasons.push(
reason('declared-days', error instanceof Error ? error.message : String(error)),
);
}
const balanceResult = normalizeBalances(source, { start, end });
const ruleResult = normalizeRules(source);
reasons.push(...balanceResult.reasons, ...ruleResult.reasons);
@@ -1938,6 +1965,7 @@ function normalizeRequest(request: unknown): {
? undefined
: { start, end },
balances: balanceResult.balances,
declaredDays,
rules: ruleResult.rules,
reasons,
};
@@ -2168,6 +2196,17 @@ function assignDate(
);
}
function rejectDeclaredDate(context: SearchContext, date: string): boolean {
if (!context.declaredDates.has(date)) return false;
recordTraceIneligible(
context,
date,
'declared-day',
'The date is a declared non-working day fixed by the user.',
);
return true;
}
function assignDateVariants(
state: State,
date: string,
@@ -2178,6 +2217,7 @@ function assignDateVariants(
selectedEvaluation?: (candidate: MutableTraceCandidate | undefined) => void,
): State[] {
context.check();
if (rejectDeclaredDate(context, date)) return [];
const existing = state.placements.get(date);
if (existing) {
if (!selectorMatches(selector, existing.type)) {
@@ -2338,6 +2378,7 @@ function assignDateToBalance(
>,
): State | null {
context.check();
if (rejectDeclaredDate(context, date)) return null;
if (state.placements.has(date)) {
recordTraceCandidate(context, {
status: 'ineligible',
@@ -2400,6 +2441,7 @@ function assignDateToBalanceInPlace(
>,
): boolean {
context.check();
if (rejectDeclaredDate(context, date)) return false;
if (state.placements.has(date)) {
recordTraceCandidate(context, {
status: 'ineligible',
@@ -4894,6 +4936,7 @@ function planExplanation(
const vector = objectiveVector(metrics, preferences, state, context);
return {
kind: 'PlanExplanation',
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
placements: placements.map((placement) => {
const balance = rawBalances
.map(recordValue)
@@ -5051,6 +5094,7 @@ function makePlan(
const remaining = remainingBalances(state, context);
const plan: PlanOutcome = {
kind: 'Plan',
...(context.declaredDays.length ? { declaredDays: context.declaredDays } : {}),
horizon: context.horizon,
placements,
timeOffPeriods: periods,
@@ -5642,7 +5686,7 @@ function* prepareCalendar(
year++
) {
yield true;
for (const holiday of holidaysForYear(year)) holidays.add(holiday);
for (const holiday of frenchPublicHolidaysForYear(year)) holidays.add(holiday);
progress();
}
const workingDays: string[] = [];
@@ -5719,6 +5763,9 @@ function* calculatePlanningSteps(
...balance,
remaining: balance.quantity,
})),
...(normalized.declaredDays.length
? { declaredDays: normalized.declaredDays }
: {}),
placements: [],
earnedActivations: [],
},
@@ -5744,6 +5791,8 @@ function* calculatePlanningSteps(
workingCalendar,
workingPrefix,
workingDays,
declaredDays: normalized.declaredDays,
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
initialBalances: normalized.balances,
rules: normalized.rules,
deadline,
@@ -5772,6 +5821,9 @@ function* calculatePlanningSteps(
...balance,
remaining: balance.quantity,
})),
...(normalized.declaredDays.length
? { declaredDays: normalized.declaredDays }
: {}),
placements: [],
earnedActivations: [],
},
@@ -5893,7 +5945,7 @@ function* calculatePlanningSteps(
observeCallback(context, () =>
checkpointCallback(
makePlan(retained!.state, context, retained!.preferences, stages),
planningTraceState(retained!.state, horizon),
planningTraceState(retained!.state, horizon, context.declaredDays),
),
);
}
@@ -5964,7 +6016,11 @@ function* calculatePlanningSteps(
}
try {
if (explanationState)
finalState = planningTraceState(explanationState, context.horizon);
finalState = planningTraceState(
explanationState,
context.horizon,
context.declaredDays,
);
} catch (error) {
explanationError ??= reason(
'explanation-error',
@@ -6012,6 +6068,9 @@ function* calculatePlanningSteps(
...balance,
remaining: balance.quantity,
})),
...(normalized.declaredDays.length
? { declaredDays: normalized.declaredDays }
: {}),
placements: [],
earnedActivations: [],
},
@@ -6079,7 +6138,11 @@ function* calculatePlanningSteps(
let checkpointSnapshot: PlanningTraceState;
const captureStarted = now();
try {
checkpointSnapshot = planningTraceState(checkpointState, context.horizon);
checkpointSnapshot = planningTraceState(
checkpointState,
context.horizon,
context.declaredDays,
);
} finally {
const captureTime = now() - captureStarted;
context.traceTime += captureTime;
@@ -6375,6 +6438,8 @@ export function explainIncompleteCheckpoint(
workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
isWorking(date, holidays),
),
declaredDays: normalized.declaredDays,
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
initialBalances: normalized.balances,
rules: normalized.rules,
deadline: Number.POSITIVE_INFINITY,
@@ -6433,7 +6498,7 @@ export function explainIncompleteCheckpoint(
uncompletedStages,
},
},
finalState: planningTraceState(state, horizon),
finalState: planningTraceState(state, horizon, context.declaredDays),
};
} catch (error) {
return {
@@ -6477,6 +6542,8 @@ function counterfactualContext(
workingDays: dateRange(horizon.start, horizon.end).filter((date) =>
isWorking(date, holidays),
),
declaredDays: normalized.declaredDays,
declaredDates: new Set(normalized.declaredDays.map(({ date }) => date)),
initialBalances: normalized.balances,
rules: normalized.rules,
deadline: Number.POSITIVE_INFINITY,
@@ -6541,6 +6608,11 @@ export function historicalCounterfactualReasons(
'non-working',
'The date is a weekend or public holiday.',
);
else if (context.declaredDates.has(date))
ineligible = counterfactualReason(
'declared-day',
'The date is a declared non-working day fixed by the user.',
);
else if (baseline.placements.has(date))
ineligible = counterfactualReason(
'occupied',
@@ -6801,6 +6873,11 @@ export function analyzeCounterfactualInternal(
'non-working',
'The date is a weekend or public holiday.',
);
else if (context.declaredDates.has(date))
ineligible = counterfactualReason(
'declared-day',
'The date is a declared non-working day fixed by the user.',
);
else if (baseline.placements.has(date))
ineligible = counterfactualReason(
'occupied',
@@ -46,6 +46,65 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
}
describe('planLeave', () => {
it('keeps declared non-working days fixed without consuming their remaining balances', async () => {
const declaredDays = [
{ date: '2024-01-02', type: 'cp' as const },
{ date: '2024-01-03', type: 'rtt' as const },
{ date: '2024-01-05', type: 'other' as const },
{ date: '2026-10-06', type: 'other' as const },
];
const result = isPlan(
await runInNonBrowser({
horizon: { start: '2024-01-02', end: '2024-01-06' },
balances: [balance('remaining-cp', 'cp', 4, '2024-01-05')],
rules: [],
declaredDays,
}),
);
expect(result).toMatchObject({ declaredDays });
expect(result.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
expect(result.remainingBalances).toMatchObject([{ id: 'remaining-cp', quantity: 3 }]);
});
it('accepts declarations without a source balance or a new placement', async () => {
const result = isPlan(
await runInNonBrowser({
horizon,
balances: [],
rules: [],
declaredDays: [{ date: '2024-01-02', type: 'other' }],
}),
);
expect(result.declaredDays).toEqual([{ date: '2024-01-02', type: 'other' }]);
expect(result.placements).toEqual([]);
expect(result.remainingBalances).toEqual([]);
});
it.each([
null,
{},
[null],
[{ date: '2024-02-30', type: 'cp' }],
[{ date: '2024-01-06', type: 'cp' }],
[{ date: '2024-05-08', type: 'cp' }],
[{ date: '2024-01-02', type: 'any' }],
[
{ date: '2024-01-02', type: 'cp' },
{ date: '2024-01-02', type: 'rtt' },
],
])('reports malformed declarations as invalid planning input: %j', async (declaredDays) => {
const outcome = await runInNonBrowser({
horizon,
balances: [],
rules: [],
declaredDays,
} as unknown as PlanningRequest);
expect(outcome).toMatchObject({
kind: 'InvalidInput',
reasons: [{ code: 'declared-days' }],
});
});
it.each([
['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'],
['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
@@ -1597,6 +1656,54 @@ describe('planLeave transport', () => {
});
describe('planning calculation context', () => {
it('detaches declared dates and types across results, explanations, trace and comparisons', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
try {
const submitted: PlanningRequest = {
horizon: { start: '2024-01-02', end: '2024-02-01' },
balances: [balance('cp', 'cp', 4, '2024-01-05')],
rules: [],
declaredDays: [
{ date: '2024-01-02', type: 'cp' },
{ date: '2024-01-03', type: 'rtt' },
{ date: '2024-01-05', type: 'other' },
],
};
const original = structuredClone(submitted.declaredDays);
const calculation = createPlanningCalculation(submitted);
submitted.declaredDays![0].type = 'other';
submitted.horizon.end = '2024-01-03';
expect(calculation.snapshot.declaredDays).toEqual(original);
expect(Object.isFrozen(calculation.snapshot.declaredDays![0])).toBe(true);
const outcome = isPlan(await calculation.outcome);
expect(outcome.declaredDays).toEqual(original);
expect(outcome.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
declaredDays: original,
placements: [{ placement: { date: '2024-01-04' } }],
});
const trace = await calculation.trace;
expect(trace?.initialState.declaredDays).toEqual(original);
expect(trace?.nonWorkingDates).toContainEqual({
date: '2024-01-02',
reason: expect.objectContaining({ code: 'declared-day' }),
});
const frozenOutcome = structuredClone(outcome);
const analysis = await calculation.analyzeCounterfactual(outcome.placements[0]);
for (const { date } of original!)
expect(analysis.results.find((result) => result.date === date)).toMatchObject({
status: 'ineligible',
reason: { code: 'declared-day' },
historicalStatus: 'ineligible',
});
expect(outcome).toEqual(frozenOutcome);
} finally {
vi.stubGlobal('window', browserWindow);
}
});
it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
+14
View File
@@ -6,6 +6,7 @@ import type {
LeaveBalanceInput,
LeavePlacement,
LeaveType,
DeclaredNonWorkingDay,
PlanOutcome,
PlanningArtifacts,
PlanningExplanation,
@@ -41,6 +42,7 @@ export type {
LeavePlacement,
LeaveSelector,
LeaveType,
DeclaredNonWorkingDay,
PlanOutcome,
PlanExplanation,
PlacementMode,
@@ -89,6 +91,7 @@ interface UntrustedRecord {
start?: unknown;
end?: unknown;
balances?: unknown;
declaredDays?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
@@ -116,6 +119,7 @@ interface UntrustedRecord {
interface TransportSnapshot {
horizon?: { start?: unknown; end?: unknown };
balances?: unknown;
declaredDays?: unknown;
rules?: unknown;
identities?: unknown;
labels?: unknown;
@@ -190,6 +194,9 @@ function publicSnapshot(request: TransportSnapshot): PlanningSnapshot {
end: String(snapshotValue(horizon.end) ?? ''),
},
balances,
...(request.declaredDays === undefined
? {}
: { declaredDays: snapshotValue(request.declaredDays) }),
rules,
identities: stringMap(request.identities),
labels,
@@ -254,6 +261,7 @@ function snapshotRequest(request: unknown): TransportSnapshot {
}
: undefined,
balances,
declaredDays: cloneUnknown(source.declaredDays),
rules,
identities: cloneUnknown(source.identities),
labels: cloneUnknown(source.labels),
@@ -363,6 +371,9 @@ function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
earned: false,
};
}),
...(Array.isArray(snapshot.declaredDays)
? { declaredDays: snapshot.declaredDays as DeclaredNonWorkingDay[] }
: {}),
placements: [],
earnedActivations: [],
};
@@ -1072,6 +1083,9 @@ export function applyReplayDeltas(
function replayedState(trace: PlanningTrace, state: ReplayState): PlanningTraceState {
return {
horizon: { ...trace.initialState.horizon },
...(trace.initialState.declaredDays
? { declaredDays: trace.initialState.declaredDays }
: {}),
balances: state.balances,
placements: [...state.placements.values()],
earnedActivations: state.earnedActivations,
+83 -1
View File
@@ -78,6 +78,84 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
afterAll(() => rmSync(directory, { recursive: true, force: true }));
it.each([false, true])(
'keeps declarations fixed in direct and worker planning and date comparisons (cancelled: %s)',
async (cancelled) => {
const submitted = {
horizon: { start: '2024-01-02', end: '2024-02-01' },
balances: [
{ id: 'cp', type: 'cp' as const, quantity: 4, expiryDate: '2024-01-05' },
],
rules: [],
declaredDays: [
{ date: '2024-01-02', type: 'cp' as const },
{ date: '2024-01-03', type: 'rtt' as const },
{ date: '2024-01-05', type: 'other' as const },
],
};
const { calculatePlanningInternal, calculatePlanningAsyncInternal } =
await import('./leave-planner.core');
let interruption = false;
const direct = cancelled
? await calculatePlanningAsyncInternal(
{
...submitted,
progress: (stage: string) => {
if (stage === 'non-working-day-gain') interruption = true;
},
},
undefined,
undefined,
undefined,
undefined,
() => interruption,
)
: calculatePlanningInternal(submitted);
const calculation = createPlanningCalculation({
...submitted,
progress: (stage) => {
if (cancelled && stage === 'non-working-day-gain') calculation.cancel();
},
});
const outcome = await calculation.outcome;
expect(outcome.kind).toBe(cancelled ? 'PlanningIncomplete' : 'Plan');
expect(direct.kind).toBe(outcome.kind);
const plan =
outcome.kind === 'Plan'
? outcome
: outcome.kind === 'PlanningIncomplete'
? outcome.incumbent
: undefined;
const directPlan =
direct.kind === 'Plan'
? direct
: direct.kind === 'PlanningIncomplete'
? direct.incumbent
: undefined;
expect(plan).toBeDefined();
expect(plan?.declaredDays).toEqual(submitted.declaredDays);
expect(plan?.placements.map(({ date }) => date)).toEqual(['2024-01-04']);
expect(directPlan?.placements).toEqual(plan?.placements);
expect(plan?.remainingBalances).toMatchObject([{ id: 'cp', quantity: 3 }]);
if (!cancelled) expect(outcome).toEqual(direct);
expect(await calculation.explanation).toMatchObject({
kind: 'PlanExplanation',
declaredDays: submitted.declaredDays,
});
expect((await calculation.trace)?.initialState.declaredDays).toEqual(
submitted.declaredDays,
);
const original = structuredClone(outcome);
const comparison = await calculation.analyzeCounterfactual(plan!.placements[0]);
for (const { date } of submitted.declaredDays)
expect(comparison.results.find((result) => result.date === date)).toMatchObject({
status: 'ineligible',
reason: { code: 'declared-day' },
});
expect(await calculation.outcome).toEqual(original);
},
);
it('stops during calendar preparation without a plan or an obligation conflict', async () => {
const stages: string[] = [];
let preparationUpdates = 0;
@@ -85,6 +163,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
horizon: { start: '2024-01-01', end: '2027-01-01' },
balances: [],
rules: [],
declaredDays: [{ date: '2024-01-02', type: 'other' }],
progress: (stage) => {
stages.push(stage);
if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel();
@@ -99,7 +178,10 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
expect(outcome.incumbent).toBeUndefined();
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
expect(stages).not.toContain('obligations');
expect((await calculation.trace)?.status).toBe('incomplete');
expect(await calculation.trace).toMatchObject({
status: 'incomplete',
initialState: { declaredDays: [{ date: '2024-01-02', type: 'other' }] },
});
expect(await calculation.explanationError).toBeUndefined();
expect(heartbeats).toBeGreaterThan(0);
});
@@ -62,6 +62,7 @@ export class PlanningDurationService {
if (!horizon) return null;
return {
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
balances: this.userInput.vacationNumberSignal().map((balance) => ({
id: balance.id,
type: balance.type,
+10
View File
@@ -4,6 +4,16 @@ import { storedSignal } from '../shared/utils/stored-signal';
export interface AppTranslations {
locale: string;
bookedDays: {
enter: string;
booked: string;
help: string;
fixed: string;
removed: string;
erase: string;
rangeSaved: string;
rangeRemoved: string;
};
app: {
title: string;
description: string;
+106
View File
@@ -31,6 +31,112 @@ describe('UserInputService', () => {
expect(service).toBeTruthy();
});
it('adds, removes and replaces saved declarations without changing balances or calculating', () => {
const balances = service.vacationNumberSignal();
expect(service.declaredDaysSignal()).toEqual([]);
service.toggleDeclaredDay('2024-01-02', 'cp');
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-01-02', type: 'cp' },
]);
service.toggleDeclaredDay('2024-01-02', 'cp');
expect(new UserInputService().declaredDaysSignal()).toEqual([]);
service.toggleDeclaredDay('2024-01-02', 'rtt');
service.toggleDeclaredDay('2024-01-02', 'other');
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-01-02', type: 'other' },
]);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
expect(localStorage.getItem('leaveBalances')).toBeNull();
});
it('rejects weekends, French national holidays and malformed calendar dates', () => {
for (const date of [
'2024-01-06',
'2024-01-07',
'2024-05-08',
'2024-05-09',
'2024-02-30',
'not-a-date',
]) {
service.toggleDeclaredDay(date, 'cp');
}
expect(service.declaredDaysSignal()).toEqual([]);
expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull();
service.toggleDeclaredDay('2024-05-10', 'other');
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-05-10', type: 'other' },
]);
});
it('toggles each date in a batch once and preserves unrelated declarations', () => {
service.toggleDeclaredDay('2024-04-19', 'other');
service.toggleDeclaredDay('2024-04-25', 'rtt');
const balances = service.vacationNumberSignal();
const save = vi.spyOn(Storage.prototype, 'setItem');
service.toggleDeclaredDays(
['2024-04-22', '2024-04-23', '2024-04-24', '2024-04-25', '2024-04-26'],
'cp',
);
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-04-19', type: 'other' },
{ date: '2024-04-22', type: 'cp' },
{ date: '2024-04-23', type: 'cp' },
{ date: '2024-04-24', type: 'cp' },
{ date: '2024-04-25', type: 'cp' },
{ date: '2024-04-26', type: 'cp' },
]);
expect(save).toHaveBeenCalledOnce();
service.toggleDeclaredDays(
[
'2024-04-25',
'2024-04-26',
'2024-04-27',
'2024-04-28',
'2024-04-29',
'2024-04-30',
'2024-05-01',
'2024-04-29',
'invalid',
],
'cp',
);
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-04-19', type: 'other' },
{ date: '2024-04-22', type: 'cp' },
{ date: '2024-04-23', type: 'cp' },
{ date: '2024-04-24', type: 'cp' },
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-04-30', type: 'cp' },
]);
expect(save).toHaveBeenCalledTimes(2);
service.toggleDeclaredDays(['2024-04-29', '2024-04-30'], null);
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-04-19', type: 'other' },
{ date: '2024-04-22', type: 'cp' },
{ date: '2024-04-23', type: 'cp' },
{ date: '2024-04-24', type: 'cp' },
]);
expect(save).toHaveBeenCalledTimes(3);
expect(service.vacationNumberSignal()).toBe(balances);
expect(service.computeTriggerSignal()).toBe(0);
});
it('restores an empty list when saved declarations are unreadable', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
for (const saved of [
'{',
'null',
'[{"date":"2024-05-08","type":"cp"}]',
'[{"date":"2024-01-02","type":"any"}]',
]) {
localStorage.setItem('declaredNonWorkingDays', saved);
expect(new UserInputService().declaredDaysSignal()).toEqual([]);
expect(localStorage.getItem('declaredNonWorkingDays')).toBeNull();
}
expect(warn).toHaveBeenCalled();
});
it('should initialize with vacation days array', () => {
const vacations = service.vacationNumberSignal();
expect(Array.isArray(vacations)).toBe(true);
+56
View File
@@ -1,11 +1,67 @@
import { Injectable, signal } from '@angular/core';
import { SelectableDayType, VacDay } from './calendar-service';
import dayjs from 'dayjs';
import {
DeclaredNonWorkingDay,
isWorkingPlanningDate,
parseDeclaredDays,
} from './declared-non-working-days';
import type { LeaveType } from './leave-planner.service';
@Injectable({
providedIn: 'root',
})
export class UserInputService {
private readonly declaredDays = signal(this.restoreDeclaredDays());
readonly declaredDaysSignal = this.declaredDays.asReadonly();
readonly declarationEntrySignal = signal(false);
readonly declaredTypeSignal = signal<LeaveType | null>('cp');
private restoreDeclaredDays(): DeclaredNonWorkingDay[] {
try {
const saved =
typeof localStorage === 'undefined'
? null
: localStorage.getItem('declaredNonWorkingDays');
return saved === null ? [] : parseDeclaredDays(JSON.parse(saved));
} catch (error) {
console.warn('Failed to restore declared non-working days from localStorage.', error);
try {
localStorage.removeItem('declaredNonWorkingDays');
} catch {
// Browser storage can be unavailable; keep calendar entry usable.
}
return [];
}
}
toggleDeclaredDay(date: string, type: LeaveType): void {
this.toggleDeclaredDays([date], type);
}
toggleDeclaredDays(dates: readonly string[], type: LeaveType | null): void {
const workingDates = new Set(dates.filter(isWorkingPlanningDate));
if (!workingDates.size) return;
this.declaredDays.update((days) => {
const existing = new Map(days.map((day) => [day.date, day.type]));
const others = days.filter((day) => !workingDates.has(day.date));
return [
...others,
...(type
? [...workingDates]
.filter((date) => existing.get(date) !== type)
.map((date) => ({ date, type }))
: []),
].sort((a, b) => a.date.localeCompare(b.date));
});
try {
if (typeof localStorage !== 'undefined')
localStorage.setItem('declaredNonWorkingDays', JSON.stringify(this.declaredDays()));
} catch (error) {
console.warn('Failed to save declared non-working days to localStorage.', error);
}
}
private restoreDate(value: unknown): Date {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const date = new Date(`${value}T00:00:00`);