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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user