This commit is contained in:
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;
|
||||
|
||||
@@ -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