This commit is contained in:
1 parent
03841c6e3d
commit
036a8c0e1e
32 files changed
+1805
-89
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user