This commit is contained in:
1 parent
03841c6e3d
commit
036a8c0e1e
32 files changed
+1805
-89
No files matched your search
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in new issue
Block a user