import { Injectable, inject } from '@angular/core'; import { DateRange } from '@angular/material/datepicker'; import Holidays, { HolidaysTypes } from 'date-holidays'; import dayjs from 'dayjs'; import weekOfYear from 'dayjs/plugin/weekOfYear'; import isoWeek from 'dayjs/plugin/isoWeek'; import { DateCacheService } from './date-cache.service'; import { planLeave as planLeaveOperation, LeaveType, PlanOutcome, PlanningReason, RemainingClassification, } from './leave-planner.service'; import { addPlanningYears, planningDateInParis } from './planning-date'; dayjs.extend(weekOfYear); dayjs.extend(isoWeek); const lang = 'en'; const allTypesOptions: HolidaysTypes.Options = { languages: [lang], }; @Injectable({ providedIn: 'root', }) export class CalendarService { private dateCache = inject(DateCacheService); readonly planLeave = planLeaveOperation; getDefaultPlanningHorizon(now = new Date()): { start: string; end: string } { const start = planningDateInParis(now); return { start, end: addPlanningYears(start, 1) }; } selectedDatesForPlan(plan: PlanOutcome): SelectedDates { const selected = this.getSelectedDatesForYear(); for (const placement of plan.placements) { selected.push( new SelectedDate( dayTypeForLeaveType[placement.type], new DateRange( new Date(`${placement.date}T00:00:00`), new Date(`${placement.date}T00:00:00`), ), ), ); } return selected; } // Cache for holiday timestamps private holidayTimestampsCache: Set | null = null; private holidayCacheYear: number | null = null; // 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 { const holidays = this.getHolidays(); const monthInAYear = this.monthsInAYearFromNow(); 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 monthInAYear) { 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(day, day)), ); } if ( day.getDate() === todayDate && day.getMonth() === todayMonth && day.getFullYear() === todayYear ) { selectedDates._datesSelected.push( new SelectedDate(DayType.TODAY, new DateRange(day, day)), ); } } } } } selectedDates.update(); return selectedDates; } setWeekendSelection(date: Date, selectedDates: SelectedDates) { if (date.getDay() === 6) { selectedDates._datesSelected.push( new SelectedDate( DayType.WEEKEND, new DateRange( date, new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1), ), ), ); } if (date.getDate() === 1 && date.getDay() === 0) { selectedDates._datesSelected.push( new SelectedDate( DayType.WEEKEND, new DateRange( new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1), date, ), ), ); } } getHolidays(): Date[] { // Cache holidays to avoid recalculation const currentYear = this.dateCache.getCurrentYear(); if (this.holidayCacheYear === currentYear && this.holidayTimestampsCache) { // Return timestamps directly for efficient lookup return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts)); } // Logic to return holidays for the given year const hd = new Holidays('FR', allTypesOptions); const holidays = hd .getHolidays(currentYear, lang) .filter((holiday) => holiday.type === 'public'); const holidaysPlusOne = hd .getHolidays(currentYear + 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 = currentYear; return holidaysDate; } static daysInWeek(weekIndex: number, monthIndex: number, year: number): Week { const firstDayOfMonth = new Date(year, monthIndex, 1); 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 = { 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; constructor(type: DayType | null, range: DateRange) { 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) { const timeDiff = this.range.end.getTime() - this.range.start.getTime(); return Math.floor(timeDiff / (1000 * 3600 * 24)) + 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; } 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; 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 if (currStart - currentGroupMaxEnd <= oneDayInMs) { 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(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(' + '); } }