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(' ');
}
}