From 502d561f6f3389cb3ef54610d5649b7ccdb11c92 Mon Sep 17 00:00:00 2001 From: Nessar Date: Sat, 17 Oct 2026 11:18:33 +0200 Subject: [PATCH] refactor(calendar): encapsulate calendar projection and selection ranges CalendarService.project returns the covered months together with the projected selections, so month enumeration and selected-day rendering share one projection horizon. SelectedDates is built once from the complete range list and groups connected periods internally, exposing read-only selection, boundary and day-type queries. In declaration entry, months reached by adjacent declared or non-working days are now shown, matching the projected selections. Refs #276 Co-Authored-By: Claude Opus 5.5 --- .../calendar-month/calendar-month.ts | 12 +- .../calendar-year-planning.spec.ts | 49 ++- .../components/calendar-year/calendar-year.ts | 63 +--- src/app/services/calendar-service.spec.ts | 224 ++++++------ src/app/services/calendar-service.ts | 327 ++++++------------ 5 files changed, 268 insertions(+), 407 deletions(-) diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts index 06ba781..3043aad 100644 --- a/src/app/components/calendar-month/calendar-month.ts +++ b/src/app/components/calendar-month/calendar-month.ts @@ -184,14 +184,10 @@ export class CalendarMonth implements OnInit { getDayTooltip(day: Date): string | null { const previewed = this.declarationPreview?.dates.has(this.getDateKey(day)); - const tooltips: string[] = this.selectedDates.datesSelected - .filter( - (dateSelected) => - dateSelected.isSelected(day) && - dateSelected.type && - (!previewed || !['cp', 'rtt', 'other'].includes(dateSelected.type)), - ) - .map((dateSelected) => this.translationService.getDayTypeLabel(dateSelected.type!)); + const tooltips: string[] = this.selectedDates + .dayTypes(day) + .filter((type) => !previewed || !['cp', 'rtt', 'other'].includes(type)) + .map((type) => this.translationService.getDayTypeLabel(type)); const declaredType = this.getDeclaredType(this.getDateKey(day)); if (declaredType) tooltips.push( diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index a6a47ac..b8400c0 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -9,6 +9,7 @@ import { DayType, SelectedDate, SelectedDates, + calendarProjectionHorizon, SelectableDayType, } from '../../services/calendar-service'; import { @@ -125,17 +126,43 @@ const explanation: PlanningExplanation = { })), }; -function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates { - const selected = new SelectedDates(); +function projectionFor( + value: Parameters[0] & { + horizon: { start: string; end: string }; + }, + includeHoliday = false, +) { + const horizon = calendarProjectionHorizon(value)!; + const months: { monthIndex: number; year: number }[] = []; + const last = new Date(`${horizon.end}T00:00:00`); + last.setDate(last.getDate() - 1); + const cursor = new Date(`${horizon.start}T00:00:00`); + cursor.setDate(1); + while (cursor <= last) { + months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); + cursor.setMonth(cursor.getMonth() + 1); + } + return { + horizon, + months, + selections: selectedDatesForPlan(value, includeHoliday), + }; +} + +function selectedDatesForPlan( + value: { placements: readonly PlanOutcome['placements'][number][] }, + includeHoliday = false, +): SelectedDates { + const ranges: SelectedDate[] = []; if (includeHoliday) { const holiday = new Date('2024-05-08T00:00:00'); - selected.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday))); + ranges.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday))); } for (const placement of value.placements) { const date = new Date(`${placement.date}T00:00:00`); - selected.push(new SelectedDate(DayType.CP, new DateRange(date, date))); + ranges.push(new SelectedDate(DayType.CP, new DateRange(date, date))); } - return selected; + return new SelectedDates(ranges); } function placementForSelection(selection: CounterfactualSelection): LeavePlacement { @@ -209,11 +236,9 @@ function calendarFor(outcome: PlanningOutcome, stableExplanation?: PlanningExpla return { calculation, calendar: { - getSelectedDatesForYear: vi.fn(() => new SelectedDates()), - getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()), getDefaultPlanningHorizon: vi.fn(() => plan.horizon), createPlanningCalculation: vi.fn(() => calculation), - selectedDatesForPlan: vi.fn(selectedDatesForPlan), + project: vi.fn((value: Parameters[0]) => projectionFor(value)), }, }; } @@ -2340,9 +2365,7 @@ describe('CalendarYear planning outcome boundary', () => { placeType: 'cp', }, ]; - calendar.selectedDatesForPlan.mockImplementation((value) => - selectedDatesForPlan(value, true), - ); + calendar.project.mockImplementation((value) => projectionFor(value, true)); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ @@ -2952,14 +2975,12 @@ describe('CalendarYear planning outcome boundary', () => { it('turns planner rejection into an incomplete error and clears loading', async () => { const calendar = { - getSelectedDatesForYear: vi.fn(() => new SelectedDates()), - getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()), getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), createPlanningCalculation: vi.fn(() => ({ ...calculationFor(plan), outcome: Promise.reject(new Error('planner exploded')), })), - selectedDatesForPlan: vi.fn(() => new SelectedDates()), + project: vi.fn((value: Parameters[0]) => projectionFor(value)), }; await TestBed.configureTestingModule({ imports: [CalendarYear], diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 1a0fd9a..590fdce 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -33,11 +33,7 @@ import { import { CalendarMonth, DeclarationPreview } from '../calendar-month/calendar-month'; import { addPlanningDays } from '../../services/planning-date'; import { isWorkingPlanningDate } from '../../services/declared-non-working-days'; -import { - CalendarService, - calendarProjectionHorizon, - SelectedDates, -} from '../../services/calendar-service'; +import { CalendarService, SelectedDates } from '../../services/calendar-service'; import { TranslationService } from '../../services/translation-service'; import { UserInputService } from '../../services/user-input-service'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; @@ -115,7 +111,6 @@ export class CalendarYear { protected readonly comparisonDateInput = signal(null); protected readonly liveMessage = signal(''); protected readonly planningHorizon = this.calculations.displayedHorizon; - protected readonly selectedDates = signal(new SelectedDates()); protected readonly declarationEntry = this.userInput.declarationEntrySignal; private readonly monthGrid = viewChild>('monthGrid'); private readonly declarationDrag = signal<{ @@ -270,20 +265,23 @@ export class CalendarYear { this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after), ); - protected readonly calendarSelectedDates = computed(() => { - if (this.declarationEntry()) { - const horizon = this.calendarHorizon(); - return horizon - ? this.calendarService.selectedDatesForPlan({ - placements: [], - horizon, - declaredDays: this.userInput.declaredDaysSignal(), - }) - : new SelectedDates(); - } - - return this.selectedDates(); + protected readonly calendarProjection = computed(() => { + const horizon = this.calendarHorizon(); + if (!horizon) return undefined; + if (this.declarationEntry()) + return this.calendarService.project({ + placements: [], + horizon, + declaredDays: this.userInput.declaredDaysSignal(), + }); + const plan = this.displayedPlan(); + return this.calendarService.project( + plan ? { ...plan, horizon: plan.horizon ?? horizon } : { placements: [], horizon }, + ); }); + protected readonly calendarSelectedDates = computed( + () => this.calendarProjection()?.selections ?? new SelectedDates(), + ); protected readonly currentPlacementDate = computed(() => { return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null; @@ -293,26 +291,7 @@ export class CalendarYear { this.planDetailsOpen() ? this.selectedExplanation()?.timeOffPeriod : undefined, ); - protected readonly months = computed(() => { - const horizon = this.calendarHorizon(); - if (!horizon) return []; - const plan = this.displayedPlan(); - const projectionHorizon = - !this.declarationEntry() && plan - ? (calendarProjectionHorizon(plan) ?? horizon) - : horizon; - const first = new Date(`${projectionHorizon.start}T00:00:00`); - const last = new Date(`${projectionHorizon.end}T00:00:00`); - last.setDate(last.getDate() - 1); - const result: { monthIndex: number; year: number }[] = []; - const cursor = new Date(first); - cursor.setDate(1); - while (cursor <= last) { - result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); - cursor.setMonth(cursor.getMonth() + 1); - } - return result; - }); + protected readonly months = computed(() => this.calendarProjection()?.months ?? []); constructor() { effect(() => this.dateAdapter.setLocale(this.translationService.locale())); @@ -352,12 +331,6 @@ export class CalendarYear { this.selectedPlacementIndex.set(0); this.planDetailsOpen.set(false); this.clearCounterfactual(); - const plan = this.displayedPlan(); - this.selectedDates.set( - plan - ? this.calendarService.selectedDatesForPlan(plan) - : this.calendarService.getSelectedDatesForHorizon(accepted.horizon), - ); this.liveMessage.set(this.outcomeLabel(accepted.outcome)); if (accepted.successful && !this.destroyRef.destroyed) afterNextRender( diff --git a/src/app/services/calendar-service.spec.ts b/src/app/services/calendar-service.spec.ts index fa703a0..1df7ce3 100644 --- a/src/app/services/calendar-service.spec.ts +++ b/src/app/services/calendar-service.spec.ts @@ -46,21 +46,18 @@ describe('CalendarService', () => { const previousTimeZone = process.env['TZ']; process.env['TZ'] = 'Europe/Paris'; try { - const selected = service.selectedDatesForPlan({ + const { selections: selected } = service.project({ horizon: { start: date, end }, placements: [ { date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false }, ], }); - const period = selected.datesSelected.find( - (item) => - item.type === null && - item.range.start?.getTime() === - new Date(`${periodStart}T00:00:00`).getTime() && - item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(), - ); - expect(period).toBeDefined(); - expect(period?.getLengthInDays()).toBe(3); + const day = (key: string) => new Date(`${key}T00:00:00`); + expect(selected.isStart(day(periodStart))).toBe(true); + expect(selected.isEnd(day(periodStart))).toBe(false); + expect(selected.isStart(day(date))).toBe(false); + expect(selected.isEnd(day(date))).toBe(true); + expect(selected.isSelected(day(end))).toBe(false); } finally { if (previousTimeZone === undefined) delete process.env['TZ']; else process.env['TZ'] = previousTimeZone; @@ -70,7 +67,7 @@ describe('CalendarService', () => { it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => { const horizon = { start: '2024-04-02', end: '2024-04-03' }; - const selected = service.selectedDatesForPlan({ + const { selections: selected } = service.project({ horizon, placements: [ { date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false }, @@ -107,21 +104,15 @@ describe('CalendarService', () => { ], }); if (outcome.kind !== 'Plan') throw new Error(`Expected Plan, got ${outcome.kind}`); - const selected = service.selectedDatesForPlan(outcome); + const selected = service.project(outcome).selections; const date = (key: string) => new Date(`${key}T00:00:00`); expect(selected.getClasses(date('2024-05-31'))).toContain(DayType.OTHER); expect(selected.getClasses(date('2024-06-01'))).toContain(DayType.WEEKEND); expect(selected.getClasses(date('2024-06-03'))).toContain(DayType.CP); expect(selected.isSelected(date('2024-06-05'))).toBe(false); - expect( - selected.datesSelected - .find( - (selection) => - selection.type === null && - selection.range.start?.getTime() === date('2024-05-31').getTime(), - ) - ?.getLengthInDays(), - ).toBe(4); + expect(selected.isStart(date('2024-05-31'))).toBe(true); + expect(selected.isEnd(date('2024-06-03'))).toBe(true); + expect(selected.isEnd(date('2024-06-02'))).toBe(false); expect(outcome.placements).toEqual([]); expect(outcome.horizon).toEqual(horizon); }); @@ -134,7 +125,7 @@ describe('CalendarService', () => { ]; const before = { horizon, declaredDays, placements: [] }; expect(calendarProjectionHorizon(before)).toEqual(horizon); - expect(service.selectedDatesForPlan(before).isSelected(new Date('2024-08-01'))).toBe(false); + expect(service.project(before).selections.isSelected(new Date('2024-08-01'))).toBe(false); const after = { ...before, placements: [ @@ -147,7 +138,7 @@ describe('CalendarService', () => { }, ], }; - const selected = service.selectedDatesForPlan(after); + const selected = service.project(after).selections; expect(calendarProjectionHorizon(after)).toEqual({ start: horizon.start, end: '2024-08-02', @@ -201,52 +192,37 @@ describe('CalendarService', () => { }); }); - describe('getSelectedDatesForYear', () => { - it('should return selected dates for the year', () => { - const selectedDates = service.getSelectedDatesForYear(); - expect(selectedDates).toBeDefined(); - expect(selectedDates._datesSelected.length).toBeGreaterThan(0); + describe('project', () => { + const project = (horizon: { start: string; end: string }) => + service.project({ placements: [], horizon }); + const day = (key: string) => new Date(`${key}T00:00:00`); + + it('projects weekends, holidays and today for the default planning horizon', () => { + const { selections } = project({ start: '2026-01-01', end: '2027-01-01' }); + expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND); + expect(selections.dayTypes(day('2026-05-01'))).toContain(DayType.CLOSED_DAY); + const horizon = service.getDefaultPlanningHorizon(); + expect(project(horizon).selections.getClasses(day(horizon.start))).toContain( + DayType.TODAY, + ); }); - it('should include weekends', () => { - const selectedDates = service.getSelectedDatesForYear(); - const hasWeekend = selectedDates._datesSelected.some( - (date) => date.type === DayType.WEEKEND, - ); - expect(hasWeekend).toBe(true); - }); - - it('should include holidays', () => { - const selectedDates = service.getSelectedDatesForYear(); - const hasHoliday = selectedDates._datesSelected.some( - (date) => date.type === DayType.CLOSED_DAY, - ); - expect(hasHoliday).toBe(true); - }); - - it('should include today', () => { - const selectedDates = service.getSelectedDatesForYear(); - const hasToday = selectedDates._datesSelected.some( - (date) => date.type === DayType.TODAY, - ); - expect(hasToday).toBe(true); + it('lists the covered months, including partial boundary months and year changes', () => { + expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([ + { monthIndex: 10, year: 2024 }, + { monthIndex: 11, year: 2024 }, + { monthIndex: 0, year: 2025 }, + { monthIndex: 1, year: 2025 }, + ]); }); it('includes the final partial month of a half-open one-year horizon', () => { - const selectedDates = service.getSelectedDatesForHorizon({ - start: '2024-01-15', - end: '2025-01-15', - }); - expect(selectedDates.getClasses(new Date('2025-01-04T00:00:00'))).toContain( - DayType.WEEKEND, - ); + const { selections } = project({ start: '2024-01-15', end: '2025-01-15' }); + expect(selections.dayTypes(day('2025-01-04'))).toContain(DayType.WEEKEND); }); it('projects holidays in every covered year and in both partial boundary months', () => { - const selected = service.getSelectedDatesForHorizon({ - start: '2023-12-26', - end: '2027-01-03', - }); + const { selections } = project({ start: '2023-12-26', end: '2027-01-03' }); for (const date of [ '2023-12-25', '2024-04-01', @@ -254,20 +230,32 @@ describe('CalendarService', () => { '2026-04-06', '2027-01-01', ]) { - expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain( - DayType.CLOSED_DAY, - ); + expect(selections.dayTypes(day(date))).toContain(DayType.CLOSED_DAY); } - expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND); + expect(selections.dayTypes(day('2027-01-02'))).toContain(DayType.WEEKEND); }); - }); - describe('setWeekendSelection', () => { - it('should add weekend for Saturday', () => { - const selectedDates = new SelectedDates(); - const saturday = new Date(2024, 0, 6); // A Saturday - service.setWeekendSelection(saturday, selectedDates); - expect(selectedDates._datesSelected.length).toBeGreaterThan(0); + it('does not mutate the submitted plan or its horizon', () => { + const plan = { + horizon: { start: '2024-04-02', end: '2024-04-03' }, + placements: [ + { + date: '2024-04-02', + balanceId: 'cp', + type: 'cp' as const, + ruleIds: [], + supporting: false, + }, + ], + declaredDays: [{ date: '2024-04-03', type: 'rtt' as const }], + }; + const before = structuredClone(plan); + const frozen = JSON.parse(JSON.stringify(plan), (_, value) => + typeof value === 'object' && value ? Object.freeze(value) : value, + ); + const projection = service.project(frozen); + expect(projection.horizon.end).toBe('2024-04-04'); + expect(frozen).toEqual(before); }); }); }); @@ -298,13 +286,6 @@ describe('SelectedDate', () => { expect(classes).toContain(DayType.CP); }); - it('should return tooltip for selected date', () => { - const date = new Date(2024, 0, 1); - const selectedDate = new SelectedDate(DayType.CP, new DateRange(date, date)); - const tooltip = selectedDate.getTooltip(date); - expect(tooltip).toBeTruthy(); - }); - it('should calculate length in days', () => { const startDate = new Date(2024, 0, 1); const endDate = new Date(2024, 0, 3); @@ -320,47 +301,64 @@ describe('SelectedDate', () => { }); describe('SelectedDates', () => { - it('should push selected dates', () => { - const selectedDates = new SelectedDates(); - const date = new Date(2024, 0, 1); - selectedDates.push(new SelectedDate(DayType.CP, new DateRange(date, date))); - expect(selectedDates._datesSelected.length).toBe(1); - }); + const range = (type: DayType, start: Date, end = start) => + new SelectedDate(type, new DateRange(start, end)); - it('should check if date is selected', () => { - const selectedDates = new SelectedDates(); + it('selects covered dates and reports their day types', () => { const date = new Date(2024, 0, 1); - selectedDates.push(new SelectedDate(DayType.CP, new DateRange(date, date))); + const selectedDates = new SelectedDates([range(DayType.CP, date)]); expect(selectedDates.isSelected(date)).toBe(true); + expect(selectedDates.dayTypes(date)).toEqual([DayType.CP]); + expect(selectedDates.getClasses(date)).toContain(DayType.CP); }); - it('should group adjacent dates', () => { - const selectedDates = new SelectedDates(); - const date1 = new Date(2024, 0, 1); - const date2 = new Date(2024, 0, 2); - selectedDates._datesSelected.push( - new SelectedDate(DayType.CP, new DateRange(date1, date1)), - ); - selectedDates._datesSelected.push( - new SelectedDate(DayType.RTT, new DateRange(date2, date2)), - ); - selectedDates.update(); - expect(selectedDates.datesSelected.length).toBeGreaterThan(2); + it('treats adjacent ranges as one period with shared boundaries', () => { + const first = new Date(2024, 0, 1); + const second = new Date(2024, 0, 2); + const third = new Date(2024, 0, 3); + const selectedDates = new SelectedDates([ + range(DayType.CP, first), + range(DayType.RTT, second), + range(DayType.OTHER, third), + ]); + expect(selectedDates.isStart(first)).toBe(true); + expect(selectedDates.isEnd(first)).toBe(false); + expect(selectedDates.isStart(second)).toBe(false); + expect(selectedDates.isEnd(second)).toBe(false); + expect(selectedDates.isEnd(third)).toBe(true); + expect(selectedDates.dayTypes(second)).toEqual([DayType.RTT]); }); - it('should return correct classes', () => { - const selectedDates = new SelectedDates(); + it('keeps separated ranges as separate periods', () => { + const first = new Date(2024, 0, 1); + const later = new Date(2024, 0, 5); + const selectedDates = new SelectedDates([ + range(DayType.CP, first), + range(DayType.CP, later), + ]); + expect(selectedDates.isStart(later)).toBe(true); + expect(selectedDates.isEnd(first)).toBe(true); + }); + + it('merges overlapping ranges and keeps every overlapping day type', () => { + const start = new Date(2024, 0, 1); + const overlap = new Date(2024, 0, 2); + const end = new Date(2024, 0, 3); + const selectedDates = new SelectedDates([ + range(DayType.WEEKEND, start, overlap), + range(DayType.CLOSED_DAY, overlap, end), + ]); + expect(selectedDates.dayTypes(overlap)).toEqual([DayType.WEEKEND, DayType.CLOSED_DAY]); + expect(selectedDates.isStart(start)).toBe(true); + expect(selectedDates.isEnd(end)).toBe(true); + expect(selectedDates.isStart(overlap)).toBe(false); + }); + + it('does not select today', () => { const date = new Date(2024, 0, 1); - selectedDates.push(new SelectedDate(DayType.CP, new DateRange(date, date))); - const classes = selectedDates.getClasses(date); - expect(classes).toContain(DayType.CP); - }); - - it('should return tooltip', () => { - const selectedDates = new SelectedDates(); - const date = new Date(2024, 0, 1); - selectedDates.push(new SelectedDate(DayType.CP, new DateRange(date, date))); - const tooltip = selectedDates.getTooltip(date); - expect(tooltip).toBeTruthy(); + const selectedDates = new SelectedDates([range(DayType.TODAY, date)]); + expect(selectedDates.isSelected(date)).toBe(false); + expect(selectedDates.dayTypes(date)).toEqual([]); + expect(selectedDates.getClasses(date)).toContain(DayType.TODAY); }); }); diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index abc85a4..cdf5d28 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -52,6 +52,12 @@ export function calendarProjectionHorizon(plan: CalendarPlan) { return horizon; } +export interface CalendarProjection { + horizon: { start: string; end: string }; + months: { monthIndex: number; year: number }[]; + selections: SelectedDates; +} + @Injectable({ providedIn: 'root', }) @@ -66,112 +72,76 @@ export class CalendarService { 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( - 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`); + project( + plan: CalendarPlan & { horizon: PlanningStateSnapshot['horizon'] }, + ): CalendarProjection { + const horizon = calendarProjectionHorizon(plan)!; + const first = 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 holidays = new Set( + this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()), + ); 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(day, day)), - ); - } - if ( - day.getDate() === todayDate && - day.getMonth() === todayMonth && - day.getFullYear() === todayYear - ) { - selectedDates._datesSelected.push( - new SelectedDate(DayType.TODAY, new DateRange(day, day)), - ); - } + const months: CalendarProjection['months'] = []; + const ranges: SelectedDate[] = []; + const cursor = new Date(first); + cursor.setDate(1); + while (cursor <= last) { + months.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); + for (const week of CalendarService.weeksInMonth( + cursor.getMonth(), + cursor.getFullYear(), + )) { + for (const day of week) { + if (!day) continue; + if (day.getDay() === 6) { + const end = new Date(day); + end.setDate(end.getDate() + 1); + ranges.push( + new SelectedDate(DayType.WEEKEND, new DateRange(day, end)), + ); } + if (day.getDate() === 1 && day.getDay() === 0) { + const start = new Date(day); + start.setDate(start.getDate() - 1); + ranges.push( + new SelectedDate(DayType.WEEKEND, new DateRange(start, day)), + ); + } + if (holidays.has(day.getTime())) + ranges.push( + new SelectedDate(DayType.CLOSED_DAY, new DateRange(day, day)), + ); + if ( + day.getDate() === todayDate && + day.getMonth() === todayMonth && + day.getFullYear() === todayYear + ) + ranges.push(new SelectedDate(DayType.TODAY, new DateRange(day, day))); } } + cursor.setMonth(cursor.getMonth() + 1); } - 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, 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(start, date)), + for (const placement of [ + ...plan.placements, + ...(plan.declaredDays ?? []).filter( + (day) => day.date >= horizon.start && day.date < horizon.end, + ), + ]) { + const day = new Date(`${placement.date}T00:00:00`); + ranges.push( + new SelectedDate( + dayTypeForLeaveType[placement.type], + new DateRange(day, day), + ), ); } + return { horizon, months, selections: new SelectedDates(ranges) }; } getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] { @@ -281,16 +251,6 @@ const dayTypeForLeaveType: Record = { 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', @@ -350,15 +310,6 @@ export class SelectedDate implements SelectedDateInterface { 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; @@ -368,113 +319,60 @@ export class SelectedDate implements SelectedDateInterface { } export class SelectedDates implements SelectedDateInterface { - _datesSelected: SelectedDate[] = []; - datesSelected: SelectedDate[] = []; + private readonly ranges: readonly SelectedDate[]; + // Ranges plus synthetic null-type groups for connected ranges, so connected periods share boundaries. + private readonly grouped: readonly SelectedDate[]; - private minDate: Date | null = null; - private maxDate: Date | null = null; - - update(): void { - this.datesSelected = this._datesSelected.slice(); - this.grouping(); + constructor(ranges: readonly SelectedDate[] = []) { + this.ranges = ranges.slice(); + this.grouped = SelectedDates.group(this.ranges); } - 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 + private static group(ranges: readonly SelectedDate[]): SelectedDate[] { + const result = ranges.slice(); + if (ranges.length <= 1) return result; + const sorted = [...ranges].sort( + (a, b) => (a.range.start?.getTime() ?? 0) - (b.range.start?.getTime() ?? 0), + ); 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); + let current: SelectedDate[] = [sorted[0]]; + let maxEnd = sorted[0].range.end?.getTime() ?? 0; + for (const next of sorted.slice(1)) { + const nextDay = new Date(maxEnd); 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); + if ((next.range.start?.getTime() ?? 0) > nextDay.getTime()) { + if (current.length > 1) groups.push(current); + current = []; } + current.push(next); + maxEnd = Math.max(maxEnd, next.range.end?.getTime() ?? 0); } - - // 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]; - + if (current.length > 1) groups.push(current); 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)); - + const start = Math.min(...group.map((d) => d.range.start?.getTime() ?? Infinity)); + const end = Math.max(...group.map((d) => d.range.end?.getTime() ?? 0)); result.push( - new SelectedDate( - null, - new DateRange(new Date(groupStart), new Date(groupEnd)), - ), + new SelectedDate(null, new DateRange(new Date(start), new Date(end))), ); } + return result; + } - this.datesSelected = result; + /** Day types covering the date, in insertion order (today is never a selection). */ + dayTypes(date: Date): readonly DayType[] { + return this.ranges + .filter((selected) => selected.type && selected.isSelected(date)) + .map((selected) => selected.type!); } isSelected(date: Date): boolean { - return this.datesSelected.some((selectedDate) => selectedDate.isSelected(date)); + return this.grouped.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) { + for (const selected of this.grouped) { if (selected.range.start && date.getTime() === selected.range.start.getTime()) { starts++; } @@ -497,27 +395,9 @@ export class SelectedDates implements SelectedDateInterface { 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. - */ + // True when the date lies strictly inside at least one selected range. private existInARangeButNotABoundary(dateTest: Date): boolean { - return this.datesSelected.some( + return this.grouped.some( (dateSelected) => dateSelected.isSelected(dateTest) && !dateSelected.isStart(dateTest) && @@ -526,13 +406,6 @@ export class SelectedDates implements SelectedDateInterface { } 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(' + '); + return this.grouped.map((dateSelected) => dateSelected.getClasses(date)).join(' '); } }