This commit is contained in:
nessar committed 2026-10-07 00:28:29 +02:00
1 parent 03841c6e3d
commit 036a8c0e1e
32 files changed
+1805 -89

No files matched your search

+293 -18
View File
@@ -23,7 +23,13 @@ import { MatButton } from '@angular/material/button';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
import {
CalendarMonth,
CalendarDayStatus,
DeclarationPreview,
} from '../calendar-month/calendar-month';
import { addPlanningDays } from '../../services/planning-date';
import { isWorkingPlanningDate } from '../../services/declared-non-working-days';
import {
CalendarService,
DayType,
@@ -33,12 +39,15 @@ import {
} from '../../services/calendar-service';
import { TranslationService } from '../../services/translation-service';
import { UserInputService } from '../../services/user-input-service';
import { CalendarSettingsService } from '../../services/calendar-settings-service';
import { PlanningDurationService } from '../../services/planning-duration.service';
import {
applyReplayDeltas,
CounterfactualAnalysis,
CounterfactualSelection,
LeavePlacement,
LeaveType,
DeclaredNonWorkingDay,
PlanExplanation,
PlanOutcome,
PlanningCalculationContext,
@@ -80,6 +89,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
export class CalendarYear {
private readonly userInput = inject(UserInputService);
private readonly calendarService = inject(CalendarService);
private readonly settings = inject(CalendarSettingsService);
private readonly duration = inject(PlanningDurationService);
private readonly injector = inject(Injector);
private readonly dialog = inject(MatDialog);
@@ -124,11 +134,48 @@ export class CalendarYear {
protected readonly historicalEligibilityError = signal<string | null>(null);
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
protected readonly declarationEntry = this.userInput.declarationEntrySignal;
private readonly monthGrid = viewChild<ElementRef<HTMLElement>>('monthGrid');
private readonly declarationDrag = signal<{
pointerId: number;
start: string;
end: string;
} | null>(null);
private suppressDeclarationClick = false;
private declarationEvents?: AbortController;
private declarationScrollFrame?: number;
private declarationPointer?: PointerEvent;
protected readonly declarationPreview = computed<DeclarationPreview | null>(() => {
const drag = this.declarationDrag();
const horizon = this.calendarHorizon();
if (!drag || !horizon || !this.declarationEntry()) return null;
const dates = new Set<string>();
const start = drag.start < drag.end ? drag.start : drag.end;
const end = drag.start > drag.end ? drag.start : drag.end;
for (let date = start; date <= end; date = addPlanningDays(date, 1)) {
if (date >= horizon.start && date < horizon.end && isWorkingPlanningDate(date))
dates.add(date);
}
return { dates, type: this.userInput.declaredTypeSignal() };
});
private readonly resultDeclaredDays = signal<readonly DeclaredNonWorkingDay[]>([]);
protected readonly calendarHorizon = computed(() =>
this.declarationEntry() ? this.settings.planningHorizon() : this.planningHorizon(),
);
protected readonly calendarDeclaredDays = computed<Readonly<Record<string, LeaveType>>>(() => {
const days = this.declarationEntry()
? this.userInput.declaredDaysSignal()
: (this.traceState()?.declaredDays ??
this.displayedPlan()?.declaredDays ??
this.resultDeclaredDays());
return Object.fromEntries(days.map(({ date, type }) => [date, type]));
});
private calculationVersion = 0;
private counterfactualVersion = 0;
private historicalEligibilityVersion = 0;
private activeCalculation: PlanningCalculationContext | null = null;
private runningCalculation: PlanningCalculationContext | null = null;
private readonly traceDecisionIndices = new WeakMap<
PlanningTrace,
ReadonlyMap<string, { decision: PlanningTraceDecision; ordinal: number }>
@@ -255,10 +302,21 @@ export class CalendarYear {
});
protected readonly calendarSelectedDates = computed(() => {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
return horizon
? this.calendarService.selectedDatesForPlan({
placements: [],
horizon,
declaredDays: this.userInput.declaredDaysSignal(),
})
: new SelectedDates();
}
const state = this.traceState();
return state
? this.calendarService.selectedDatesForPlan({
placements: [...state.placements],
declaredDays: state.declaredDays,
horizon: { ...state.horizon },
})
: this.selectedDates();
@@ -421,12 +479,14 @@ export class CalendarYear {
});
protected readonly months = computed(() => {
const horizon = this.planningHorizon();
const horizon = this.calendarHorizon();
if (!horizon) return [];
let first = new Date(`${horizon.start}T00:00:00`);
let last = new Date(`${horizon.end}T00:00:00`);
last.setDate(last.getDate() - 1);
for (const period of this.displayedPlan()?.timeOffPeriods ?? []) {
for (const period of this.declarationEntry()
? []
: (this.displayedPlan()?.timeOffPeriods ?? [])) {
for (const date of period.nonWorkingDates) {
const contextDate = new Date(`${date}T00:00:00`);
if (date < horizon.start && contextDate < first) first = contextDate;
@@ -445,7 +505,23 @@ export class CalendarYear {
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
this.destroyRef.onDestroy(() => this.clearCounterfactual());
this.destroyRef.onDestroy(() => {
this.clearCounterfactual();
this.cancelDeclarationRange();
});
effect(() => {
this.declarationEntry();
this.settings.planningHorizon();
this.userInput.declaredTypeSignal();
untracked(() => this.cancelDeclarationRange());
});
effect(() => {
if (this.declarationEntry())
untracked(() => {
this.showFinalPlan();
this.liveMessage.set(this.tr().bookedDays.enter);
});
});
effect(() => {
this.userInput.computeTriggerSignal();
untracked(() => this.startCalculation());
@@ -458,8 +534,8 @@ export class CalendarYear {
const request = this.duration.request();
if (!request) return;
const { horizon, rules: currentRules } = request;
this.planningHorizon.set(horizon);
this.resetCalculationState(horizon);
this.declarationEntry.set(false);
if (!this.planningHorizon()) this.planningHorizon.set(horizon);
const version = ++this.calculationVersion;
this.isLoading.set(true);
this.isCancelling.set(false);
@@ -483,19 +559,27 @@ export class CalendarYear {
},
});
} catch (error) {
this.resetCalculationState(horizon);
this.planningHorizon.set(horizon);
this.failCalculation(error, version);
return;
}
this.activeCalculation = calculation;
this.loadArtifacts(calculation, version);
this.runningCalculation = calculation;
void calculation.outcome
.then((outcome) => {
if (version !== this.calculationVersion) return;
this.resetCalculationState(horizon);
this.activeCalculation = calculation;
this.planningHorizon.set(horizon);
this.applyOutcome(outcome);
this.loadArtifacts(calculation);
completed = outcome.kind === 'Plan' && !this.isCancelling();
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
this.resetCalculationState(horizon);
this.activeCalculation = calculation;
this.planningHorizon.set(horizon);
this.applyOutcome({
kind: 'PlanningIncomplete',
completedStages: [],
@@ -509,9 +593,11 @@ export class CalendarYear {
},
],
});
this.loadArtifacts(calculation);
})
.finally(() => {
if (version !== this.calculationVersion) return;
this.runningCalculation = null;
this.loadingProgress.set(100);
this.isLoading.set(false);
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
@@ -531,10 +617,10 @@ export class CalendarYear {
}
protected cancelCalculation(): void {
if (!this.isLoading() || this.isCancelling() || !this.activeCalculation) return;
if (!this.isLoading() || this.isCancelling() || !this.runningCalculation) return;
this.isCancelling.set(true);
this.liveMessage.set(this.tr().planning.cancelling);
this.activeCalculation.cancel();
this.runningCalculation.cancel();
}
private failCalculation(error: unknown, version: number): void {
@@ -574,10 +660,10 @@ export class CalendarYear {
this.userInput.remainingVacationDaysSignal.set([]);
}
private loadArtifacts(calculation: PlanningCalculationContext, version: number): void {
private loadArtifacts(calculation: PlanningCalculationContext): void {
void calculation.explanation
.then((explanation) => {
if (version !== this.calculationVersion) return;
if (calculation !== this.activeCalculation) return;
this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation') {
this.selectPlacement(0, false);
@@ -587,7 +673,7 @@ export class CalendarYear {
}
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
if (calculation !== this.activeCalculation) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
@@ -597,10 +683,11 @@ export class CalendarYear {
});
void calculation.explanationError
.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
if (calculation === this.activeCalculation)
this.explanationError.set(reason ?? null);
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
if (calculation !== this.activeCalculation) return;
const reason = {
code: 'explanation-error',
message: error instanceof Error ? error.message : String(error),
@@ -610,7 +697,7 @@ export class CalendarYear {
});
void calculation.trace
.then((trace) => {
if (version !== this.calculationVersion) return;
if (calculation !== this.activeCalculation) return;
this.planningTrace.set(trace ?? null);
this.traceResolved.set(true);
this.liveMessage.set(
@@ -620,11 +707,14 @@ export class CalendarYear {
);
})
.catch((error: unknown) => {
if (version !== this.calculationVersion) return;
if (calculation !== this.activeCalculation) return;
this.planningTrace.set({
status: 'failed',
initialState: {
horizon: calculation.snapshot.horizon,
...(calculation.snapshot.declaredDays
? { declaredDays: calculation.snapshot.declaredDays }
: {}),
balances: [],
placements: [],
earnedActivations: [],
@@ -640,7 +730,7 @@ export class CalendarYear {
this.liveMessage.set(this.tr().planning.traceUnavailable);
})
.finally(() => {
if (version === this.calculationVersion) this.traceLoading.set(false);
if (calculation === this.activeCalculation) this.traceLoading.set(false);
});
}
@@ -648,6 +738,9 @@ export class CalendarYear {
this.planningOutcome.set(outcome);
this.liveMessage.set(this.outcomeLabel(outcome));
const plan = this.displayedPlan();
this.resultDeclaredDays.set(
plan?.declaredDays ?? this.activeCalculation?.snapshot.declaredDays ?? [],
);
if (plan) {
this.planningHorizon.set(plan.horizon);
this.selectedDates.set(this.calendarService.selectedDatesForPlan(plan));
@@ -722,7 +815,186 @@ export class CalendarYear {
if (target !== current) this.selectPlacement(target);
}
private declarationButton(target: EventTarget | null): HTMLButtonElement | null {
const button =
target instanceof Element ? target.closest<HTMLButtonElement>('[data-date]') : null;
return button && this.monthGrid()?.nativeElement.contains(button) ? button : null;
}
protected startDeclarationRange(event: PointerEvent): void {
if (!this.declarationEntry() || event.button !== 0 || !event.isPrimary) return;
this.suppressDeclarationClick = false;
// Keep native touch scrolling and single-day taps available on the calendar.
if (event.pointerType === 'touch') return;
const button = this.declarationButton(event.target);
const date = button?.dataset['date'];
if (!date || button?.disabled) return;
this.declarationDrag.set({
pointerId: event.pointerId,
start: date,
end: date,
});
const grid = this.monthGrid()!.nativeElement;
this.declarationEvents = new AbortController();
const options = { signal: this.declarationEvents.signal };
grid.ownerDocument.addEventListener(
'pointermove',
(e) => this.moveDeclarationRange(e),
options,
);
grid.ownerDocument.addEventListener(
'pointerup',
(e) => this.finishDeclarationRange(e),
options,
);
grid.ownerDocument.addEventListener(
'pointercancel',
(e) => this.cancelDeclarationRange(e),
options,
);
grid.ownerDocument.addEventListener(
'keydown',
(e) => {
if (e.key === 'Escape') this.cancelDeclarationRange();
},
options,
);
window.addEventListener('blur', () => this.cancelDeclarationRange(), options);
grid.addEventListener('lostpointercapture', (e) => this.cancelDeclarationRange(e), options);
grid.setPointerCapture?.(event.pointerId);
button?.focus({ preventScroll: true });
}
protected moveDeclarationRange(event: PointerEvent): void {
const drag = this.declarationDrag();
if (!drag || event.pointerId !== drag.pointerId) return;
if (!(event.buttons & 1)) {
this.cancelDeclarationRange();
return;
}
this.declarationPointer = event;
this.updateDeclarationEndpoint(event);
this.scrollDeclarationRange();
}
private updateDeclarationEndpoint(event: PointerEvent): void {
const drag = this.declarationDrag();
if (!drag) return;
const grid = this.monthGrid()?.nativeElement;
const target =
grid?.ownerDocument.elementFromPoint?.(event.clientX, event.clientY) ?? event.target;
const date = this.declarationButton(target)?.dataset['date'];
if (date && date !== drag.end) this.declarationDrag.set({ ...drag, end: date });
}
private scrollDeclarationRange(): void {
if (this.declarationScrollFrame !== undefined) return;
let lastFrame = 0;
const scroll = (now: number) => {
this.declarationScrollFrame = undefined;
const pointer = this.declarationPointer;
if (!pointer || !this.declarationDrag()) return;
const direction =
pointer.clientY < 48 ? -1 : pointer.clientY > window.innerHeight - 48 ? 1 : 0;
if (!direction || pointer.clientX < 0 || pointer.clientX > window.innerWidth) return;
const before = window.scrollY;
window.scrollBy(0, direction * (lastFrame ? Math.min(now - lastFrame, 32) : 16) * 0.5);
lastFrame = now;
this.updateDeclarationEndpoint(pointer);
if (window.scrollY !== before)
this.declarationScrollFrame = requestAnimationFrame(scroll);
};
this.declarationScrollFrame = requestAnimationFrame(scroll);
}
protected finishDeclarationRange(event: PointerEvent): void {
const drag = this.declarationDrag();
if (!drag || event.pointerId !== drag.pointerId || event.button !== 0) return;
this.updateDeclarationEndpoint(event);
const preview = this.declarationPreview();
if (!preview) return;
const dates = [...preview.dates];
const previous = this.calendarDeclaredDays();
this.userInput.toggleDeclaredDays(dates, preview.type);
const next = this.calendarDeclaredDays();
const added = dates.filter((date) => next[date]).length;
const removed = dates.filter((date) => previous[date] && !next[date]).length;
const messages: string[] = [];
if (added && preview.type)
messages.push(
this.tr()
.bookedDays.rangeSaved.replace('{count}', String(added))
.replace('{type}', this.translationService.getDayTypeLabel(preview.type)),
);
if (removed || !added)
messages.push(this.tr().bookedDays.rangeRemoved.replace('{count}', String(removed)));
this.liveMessage.set(messages.join(' '));
this.cancelDeclarationRange();
this.animateDeclaration(preview.dates);
}
protected cancelDeclarationRange(event?: PointerEvent): void {
const drag = this.declarationDrag();
if (!drag || (event && event.pointerId !== drag.pointerId)) return;
this.declarationDrag.set(null);
this.suppressDeclarationClick = true;
this.declarationEvents?.abort();
this.declarationEvents = undefined;
if (this.declarationScrollFrame !== undefined)
cancelAnimationFrame(this.declarationScrollFrame);
this.declarationScrollFrame = undefined;
this.declarationPointer = undefined;
const grid = this.monthGrid()?.nativeElement;
if (grid?.hasPointerCapture?.(drag.pointerId)) grid.releasePointerCapture(drag.pointerId);
}
protected selectDeclaredDate(event: MouseEvent): void {
if (!this.declarationEntry() || (event.detail > 0 && this.suppressDeclarationClick)) return;
const button = this.declarationButton(event.target);
const date = button?.dataset['date'];
if (date && !button?.disabled) {
this.selectCalendarDate(date);
this.animateDeclaration(new Set([date]));
}
}
private animateDeclaration(dates: ReadonlySet<string>): void {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
for (const button of this.monthGrid()?.nativeElement.querySelectorAll<HTMLElement>(
'[data-date]',
) ?? []) {
if (!dates.has(button.dataset['date']!)) continue;
button.animate?.(
[
{ transform: 'scale(1.025, 0.97)' },
{ transform: 'scale(0.99, 1.015)' },
{ transform: 'scale(1)' },
],
{ duration: 240, easing: 'ease-out' },
);
}
}
protected selectCalendarDate(date: string): void {
if (this.declarationEntry()) {
const horizon = this.calendarHorizon();
if (!horizon || date < horizon.start || date >= horizon.end) return;
const selectedType = this.userInput.declaredTypeSignal();
if (selectedType) this.userInput.toggleDeclaredDay(date, selectedType);
else this.userInput.toggleDeclaredDays([date], null);
const type = this.calendarDeclaredDays()[date];
this.liveMessage.set(
`${this.displayDate(date)}. ${type ? `${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(type)}). ${this.tr().bookedDays.fixed}` : this.tr().bookedDays.removed}`,
);
return;
}
const declaredType = this.calendarDeclaredDays()[date];
if (declaredType && !this.traceReplay() && !this.counterfactual()) {
this.liveMessage.set(
`${this.displayDate(date)}. ${this.tr().bookedDays.booked} (${this.translationService.getDayTypeLabel(declaredType)}). ${this.tr().bookedDays.fixed}`,
);
return;
}
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
@@ -1035,6 +1307,9 @@ export class CalendarYear {
};
const snapshot = (): PlanningTraceState => ({
horizon: { ...trace.initialState.horizon },
...(trace.initialState.declaredDays
? { declaredDays: trace.initialState.declaredDays }
: {}),
balances: state.balances.map((balance) => ({ ...balance })),
placements: [...state.placements.values()].map((placement) => ({
...placement,