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:
nessarandClaude Opus 5.5 committed 2026-10-17 11:18:33 +02:00
1 parent 5cb0b93339
commit 502d561f6f
5 files changed
+268 -407

No files matched your search

@@ -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(