feat(planner): implement cancellation for planning calculations

- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations.
- Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations.
- Add cancellation handling in the worker message event.
- Update planning-date.ts with new utility functions for date manipulation.
- Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation.
- Create documentation for the cancellation feature in planner-cancellation.md.
- Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts.

#57 #58
This commit is contained in:
nessar committed 2026-10-05 15:34:07 +02:00
1 parent 5c35a59805
commit 2edf2004f5
31 files changed
+2275 -304

No files matched your search

+51 -54
View File
@@ -13,7 +13,7 @@ import {
PlanningReason,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningYears, planningDateInParis } from './planning-date';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
@@ -39,10 +39,21 @@ export class CalendarService {
}
selectedDatesForPlan(
plan: Pick<PlanOutcome, 'placements'> & { horizon?: { start: string; end: string } },
plan: Pick<PlanOutcome, 'placements'> & {
horizon?: { start: string; end: string };
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
},
): SelectedDates {
const selected = plan.horizon
? this.getSelectedDatesForHorizon(plan.horizon)
const projectionHorizon = plan.horizon ? { ...plan.horizon } : undefined;
if (projectionHorizon) {
for (const period of plan.timeOffPeriods ?? []) {
if (period.start < projectionHorizon.start) projectionHorizon.start = period.start;
if (period.end >= projectionHorizon.end)
projectionHorizon.end = addPlanningDays(period.end, 1);
}
}
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of plan.placements) {
selected.push(
@@ -58,9 +69,7 @@ export class CalendarService {
return selected;
}
// Cache for holiday timestamps
private holidayTimestampsCache: Set<number> | null = null;
private holidayCacheYear: number | null = null;
private readonly holidaysByYear = new Map<number, number[]>();
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
@@ -78,9 +87,10 @@ export class CalendarService {
const start = new Date(`${horizon.start}T00:00:00`);
const last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
const holidays = this.getHolidays(start.getFullYear());
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
const monthInHorizon: Year = [];
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
const cursor = new Date(start);
cursor.setDate(1);
while (cursor <= last) {
monthInHorizon.push(
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
@@ -128,59 +138,47 @@ export class CalendarService {
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
if (date.getDay() === 6) {
const end = new Date(date);
end.setDate(end.getDate() + 1);
selectedDates._datesSelected.push(
new SelectedDate(
DayType.WEEKEND,
new DateRange<Date>(
date,
new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1),
),
),
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(date, end)),
);
}
if (date.getDate() === 1 && date.getDay() === 0) {
const start = new Date(date);
start.setDate(start.getDate() - 1);
selectedDates._datesSelected.push(
new SelectedDate(
DayType.WEEKEND,
new DateRange<Date>(
new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1),
date,
),
),
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, date)),
);
}
}
getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] {
// Cache holidays to avoid recalculation
if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) {
// Return timestamps directly for efficient lookup
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
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)));
}
// Logic to return holidays for the given year
const hd = new Holidays('FR', allTypesOptions);
const holidays = hd
.getHolidays(startYear, lang)
.filter((holiday) => holiday.type === 'public');
const holidaysPlusOne = hd
.getHolidays(startYear + 1, lang)
.filter((holiday) => holiday.type === 'public');
holidays.push(...holidaysPlusOne);
const holidaysDate: Date[] = holidays.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);
});
// Update cache
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
this.holidayCacheYear = startYear;
return holidaysDate;
return dates;
}
static daysInWeek(weekIndex: number, monthIndex: number, year: number): Week {
const firstDayOfMonth = new Date(year, monthIndex, 1);
const firstDayOfMonth = new Date(0);
firstDayOfMonth.setFullYear(year, monthIndex, 1);
firstDayOfMonth.setHours(0, 0, 0, 0);
const firstDayOfWeek = new Date(firstDayOfMonth);
let offset = firstDayOfMonth.getDay() - 1; // Adjust to make Monday the first day of the week
if (offset < 0) {
@@ -357,8 +355,7 @@ export class SelectedDate implements SelectedDateInterface {
getLengthInDays(): number {
if (this.range.start && this.range.end) {
const timeDiff = this.range.end.getTime() - this.range.start.getTime();
return Math.floor(timeDiff / (1000 * 3600 * 24)) + 1;
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
}
return 0;
}
@@ -409,8 +406,6 @@ export class SelectedDates implements SelectedDateInterface {
return;
}
const oneDayInMs = 24 * 60 * 60 * 1000;
// Sort dates by start date
const sorted = [...this._datesSelected].sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
@@ -429,7 +424,9 @@ export class SelectedDates implements SelectedDateInterface {
const currEnd = current.range.end?.getTime() ?? 0;
// Check if current date is adjacent or overlapping with the maximum end of the current group
if (currStart - currentGroupMaxEnd <= oneDayInMs) {
const nextDay = new Date(currentGroupMaxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if (currStart <= nextDay.getTime()) {
currentGroup.push(current);
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
} else {