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 <noreply@anthropic.com>
This commit is contained in:
1 parent
5cb0b93339
commit
502d561f6f
5 files changed
+268
-407
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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<typeof calendarProjectionHorizon>[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<typeof projectionFor>[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<typeof projectionFor>[0]) => projectionFor(value)),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
|
||||
@@ -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<Date | null>(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<ElementRef<HTMLElement>>('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(
|
||||
|
||||
@@ -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, 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, date)));
|
||||
expect(selectedDates._datesSelected.length).toBe(1);
|
||||
});
|
||||
const range = (type: DayType, start: Date, end = start) =>
|
||||
new SelectedDate(type, new DateRange<Date>(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, 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<Date>(date1, date1)),
|
||||
);
|
||||
selectedDates._datesSelected.push(
|
||||
new SelectedDate(DayType.RTT, new DateRange<Date>(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, 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, 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);
|
||||
});
|
||||
});
|
||||
@@ -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<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`);
|
||||
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<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)),
|
||||
);
|
||||
}
|
||||
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<Date>(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<Date>(start, day)),
|
||||
);
|
||||
}
|
||||
if (holidays.has(day.getTime()))
|
||||
ranges.push(
|
||||
new SelectedDate(DayType.CLOSED_DAY, new DateRange<Date>(day, day)),
|
||||
);
|
||||
if (
|
||||
day.getDate() === todayDate &&
|
||||
day.getMonth() === todayMonth &&
|
||||
day.getFullYear() === todayYear
|
||||
)
|
||||
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(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>(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)),
|
||||
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<Date>(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<LeaveType, DayType> = {
|
||||
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<Date>(new Date(groupStart), new Date(groupEnd)),
|
||||
),
|
||||
new SelectedDate(null, new DateRange<Date>(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(' ');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user