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

@@ -2,6 +2,7 @@ import { provideZonelessChangeDetection } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { CalendarYear } from './calendar-year';
import { UserInput } from '../user-input/user-input';
import {
CalendarService,
DayType,
@@ -15,6 +16,7 @@ import {
HistoricalEligibilityReason,
LeavePlacement,
PlanOutcome,
PlanExplanation,
PlanningCalculationContext,
PlanningConflictOutcome,
PlanningExplanation,
@@ -405,6 +407,410 @@ describe('CalendarYear planning outcome boundary', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('previews a growing and shrinking range across months, then saves it once on release', async () => {
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem('vacationRules', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear, UserInput],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calculate = vi
.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation')
.mockReturnValue(calculationFor({ ...plan, placements: [] }));
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-04-26', '2024-05-10');
const input = TestBed.inject(UserInputService);
input.toggleDeclaredDay('2024-04-26', 'other');
input.toggleDeclaredDay('2024-04-30', 'cp');
input.toggleDeclaredDay('2024-05-02', 'rtt');
const controlsFixture = TestBed.createComponent(UserInput);
const fixture = TestBed.createComponent(CalendarYear);
controlsFixture.detectChanges();
fixture.detectChanges();
await fixture.whenStable();
const controls: HTMLElement = controlsFixture.nativeElement;
const root: HTMLElement = fixture.nativeElement;
controls.querySelector<HTMLButtonElement>('button.declaration-entry-toggle')!.click();
controlsFixture.detectChanges();
fixture.detectChanges();
const day = (date: string) =>
root.querySelector<HTMLButtonElement>(`[data-date="${date}"]`)!;
const pointer = (event: string, date: string) => {
day(date).dispatchEvent(
new PointerEvent(event, {
bubbles: true,
pointerId: 1,
pointerType: 'mouse',
isPrimary: true,
button: 0,
buttons: event === 'pointerup' ? 0 : 1,
clientX: 200,
clientY: 200,
}),
);
fixture.detectChanges();
};
const saved = localStorage.getItem('declaredNonWorkingDays');
pointer('pointerdown', '2024-04-29');
pointer('pointermove', '2024-05-07');
expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(true);
expect(day('2024-05-02').classList.contains('cp')).toBe(true);
expect(day('2024-05-02').classList.contains('rtt')).toBe(false);
expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true);
expect(day('2024-04-30').classList.contains('cp')).toBe(false);
expect(day('2024-04-30').getAttribute('aria-pressed')).toBe('false');
expect(day('2024-05-01').classList.contains('declaration-preview')).toBe(false);
expect(day('2024-05-04').classList.contains('declaration-preview')).toBe(false);
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(saved);
pointer('pointermove', '2024-04-29');
expect(day('2024-04-30').classList.contains('declaration-preview')).toBe(false);
expect(day('2024-04-30').classList.contains('cp')).toBe(true);
pointer('pointermove', '2024-05-03');
expect(day('2024-04-30').classList.contains('declaration-erase')).toBe(true);
expect(day('2024-05-07').classList.contains('declaration-preview')).toBe(false);
pointer('pointerup', '2024-05-03');
// A browser click follows pointerup; it must not toggle the endpoint again.
day('2024-05-03').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
fixture.detectChanges();
expect(root.querySelector('.declaration-preview')).toBeNull();
expect(root.querySelector('[role="status"]')?.textContent).toContain(
'Jours déjà posés enregistrés : 3 (Congé Payé). Déclarations supprimées : 1.',
);
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-04-26', type: 'other' },
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-05-02', type: 'cp' },
{ date: '2024-05-03', type: 'cp' },
]);
expect(day('2024-05-02').textContent?.trim()).toBe('2');
expect(day('2024-05-02').getAttribute('aria-label')).toContain('Déjà posé (CP)');
controls.querySelector<HTMLButtonElement>('[data-declaration-erase]')!.click();
controlsFixture.detectChanges();
fixture.detectChanges();
pointer('pointerdown', '2024-05-03');
pointer('pointermove', '2024-04-30');
expect(day('2024-05-02').classList.contains('declaration-erase')).toBe(true);
expect(day('2024-05-02').classList.contains('cp')).toBe(false);
expect(input.declaredDaysSignal()).toHaveLength(4);
pointer('pointerup', '2024-04-30');
expect(root.querySelector('[role="status"]')?.textContent).toContain(
'Déclarations supprimées : 2.',
);
expect(new UserInputService().declaredDaysSignal()).toEqual([
{ date: '2024-04-26', type: 'other' },
{ date: '2024-04-29', type: 'cp' },
]);
expect(input.vacationNumberSignal()).toEqual([]);
expect(calculate).toHaveBeenCalledOnce();
const beforeCancellation = localStorage.getItem('declaredNonWorkingDays');
pointer('pointerdown', '2024-04-29');
pointer('pointermove', '2024-05-03');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
pointer('pointerup', '2024-05-03');
expect(root.querySelector('.declaration-preview')).toBeNull();
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation);
pointer('pointerdown', '2024-04-29');
pointer('pointermove', '2024-05-03');
pointer('pointercancel', '2024-05-03');
pointer('pointerup', '2024-05-03');
expect(localStorage.getItem('declaredNonWorkingDays')).toBe(beforeCancellation);
// The first animation frame may have the same timestamp as the pointer move.
let scrollFrame: FrameRequestCallback | undefined;
const requestFrame = vi
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((callback) => {
scrollFrame = callback;
return 1;
});
const scroll = vi.spyOn(window, 'scrollBy').mockImplementation(() => undefined);
pointer('pointerdown', '2024-04-29');
day('2024-05-03').dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
pointerId: 1,
pointerType: 'mouse',
buttons: 1,
clientX: 200,
clientY: window.innerHeight - 1,
}),
);
scrollFrame!(0);
expect(scroll.mock.calls[0][1]).toBeGreaterThan(0);
pointer('pointercancel', '2024-05-03');
requestFrame.mockRestore();
scroll.mockRestore();
// Touch scrolling remains native, and a subsequent tap must still work after a drag.
controls.querySelector<HTMLButtonElement>('[data-declared-type="other"]')!.click();
controlsFixture.detectChanges();
fixture.detectChanges();
for (const event of ['pointerdown', 'pointermove', 'pointerup']) {
day('2024-05-06').dispatchEvent(
new PointerEvent(event, {
bubbles: true,
pointerId: 2,
pointerType: 'touch',
isPrimary: true,
button: 0,
}),
);
}
expect(root.querySelector('.declaration-preview')).toBeNull();
day('2024-05-06').dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }));
fixture.detectChanges();
expect(day('2024-05-06').getAttribute('aria-label')).toContain('Déjà posé (Autre)');
pointer('pointerdown', '2024-04-29');
pointer('pointermove', '2024-05-03');
controls.querySelector<HTMLButtonElement>('button.declaration-entry-toggle')!.click();
controlsFixture.detectChanges();
fixture.detectChanges();
pointer('pointerup', '2024-05-03');
expect(input.declaredDaysSignal()).toEqual([
{ date: '2024-04-26', type: 'other' },
{ date: '2024-04-29', type: 'cp' },
{ date: '2024-05-06', type: 'other' },
]);
});
it('edits declarations from configuration and restores the preceding result when switched off', async () => {
localStorage.setItem('app.language', '"fr"');
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('leaveBalances', '[]');
localStorage.setItem(
'declaredNonWorkingDays',
JSON.stringify([{ date: '2024-01-03', type: 'rtt' }]),
);
await TestBed.configureTestingModule({
imports: [CalendarYear, UserInput],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calendar = TestBed.inject(CalendarService);
const savedPlan: PlanOutcome = {
...plan,
horizon: { start: '2024-01-02', end: '2024-01-08' },
placements: [],
declaredDays: [{ date: '2024-01-03', type: 'rtt' }],
};
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
.mockReturnValue(calculationFor(savedPlan));
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-02', '2024-01-07');
const input = TestBed.inject(UserInputService);
const controlsFixture = TestBed.createComponent(UserInput);
controlsFixture.detectChanges();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const controls: HTMLElement = controlsFixture.nativeElement;
expect(root.querySelector('.declaration-controls')).toBeNull();
expect(controls.querySelector('.declaration-view-toggle')).toBeNull();
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
const editButton = controls.querySelector<HTMLButtonElement>(
'button.declaration-entry-toggle',
)!;
expect(editButton.getAttribute('aria-pressed')).toBe('false');
expect(root.querySelector('.calendar-legend li')?.textContent?.trim()).toBe('Déjà posé');
const day = (date: string) =>
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)!;
expect(calculate.mock.calls[0][0]).toMatchObject({ declaredDays: savedPlan.declaredDays });
expect(day('2024-01-03').getAttribute('aria-label')).toContain('Déjà posé');
expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT');
settings.setPlanningDates('2024-01-08', '2024-02-02');
activateWithKeyboard(editButton, ' ');
controlsFixture.detectChanges();
fixture.detectChanges();
expect(editButton.getAttribute('aria-pressed')).toBe('true');
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(3);
expect(
controls.querySelector('[data-declared-type="cp"]')?.getAttribute('aria-pressed'),
).toBe('true');
expect(controls.querySelector('[data-declared-type="any"]')).toBeNull();
expect(day('2024-01-03').disabled).toBe(true);
expect(day('2024-01-08').disabled).toBe(false);
activateWithKeyboard(day('2024-01-08'), ' ');
fixture.detectChanges();
expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('true');
expect(day('2024-01-08').getAttribute('aria-label')).toContain('Déjà posé');
expect(day('2024-01-08').getAttribute('aria-label')).toContain('CP');
activateWithKeyboard(day('2024-01-08'));
fixture.detectChanges();
expect(day('2024-01-08').getAttribute('aria-pressed')).toBe('false');
day('2024-01-08').click();
activateWithKeyboard(
controls.querySelector<HTMLButtonElement>('[data-declared-type="other"]')!,
);
controlsFixture.detectChanges();
fixture.detectChanges();
day('2024-01-08').click();
fixture.detectChanges();
expect(day('2024-01-08').getAttribute('aria-label')).toContain('Autre');
expect(day('2024-01-13').disabled).toBe(true);
expect(input.declaredDaysSignal()).toEqual([
{ date: '2024-01-03', type: 'rtt' },
{ date: '2024-01-08', type: 'other' },
]);
expect(input.vacationNumberSignal()).toEqual([]);
expect(calculate).toHaveBeenCalledOnce();
activateWithKeyboard(editButton, ' ');
controlsFixture.detectChanges();
fixture.detectChanges();
expect(editButton.getAttribute('aria-pressed')).toBe('false');
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
expect(day('2024-01-03').getAttribute('aria-label')).toContain('RTT');
expect(day('2024-01-08').disabled).toBe(true);
activateWithKeyboard(day('2024-01-03'));
fixture.detectChanges();
expect(root.querySelector('[role="status"]')?.textContent).toContain('Déjà posé');
expect(root.querySelector('[role="status"]')?.textContent).toContain('solde');
});
it('keeps the preceding calendar until a submitted snapshot returns and ignores later edits', async () => {
localStorage.setItem('vacationRules', '[]');
localStorage.setItem('leaveBalances', '[]');
await TestBed.configureTestingModule({
imports: [CalendarYear, UserInput],
providers: [provideZonelessChangeDetection()],
}).compileComponents();
const calendar = TestBed.inject(CalendarService);
const initial: PlanOutcome = {
...plan,
horizon: { start: '2024-01-02', end: '2024-01-08' },
placements: [{ ...plan.placements[0], date: '2024-01-04', ruleIds: ['linked'] }],
declaredDays: [{ date: '2024-01-03', type: 'rtt' }],
};
const oldExplanation: PlanExplanation = {
kind: 'PlanExplanation',
placements: [
{
...explanation.placements[0],
placement: initial.placements[0],
ruleIds: ['linked'],
},
],
};
const lateExplanation = deferred<PlanningExplanation | undefined>();
const lateTrace = deferred<PlanningTrace | undefined>();
const initialCalculation = {
...calculationFor(initial, oldExplanation),
explanation: lateExplanation.promise,
trace: lateTrace.promise,
};
const rule = {
id: 'linked',
name: 'Original rule',
type: 'time_window' as const,
startDate: '2024-01-02',
endDate: '2024-01-07',
placeDays: 1,
placeType: 'cp' as const,
};
initialCalculation.snapshot.rules = [rule];
const calculate = vi
.spyOn(calendar, 'createPlanningCalculation')
.mockReturnValueOnce(initialCalculation);
const settings = TestBed.inject(CalendarSettingsService);
settings.setPlanningDates('2024-01-02', '2024-01-07');
const input = TestBed.inject(UserInputService);
const controlsFixture = TestBed.createComponent(UserInput);
controlsFixture.detectChanges();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const root: HTMLElement = fixture.nativeElement;
const controls: HTMLElement = controlsFixture.nativeElement;
const editButton = controls.querySelector<HTMLButtonElement>(
'button.declaration-entry-toggle',
)!;
input.toggleDeclaredDay('2024-05-10', 'cp');
settings.setPlanningDates('2024-05-06', '2024-05-12');
editButton.click();
controlsFixture.detectChanges();
fixture.detectChanges();
for (const date of ['2024-05-08', '2024-05-09', '2024-05-11', '2024-05-12']) {
const day = root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)!;
expect(day.disabled).toBe(true);
day.click();
}
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'cp' }]);
const submitted: PlanOutcome = {
...initial,
horizon: { start: '2024-05-06', end: '2024-05-13' },
declaredDays: [{ date: '2024-05-10', type: 'cp' }],
};
const pending = deferred<PlanningOutcome>();
const calculation = calculationFor(submitted);
calculation.snapshot.rules = [{ ...rule, name: 'Replacement rule' }];
calculate.mockReturnValueOnce({
...calculation,
snapshot: { ...calculation.snapshot, ...submitted },
outcome: pending.promise,
});
controls.querySelector<HTMLButtonElement>('.calculate-button')!.click();
fixture.detectChanges();
controlsFixture.detectChanges();
expect(editButton.getAttribute('aria-pressed')).toBe('false');
expect(controls.querySelectorAll('[data-declared-type]')).toHaveLength(0);
input.toggleDeclaredDay('2024-05-10', 'other');
settings.setPlanningDates('2024-06-03', '2024-06-07');
fixture.detectChanges();
expect(fixture.componentInstance['planningOutcome']()).toBe(initial);
expect(
root.querySelector('button[data-date="2024-01-03"]')?.getAttribute('aria-label'),
).toContain('RTT');
expect(root.querySelector('button[data-date="2024-05-10"]')).toBeNull();
expect(calculate.mock.calls[1][0]).toMatchObject({
horizon: submitted.horizon,
declaredDays: submitted.declaredDays,
});
const originalTrace: PlanningTrace = {
status: 'ready',
initialState: {
horizon: initial.horizon,
declaredDays: initial.declaredDays,
balances: [],
placements: [],
earnedActivations: [],
},
phases: [],
completedStages: initial.completedStages,
};
lateExplanation.resolve(oldExplanation);
lateTrace.resolve(originalTrace);
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
expect(fixture.componentInstance['planningTrace']()).toBe(originalTrace);
expect(fixture.componentInstance['traceLoading']()).toBe(false);
expect(fixture.componentInstance['traceResolved']()).toBe(true);
const details = root.querySelector<HTMLDetailsElement>('.plan-details')!;
details.open = true;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(root.querySelector('.leave-story')?.textContent).toContain('Original rule');
expect(root.querySelector('.leave-story')?.textContent).not.toContain('Replacement rule');
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
await fixture.whenStable();
expect(initialCalculation.analyzeCounterfactual).toHaveBeenCalled();
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
pending.resolve(submitted);
await fixture.whenStable();
fixture.detectChanges();
expect(
root.querySelector('button[data-date="2024-05-10"]')?.getAttribute('aria-label'),
).toContain('CP');
expect(root.querySelector('button[data-date="2024-06-03"]')).toBeNull();
expect(input.declaredDaysSignal()).toEqual([{ date: '2024-05-10', type: 'other' }]);
});
it('calibrates the wait through calendar rendering while a long trace remains pending', async () => {
let now = 1_000;
const clock = vi.spyOn(performance, 'now').mockImplementation(() => now);
@@ -899,11 +899,19 @@
</div>
}
<p class="calendar-help">
<mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }}
<mat-icon aria-hidden="true">touch_app</mat-icon
>{{ declarationEntry() ? tr().bookedDays.help : tr().planning.calendarHelp }}
</p>
@if (declarationEntry() && !calendarHorizon()) {
<p role="alert">{{ tr().settings.invalidHorizon }}</p>
}
<div class="calendar-legend" role="group" [attr.aria-label]="tr().planning.legend">
<strong>{{ tr().planning.legend }}</strong>
<ul>
<li>
<span class="legend-swatch declared" aria-hidden="true"></span
>{{ tr().bookedDays.booked }}
</li>
<li>
<span class="legend-swatch weekend" aria-hidden="true"></span
>{{ tr().dayTypes.weekend }}
@@ -995,7 +1003,13 @@
</ul>
</div>
<div class="month-grid">
<div
#monthGrid
class="month-grid"
[class.declaration-entry]="declarationEntry()"
(pointerdown)="startDeclarationRange($event)"
(click)="selectDeclaredDate($event)"
>
@for (month of months(); track month.year + '-' + month.monthIndex) {
<mat-card class="month-card">
<mat-card-content>
@@ -1003,12 +1017,15 @@
[monthIndex]="month.monthIndex"
[year]="month.year"
[selectedDates]="calendarSelectedDates()"
[horizonStart]="planningHorizon()?.start"
[horizonEnd]="planningHorizon()?.end"
[currentDate]="currentPlacementDate()"
[explainedPeriod]="explainedPeriod()"
[dateStatuses]="traceReplay() ? dateStatuses() : {}"
(dateSelected)="selectCalendarDate($event)"
[horizonStart]="calendarHorizon()?.start"
[horizonEnd]="calendarHorizon()?.end"
[declarationEntry]="declarationEntry()"
[declaredDays]="calendarDeclaredDays()"
[declarationPreview]="declarationPreview()"
[currentDate]="declarationEntry() ? null : currentPlacementDate()"
[explainedPeriod]="declarationEntry() ? undefined : explainedPeriod()"
[dateStatuses]="!declarationEntry() && traceReplay() ? dateStatuses() : {}"
(dateSelected)="!declarationEntry() && selectCalendarDate($event)"
></app-calendar-month>
</mat-card-content>
</mat-card>
@@ -420,6 +420,11 @@ output {
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
flex-shrink: 0;
&.declared {
background-image: var(--declared-day-pattern);
border: 1px solid var(--ui-border);
}
&.weekend {
background: var(--weekend-bg);
}
@@ -455,6 +460,9 @@ output {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
gap: 1rem;
&.declaration-entry {
user-select: none;
}
}
.month-card {
min-width: 0;
+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,