Files
vac-optimizer/src/app/services/calendar-service.ts
T
nessar d9d9befa12
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 1m15s
CI / test (push) Successful in 3m18s
CI / benchmark (push) Failing after 5m53s
CI / build (push) Successful in 1m23s
CI / security (push) Successful in 7m6s
CI / policy (pull_request) Successful in 7s
CI / validate (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 43s
CI / test (pull_request) Successful in 3m19s
CI / images (push) Successful in 3m7s
CI / required (push) Successful in 2s
CI / benchmark (pull_request) Failing after 5m28s
CI / security (pull_request) Successful in 6m37s
CI / images (pull_request) Successful in 3m18s
CI / required (pull_request) Successful in 1s
refactor(planning): remove the original decision trace
2026-10-15 20:42:33 +02:00

543 lines
16 KiB
TypeScript

import { Injectable, inject } from '@angular/core';
import { DateRange } from '@angular/material/datepicker';
import { frenchPublicHolidaysForYear, isWorkingPlanningDate } from './declared-non-working-days';
import dayjs from 'dayjs';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import isoWeek from 'dayjs/plugin/isoWeek';
import { DateCacheService } from './date-cache.service';
import {
createPlanningCalculation as createPlanningCalculationOperation,
planLeave as planLeaveOperation,
LeaveType,
PlanOutcome,
PlanningReason,
PlanningStateSnapshot,
RemainingClassification,
} from './leave-planner.service';
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
dayjs.extend(weekOfYear);
dayjs.extend(isoWeek);
type CalendarPlan = Pick<PlanningStateSnapshot, 'placements' | 'declaredDays'> & {
horizon?: PlanningStateSnapshot['horizon'];
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
};
export function calendarProjectionHorizon(plan: CalendarPlan) {
if (!plan.horizon) return undefined;
const horizon = { ...plan.horizon };
if (plan.timeOffPeriods) {
for (const period of plan.timeOffPeriods) {
if (period.start < horizon.start) horizon.start = period.start;
if (period.end >= horizon.end) horizon.end = addPlanningDays(period.end, 1);
}
return horizon;
}
const declaredDates = new Set(plan.declaredDays?.map(({ date }) => date));
const anchors = [...plan.placements, ...(plan.declaredDays ?? [])]
.map(({ date }) => date)
.filter((date) => date >= horizon.start && date < horizon.end)
.sort();
if (!anchors.length) return horizon;
for (const [anchor, direction] of [
[anchors[0], -1],
[anchors[anchors.length - 1], 1],
] as const) {
let date = addPlanningDays(anchor, direction);
while (declaredDates.has(date) || !isWorkingPlanningDate(date)) {
if (date < horizon.start) horizon.start = date;
if (date >= horizon.end) horizon.end = addPlanningDays(date, 1);
date = addPlanningDays(date, direction);
}
}
return horizon;
}
@Injectable({
providedIn: 'root',
})
export class CalendarService {
private dateCache = inject(DateCacheService);
readonly planLeave = planLeaveOperation;
readonly createPlanningCalculation = createPlanningCalculationOperation;
getDefaultPlanningHorizon(now = new Date()): { start: string; end: string } {
const start = planningDateInParis(now);
return { start, end: addPlanningYears(start, 1) };
}
selectedDatesForPlan(plan: CalendarPlan): SelectedDates {
const projectionHorizon = calendarProjectionHorizon(plan);
const selected = projectionHorizon
? this.getSelectedDatesForHorizon(projectionHorizon)
: this.getSelectedDatesForYear();
for (const placement of [
...plan.placements,
...(plan.declaredDays ?? []).filter(
(day) =>
!projectionHorizon ||
(day.date >= projectionHorizon.start && day.date < projectionHorizon.end),
),
]) {
selected.push(
new SelectedDate(
dayTypeForLeaveType[placement.type],
new DateRange<Date>(
new Date(`${placement.date}T00:00:00`),
new Date(`${placement.date}T00:00:00`),
),
),
);
}
return selected;
}
// Get selected dates from current date to the next year
getSelectedDatesFromNow(): SelectedDates {
const _holidays = this.getHolidays();
const selectedDates: SelectedDates = new SelectedDates();
// Logic to populate selectedDates from now until the next year
return selectedDates;
}
getSelectedDatesForYear(): SelectedDates {
return this.getSelectedDatesForHorizon(this.getDefaultPlanningHorizon());
}
getSelectedDatesForHorizon(horizon: { start: string; end: string }): SelectedDates {
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(), last.getFullYear());
const monthInHorizon: Year = [];
const cursor = new Date(start);
cursor.setDate(1);
while (cursor <= last) {
monthInHorizon.push(
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
);
cursor.setMonth(cursor.getMonth() + 1);
}
const selectedDates: SelectedDates = new SelectedDates();
// Use DateCacheService to get today's date components
const {
date: todayDate,
month: todayMonth,
year: todayYear,
} = this.dateCache.getTodayComponents();
// Create a Set of holiday timestamps for O(1) lookup instead of O(n)
const holidayTimestamps = new Set(holidays.map((h) => h.getTime()));
for (const weeksInAMonth of monthInHorizon) {
for (const daysInAWeek of weeksInAMonth) {
for (const day of daysInAWeek) {
if (day) {
this.setWeekendSelection(day, selectedDates);
if (holidayTimestamps.has(day.getTime())) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
);
}
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getFullYear() === todayYear
) {
selectedDates._datesSelected.push(
new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)),
);
}
}
}
}
}
selectedDates.update();
return selectedDates;
}
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, 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>(start, date)),
);
}
}
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
const dates: Date[] = [];
for (let year = startYear; year <= lastYear; year++) {
for (const date of frenchPublicHolidaysForYear(year))
dates.push(new Date(`${date}T00:00:00`));
}
return dates;
}
static daysInWeek(weekIndex: number, monthIndex: number, year: number): Week {
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) {
offset = 6; // Sunday case
}
firstDayOfWeek.setDate(firstDayOfMonth.getDate() + weekIndex * 7 - offset);
const days: Week = [];
for (let i = 0; i < 7; i++) {
const currentDay = new Date(firstDayOfWeek);
currentDay.setDate(firstDayOfWeek.getDate() + i);
if (currentDay.getMonth() === monthIndex) {
days.push(currentDay);
} else {
days.push(null);
}
}
return days;
}
static weeksInMonth(monthIndex: number, year: number): Month {
const weeks: Month = [];
let weekIndex = 0;
while (true) {
const week = this.daysInWeek(weekIndex, monthIndex, year);
if (week.every((day) => day === null)) {
break;
}
weeks.push(week);
weekIndex++;
}
return weeks;
}
static monthsInYear(year: number): Year {
const months: Year = [];
for (let monthIndex = 0; monthIndex < 12; monthIndex++) {
months.push(this.weeksInMonth(monthIndex, year));
}
return months;
}
monthsInAYearFromNow(): Year {
const months: Year = [];
const currentMonthIndex = this.dateCache.getCurrentMonth();
const currentYear = this.dateCache.getCurrentYear();
for (let i = 0; i < 12; i++) {
const monthIndex = (currentMonthIndex + i) % 12;
const year = currentYear + Math.floor((currentMonthIndex + i) / 12);
months.push(CalendarService.weeksInMonth(monthIndex, year));
}
return months;
}
static getWeekNumber(date: Date): number {
return dayjs(date).isoWeek();
}
}
export type VacDay = {
id: string;
type: SelectableDayType;
numberOfDays: number;
/** Legacy balances omit this value; the planner then uses the horizon start. */
availableDate?: Date;
expiryDate: Date;
classification?: RemainingClassification;
reason?: PlanningReason;
};
export type Week = (Date | null)[];
export type Month = Week[];
export type Year = Month[];
export enum DayType {
WEEKEND = 'weekend',
CLOSED_DAY = 'closed-day',
RTT = 'rtt',
CP = 'cp',
OTHER = 'other',
TODAY = 'today',
}
export enum SelectableDayType {
CP = DayType.CP,
RTT = DayType.RTT,
OTHER = DayType.OTHER,
ANY = 'any',
}
const dayTypeForLeaveType: Record<LeaveType, DayType> = {
cp: DayType.CP,
rtt: DayType.RTT,
other: DayType.OTHER,
};
export const tooltipTypeMapping: { [key in DayType | SelectableDayType]: string } = {
[DayType.WEEKEND]: 'Weekend',
[DayType.CLOSED_DAY]: 'Jour Férié',
[DayType.RTT]: 'RTT',
[DayType.CP]: 'Congé Payé',
[DayType.OTHER]: 'Autre',
[DayType.TODAY]: "Aujourd'hui",
[SelectableDayType.ANY]: "N'importe",
};
export const monthNames: string[] = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre',
];
export interface SelectedDateInterface {
isSelected(date: Date): boolean;
isStart(date: Date): boolean;
isEnd(date: Date): boolean;
getClasses(date: Date): string;
}
export class SelectedDate implements SelectedDateInterface {
type: DayType | null;
range: DateRange<Date>;
constructor(type: DayType | null, range: DateRange<Date>) {
this.type = type;
this.range = range;
}
isSelected(date: Date): boolean {
return (
this.type !== DayType.TODAY &&
this.range.start !== null &&
this.range.end !== null &&
date >= this.range.start &&
date <= this.range.end
);
}
isStart(date: Date): boolean {
return this.range.start !== null && date.getTime() === this.range.start.getTime();
}
isEnd(date: Date): boolean {
return this.range.end !== null && date.getTime() === this.range.end.getTime();
}
getClasses(date: Date): string {
let returnType: DayType | string = '';
if (this.type && date >= this.range.start! && date <= this.range.end!) {
returnType += this.type + ' ';
}
return returnType;
}
getTooltip(date: Date): string | null | undefined {
if (this.isSelected(date)) {
if (this.type) {
return tooltipTypeMapping[this.type];
}
}
return null;
}
getLengthInDays(): number {
if (this.range.start && this.range.end) {
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
}
return 0;
}
}
export class SelectedDates implements SelectedDateInterface {
_datesSelected: SelectedDate[] = [];
datesSelected: SelectedDate[] = [];
private minDate: Date | null = null;
private maxDate: Date | null = null;
update(): void {
this.datesSelected = this._datesSelected.slice();
this.grouping();
}
push(selectedDate: SelectedDate) {
this._datesSelected.push(selectedDate);
this.datesSelected = this._datesSelected.slice();
this.updateMinMaxDates();
this.grouping();
}
private updateMinMaxDates(): void {
if (this._datesSelected.length === 0) {
this.minDate = null;
this.maxDate = null;
return;
}
this.minDate = this._datesSelected.reduce((min, date) => {
if (!date.range.start) return min;
if (!min) return date.range.start;
return date.range.start < min ? date.range.start : min;
}, this._datesSelected[0].range.start);
this.maxDate = this._datesSelected.reduce((max, date) => {
if (!date.range.end) return max;
if (!max) return date.range.end;
return date.range.end > max ? date.range.end : max;
}, this._datesSelected[0].range.end);
}
// make a "mother group" with type = null when two or more groups are adjacent or overlapping; keep the children groups
grouping(): void {
if (this._datesSelected.length <= 1) {
return;
}
// Sort dates by start date
const sorted = [...this._datesSelected].sort((a, b) => {
const aStart = a.range.start?.getTime() ?? 0;
const bStart = b.range.start?.getTime() ?? 0;
return aStart - bStart;
});
// Group adjacent or overlapping dates
const groups: SelectedDate[][] = [];
let currentGroup: SelectedDate[] = [sorted[0]];
let currentGroupMaxEnd = sorted[0].range.end?.getTime() ?? 0;
for (let i = 1; i < sorted.length; i++) {
const current = sorted[i];
const currStart = current.range.start?.getTime() ?? 0;
const currEnd = current.range.end?.getTime() ?? 0;
// Check if current date is adjacent or overlapping with the maximum end of the current group
const nextDay = new Date(currentGroupMaxEnd);
nextDay.setDate(nextDay.getDate() + 1);
if (currStart <= nextDay.getTime()) {
currentGroup.push(current);
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
} else {
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
currentGroup = [current];
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
}
}
// Add the last group if it has more than one element
if (currentGroup.length > 1) {
groups.push(currentGroup);
}
// Create mother groups and add to result
const result: SelectedDate[] = [...this._datesSelected];
for (const group of groups) {
const groupStart = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity));
const groupEnd = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0));
result.push(
new SelectedDate(
null,
new DateRange<Date>(new Date(groupStart), new Date(groupEnd)),
),
);
}
this.datesSelected = result;
}
isSelected(date: Date): boolean {
return this.datesSelected.some((selectedDate) => selectedDate.isSelected(date));
}
private getRangeBoundaryCounts(date: Date): { starts: number; ends: number } {
let starts = 0;
let ends = 0;
for (const selected of this.datesSelected) {
if (selected.range.start && date.getTime() === selected.range.start.getTime()) {
starts++;
}
if (selected.range.end && date.getTime() === selected.range.end.getTime()) {
ends++;
}
}
return { starts, ends };
}
isStart(date: Date): boolean {
if (this.existInARangeButNotABoundary(date)) return false;
const { starts, ends } = this.getRangeBoundaryCounts(date);
return starts >= ends;
}
isEnd(date: Date): boolean {
if (this.existInARangeButNotABoundary(date)) return false;
const { starts, ends } = this.getRangeBoundaryCounts(date);
return ends >= starts;
}
/**
* Check whether a given date is selected and lies strictly inside a selected range
* (i.e., it is part of at least one selected range but is not the start or end of that range).
*
* This method returns true only if:
* - `this.isSelected(dateTest)` is true (the date is considered selected), and
* - there exists at least one element in `this.datesSelected` for which
* `dateSelected.isStart(dateTest)` and `dateSelected.isEnd(dateTest)` are both false.
*
* Notes:
* - The exact equality/comparison semantics are delegated to `isSelected`, `isStart`
* and `isEnd` implementations on the respective objects.
* - `dateTest` is expected to be a valid Date. Behavior for null/undefined/invalid Date
* values is not defined here.
*
* @private
* @param dateTest - The date to test.
* @returns True if the date is an interior day of at least one selected range; otherwise false.
*/
private existInARangeButNotABoundary(dateTest: Date): boolean {
return this.datesSelected.some(
(dateSelected) =>
dateSelected.isSelected(dateTest) &&
!dateSelected.isStart(dateTest) &&
!dateSelected.isEnd(dateTest),
);
}
getClasses(date: Date): string {
return this.datesSelected.map((dateSelected) => dateSelected.getClasses(date)).join(' ');
}
getTooltip(date: Date): string | null | undefined {
return this.datesSelected
.map((dateSelected) => dateSelected.getTooltip(date))
.filter((tooltip) => tooltip)
.join(' + ');
}
}