feat(planner): implement cancellation for planning calculations
- Refactor leave-planner.worker.ts to support cancellation of ongoing calculations. - Introduce `calculatePlanningAsyncInternal` for asynchronous planning calculations. - Add cancellation handling in the worker message event. - Update planning-date.ts with new utility functions for date manipulation. - Extend translation-service.ts and translation files (en.json, de.json, es.json, fr.json) to include new strings related to planning horizon and cancellation. - Create documentation for the cancellation feature in planner-cancellation.md. - Add unit tests for cancellation functionality in leave-planner-cancellation.spec.ts and leave-planner.worker.spec.ts. #57 #58
This commit is contained in:
1 parent
5c35a59805
commit
2edf2004f5
31 files changed
+2275
-304
No files matched your search
@@ -22,7 +22,7 @@ flowchart LR
|
|||||||
State["Signals and localStorage-backed settings"]
|
State["Signals and localStorage-backed settings"]
|
||||||
Facade["Planning calculation context<br/>immutable snapshot + async artifacts"]
|
Facade["Planning calculation context<br/>immutable snapshot + async artifacts"]
|
||||||
Worker["Leave-planner Web Worker"]
|
Worker["Leave-planner Web Worker"]
|
||||||
Core["Synchronous planner core"]
|
Core["Resumable planner core"]
|
||||||
Holidays["date-holidays<br/>French public calendar"]
|
Holidays["date-holidays<br/>French public calendar"]
|
||||||
|
|
||||||
UI <--> State
|
UI <--> State
|
||||||
@@ -40,18 +40,18 @@ Leave calculation stays in the browser. The production Express process serves th
|
|||||||
### Main execution path
|
### Main execution path
|
||||||
|
|
||||||
1. `CalendarYear` takes a snapshot of leave balances and ordered rules.
|
1. `CalendarYear` takes a snapshot of leave balances and ordered rules.
|
||||||
2. `CalendarService` supplies the Europe/Paris planning horizon and opens one immutable planning calculation context.
|
2. `CalendarSettingsService` supplies the selected planning horizon, and `CalendarService` opens one immutable planning calculation context.
|
||||||
3. `leave-planner.service.ts` copies the request into a canonical transport shape and starts the worker.
|
3. `leave-planner.service.ts` copies the request into a canonical transport shape and starts the worker.
|
||||||
4. `leave-planner.worker.ts` runs the framework-free core, sends progress and valid checkpoints, publishes the outcome first, then delivers its stable explanation and original trace.
|
4. `leave-planner.worker.ts` runs the framework-free core, sends progress and valid checkpoints, publishes the outcome first, then delivers its stable explanation and original trace.
|
||||||
5. `CalendarYear` ignores stale calculations, renders the selected dates, and reports remaining balances or errors.
|
5. `CalendarYear` ignores stale calculations, renders the selected dates, and reports remaining balances or errors.
|
||||||
|
|
||||||
Planning work has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is `PlanningIncomplete` with that incumbent; it is not reported as a constraint conflict. Explanation and trace delivery happen after the outcome and do not consume that planning budget.
|
Planning work has a default and hard maximum wall-clock limit of ten seconds. If it expires after a valid checkpoint, the result is `PlanningIncomplete` with that incumbent; it is not reported as a constraint conflict. Explanation and trace delivery happen after the outcome and do not consume that planning budget. During preparation and optimization, Cancel interrupts the worker cooperatively and preserves its best jointly valid candidate as a cancelled, incomplete calculation. A cancellation before a valid candidate reports no plan. The original trace continues draining after optimization stops, and a late artifact error never removes the retained plan. See [the cancellation mechanism and checks](docs/planner-cancellation.md).
|
||||||
|
|
||||||
## Planning model
|
## Planning model
|
||||||
|
|
||||||
A request contains:
|
A request contains:
|
||||||
|
|
||||||
- a half-open planning horizon of at most 12 months;
|
- a user-selected half-open planning horizon, including past dates and multiple years;
|
||||||
- CP, RTT, or Other leave balances with quantities, availability dates, and expiry dates;
|
- CP, RTT, or Other leave balances with quantities, availability dates, and expiry dates;
|
||||||
- ordered `TIME_WINDOW` and `IF_THEN` rules;
|
- ordered `TIME_WINDOW` and `IF_THEN` rules;
|
||||||
- an optional deadline and progress callback.
|
- an optional deadline and progress callback.
|
||||||
@@ -70,10 +70,12 @@ The outcome is one of:
|
|||||||
| `InvalidInput` | The request is malformed. |
|
| `InvalidInput` | The request is malformed. |
|
||||||
| `PlanningConflict` | Valid obligations cannot all be satisfied together. |
|
| `PlanningConflict` | Valid obligations cannot all be satisfied together. |
|
||||||
| `Plan` | All stages completed with placements, periods, metrics, provenance, and remaining balances. |
|
| `Plan` | All stages completed with placements, periods, metrics, provenance, and remaining balances. |
|
||||||
| `PlanningIncomplete` | The deadline or worker stopped calculation; a valid incumbent may be present. |
|
| `PlanningIncomplete` | Cancellation, the deadline, or worker failure stopped calculation; a valid incumbent may be present. |
|
||||||
|
|
||||||
All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/Paris domain. The planner treats weekdays excluding French national public holidays as working days.
|
All planning dates are normalized as `YYYY-MM-DD` calendar values in the Europe/Paris domain. The planner treats weekdays excluding French national public holidays as working days.
|
||||||
|
|
||||||
|
Settings save a fixed start date and an inclusive last planning date. The planner's exclusive end is the following calendar date, so matching settings dates cover one day. Without saved dates, the default is today in Europe/Paris through the same date twelve months later, exclusive (February 29 uses February 28 in the following year). Invalid edits prevent calculation and preserve the last valid saved dates. Changes to dates, balances, and rules apply to the displayed calendar on the next explicit Calculate action.
|
||||||
|
|
||||||
## Optimization pipeline
|
## Optimization pipeline
|
||||||
|
|
||||||
The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one; complete public-holiday weeks are selected before broader calendar-gain and work-sequence optimization:
|
The core is a deterministic, constraint-first heuristic rather than a mathematical solver. It applies these objectives in order, never improving a lower objective by degrading a higher one; complete public-holiday weeks are selected before broader calendar-gain and work-sequence optimization:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# User-Selected Horizons and Cancellable Calculations
|
# User-Selected Horizons and Cancellable Calculations
|
||||||
|
|
||||||
Accepted for [issue #12](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/12); implementation pending.
|
Accepted for [issue #12](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/12). Fixed, saved planning horizons are implemented by [issue #57](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/57); user cancellation with a retained valid plan and original trace is implemented by [issue #58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58). The duration estimate and removal of the automatic calculation deadline remain pending.
|
||||||
|
|
||||||
VacOptimizer accepts explicitly selected, fixed planning horizons whose start may be past and whose duration may exceed twelve months, retaining today through the same date twelve months later as the default. Calculations remain in the browser and run to completion without an automatic elapsed-time cutoff, with an indicative duration estimate and an explicit Cancel action; this accepts variable waiting time and resource use to make longer horizons usable without silently changing the user's dates. This supersedes ADR 0003's ten-second automatic planning limit and its retention in both ADR 0004 documents, while preserving the separation between the planning outcome, stable explanation, and original trace.
|
VacOptimizer accepts explicitly selected, fixed planning horizons whose start may be past and whose duration may exceed twelve months, retaining today through the same date twelve months later as the default. Calculations remain in the browser and run to completion without an automatic elapsed-time cutoff, with an indicative duration estimate and an explicit Cancel action; this accepts variable waiting time and resource use to make longer horizons usable without silently changing the user's dates. This supersedes ADR 0003's ten-second automatic planning limit and its retention in both ADR 0004 documents, while preserving the separation between the planning outcome, stable explanation, and original trace.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Cancelling a planning calculation
|
||||||
|
|
||||||
|
Issue [#58](https://gitlab.nessar.fr/nessar/vac-optimizer/-/work_items/58) implements the cancellation portion of ADR 0006. The duration estimate and removal of the existing automatic deadline belong to the remaining work for issue #12.
|
||||||
|
|
||||||
|
`PlanningCalculationContext.cancel()` requests user cancellation once while its outcome is pending. `CalendarYear` keeps an accessible Cancel button visible during preparation, obligation search, and optimization. It announces the request, disables duplicate cancellation, and moves keyboard focus to the result heading when the outcome arrives. Cancellation never triggers Calculate. A subsequent explicit Calculate creates a fresh immutable calculation snapshot.
|
||||||
|
|
||||||
|
## Stopping CPU work
|
||||||
|
|
||||||
|
The core exposes resumable generator steps. `calculatePlanningAsyncInternal` drives those same steps in roughly eight-millisecond slices and yields to the worker event loop at phase boundaries and between preparation years. A `cancel` message sets the active run's cancellation flag; the next resume throws the cancellation signal into the suspended calculation. Recursive obligation and preference searches, quality passes, and calendar preparation unwind through their existing cleanup blocks. An individual synchronous operation, such as the holiday library's work for one year, finishes before the next pause.
|
||||||
|
|
||||||
|
The synchronous `calculatePlanningInternal` adapter drains the same steps without pauses for existing direct core callers. The public non-browser calculation context uses the asynchronous driver so its cancellation method also works.
|
||||||
|
|
||||||
|
User cancellation is distinct from replacement by another calculation (`stale-calculation`) and isolated worker failure (`worker-error` or `worker-unavailable`). Replacement retains the existing termination and stale-response protection. User cancellation leaves the producing worker alive until its explanation and trace have been delivered.
|
||||||
|
|
||||||
|
## Retaining a valid plan
|
||||||
|
|
||||||
|
The asynchronous calculation retains a copied incumbent as soon as the obligation search finds a jointly valid solution, before that entire stage has completed. It also considers accepted placements and every feasible evaluated quality candidate, including candidates rejected by a stage's narrower comparator. Each candidate is reconciled for earned entitlement availability and checked against all obligation rules before retention. The existing full lexicographic comparator chooses the best available incumbent; its comparisons are recorded in the original trace.
|
||||||
|
|
||||||
|
Retention reuses quality metrics already computed for an evaluated candidate. An intermediate state with more unplaced at-risk leave, or worse preferences at equal unplaced leave, is rejected using that objective prefix alone, with the actual short-circuited comparison recorded in the trace. This avoids rescoring dominated states during large obligation previews without skipping any produced history or a candidate that could improve the retained plan.
|
||||||
|
|
||||||
|
Preference occurrence dates and patterns are calculated once for the run's immutable calendar and rules. Each comparison still checks the candidate's actual placements against those patterns; candidate scores and trace records are not cached.
|
||||||
|
|
||||||
|
On cancellation, the core materializes the retained state with consistent placements, balances, periods, rule provenance, and preference satisfaction. It returns `PlanningIncomplete` with reason `user-cancelled`. With an incumbent the French status is **Calcul annulé — plan partiel**; without one it is **Calcul annulé**, without a plan or an obligation conflict. Temporary search states never become the displayed incumbent.
|
||||||
|
|
||||||
|
## Preserving the original trace
|
||||||
|
|
||||||
|
Cancellation follows the core's normal finish path. Quality evaluations are recorded before their incumbent comparisons, and each greedy placement attempt starts its own atomic decision. The recorder closes interrupted and unexecuted phases and drains every remaining operation from that run. The worker retains staged updates, including ones not yet posted, and continues its bounded, acknowledged trace delivery after optimization has stopped. It sends `artifacts-complete` only when all queued updates have been sent and acknowledged. The facade drains its own received update queue before resolving the trace. Neither layer replays planning to reconstruct this history.
|
||||||
|
|
||||||
|
The stable explanation is derived from the retained state. The outcome, explanation, and trace have separate promises: delayed or failed artifact delivery cannot replace or remove an already settled valid partial plan. Repeated and late outcome messages cannot change that outcome, and calculation identities protect the UI against obsolete results and artifacts.
|
||||||
|
|
||||||
|
If Cancel is accepted just before a queued natural result reaches the facade, the pending outcome still becomes a user cancellation. An already computed valid plan can be retained, while a queued conflict cannot become the cancellation result. Its existing explanation receives interruption metadata, and the original phase history is preserved even when the worker had already completed those phases.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run the focused checks with the project's installed Bun and Node:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bunx vitest run src/app/services/leave-planner-cancellation.spec.ts
|
||||||
|
bunx vitest run src/app/services/leave-planner.worker.spec.ts
|
||||||
|
bunx vitest run src/app/components/calendar-year/calendar-year-planning.spec.ts
|
||||||
|
bunx tsc --noEmit -p tsconfig.app.json
|
||||||
|
bunx tsc --noEmit -p tsconfig.spec.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The public cancellation tests cover immediate cancellation, queued trace updates on both sides of the request, a stable partial outcome despite late completion or artifact error, distinct worker failure and replacement (including non-browser explanation metadata), and cancellation after settlement. A core regression compares the retained plan's full objective vector with every valid candidate actually evaluated before cancellation. The CalendarYear tests cover keyboard activation, translated statuses with and without an incumbent, focus, no implicit recalculation, explicit recalculation, and obsolete trace delivery.
|
||||||
|
|
||||||
|
The real-worker suite bundles the production worker source with Bun and runs it on a separate Node worker thread. Its small host adapter maps browser worker messages to the thread's message port; the planner, CPU work, cancellation handling, and acknowledged trace protocol are real. It checks interruption after preparation has started, before the first obligation solution, during active obligation CPU work after a solution exists, and after a completed checkpoint. It also compares an uncancelled outcome with the synchronous adapter's deterministic result. These checks complement the controlled worker boundary tests.
|
||||||
|
|
||||||
|
For a browser check, serve the production browser build and use native Chromium Web Workers with a saved annual horizon. Check cancellation during calendar preparation, a broad obligation search, and a quality pass. Confirm one planning request per Calculate, no new request on Cancel, the appropriate translated status, joint rule satisfaction and balance accounting for an incumbent, and complete trace delivery after the outcome. Also check the Cancel control with the keyboard, at 375px width, and in light and dark themes.
|
||||||
|
|
||||||
|
The production build was checked in native Chromium for all three interruption points, including keyboard cancellation during a 375px dark-theme obligation search and desktop light-theme checks. An editable input could still receive focus during CPU work, and focus moved to the result heading after keyboard cancellation. Each run sent one planning request and one cancellation request, displayed the appropriate French status, and completed acknowledged artifact delivery. The Cancel control was 44px high and the page had no horizontal overflow. In these sample runs, cancellation through artifact completion took under 200ms; this is measured evidence, not a latency guarantee.
|
||||||
@@ -79,7 +79,7 @@ export class CalendarMonth implements OnInit {
|
|||||||
|
|
||||||
getDateKey(day: Date): string {
|
getDateKey(day: Date): string {
|
||||||
return [
|
return [
|
||||||
day.getFullYear(),
|
String(day.getFullYear()).padStart(4, '0'),
|
||||||
String(day.getMonth() + 1).padStart(2, '0'),
|
String(day.getMonth() + 1).padStart(2, '0'),
|
||||||
String(day.getDate()).padStart(2, '0'),
|
String(day.getDate()).padStart(2, '0'),
|
||||||
].join('-');
|
].join('-');
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
PlanningTrace,
|
PlanningTrace,
|
||||||
} from '../../services/leave-planner.service';
|
} from '../../services/leave-planner.service';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
|
import { RuleService, RuleType } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { DateRange } from '@angular/material/datepicker';
|
import { DateRange } from '@angular/material/datepicker';
|
||||||
|
|
||||||
@@ -182,6 +184,7 @@ function calculationFor(
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
id: 'test',
|
id: 'test',
|
||||||
|
cancel: vi.fn(),
|
||||||
snapshot: {
|
snapshot: {
|
||||||
horizon: plan.horizon,
|
horizon: plan.horizon,
|
||||||
balances: [],
|
balances: [],
|
||||||
@@ -217,6 +220,7 @@ function calendarFor(
|
|||||||
calculation,
|
calculation,
|
||||||
calendar: {
|
calendar: {
|
||||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||||
|
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
||||||
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
getDefaultPlanningHorizon: vi.fn(() => plan.horizon),
|
||||||
createPlanningCalculation: vi.fn(() => calculation),
|
createPlanningCalculation: vi.fn(() => calculation),
|
||||||
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
|
selectedDatesForPlan: vi.fn(selectedDatesForPlan),
|
||||||
@@ -397,6 +401,265 @@ function jointTrace(): PlanningTrace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('CalendarYear planning outcome boundary', () => {
|
describe('CalendarYear planning outcome boundary', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
'cancels accessibly with a valid incumbent: %s, then allows a new calculation',
|
||||||
|
async (withIncumbent) => {
|
||||||
|
const pending = deferred<PlanningOutcome>();
|
||||||
|
const delayedTrace = deferred<PlanningTrace | undefined>();
|
||||||
|
const cancelled: PlanningIncompleteOutcome = {
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
incumbent: withIncumbent ? plan : undefined,
|
||||||
|
completedStages: ['validation'],
|
||||||
|
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
|
||||||
|
};
|
||||||
|
const { calendar, calculation } = calendarFor(
|
||||||
|
cancelled,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
delayedTrace.promise,
|
||||||
|
);
|
||||||
|
calendar.createPlanningCalculation
|
||||||
|
.mockReturnValueOnce({ ...calculation, outcome: pending.promise })
|
||||||
|
.mockReturnValueOnce(calculationFor(plan, explanation));
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [
|
||||||
|
provideZonelessChangeDetection(),
|
||||||
|
{ provide: CalendarService, useValue: calendar },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
TestBed.inject(TranslationService).language.set('fr');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const cancel = root.querySelector<HTMLButtonElement>('.cancel-calculation');
|
||||||
|
expect(cancel?.textContent).toContain('Annuler');
|
||||||
|
activateWithKeyboard(cancel!);
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(calculation.cancel).toHaveBeenCalledOnce();
|
||||||
|
expect(cancel?.disabled).toBe(true);
|
||||||
|
expect(calendar.createPlanningCalculation).toHaveBeenCalledOnce();
|
||||||
|
pending.resolve(cancelled);
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(root.querySelector('.planning-status')?.textContent).toContain(
|
||||||
|
withIncumbent ? 'Calcul annulé — plan partiel' : 'Calcul annulé',
|
||||||
|
);
|
||||||
|
expect(root.querySelectorAll('button.selected')).toHaveLength(withIncumbent ? 2 : 0);
|
||||||
|
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||||
|
expect(document.activeElement).toBe(root.querySelector('#planning-results-title'));
|
||||||
|
TestBed.inject(UserInputService).requestComputation();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(calendar.createPlanningCalculation).toHaveBeenCalledTimes(2);
|
||||||
|
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||||||
|
delayedTrace.resolve({ ...jointTrace(), status: 'incomplete' });
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('displays an accepted historical year below 100 without shifting it to the twentieth century', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [provideZonelessChangeDetection()],
|
||||||
|
}).compileComponents();
|
||||||
|
vi.spyOn(TestBed.inject(CalendarService), 'createPlanningCalculation').mockReturnValue(
|
||||||
|
calculationFor(conflict),
|
||||||
|
);
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('0099-05-14', '0099-05-14');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-14"]')?.disabled,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
root.querySelector<HTMLButtonElement>('button[data-date="0099-05-15"]')?.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['invalid balances', invalid],
|
||||||
|
['conflict', conflict],
|
||||||
|
[
|
||||||
|
'cancellation without a plan',
|
||||||
|
{ ...incomplete, reasons: [{ code: 'cancelled', message: 'Cancelled.' }] },
|
||||||
|
],
|
||||||
|
['worker error', new Error('Worker stopped.')],
|
||||||
|
['startup error', new Error('Worker could not start.')],
|
||||||
|
] satisfies [string, PlanningOutcome | Error][])(
|
||||||
|
'uses the requested historical three-year calendar after %s',
|
||||||
|
async (label, outcome) => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [provideZonelessChangeDetection()],
|
||||||
|
}).compileComponents();
|
||||||
|
const calendar = TestBed.inject(CalendarService);
|
||||||
|
vi.spyOn(calendar, 'createPlanningCalculation').mockImplementation(() => {
|
||||||
|
if (label === 'startup error') throw outcome;
|
||||||
|
return outcome instanceof Error
|
||||||
|
? { ...calculationFor(incomplete), outcome: Promise.reject(outcome) }
|
||||||
|
: calculationFor(outcome);
|
||||||
|
});
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2021-03-15', '2024-02-14');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
expect(root.querySelectorAll('app-calendar-month')).toHaveLength(36);
|
||||||
|
for (const date of ['2021-03-15', '2024-02-14'])
|
||||||
|
expect(
|
||||||
|
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
|
||||||
|
).toBe(false);
|
||||||
|
for (const date of ['2021-03-14', '2024-02-15'])
|
||||||
|
expect(
|
||||||
|
root.querySelector<HTMLButtonElement>(`button[data-date="${date}"]`)?.disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
root
|
||||||
|
.querySelector('button[data-date="2023-04-10"]')
|
||||||
|
?.classList.contains('closed-day'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(root.querySelector('.loading-overlay')).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('ignores an older outcome, explanation and trace after a new horizon is submitted', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [provideZonelessChangeDetection()],
|
||||||
|
}).compileComponents();
|
||||||
|
const oldOutcome = deferred<PlanningOutcome>();
|
||||||
|
const oldExplanation = deferred<PlanningExplanation | undefined>();
|
||||||
|
const oldTrace = deferred<PlanningTrace | undefined>();
|
||||||
|
const calendar = TestBed.inject(CalendarService);
|
||||||
|
const calculate = vi
|
||||||
|
.spyOn(calendar, 'createPlanningCalculation')
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
...calculationFor(plan),
|
||||||
|
outcome: oldOutcome.promise,
|
||||||
|
explanation: oldExplanation.promise,
|
||||||
|
trace: oldTrace.promise,
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce(calculationFor(conflict));
|
||||||
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
|
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||||
|
TestBed.inject(UserInputService).requestComputation();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
oldOutcome.resolve(plan);
|
||||||
|
oldExplanation.resolve(explanation);
|
||||||
|
oldTrace.resolve(jointTrace());
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(calculate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.componentInstance['planningOutcome']()).toBe(conflict);
|
||||||
|
expect(fixture.componentInstance['planningExplanation']()).toBeNull();
|
||||||
|
expect(fixture.componentInstance['planningTrace']()).toBeNull();
|
||||||
|
expect(fixture.componentInstance['planningHorizon']()).toEqual({
|
||||||
|
start: '2021-05-14',
|
||||||
|
end: '2021-05-15',
|
||||||
|
});
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures selected dates with balances and rules only on an explicit calculation request', async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [CalendarYear],
|
||||||
|
providers: [provideZonelessChangeDetection()],
|
||||||
|
}).compileComponents();
|
||||||
|
const calendar = TestBed.inject(CalendarService);
|
||||||
|
const calculate = vi
|
||||||
|
.spyOn(calendar, 'createPlanningCalculation')
|
||||||
|
.mockReturnValueOnce(calculationFor(plan, explanation))
|
||||||
|
.mockReturnValueOnce(calculationFor(conflict));
|
||||||
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
|
settings.setPlanningDates('2024-01-01', '2024-12-31');
|
||||||
|
const input = TestBed.inject(UserInputService);
|
||||||
|
const rules = TestBed.inject(RuleService);
|
||||||
|
const fixture = TestBed.createComponent(CalendarYear);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(calculate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(calculate.mock.calls[0][0].horizon).toEqual(plan.horizon);
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||||
|
settings.setPlanningDates('2021-05-14', '2021-05-14');
|
||||||
|
input.updateBalances(() => [
|
||||||
|
{
|
||||||
|
id: 'historical',
|
||||||
|
type: SelectableDayType.CP,
|
||||||
|
numberOfDays: 1,
|
||||||
|
availableDate: new Date(2021, 4, 14),
|
||||||
|
expiryDate: new Date(2021, 4, 14),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
rules.addRule({
|
||||||
|
id: 'historical-rule',
|
||||||
|
name: 'Historical rule',
|
||||||
|
type: RuleType.TIME_WINDOW,
|
||||||
|
force: 'obligation',
|
||||||
|
enabled: true,
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: SelectableDayType.CP,
|
||||||
|
startDate: new Date(2021, 4, 14),
|
||||||
|
endDate: new Date(2021, 4, 14),
|
||||||
|
});
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(calculate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fixture.nativeElement.querySelector('.plan-summary')).not.toBeNull();
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(12);
|
||||||
|
input.requestComputation();
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(calculate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(calculate.mock.calls[1][0]).toMatchObject({
|
||||||
|
horizon: { start: '2021-05-14', end: '2021-05-15' },
|
||||||
|
balances: [
|
||||||
|
{
|
||||||
|
id: 'historical',
|
||||||
|
type: 'cp',
|
||||||
|
quantity: 1,
|
||||||
|
availableFrom: '2021-05-14',
|
||||||
|
expiryDate: '2021-05-14',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(calculate.mock.calls[1][0].rules).toContainEqual(
|
||||||
|
expect.objectContaining({ id: 'historical-rule' }),
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('app-calendar-month')).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
(fixture.nativeElement as HTMLElement).querySelector<HTMLButtonElement>(
|
||||||
|
'button[data-date="2021-05-14"]',
|
||||||
|
)?.disabled,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('button[data-date="2021-05-13"]')
|
||||||
|
?.classList.contains('closed-day'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['InvalidInput', invalid],
|
['InvalidInput', invalid],
|
||||||
['PlanningConflict', conflict],
|
['PlanningConflict', conflict],
|
||||||
@@ -1472,6 +1735,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
|||||||
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
it('turns planner rejection into an incomplete error and clears loading', async () => {
|
||||||
const calendar = {
|
const calendar = {
|
||||||
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
getSelectedDatesForYear: vi.fn(() => new SelectedDates()),
|
||||||
|
getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()),
|
||||||
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })),
|
||||||
createPlanningCalculation: vi.fn(() => ({
|
createPlanningCalculation: vi.fn(() => ({
|
||||||
...calculationFor(plan),
|
...calculationFor(plan),
|
||||||
|
|||||||
@@ -14,14 +14,25 @@
|
|||||||
></mat-spinner>
|
></mat-spinner>
|
||||||
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<p role="status">{{ tr().loading }}</p>
|
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
type="button"
|
||||||
|
class="cancel-calculation"
|
||||||
|
[disabled]="isCancelling()"
|
||||||
|
(click)="cancelCalculation()"
|
||||||
|
>
|
||||||
|
{{ tr().planning.cancel }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<header class="results-header">
|
<header class="results-header">
|
||||||
<div class="results-heading">
|
<div class="results-heading">
|
||||||
<h2 id="planning-results-title" tabindex="-1">{{ tr().planning.title }}</h2>
|
<h2 #resultsTitle id="planning-results-title" tabindex="-1">
|
||||||
|
{{ tr().planning.title }}
|
||||||
|
</h2>
|
||||||
@if (planningOutcome()?.kind === 'InvalidInput') {
|
@if (planningOutcome()?.kind === 'InvalidInput') {
|
||||||
<p class="planning-status error" role="alert">
|
<p class="planning-status error" role="alert">
|
||||||
<mat-icon aria-hidden="true">error</mat-icon>
|
<mat-icon aria-hidden="true">error</mat-icon>
|
||||||
@@ -42,7 +53,7 @@
|
|||||||
<mat-icon aria-hidden="true">{{
|
<mat-icon aria-hidden="true">{{
|
||||||
hasPlannerError() ? 'error' : 'warning'
|
hasPlannerError() ? 'error' : 'warning'
|
||||||
}}</mat-icon>
|
}}</mat-icon>
|
||||||
{{ hasPlannerError() ? tr().planning.error : tr().planning.incomplete }}
|
{{ outcomeLabel(planningOutcome()!) }}
|
||||||
</p>
|
</p>
|
||||||
} @else if (planningOutcome()?.kind === 'Plan') {
|
} @else if (planningOutcome()?.kind === 'Plan') {
|
||||||
<p class="planning-status success" role="status">
|
<p class="planning-status success" role="status">
|
||||||
|
|||||||
@@ -526,6 +526,9 @@ output {
|
|||||||
justify-items: center;
|
justify-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
.cancel-calculation {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
.spinner-container {
|
.spinner-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core';
|
|||||||
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
|
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
import { MatButton } from '@angular/material/button';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
|
||||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
import { RuleService } from '../../services/rule-service';
|
import { RuleService } from '../../services/rule-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import {
|
import {
|
||||||
applyReplayDeltas,
|
applyReplayDeltas,
|
||||||
CounterfactualAnalysis,
|
CounterfactualAnalysis,
|
||||||
@@ -64,6 +66,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra
|
|||||||
MatDatepickerModule,
|
MatDatepickerModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
|
MatButton,
|
||||||
MatCard,
|
MatCard,
|
||||||
CalendarMonth,
|
CalendarMonth,
|
||||||
MatCardContent,
|
MatCardContent,
|
||||||
@@ -78,6 +81,7 @@ export class CalendarYear {
|
|||||||
private readonly userInput = inject(UserInputService);
|
private readonly userInput = inject(UserInputService);
|
||||||
private readonly calendarService = inject(CalendarService);
|
private readonly calendarService = inject(CalendarService);
|
||||||
private readonly ruleService = inject(RuleService);
|
private readonly ruleService = inject(RuleService);
|
||||||
|
private readonly calendarSettings = inject(CalendarSettingsService);
|
||||||
private readonly dialog = inject(MatDialog);
|
private readonly dialog = inject(MatDialog);
|
||||||
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
@@ -86,7 +90,9 @@ export class CalendarYear {
|
|||||||
private comparisonDialog: MatDialogRef<unknown> | null = null;
|
private comparisonDialog: MatDialogRef<unknown> | null = null;
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
|
|
||||||
protected readonly isLoading = signal(true);
|
protected readonly isLoading = signal(false);
|
||||||
|
protected readonly isCancelling = signal(false);
|
||||||
|
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
||||||
protected readonly loadingProgress = signal(0);
|
protected readonly loadingProgress = signal(0);
|
||||||
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
|
protected readonly planningOutcome = signal<PlanningOutcome | null>(null);
|
||||||
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
protected readonly planningExplanation = signal<PlanningExplanation | null>(null);
|
||||||
@@ -201,7 +207,7 @@ export class CalendarYear {
|
|||||||
// Material returns local midnight; converting to UTC can change the calendar day.
|
// Material returns local midnight; converting to UTC can change the calendar day.
|
||||||
const date = selected
|
const date = selected
|
||||||
? [
|
? [
|
||||||
selected.getFullYear(),
|
String(selected.getFullYear()).padStart(4, '0'),
|
||||||
String(selected.getMonth() + 1).padStart(2, '0'),
|
String(selected.getMonth() + 1).padStart(2, '0'),
|
||||||
String(selected.getDate()).padStart(2, '0'),
|
String(selected.getDate()).padStart(2, '0'),
|
||||||
].join('-')
|
].join('-')
|
||||||
@@ -283,7 +289,7 @@ export class CalendarYear {
|
|||||||
if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
|
if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue;
|
||||||
const day = selected.range.start;
|
const day = selected.range.start;
|
||||||
closedDayDates.add(
|
closedDayDates.add(
|
||||||
`${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
|
`${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.traceReplay() && trace) {
|
if (this.traceReplay() && trace) {
|
||||||
@@ -428,7 +434,8 @@ export class CalendarYear {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result: { monthIndex: number; year: number }[] = [];
|
const result: { monthIndex: number; year: number }[] = [];
|
||||||
const cursor = new Date(first.getFullYear(), first.getMonth(), 1);
|
const cursor = new Date(first);
|
||||||
|
cursor.setDate(1);
|
||||||
while (cursor <= last) {
|
while (cursor <= last) {
|
||||||
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() });
|
||||||
cursor.setMonth(cursor.getMonth() + 1);
|
cursor.setMonth(cursor.getMonth() + 1);
|
||||||
@@ -441,74 +448,87 @@ export class CalendarYear {
|
|||||||
this.destroyRef.onDestroy(() => this.clearCounterfactual());
|
this.destroyRef.onDestroy(() => this.clearCounterfactual());
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.userInput.computeTriggerSignal();
|
this.userInput.computeTriggerSignal();
|
||||||
this.resetCalculationState();
|
untracked(() => this.startCalculation());
|
||||||
const vacationData = untracked(() => this.userInput.vacationNumberSignal());
|
|
||||||
const currentRules = untracked(() => this.ruleService.rules);
|
|
||||||
const horizon = this.calendarService.getDefaultPlanningHorizon();
|
|
||||||
this.planningHorizon.set(horizon);
|
|
||||||
const version = ++this.calculationVersion;
|
|
||||||
this.isLoading.set(true);
|
|
||||||
this.loadingProgress.set(0);
|
|
||||||
this.liveMessage.set(this.tr().loading);
|
|
||||||
queueMicrotask(() => {
|
|
||||||
let calculation: PlanningCalculationContext;
|
|
||||||
try {
|
|
||||||
calculation = this.calendarService.createPlanningCalculation({
|
|
||||||
horizon,
|
|
||||||
balances: vacationData.map((balance) => ({
|
|
||||||
id: balance.id,
|
|
||||||
type: balance.type,
|
|
||||||
quantity: balance.numberOfDays,
|
|
||||||
label: this.translationService.getDayTypeLabel(balance.type),
|
|
||||||
availableFrom: balance.availableDate
|
|
||||||
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
|
||||||
: undefined,
|
|
||||||
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
|
||||||
})),
|
|
||||||
rules: currentRules,
|
|
||||||
labels: Object.fromEntries(
|
|
||||||
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
|
||||||
),
|
|
||||||
progress: (stage) => {
|
|
||||||
if (version === this.calculationVersion)
|
|
||||||
this.loadingProgress.set(this.stageProgress(stage));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.failCalculation(error, version);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.activeCalculation = calculation;
|
|
||||||
this.loadArtifacts(calculation, version);
|
|
||||||
void calculation.outcome
|
|
||||||
.then((outcome) => {
|
|
||||||
if (version === this.calculationVersion) this.applyOutcome(outcome);
|
|
||||||
})
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
if (version !== this.calculationVersion) return;
|
|
||||||
this.applyOutcome({
|
|
||||||
kind: 'PlanningIncomplete',
|
|
||||||
completedStages: [],
|
|
||||||
reasons: [
|
|
||||||
{
|
|
||||||
code: 'planner-error',
|
|
||||||
message:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'The planner failed unexpectedly.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (version !== this.calculationVersion) return;
|
|
||||||
this.loadingProgress.set(100);
|
|
||||||
this.isLoading.set(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startCalculation(): void {
|
||||||
|
const horizon = this.calendarSettings.planningHorizon();
|
||||||
|
if (!horizon) return;
|
||||||
|
this.planningHorizon.set(horizon);
|
||||||
|
this.resetCalculationState(horizon);
|
||||||
|
const vacationData = this.userInput.vacationNumberSignal();
|
||||||
|
const currentRules = this.ruleService.rules;
|
||||||
|
const version = ++this.calculationVersion;
|
||||||
|
this.isLoading.set(true);
|
||||||
|
this.isCancelling.set(false);
|
||||||
|
this.loadingProgress.set(0);
|
||||||
|
this.liveMessage.set(this.tr().loading);
|
||||||
|
let calculation: PlanningCalculationContext;
|
||||||
|
try {
|
||||||
|
calculation = this.calendarService.createPlanningCalculation({
|
||||||
|
horizon,
|
||||||
|
balances: vacationData.map((balance) => ({
|
||||||
|
id: balance.id,
|
||||||
|
type: balance.type,
|
||||||
|
quantity: balance.numberOfDays,
|
||||||
|
label: this.translationService.getDayTypeLabel(balance.type),
|
||||||
|
availableFrom: balance.availableDate
|
||||||
|
? dayjs(balance.availableDate).format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
expiryDate: dayjs(balance.expiryDate).format('YYYY-MM-DD'),
|
||||||
|
})),
|
||||||
|
rules: currentRules,
|
||||||
|
labels: Object.fromEntries(
|
||||||
|
currentRules.map((rule) => [rule.id, rule.name ?? rule.id]),
|
||||||
|
),
|
||||||
|
progress: (stage) => {
|
||||||
|
if (version === this.calculationVersion)
|
||||||
|
this.loadingProgress.set(this.stageProgress(stage));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.failCalculation(error, version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.activeCalculation = calculation;
|
||||||
|
this.loadArtifacts(calculation, version);
|
||||||
|
void calculation.outcome
|
||||||
|
.then((outcome) => {
|
||||||
|
if (version === this.calculationVersion) this.applyOutcome(outcome);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (version !== this.calculationVersion) return;
|
||||||
|
this.applyOutcome({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [
|
||||||
|
{
|
||||||
|
code: 'planner-error',
|
||||||
|
message:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The planner failed unexpectedly.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (version !== this.calculationVersion) return;
|
||||||
|
this.loadingProgress.set(100);
|
||||||
|
this.isLoading.set(false);
|
||||||
|
if (this.isCancelling()) this.resultsTitle()?.nativeElement.focus();
|
||||||
|
this.isCancelling.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected cancelCalculation(): void {
|
||||||
|
if (!this.isLoading() || this.isCancelling() || !this.activeCalculation) return;
|
||||||
|
this.isCancelling.set(true);
|
||||||
|
this.liveMessage.set(this.tr().planning.cancelling);
|
||||||
|
this.activeCalculation.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
private failCalculation(error: unknown, version: number): void {
|
private failCalculation(error: unknown, version: number): void {
|
||||||
if (version !== this.calculationVersion) return;
|
if (version !== this.calculationVersion) return;
|
||||||
const reason = {
|
const reason = {
|
||||||
@@ -523,7 +543,7 @@ export class CalendarYear {
|
|||||||
this.isLoading.set(false);
|
this.isLoading.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetCalculationState(): void {
|
private resetCalculationState(horizon: { start: string; end: string }): void {
|
||||||
this.activeCalculation = null;
|
this.activeCalculation = null;
|
||||||
this.planningOutcome.set(null);
|
this.planningOutcome.set(null);
|
||||||
this.planningExplanation.set(null);
|
this.planningExplanation.set(null);
|
||||||
@@ -542,7 +562,7 @@ export class CalendarYear {
|
|||||||
this.traceReplay.set(false);
|
this.traceReplay.set(false);
|
||||||
this.clearHistoricalEligibility();
|
this.clearHistoricalEligibility();
|
||||||
this.selectedTraceDate.set(null);
|
this.selectedTraceDate.set(null);
|
||||||
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
|
this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon));
|
||||||
this.userInput.remainingVacationDaysSignal.set([]);
|
this.userInput.remainingVacationDaysSignal.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +646,9 @@ export class CalendarYear {
|
|||||||
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
|
this.selectedDates.set(
|
||||||
|
this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!),
|
||||||
|
);
|
||||||
this.userInput.remainingVacationDaysSignal.set([]);
|
this.userInput.remainingVacationDaysSignal.set([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1114,7 +1136,10 @@ export class CalendarYear {
|
|||||||
protected hasPlannerError(): boolean {
|
protected hasPlannerError(): boolean {
|
||||||
const outcome = this.planningOutcome();
|
const outcome = this.planningOutcome();
|
||||||
return (
|
return (
|
||||||
outcome?.kind === 'PlanningIncomplete' && outcome.reasons[0]?.code === 'planner-error'
|
outcome?.kind === 'PlanningIncomplete' &&
|
||||||
|
outcome.reasons.some((reason) =>
|
||||||
|
['planner-error', 'worker-error', 'worker-unavailable'].includes(reason.code),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,10 +1270,14 @@ export class CalendarYear {
|
|||||||
return value.replaceAll('-', ' ').replaceAll(':', ': ');
|
return value.replaceAll('-', ' ').replaceAll(':', ': ');
|
||||||
}
|
}
|
||||||
|
|
||||||
private outcomeLabel(outcome: PlanningOutcome): string {
|
protected outcomeLabel(outcome: PlanningOutcome): string {
|
||||||
if (outcome.kind === 'Plan') return this.tr().planning.complete;
|
if (outcome.kind === 'Plan') return this.tr().planning.complete;
|
||||||
if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput;
|
if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput;
|
||||||
if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict;
|
if (outcome.kind === 'PlanningConflict') return this.tr().planning.conflict;
|
||||||
|
if (outcome.reasons.some((reason) => reason.code === 'user-cancelled'))
|
||||||
|
return outcome.incumbent
|
||||||
|
? this.tr().planning.cancelledPartial
|
||||||
|
: this.tr().planning.cancelled;
|
||||||
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
|
return this.hasPlannerError() ? this.tr().planning.error : this.tr().planning.incomplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,11 +30,18 @@
|
|||||||
class="calculate-button"
|
class="calculate-button"
|
||||||
color="primary"
|
color="primary"
|
||||||
type="button"
|
type="button"
|
||||||
|
[disabled]="!!calendarSettings.planningHorizonError()"
|
||||||
|
[attr.aria-describedby]="
|
||||||
|
calendarSettings.planningHorizonError() ? 'invalid-planning-horizon' : null
|
||||||
|
"
|
||||||
(click)="triggerComputation()"
|
(click)="triggerComputation()"
|
||||||
>
|
>
|
||||||
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
<mat-icon aria-hidden="true">auto_awesome</mat-icon>
|
||||||
{{ tr().userInput.calculate }}
|
{{ tr().userInput.calculate }}
|
||||||
</button>
|
</button>
|
||||||
|
@if (calendarSettings.planningHorizonError()) {
|
||||||
|
<p id="invalid-planning-horizon" role="alert">{{ tr().settings.invalidHorizon }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
@if (hasRemainingDays()) {
|
@if (hasRemainingDays()) {
|
||||||
<mat-card class="warning-card">
|
<mat-card class="warning-card">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { vi } from 'vitest';
|
|||||||
import { UserInput } from './user-input';
|
import { UserInput } from './user-input';
|
||||||
import { UserInputService } from '../../services/user-input-service';
|
import { UserInputService } from '../../services/user-input-service';
|
||||||
import { UserSettings } from '../user-settings/user-settings';
|
import { UserSettings } from '../user-settings/user-settings';
|
||||||
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
|
|
||||||
describe('UserInput', () => {
|
describe('UserInput', () => {
|
||||||
let component: UserInput;
|
let component: UserInput;
|
||||||
@@ -82,6 +83,26 @@ describe('UserInput', () => {
|
|||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prevents calculation while planning dates are invalid and re-enables it after correction', () => {
|
||||||
|
const settings = TestBed.inject(CalendarSettingsService);
|
||||||
|
settings.setPlanningDates('2024-03-01', '2024-02-29');
|
||||||
|
fixture.detectChanges();
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const calculate = root.querySelector<HTMLButtonElement>('.calculate-button')!;
|
||||||
|
expect(calculate.disabled).toBe(true);
|
||||||
|
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
|
||||||
|
'planning dates in Settings',
|
||||||
|
);
|
||||||
|
calculate.click();
|
||||||
|
component.triggerComputation();
|
||||||
|
expect(userInputService.computeTriggerSignal()).toBe(0);
|
||||||
|
settings.setPlanningDates('2024-02-29', '2024-02-29');
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(calculate.disabled).toBe(false);
|
||||||
|
calculate.click();
|
||||||
|
expect(userInputService.computeTriggerSignal()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should initialize with vacation days from service', () => {
|
it('should initialize with vacation days from service', () => {
|
||||||
const vacationDays = userInputService.vacationNumberSignal();
|
const vacationDays = userInputService.vacationNumberSignal();
|
||||||
expect(vacationDays).toBeDefined();
|
expect(vacationDays).toBeDefined();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DayInput } from '../userInput/day-input/day-input';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { VacDay } from '../../services/calendar-service';
|
import { VacDay } from '../../services/calendar-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-input',
|
selector: 'app-user-input',
|
||||||
@@ -26,6 +27,7 @@ import { TranslationService } from '../../services/translation-service';
|
|||||||
})
|
})
|
||||||
export class UserInput {
|
export class UserInput {
|
||||||
private userInputService: UserInputService = inject(UserInputService);
|
private userInputService: UserInputService = inject(UserInputService);
|
||||||
|
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
|
|
||||||
get tr() {
|
get tr() {
|
||||||
@@ -99,6 +101,7 @@ export class UserInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public triggerComputation() {
|
public triggerComputation() {
|
||||||
|
if (this.calendarSettings.planningHorizonError()) return;
|
||||||
this.userInputService.requestComputation();
|
this.userInputService.requestComputation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,53 @@
|
|||||||
<mat-dialog-content>
|
<mat-dialog-content>
|
||||||
<p>{{ tr().settings.description }}</p>
|
<p>{{ tr().settings.description }}</p>
|
||||||
<div class="settings-controls">
|
<div class="settings-controls">
|
||||||
|
<fieldset class="planning-dates">
|
||||||
|
<legend>{{ tr().settings.planningHorizon }}</legend>
|
||||||
|
<p id="planning-dates-help">{{ tr().settings.planningDatesHelp }}</p>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>{{ tr().settings.planningStart }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="date"
|
||||||
|
name="planningStart"
|
||||||
|
required
|
||||||
|
#planningStart
|
||||||
|
[value]="calendarSettings.planningDates().start"
|
||||||
|
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
||||||
|
aria-describedby="planning-dates-help planning-dates-error"
|
||||||
|
(input)="
|
||||||
|
calendarSettings.setPlanningDates(
|
||||||
|
planningStart.value,
|
||||||
|
lastPlanningDate.value
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>{{ tr().settings.lastPlanningDate }}</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="date"
|
||||||
|
name="lastPlanningDate"
|
||||||
|
required
|
||||||
|
#lastPlanningDate
|
||||||
|
[value]="calendarSettings.planningDates().lastPlanningDate"
|
||||||
|
[attr.aria-invalid]="!!calendarSettings.planningHorizonError()"
|
||||||
|
aria-describedby="planning-dates-help planning-dates-error"
|
||||||
|
(input)="
|
||||||
|
calendarSettings.setPlanningDates(
|
||||||
|
planningStart.value,
|
||||||
|
lastPlanningDate.value
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
@if (calendarSettings.planningHorizonError(); as error) {
|
||||||
|
<p id="planning-dates-error" class="validation-error" role="alert">
|
||||||
|
{{ tr().settings[error] }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>{{ tr().settings.selectTheme }}</mat-label>
|
<mat-label>{{ tr().settings.selectTheme }}</mat-label>
|
||||||
<mat-select
|
<mat-select
|
||||||
|
|||||||
@@ -20,3 +20,26 @@ mat-dialog-content > p {
|
|||||||
mat-form-field {
|
mat-form-field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planning-dates {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
color: var(--ui-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-error {
|
||||||
|
color: var(--ui-error-text);
|
||||||
|
background: var(--ui-error-bg);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,71 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { UserSettings } from './user-settings';
|
import { UserSettings } from './user-settings';
|
||||||
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
|
import { TranslationService } from '../../services/translation-service';
|
||||||
|
|
||||||
describe('UserSettings', () => {
|
describe('UserSettings', () => {
|
||||||
let component: UserSettings;
|
let component: UserSettings;
|
||||||
let fixture: ComponentFixture<UserSettings>;
|
let fixture: ComponentFixture<UserSettings>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
localStorage.clear();
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [UserSettings],
|
imports: [UserSettings],
|
||||||
providers: [provideZonelessChangeDetection()],
|
providers: [provideZonelessChangeDetection()],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
|
TestBed.inject(TranslationService).setLanguage('en');
|
||||||
|
TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-29', '2027-02-28');
|
||||||
fixture = TestBed.createComponent(UserSettings);
|
fixture = TestBed.createComponent(UserSettings);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it.each(['', '2024-02-30', 'not-a-date'])(
|
||||||
|
'reports an invalid date entry without saving it: %s',
|
||||||
|
(value) => {
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
||||||
|
start.value = value;
|
||||||
|
start.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(root.querySelector('[role="alert"]')?.textContent).toContain(
|
||||||
|
'two valid calendar dates',
|
||||||
|
);
|
||||||
|
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
|
||||||
|
expect(JSON.parse(localStorage.getItem('calendarSettings.planningDates')!)).toEqual({
|
||||||
|
start: '2024-02-29',
|
||||||
|
lastPlanningDate: '2027-02-28',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('edits inclusive dates with an inline validation indication while preserving the saved horizon', () => {
|
||||||
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
|
const start = root.querySelector<HTMLInputElement>('input[name="planningStart"]')!;
|
||||||
|
const last = root.querySelector<HTMLInputElement>('input[name="lastPlanningDate"]')!;
|
||||||
|
expect(start.value).toBe('2024-02-29');
|
||||||
|
expect(last.value).toBe('2027-02-28');
|
||||||
|
expect(root.textContent).toContain('Last planning date (inclusive)');
|
||||||
|
last.value = '2024-02-28';
|
||||||
|
last.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(root.querySelector('[role="alert"]')?.textContent).toContain('on or after');
|
||||||
|
expect(last.getAttribute('aria-invalid')).toBe('true');
|
||||||
|
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toBeNull();
|
||||||
|
last.value = '2024-02-29';
|
||||||
|
last.dispatchEvent(new Event('input'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(root.querySelector('[role="alert"]')).toBeNull();
|
||||||
|
expect(TestBed.inject(CalendarSettingsService).planningHorizon()).toEqual({
|
||||||
|
start: '2024-02-29',
|
||||||
|
end: '2024-03-01',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
import { MatSlideToggle } from '@angular/material/slide-toggle';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
import { CalendarSettingsService } from '../../services/calendar-settings-service';
|
||||||
import { ThemeService } from '../../services/theme-service';
|
import { ThemeService } from '../../services/theme-service';
|
||||||
import { TranslationService } from '../../services/translation-service';
|
import { TranslationService } from '../../services/translation-service';
|
||||||
@@ -24,6 +25,7 @@ import { TranslationService } from '../../services/translation-service';
|
|||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSlideToggle,
|
MatSlideToggle,
|
||||||
|
MatInputModule,
|
||||||
],
|
],
|
||||||
templateUrl: './user-settings.html',
|
templateUrl: './user-settings.html',
|
||||||
styleUrl: './user-settings.scss',
|
styleUrl: './user-settings.scss',
|
||||||
@@ -32,7 +34,8 @@ import { TranslationService } from '../../services/translation-service';
|
|||||||
export class UserSettings {
|
export class UserSettings {
|
||||||
protected readonly themeService = inject(ThemeService);
|
protected readonly themeService = inject(ThemeService);
|
||||||
protected readonly translationService = inject(TranslationService);
|
protected readonly translationService = inject(TranslationService);
|
||||||
protected readonly showWeekNumbers = inject(CalendarSettingsService).get('showWeekNumbers');
|
protected readonly calendarSettings = inject(CalendarSettingsService);
|
||||||
|
protected readonly showWeekNumbers = this.calendarSettings.get('showWeekNumbers');
|
||||||
protected readonly tr = this.translationService.tr;
|
protected readonly tr = this.translationService.tr;
|
||||||
protected readonly themePreferences = ['system', 'light', 'dark'] as const;
|
protected readonly themePreferences = ['system', 'light', 'dark'] as const;
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,62 @@ describe('CalendarService', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2026-03-28', '2026-03-30', '2026-03-31'],
|
||||||
|
['2026-10-24', '2026-10-26', '2026-10-27'],
|
||||||
|
])(
|
||||||
|
'groups and counts calendar dates across the clock change starting %s',
|
||||||
|
(periodStart, date, end) => {
|
||||||
|
const previousTimeZone = process.env['TZ'];
|
||||||
|
process.env['TZ'] = 'Europe/Paris';
|
||||||
|
try {
|
||||||
|
const selected = service.selectedDatesForPlan({
|
||||||
|
horizon: { start: date, end },
|
||||||
|
placements: [
|
||||||
|
{ date, balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const period = selected.datesSelected.find(
|
||||||
|
(item) =>
|
||||||
|
item.type === null &&
|
||||||
|
item.range.start?.getTime() ===
|
||||||
|
new Date(`${periodStart}T00:00:00`).getTime() &&
|
||||||
|
item.range.end?.getTime() === new Date(`${date}T00:00:00`).getTime(),
|
||||||
|
);
|
||||||
|
expect(period).toBeDefined();
|
||||||
|
expect(period?.getLengthInDays()).toBe(3);
|
||||||
|
} finally {
|
||||||
|
if (previousTimeZone === undefined) delete process.env['TZ'];
|
||||||
|
else process.env['TZ'] = previousTimeZone;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('projects adjacent non-working dates into a preceding month without moving leave outside the horizon', () => {
|
||||||
|
const horizon = { start: '2024-04-02', end: '2024-04-03' };
|
||||||
|
const selected = service.selectedDatesForPlan({
|
||||||
|
horizon,
|
||||||
|
placements: [
|
||||||
|
{ date: '2024-04-02', balanceId: 'cp', type: 'cp', ruleIds: [], supporting: false },
|
||||||
|
],
|
||||||
|
timeOffPeriods: [
|
||||||
|
{
|
||||||
|
start: '2024-03-30',
|
||||||
|
end: '2024-04-02',
|
||||||
|
duration: 4,
|
||||||
|
placementDates: ['2024-04-02'],
|
||||||
|
nonWorkingDates: ['2024-03-30', '2024-03-31', '2024-04-01'],
|
||||||
|
nonWorkingDayGain: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(selected.getClasses(new Date('2024-03-30T00:00:00'))).toContain(DayType.WEEKEND);
|
||||||
|
expect(selected.getClasses(new Date('2024-04-01T00:00:00'))).toContain(DayType.CLOSED_DAY);
|
||||||
|
expect(selected.getClasses(new Date('2024-04-02T00:00:00'))).toContain(DayType.CP);
|
||||||
|
expect(selected.getClasses(new Date('2024-04-03T00:00:00'))).not.toContain(DayType.CP);
|
||||||
|
expect(horizon).toEqual({ start: '2024-04-02', end: '2024-04-03' });
|
||||||
|
});
|
||||||
|
|
||||||
describe('getHolidays', () => {
|
describe('getHolidays', () => {
|
||||||
it('should return an array of holidays', () => {
|
it('should return an array of holidays', () => {
|
||||||
const holidays = service.getHolidays();
|
const holidays = service.getHolidays();
|
||||||
@@ -115,6 +171,25 @@ describe('CalendarService', () => {
|
|||||||
DayType.WEEKEND,
|
DayType.WEEKEND,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('projects holidays in every covered year and in both partial boundary months', () => {
|
||||||
|
const selected = service.getSelectedDatesForHorizon({
|
||||||
|
start: '2023-12-26',
|
||||||
|
end: '2027-01-03',
|
||||||
|
});
|
||||||
|
for (const date of [
|
||||||
|
'2023-12-25',
|
||||||
|
'2024-04-01',
|
||||||
|
'2025-04-21',
|
||||||
|
'2026-04-06',
|
||||||
|
'2027-01-01',
|
||||||
|
]) {
|
||||||
|
expect(selected.getClasses(new Date(`${date}T00:00:00`))).toContain(
|
||||||
|
DayType.CLOSED_DAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(selected.getClasses(new Date('2027-01-02T00:00:00'))).toContain(DayType.WEEKEND);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('setWeekendSelection', () => {
|
describe('setWeekendSelection', () => {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
PlanningReason,
|
PlanningReason,
|
||||||
RemainingClassification,
|
RemainingClassification,
|
||||||
} from './leave-planner.service';
|
} from './leave-planner.service';
|
||||||
import { addPlanningYears, planningDateInParis } from './planning-date';
|
import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date';
|
||||||
|
|
||||||
dayjs.extend(weekOfYear);
|
dayjs.extend(weekOfYear);
|
||||||
dayjs.extend(isoWeek);
|
dayjs.extend(isoWeek);
|
||||||
@@ -39,10 +39,21 @@ export class CalendarService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectedDatesForPlan(
|
selectedDatesForPlan(
|
||||||
plan: Pick<PlanOutcome, 'placements'> & { horizon?: { start: string; end: string } },
|
plan: Pick<PlanOutcome, 'placements'> & {
|
||||||
|
horizon?: { start: string; end: string };
|
||||||
|
timeOffPeriods?: PlanOutcome['timeOffPeriods'];
|
||||||
|
},
|
||||||
): SelectedDates {
|
): SelectedDates {
|
||||||
const selected = plan.horizon
|
const projectionHorizon = plan.horizon ? { ...plan.horizon } : undefined;
|
||||||
? this.getSelectedDatesForHorizon(plan.horizon)
|
if (projectionHorizon) {
|
||||||
|
for (const period of plan.timeOffPeriods ?? []) {
|
||||||
|
if (period.start < projectionHorizon.start) projectionHorizon.start = period.start;
|
||||||
|
if (period.end >= projectionHorizon.end)
|
||||||
|
projectionHorizon.end = addPlanningDays(period.end, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const selected = projectionHorizon
|
||||||
|
? this.getSelectedDatesForHorizon(projectionHorizon)
|
||||||
: this.getSelectedDatesForYear();
|
: this.getSelectedDatesForYear();
|
||||||
for (const placement of plan.placements) {
|
for (const placement of plan.placements) {
|
||||||
selected.push(
|
selected.push(
|
||||||
@@ -58,9 +69,7 @@ export class CalendarService {
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for holiday timestamps
|
private readonly holidaysByYear = new Map<number, number[]>();
|
||||||
private holidayTimestampsCache: Set<number> | null = null;
|
|
||||||
private holidayCacheYear: number | null = null;
|
|
||||||
|
|
||||||
// Get selected dates from current date to the next year
|
// Get selected dates from current date to the next year
|
||||||
getSelectedDatesFromNow(): SelectedDates {
|
getSelectedDatesFromNow(): SelectedDates {
|
||||||
@@ -78,9 +87,10 @@ export class CalendarService {
|
|||||||
const start = new Date(`${horizon.start}T00:00:00`);
|
const start = new Date(`${horizon.start}T00:00:00`);
|
||||||
const last = new Date(`${horizon.end}T00:00:00`);
|
const last = new Date(`${horizon.end}T00:00:00`);
|
||||||
last.setDate(last.getDate() - 1);
|
last.setDate(last.getDate() - 1);
|
||||||
const holidays = this.getHolidays(start.getFullYear());
|
const holidays = this.getHolidays(start.getFullYear(), last.getFullYear());
|
||||||
const monthInHorizon: Year = [];
|
const monthInHorizon: Year = [];
|
||||||
const cursor = new Date(start.getFullYear(), start.getMonth(), 1);
|
const cursor = new Date(start);
|
||||||
|
cursor.setDate(1);
|
||||||
while (cursor <= last) {
|
while (cursor <= last) {
|
||||||
monthInHorizon.push(
|
monthInHorizon.push(
|
||||||
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
|
CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()),
|
||||||
@@ -128,59 +138,47 @@ export class CalendarService {
|
|||||||
|
|
||||||
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
|
setWeekendSelection(date: Date, selectedDates: SelectedDates) {
|
||||||
if (date.getDay() === 6) {
|
if (date.getDay() === 6) {
|
||||||
|
const end = new Date(date);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
selectedDates._datesSelected.push(
|
selectedDates._datesSelected.push(
|
||||||
new SelectedDate(
|
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(date, end)),
|
||||||
DayType.WEEKEND,
|
|
||||||
new DateRange<Date>(
|
|
||||||
date,
|
|
||||||
new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (date.getDate() === 1 && date.getDay() === 0) {
|
if (date.getDate() === 1 && date.getDay() === 0) {
|
||||||
|
const start = new Date(date);
|
||||||
|
start.setDate(start.getDate() - 1);
|
||||||
selectedDates._datesSelected.push(
|
selectedDates._datesSelected.push(
|
||||||
new SelectedDate(
|
new SelectedDate(DayType.WEEKEND, new DateRange<Date>(start, date)),
|
||||||
DayType.WEEKEND,
|
|
||||||
new DateRange<Date>(
|
|
||||||
new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1),
|
|
||||||
date,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] {
|
getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] {
|
||||||
// Cache holidays to avoid recalculation
|
const dates: Date[] = [];
|
||||||
if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) {
|
for (let year = startYear; year <= lastYear; year++) {
|
||||||
// Return timestamps directly for efficient lookup
|
let timestamps = this.holidaysByYear.get(year);
|
||||||
return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts));
|
if (!timestamps) {
|
||||||
|
const calendar = new Holidays('FR', allTypesOptions);
|
||||||
|
timestamps = calendar
|
||||||
|
.getHolidays(year, lang)
|
||||||
|
.filter((holiday) => holiday.type === 'public')
|
||||||
|
.map((holiday) => {
|
||||||
|
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
||||||
|
return (
|
||||||
|
dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date)
|
||||||
|
).getTime();
|
||||||
|
});
|
||||||
|
this.holidaysByYear.set(year, timestamps);
|
||||||
|
}
|
||||||
|
dates.push(...timestamps.map((timestamp) => new Date(timestamp)));
|
||||||
}
|
}
|
||||||
|
return dates;
|
||||||
// Logic to return holidays for the given year
|
|
||||||
const hd = new Holidays('FR', allTypesOptions);
|
|
||||||
const holidays = hd
|
|
||||||
.getHolidays(startYear, lang)
|
|
||||||
.filter((holiday) => holiday.type === 'public');
|
|
||||||
const holidaysPlusOne = hd
|
|
||||||
.getHolidays(startYear + 1, lang)
|
|
||||||
.filter((holiday) => holiday.type === 'public');
|
|
||||||
holidays.push(...holidaysPlusOne);
|
|
||||||
const holidaysDate: Date[] = holidays.map((holiday) => {
|
|
||||||
const dateOnly = String(holiday.date).match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
|
||||||
return dateOnly ? new Date(`${dateOnly}T00:00:00`) : new Date(holiday.date);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update cache
|
|
||||||
this.holidayTimestampsCache = new Set(holidaysDate.map((d) => d.getTime()));
|
|
||||||
this.holidayCacheYear = startYear;
|
|
||||||
|
|
||||||
return holidaysDate;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static daysInWeek(weekIndex: number, monthIndex: number, year: number): Week {
|
static daysInWeek(weekIndex: number, monthIndex: number, year: number): Week {
|
||||||
const firstDayOfMonth = new Date(year, monthIndex, 1);
|
const firstDayOfMonth = new Date(0);
|
||||||
|
firstDayOfMonth.setFullYear(year, monthIndex, 1);
|
||||||
|
firstDayOfMonth.setHours(0, 0, 0, 0);
|
||||||
const firstDayOfWeek = new Date(firstDayOfMonth);
|
const firstDayOfWeek = new Date(firstDayOfMonth);
|
||||||
let offset = firstDayOfMonth.getDay() - 1; // Adjust to make Monday the first day of the week
|
let offset = firstDayOfMonth.getDay() - 1; // Adjust to make Monday the first day of the week
|
||||||
if (offset < 0) {
|
if (offset < 0) {
|
||||||
@@ -357,8 +355,7 @@ export class SelectedDate implements SelectedDateInterface {
|
|||||||
|
|
||||||
getLengthInDays(): number {
|
getLengthInDays(): number {
|
||||||
if (this.range.start && this.range.end) {
|
if (this.range.start && this.range.end) {
|
||||||
const timeDiff = this.range.end.getTime() - this.range.start.getTime();
|
return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1;
|
||||||
return Math.floor(timeDiff / (1000 * 3600 * 24)) + 1;
|
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -409,8 +406,6 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oneDayInMs = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
// Sort dates by start date
|
// Sort dates by start date
|
||||||
const sorted = [...this._datesSelected].sort((a, b) => {
|
const sorted = [...this._datesSelected].sort((a, b) => {
|
||||||
const aStart = a.range.start?.getTime() ?? 0;
|
const aStart = a.range.start?.getTime() ?? 0;
|
||||||
@@ -429,7 +424,9 @@ export class SelectedDates implements SelectedDateInterface {
|
|||||||
const currEnd = current.range.end?.getTime() ?? 0;
|
const currEnd = current.range.end?.getTime() ?? 0;
|
||||||
|
|
||||||
// Check if current date is adjacent or overlapping with the maximum end of the current group
|
// Check if current date is adjacent or overlapping with the maximum end of the current group
|
||||||
if (currStart - currentGroupMaxEnd <= oneDayInMs) {
|
const nextDay = new Date(currentGroupMaxEnd);
|
||||||
|
nextDay.setDate(nextDay.getDate() + 1);
|
||||||
|
if (currStart <= nextDay.getTime()) {
|
||||||
currentGroup.push(current);
|
currentGroup.push(current);
|
||||||
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
|
currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,21 +1,114 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { CalendarSettingsService } from './calendar-settings-service';
|
import { CalendarSettingsService } from './calendar-settings-service';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
describe('CalendarSettingsService', () => {
|
describe('CalendarSettingsService', () => {
|
||||||
let service: CalendarSettingsService;
|
let service: CalendarSettingsService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date('2026-10-04T22:30:00Z'));
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [provideZonelessChangeDetection()],
|
providers: [provideZonelessChangeDetection()],
|
||||||
});
|
});
|
||||||
service = TestBed.inject(CalendarSettingsService);
|
service = TestBed.inject(CalendarSettingsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
it('should be created', () => {
|
||||||
expect(service).toBeTruthy();
|
expect(service).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to the Paris date and displays the day before the anniversary as the last planning date', () => {
|
||||||
|
expect(service.planningDates()).toEqual({
|
||||||
|
start: '2026-10-05',
|
||||||
|
lastPlanningDate: '2027-10-04',
|
||||||
|
});
|
||||||
|
expect(service.planningHorizon()).toEqual({ start: '2026-10-05', end: '2027-10-05' });
|
||||||
|
expect(localStorage.getItem('calendarSettings.planningDates')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves and restores fixed historical dates before applying a later default', () => {
|
||||||
|
service.setPlanningDates('2024-02-29', '2026-02-28');
|
||||||
|
vi.setSystemTime(new Date('2029-08-01T12:00:00Z'));
|
||||||
|
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||||
|
expect(restored.planningDates()).toEqual({
|
||||||
|
start: '2024-02-29',
|
||||||
|
lastPlanningDate: '2026-02-28',
|
||||||
|
});
|
||||||
|
expect(restored.planningHorizon()).toEqual({ start: '2024-02-29', end: '2026-03-01' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the existing February 29 anniversary convention for the unsaved default', () => {
|
||||||
|
vi.setSystemTime(new Date('2024-02-29T12:00:00Z'));
|
||||||
|
const leapDay = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||||
|
expect(leapDay.planningDates()).toEqual({
|
||||||
|
start: '2024-02-29',
|
||||||
|
lastPlanningDate: '2025-02-27',
|
||||||
|
});
|
||||||
|
expect(leapDay.planningHorizon()).toEqual({ start: '2024-02-29', end: '2025-02-28' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2024-02-29', '2024-03-01'],
|
||||||
|
['2026-03-29', '2026-03-30'],
|
||||||
|
['2026-10-25', '2026-10-26'],
|
||||||
|
['2026-12-31', '2027-01-01'],
|
||||||
|
])(
|
||||||
|
'converts a single inclusive planning date %s across leap days, clock changes and years',
|
||||||
|
(date, end) => {
|
||||||
|
const previousTimeZone = process.env['TZ'];
|
||||||
|
process.env['TZ'] = 'Pacific/Auckland';
|
||||||
|
try {
|
||||||
|
service.setPlanningDates(date, date);
|
||||||
|
expect(service.planningHorizon()).toEqual({ start: date, end });
|
||||||
|
} finally {
|
||||||
|
if (previousTimeZone === undefined) delete process.env['TZ'];
|
||||||
|
else process.env['TZ'] = previousTimeZone;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2024-02-30', '2024-03-01', 'invalidDate'],
|
||||||
|
['2024-02-29', '2024-13-01', 'invalidDate'],
|
||||||
|
['29/02/2024', '2024-03-01', 'invalidDate'],
|
||||||
|
['', '2024-03-01', 'invalidDate'],
|
||||||
|
['2024-03-02', '2024-03-01', 'horizonOrder'],
|
||||||
|
])(
|
||||||
|
'keeps invalid input visible without replacing the last valid saved dates (%s, %s)',
|
||||||
|
(start, last, error) => {
|
||||||
|
service.setPlanningDates('2024-02-29', '2027-02-28');
|
||||||
|
service.setPlanningDates(start, last);
|
||||||
|
expect(service.planningDates()).toEqual({ start, lastPlanningDate: last });
|
||||||
|
expect(service.planningHorizon()).toBeNull();
|
||||||
|
expect(service.planningHorizonError()).toBe(error);
|
||||||
|
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||||
|
expect(restored.planningHorizon()).toEqual({ start: '2024-02-29', end: '2027-03-01' });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'{',
|
||||||
|
'null',
|
||||||
|
'{}',
|
||||||
|
'{"start":"2024-02-30","lastPlanningDate":"2024-03-01"}',
|
||||||
|
'{"start":"2024-03-02","lastPlanningDate":"2024-03-01"}',
|
||||||
|
])('uses an unsaved default when saved dates are unreadable: %s', (saved) => {
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
localStorage.setItem('calendarSettings.planningDates', saved);
|
||||||
|
const restored = TestBed.runInInjectionContext(() => new CalendarSettingsService());
|
||||||
|
expect(restored.planningHorizon()).toEqual({ start: '2026-10-05', end: '2027-10-05' });
|
||||||
|
expect(localStorage.getItem('calendarSettings.planningDates')).toBe(saved);
|
||||||
|
});
|
||||||
|
|
||||||
it('should initialize with default values', () => {
|
it('should initialize with default values', () => {
|
||||||
expect(service.get('showWeekNumbers')()).toBe(false);
|
expect(service.get('showWeekNumbers')()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
import { Injectable, WritableSignal, inject, Injector } from '@angular/core';
|
import { Injectable, WritableSignal, inject, Injector, signal, computed } from '@angular/core';
|
||||||
import { storedSignal } from '../shared/utils/stored-signal';
|
import { storedSignal } from '../shared/utils/stored-signal';
|
||||||
|
import {
|
||||||
|
addPlanningDays,
|
||||||
|
addPlanningYears,
|
||||||
|
isPlanningDate,
|
||||||
|
planningDateInParis,
|
||||||
|
} from './planning-date';
|
||||||
|
|
||||||
|
const PLANNING_DATES_KEY = 'calendarSettings.planningDates';
|
||||||
|
|
||||||
|
function planningDatesError(
|
||||||
|
start: unknown,
|
||||||
|
lastPlanningDate: unknown,
|
||||||
|
): 'invalidDate' | 'horizonOrder' | null {
|
||||||
|
if (!isPlanningDate(start) || !isPlanningDate(lastPlanningDate)) return 'invalidDate';
|
||||||
|
if (lastPlanningDate < start) return 'horizonOrder';
|
||||||
|
return isPlanningDate(addPlanningDays(lastPlanningDate, 1)) ? null : 'invalidDate';
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingDefinition {
|
interface SettingDefinition {
|
||||||
defaultValue: boolean;
|
defaultValue: boolean;
|
||||||
@@ -11,6 +28,62 @@ interface SettingDefinition {
|
|||||||
})
|
})
|
||||||
export class CalendarSettingsService {
|
export class CalendarSettingsService {
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly selectedPlanningDates = signal(this.restorePlanningDates());
|
||||||
|
readonly planningDates = this.selectedPlanningDates.asReadonly();
|
||||||
|
readonly planningHorizonError = computed(() => {
|
||||||
|
const { start, lastPlanningDate } = this.planningDates();
|
||||||
|
return planningDatesError(start, lastPlanningDate);
|
||||||
|
});
|
||||||
|
readonly planningHorizon = computed(() =>
|
||||||
|
this.planningHorizonError()
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
start: this.planningDates().start,
|
||||||
|
end: addPlanningDays(this.planningDates().lastPlanningDate, 1),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
setPlanningDates(start: string, lastPlanningDate: string): void {
|
||||||
|
const dates = { start, lastPlanningDate };
|
||||||
|
this.selectedPlanningDates.set(dates);
|
||||||
|
if (this.planningHorizonError()) return;
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== 'undefined')
|
||||||
|
localStorage.setItem(PLANNING_DATES_KEY, JSON.stringify(dates));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save planning dates to localStorage.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private restorePlanningDates(): { start: string; lastPlanningDate: string } {
|
||||||
|
try {
|
||||||
|
const stored =
|
||||||
|
typeof localStorage === 'undefined'
|
||||||
|
? null
|
||||||
|
: localStorage.getItem(PLANNING_DATES_KEY);
|
||||||
|
if (stored !== null) {
|
||||||
|
const saved: unknown = JSON.parse(stored);
|
||||||
|
if (
|
||||||
|
typeof saved === 'object' &&
|
||||||
|
saved !== null &&
|
||||||
|
'start' in saved &&
|
||||||
|
'lastPlanningDate' in saved &&
|
||||||
|
isPlanningDate(saved.start) &&
|
||||||
|
isPlanningDate(saved.lastPlanningDate) &&
|
||||||
|
!planningDatesError(saved.start, saved.lastPlanningDate)
|
||||||
|
)
|
||||||
|
return { start: saved.start, lastPlanningDate: saved.lastPlanningDate };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to restore planning dates from localStorage.', error);
|
||||||
|
}
|
||||||
|
return this.defaultPlanningDates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultPlanningDates(): { start: string; lastPlanningDate: string } {
|
||||||
|
const start = planningDateInParis(new Date());
|
||||||
|
return { start, lastPlanningDate: addPlanningDays(addPlanningYears(start, 1), -1) };
|
||||||
|
}
|
||||||
|
|
||||||
private readonly definitions = new Map<string, SettingDefinition>([
|
private readonly definitions = new Map<string, SettingDefinition>([
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
createPlanningCalculation,
|
||||||
|
type PlanningIncompleteOutcome,
|
||||||
|
type PlanningTraceUpdate,
|
||||||
|
} from './leave-planner.service';
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||||
|
balances: [],
|
||||||
|
rules: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('user cancellation', () => {
|
||||||
|
const workers: CancellableWorker[] = [];
|
||||||
|
const currentWorker = () => workers[workers.length - 1];
|
||||||
|
class CancellableWorker {
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
postMessage = vi.fn();
|
||||||
|
terminate = vi.fn();
|
||||||
|
constructor() {
|
||||||
|
workers.push(this);
|
||||||
|
}
|
||||||
|
emit(data: unknown) {
|
||||||
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
workers.length = 0;
|
||||||
|
vi.stubGlobal('Worker', CancellableWorker);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('supports cancellation through the public non-browser calculation context', async () => {
|
||||||
|
vi.stubGlobal('window', undefined);
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
calculation.cancel();
|
||||||
|
expect(await calculation.outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
reasons: [{ code: 'user-cancelled' }],
|
||||||
|
});
|
||||||
|
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the replacement reason consistently in non-browser outcomes and artifacts', async () => {
|
||||||
|
vi.stubGlobal('window', undefined);
|
||||||
|
const replaced = createPlanningCalculation(request);
|
||||||
|
const current = createPlanningCalculation(request);
|
||||||
|
expect(await replaced.outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
reasons: [{ code: 'stale-calculation' }],
|
||||||
|
});
|
||||||
|
expect(await replaced.explanation).toMatchObject({
|
||||||
|
kind: 'IncompleteExplanation',
|
||||||
|
reason: { code: 'stale-calculation' },
|
||||||
|
});
|
||||||
|
expect((await replaced.trace)?.error).toMatchObject({ code: 'stale-calculation' });
|
||||||
|
expect(await current.outcome).toMatchObject({ kind: 'Plan' });
|
||||||
|
await current.trace;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains a plan at least as good as every valid candidate actually evaluated before cancellation', async () => {
|
||||||
|
const core = await import('./leave-planner.core');
|
||||||
|
let cancelled = false;
|
||||||
|
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||||
|
const outcome = await core.calculatePlanningAsyncInternal(
|
||||||
|
{
|
||||||
|
horizon: request.horizon,
|
||||||
|
balances: [{ id: 'cp', type: 'cp', quantity: 2, expiryDate: '2024-12-31' }],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'january',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'preference',
|
||||||
|
startDate: '2024-01-01',
|
||||||
|
endDate: '2024-01-31',
|
||||||
|
placeDays: 2,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
progress: (stage: string) => {
|
||||||
|
if (stage === 'longest-work-sequence') cancelled = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(value) => (artifacts = value),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
() => cancelled,
|
||||||
|
);
|
||||||
|
expect(outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
reasons: [{ code: 'user-cancelled' }],
|
||||||
|
});
|
||||||
|
if (artifacts.explanation?.kind !== 'PlanExplanation' || !artifacts.trace)
|
||||||
|
throw new Error('Expected a retained plan explanation and its original trace.');
|
||||||
|
const retained = artifacts.explanation.placements[0].objectiveVector.objectives;
|
||||||
|
const evaluated = artifacts.trace.phases.flatMap((phase) =>
|
||||||
|
phase.decisions.flatMap((decision) =>
|
||||||
|
decision.candidates.flatMap((candidate) =>
|
||||||
|
candidate.comparison?.after.objectives[0].value === 0
|
||||||
|
? [candidate.comparison.after.objectives]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(evaluated.length).toBeGreaterThan(0);
|
||||||
|
for (const candidate of evaluated) {
|
||||||
|
expect(candidate.map(({ id }) => id)).toEqual(retained.map(({ id }) => id));
|
||||||
|
const firstDifference = retained.findIndex(
|
||||||
|
(objective, index) => objective.value !== candidate[index].value,
|
||||||
|
);
|
||||||
|
if (firstDifference < 0) continue;
|
||||||
|
const chosen = retained[firstDifference];
|
||||||
|
const alternative = candidate[firstDifference];
|
||||||
|
expect(
|
||||||
|
chosen.direction === 'minimize'
|
||||||
|
? chosen.value < alternative.value
|
||||||
|
: chosen.value > alternative.value,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests interruption before a valid plan without terminating trace delivery', async () => {
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
calculation.cancel();
|
||||||
|
calculation.cancel();
|
||||||
|
expect(currentWorker().postMessage).toHaveBeenLastCalledWith({ type: 'cancel' });
|
||||||
|
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(currentWorker().terminate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const cancelled: PlanningIncompleteOutcome = {
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: ['validation'],
|
||||||
|
reasons: [{ code: 'user-cancelled', message: 'The calculation was cancelled.' }],
|
||||||
|
};
|
||||||
|
currentWorker().emit({ type: 'result', outcome: cancelled });
|
||||||
|
currentWorker().emit({
|
||||||
|
type: 'result',
|
||||||
|
outcome: { kind: 'PlanningConflict', conflictingRuleIds: [], reasons: [] },
|
||||||
|
});
|
||||||
|
expect(await calculation.outcome).toEqual(cancelled);
|
||||||
|
expect(currentWorker().terminate).not.toHaveBeenCalled();
|
||||||
|
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||||
|
await calculation.trace;
|
||||||
|
expect(currentWorker().terminate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drains records queued before and after cancellation while keeping the partial plan on artifact error', async () => {
|
||||||
|
const core = await import('./leave-planner.core');
|
||||||
|
const updates: PlanningTraceUpdate[] = [];
|
||||||
|
let interrupted = false;
|
||||||
|
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||||
|
const submitted = {
|
||||||
|
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||||
|
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
|
||||||
|
rules: [],
|
||||||
|
};
|
||||||
|
const cancelled = await core.calculatePlanningAsyncInternal(
|
||||||
|
{
|
||||||
|
...submitted,
|
||||||
|
traceProgress: (_: number, update: PlanningTraceUpdate) =>
|
||||||
|
updates.push(structuredClone(update)),
|
||||||
|
progress: (stage: string) => {
|
||||||
|
if (stage === 'non-working-day-gain') interrupted = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(value) => (artifacts = value),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
() => interrupted,
|
||||||
|
);
|
||||||
|
if (cancelled.kind !== 'PlanningIncomplete' || !cancelled.incumbent || !artifacts.trace)
|
||||||
|
throw new Error('Expected a cancelled fixture with a valid plan and original trace.');
|
||||||
|
const calculation = createPlanningCalculation(submitted);
|
||||||
|
const operations = updates.flatMap(({ operations }) => operations);
|
||||||
|
const split = Math.floor(operations.length / 2);
|
||||||
|
currentWorker().emit({
|
||||||
|
type: 'trace-update',
|
||||||
|
totalMs: 0,
|
||||||
|
acknowledge: true,
|
||||||
|
update: {
|
||||||
|
...updates[0],
|
||||||
|
operations: operations.slice(0, split),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
calculation.cancel();
|
||||||
|
currentWorker().emit({ type: 'result', outcome: cancelled });
|
||||||
|
currentWorker().emit({ type: 'result', outcome: cancelled.incumbent });
|
||||||
|
currentWorker().emit({
|
||||||
|
type: 'explanation',
|
||||||
|
explanationError: { code: 'explanation-error', message: 'Unavailable' },
|
||||||
|
});
|
||||||
|
currentWorker().emit({
|
||||||
|
type: 'trace-update',
|
||||||
|
totalMs: 0,
|
||||||
|
acknowledge: true,
|
||||||
|
update: {
|
||||||
|
operations: operations.slice(split),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const trace = artifacts.trace;
|
||||||
|
currentWorker().emit({
|
||||||
|
type: 'artifacts-start',
|
||||||
|
artifacts: {},
|
||||||
|
trace: {
|
||||||
|
status: trace.status,
|
||||||
|
initialState: trace.initialState,
|
||||||
|
completedStages: trace.completedStages,
|
||||||
|
nonWorkingDates: trace.nonWorkingDates,
|
||||||
|
error: trace.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
currentWorker().emit({ type: 'artifacts-complete' });
|
||||||
|
expect(await calculation.outcome).toEqual(cancelled);
|
||||||
|
expect(await calculation.explanationError).toMatchObject({ code: 'explanation-error' });
|
||||||
|
expect(await calculation.trace).toEqual(trace);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports worker failure distinctly after a cancellation request', async () => {
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
calculation.cancel();
|
||||||
|
currentWorker().onerror?.();
|
||||||
|
expect(await calculation.outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
reasons: [{ code: 'worker-error' }],
|
||||||
|
});
|
||||||
|
expect((await calculation.trace)?.status).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a cancellation accepted before a queued completion and adapts its explanation without replay', async () => {
|
||||||
|
const core = await import('./leave-planner.core');
|
||||||
|
let artifacts: import('./leave-planner.core').PlanningArtifacts = {};
|
||||||
|
const plan = core.calculatePlanningInternal(
|
||||||
|
request,
|
||||||
|
undefined,
|
||||||
|
(value) => (artifacts = value),
|
||||||
|
);
|
||||||
|
expect(plan.kind).toBe('Plan');
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
calculation.cancel();
|
||||||
|
currentWorker().emit({ type: 'result', outcome: plan });
|
||||||
|
currentWorker().emit({ type: 'artifacts', artifacts });
|
||||||
|
expect(await calculation.outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
incumbent: plan,
|
||||||
|
reasons: [{ code: 'user-cancelled' }],
|
||||||
|
});
|
||||||
|
expect(await calculation.explanation).toMatchObject({
|
||||||
|
kind: 'PlanExplanation',
|
||||||
|
incomplete: { reason: { code: 'user-cancelled' } },
|
||||||
|
});
|
||||||
|
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||||
|
expect(currentWorker().postMessage).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports replacement distinctly and ignores late replies from the cancelled calculation', async () => {
|
||||||
|
const cancelled = createPlanningCalculation(request);
|
||||||
|
cancelled.cancel();
|
||||||
|
const previousWorker = currentWorker();
|
||||||
|
const lateReply = previousWorker.onmessage!;
|
||||||
|
const newer = createPlanningCalculation(request);
|
||||||
|
expect(previousWorker.terminate).toHaveBeenCalledOnce();
|
||||||
|
lateReply({
|
||||||
|
data: {
|
||||||
|
type: 'result',
|
||||||
|
outcome: { kind: 'PlanningConflict', reasons: [], conflictingRuleIds: [] },
|
||||||
|
},
|
||||||
|
} as MessageEvent);
|
||||||
|
expect(await cancelled.outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
reasons: [{ code: 'stale-calculation' }],
|
||||||
|
});
|
||||||
|
const outcome: PlanningIncompleteOutcome = {
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
completedStages: [],
|
||||||
|
reasons: [{ code: 'user-cancelled', message: 'Cancelled' }],
|
||||||
|
};
|
||||||
|
currentWorker().emit({ type: 'result', outcome });
|
||||||
|
currentWorker().emit({ type: 'artifacts', artifacts: {} });
|
||||||
|
expect(await newer.outcome).toEqual(outcome);
|
||||||
|
await newer.trace;
|
||||||
|
const sent = currentWorker().postMessage.mock.calls.length;
|
||||||
|
newer.cancel();
|
||||||
|
expect(currentWorker().postMessage).toHaveBeenCalledTimes(sent);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,68 @@ async function runInNonBrowser(request: Parameters<typeof planLeave>[0]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('planLeave', () => {
|
describe('planLeave', () => {
|
||||||
|
it.each([
|
||||||
|
['one leap day', '2024-02-29', '2024-03-01', '2024-02-29', '2024-02-29', '2024-02-29'],
|
||||||
|
['historical day', '2021-05-14', '2021-05-15', '2021-05-14', '2021-05-13', '2021-05-16'],
|
||||||
|
['two years', '2024-01-01', '2026-01-01', '2025-12-24', '2025-12-24', '2025-12-25'],
|
||||||
|
['three years', '2024-01-01', '2027-01-01', '2026-12-24', '2026-12-24', '2026-12-27'],
|
||||||
|
])(
|
||||||
|
'plans a light %s horizon with dated provenance and French holidays',
|
||||||
|
async (_, start, end, placementDate, periodStart, periodEnd) => {
|
||||||
|
const result = isPlan(
|
||||||
|
await runInNonBrowser({
|
||||||
|
horizon: { start, end },
|
||||||
|
balances: [
|
||||||
|
{
|
||||||
|
id: 'expired',
|
||||||
|
type: 'rtt',
|
||||||
|
quantity: 2,
|
||||||
|
availableFrom: '2020-01-01',
|
||||||
|
expiryDate: '2020-12-31',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dated-cp',
|
||||||
|
type: 'cp',
|
||||||
|
quantity: 1,
|
||||||
|
availableFrom: placementDate,
|
||||||
|
expiryDate: placementDate,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'chosen-date',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: placementDate,
|
||||||
|
endDate: placementDate,
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.horizon).toEqual({ start, end });
|
||||||
|
expect(result.placements).toEqual([
|
||||||
|
{
|
||||||
|
date: placementDate,
|
||||||
|
balanceId: 'dated-cp',
|
||||||
|
type: 'cp',
|
||||||
|
ruleIds: ['chosen-date'],
|
||||||
|
supporting: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.timeOffPeriods).toHaveLength(1);
|
||||||
|
expect(result.timeOffPeriods[0]).toMatchObject({ start: periodStart, end: periodEnd });
|
||||||
|
expect(result.remainingBalances.find((item) => item.id === 'expired')).toMatchObject({
|
||||||
|
quantity: 2,
|
||||||
|
type: 'rtt',
|
||||||
|
availableFrom: '2020-01-01',
|
||||||
|
expiryDate: '2020-12-31',
|
||||||
|
classification: 'unavoidable-expiry',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('rejects malformed horizons before calculation', async () => {
|
it('rejects malformed horizons before calculation', async () => {
|
||||||
const result = await planLeave({
|
const result = await planLeave({
|
||||||
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
horizon: { start: '2025-01-01', end: '2024-01-01' },
|
||||||
@@ -57,6 +119,18 @@ describe('planLeave', () => {
|
|||||||
if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order');
|
if (result.kind === 'InvalidInput') expect(result.reasons[0].code).toBe('horizon-order');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['2024-02-30', '2025-01-01', 'horizon-start'],
|
||||||
|
['2024-13-01', '2025-01-01', 'horizon-start'],
|
||||||
|
['2024-01-01', '2024-00-01', 'horizon-end'],
|
||||||
|
['not-a-date', '2025-01-01', 'horizon-start'],
|
||||||
|
])('reports an invalid horizon date without throwing: %s to %s', async (start, end, code) => {
|
||||||
|
const outcome = await planLeave({ horizon: { start, end }, balances: [], rules: [] });
|
||||||
|
expect(outcome.kind).toBe('InvalidInput');
|
||||||
|
if (outcome.kind === 'InvalidInput')
|
||||||
|
expect(outcome.reasons.map((item) => item.code)).toContain(code);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[
|
[
|
||||||
'invalid balance quantity',
|
'invalid balance quantity',
|
||||||
@@ -620,6 +694,7 @@ describe('planLeave', () => {
|
|||||||
expect(result.timeOffPeriods[0].duration).toBe(9);
|
expect(result.timeOffPeriods[0].duration).toBe(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Async calculation yields to the test timer; allow the planner's ten-second budget.
|
||||||
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
|
it('uses the 11 November and 14 July weeks in the representative default plan', async () => {
|
||||||
const result = isPlan(
|
const result = isPlan(
|
||||||
await runInNonBrowser({
|
await runInNonBrowser({
|
||||||
@@ -677,7 +752,7 @@ describe('planLeave', () => {
|
|||||||
'2027-07-16',
|
'2027-07-16',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it('moves a satisfied time-window preference to complete a holiday week', async () => {
|
it('moves a satisfied time-window preference to complete a holiday week', async () => {
|
||||||
const result = isPlan(
|
const result = isPlan(
|
||||||
@@ -1411,6 +1486,56 @@ describe('planLeave transport', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('planning calculation context', () => {
|
describe('planning calculation context', () => {
|
||||||
|
it('keeps a multi-year horizon attached to its immutable outcome, explanation and original trace', async () => {
|
||||||
|
const browserWindow = globalThis.window;
|
||||||
|
vi.stubGlobal('window', undefined);
|
||||||
|
try {
|
||||||
|
const request = {
|
||||||
|
horizon: { start: '2024-01-01', end: '2027-01-01' },
|
||||||
|
balances: [
|
||||||
|
{
|
||||||
|
id: 'cp',
|
||||||
|
type: 'cp',
|
||||||
|
quantity: 1,
|
||||||
|
availableFrom: '2026-12-24',
|
||||||
|
expiryDate: '2026-12-24',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'final-year',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: '2026-12-24',
|
||||||
|
endDate: '2026-12-24',
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const calculation = createPlanningCalculation(request);
|
||||||
|
request.horizon.end = '2025-01-01';
|
||||||
|
request.balances[0].expiryDate = '2024-12-31';
|
||||||
|
expect(calculation.snapshot.horizon).toEqual({
|
||||||
|
start: '2024-01-01',
|
||||||
|
end: '2027-01-01',
|
||||||
|
});
|
||||||
|
expect(isPlan(await calculation.outcome).horizon).toEqual(calculation.snapshot.horizon);
|
||||||
|
const explanation = await calculation.explanation;
|
||||||
|
expect(explanation?.kind).toBe('PlanExplanation');
|
||||||
|
if (explanation?.kind === 'PlanExplanation')
|
||||||
|
expect(explanation.placements[0]).toMatchObject({
|
||||||
|
placement: { date: '2026-12-24', balanceId: 'cp', ruleIds: ['final-year'] },
|
||||||
|
balance: { availableFrom: '2026-12-24', expiryDate: '2026-12-24' },
|
||||||
|
});
|
||||||
|
expect((await calculation.trace)?.initialState.horizon).toEqual(
|
||||||
|
calculation.snapshot.horizon,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
vi.stubGlobal('window', browserWindow);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('replays full balance metadata when an existing balance changes', () => {
|
it('replays full balance metadata when an existing balance changes', () => {
|
||||||
const state = {
|
const state = {
|
||||||
balances: [
|
balances: [
|
||||||
|
|||||||
@@ -434,6 +434,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
const timings = new Promise<PlanningCalculationTimings>((resolve) => {
|
const timings = new Promise<PlanningCalculationTimings>((resolve) => {
|
||||||
resolveTimings = resolve;
|
resolveTimings = resolve;
|
||||||
});
|
});
|
||||||
|
let supersede: (() => void) | undefined;
|
||||||
let cancel: (() => void) | undefined;
|
let cancel: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
|
||||||
@@ -441,6 +442,8 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
});
|
});
|
||||||
|
|
||||||
let outcomeSettled = false;
|
let outcomeSettled = false;
|
||||||
|
let cancellationRequested = false;
|
||||||
|
let cancelledRace: PlanningIncompleteOutcome | undefined;
|
||||||
let explanationSettled = false;
|
let explanationSettled = false;
|
||||||
let artifactsSettled = false;
|
let artifactsSettled = false;
|
||||||
let checkpoint: PlanOutcome | undefined;
|
let checkpoint: PlanOutcome | undefined;
|
||||||
@@ -483,17 +486,44 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
};
|
};
|
||||||
const finishOutcome = (result: PlanningOutcome) => {
|
const finishOutcome = (result: PlanningOutcome) => {
|
||||||
if (!outcomeSettled) {
|
if (!outcomeSettled) {
|
||||||
|
if (
|
||||||
|
cancellationRequested &&
|
||||||
|
(result.kind === 'Plan' || result.kind === 'PlanningConflict')
|
||||||
|
) {
|
||||||
|
cancelledRace = interruptedOutcome(
|
||||||
|
result.kind === 'Plan' ? result : checkpoint,
|
||||||
|
deadline,
|
||||||
|
'user-cancelled',
|
||||||
|
'The calculation was cancelled by the user.',
|
||||||
|
);
|
||||||
|
result = cancelledRace;
|
||||||
|
}
|
||||||
outcomeSettled = true;
|
outcomeSettled = true;
|
||||||
clearTimeout(livenessWatchdog);
|
clearTimeout(livenessWatchdog);
|
||||||
resolveOutcome(result);
|
resolveOutcome(result);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const cancelledExplanation = (
|
||||||
|
result: PlanningExplanation | undefined,
|
||||||
|
): PlanningExplanation | undefined => {
|
||||||
|
if (!cancelledRace) return result;
|
||||||
|
const incomplete = {
|
||||||
|
kind: 'IncompleteExplanation' as const,
|
||||||
|
reason: cancelledRace.reasons[0],
|
||||||
|
completedStages: cancelledRace.completedStages,
|
||||||
|
uncompletedStages: [],
|
||||||
|
};
|
||||||
|
return cancelledRace.incumbent && result?.kind === 'PlanExplanation'
|
||||||
|
? { ...result, incomplete }
|
||||||
|
: incomplete;
|
||||||
|
};
|
||||||
const finishExplanation = (
|
const finishExplanation = (
|
||||||
result: PlanningExplanation | undefined,
|
result: PlanningExplanation | undefined,
|
||||||
error: PlanningReason | undefined,
|
error: PlanningReason | undefined,
|
||||||
finalState?: PlanningTraceState,
|
finalState?: PlanningTraceState,
|
||||||
) => {
|
) => {
|
||||||
if (!explanationSettled) {
|
if (!explanationSettled) {
|
||||||
|
if (!error) result = cancelledExplanation(result);
|
||||||
explanationSettled = true;
|
explanationSettled = true;
|
||||||
receivedExplanation = result;
|
receivedExplanation = result;
|
||||||
receivedExplanationError = error;
|
receivedExplanationError = error;
|
||||||
@@ -511,6 +541,16 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
explanationError: result.explanationError ?? receivedExplanationError,
|
explanationError: result.explanationError ?? receivedExplanationError,
|
||||||
finalState: result.finalState ?? receivedFinalState,
|
finalState: result.finalState ?? receivedFinalState,
|
||||||
};
|
};
|
||||||
|
if (cancelledRace) {
|
||||||
|
if (!complete.explanationError)
|
||||||
|
complete.explanation = cancelledExplanation(complete.explanation);
|
||||||
|
if (complete.trace && complete.trace.status !== 'failed')
|
||||||
|
complete.trace = {
|
||||||
|
...complete.trace,
|
||||||
|
status: 'incomplete',
|
||||||
|
error: cancelledRace.reasons[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
if (finishExplanationPromise)
|
if (finishExplanationPromise)
|
||||||
finishExplanation(
|
finishExplanation(
|
||||||
complete.explanation,
|
complete.explanation,
|
||||||
@@ -529,7 +569,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
traceTransferMs,
|
traceTransferMs,
|
||||||
});
|
});
|
||||||
terminate();
|
terminate();
|
||||||
if (cancel) latestWorkerCancels.delete(cancel);
|
if (supersede) latestWorkerCancels.delete(supersede);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const partialArtifacts = (
|
const partialArtifacts = (
|
||||||
@@ -601,7 +641,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
deltas.push(operation.delta);
|
deltas.push(operation.delta);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
cancel = () => {
|
supersede = () => {
|
||||||
if (artifactsSettled) return;
|
if (artifactsSettled) return;
|
||||||
const hadOutcome = outcomeSettled;
|
const hadOutcome = outcomeSettled;
|
||||||
const reason = {
|
const reason = {
|
||||||
@@ -628,7 +668,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
if (cancel) latestWorkerCancels.add(cancel);
|
if (supersede) latestWorkerCancels.add(supersede);
|
||||||
const finalizeFailure = (reason: { code: string; message: string }) => {
|
const finalizeFailure = (reason: { code: string; message: string }) => {
|
||||||
if (!explanationSettled && checkpoint && checkpointState) {
|
if (!explanationSettled && checkpoint && checkpointState) {
|
||||||
receivedFinalState ??= checkpointState;
|
receivedFinalState ??= checkpointState;
|
||||||
@@ -757,6 +797,15 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
|
|||||||
};
|
};
|
||||||
scheduleLivenessWatchdog();
|
scheduleLivenessWatchdog();
|
||||||
|
|
||||||
|
cancel = () => {
|
||||||
|
if (outcomeSettled || cancellationRequested) return;
|
||||||
|
cancellationRequested = true;
|
||||||
|
try {
|
||||||
|
worker.postMessage({ type: 'cancel' });
|
||||||
|
} catch {
|
||||||
|
fail('worker-error', 'The cancellation request could not reach the worker.');
|
||||||
|
}
|
||||||
|
};
|
||||||
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
worker.onmessage = (event: MessageEvent<PlannerWorkerResponse>) => {
|
||||||
const message = event.data;
|
const message = event.data;
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
@@ -1489,6 +1538,7 @@ function explainCheckpointInWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PlanningCalculationContext {
|
export interface PlanningCalculationContext {
|
||||||
|
readonly cancel: () => void;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly snapshot: PlanningSnapshot;
|
readonly snapshot: PlanningSnapshot;
|
||||||
readonly outcome: Promise<PlanningOutcome>;
|
readonly outcome: Promise<PlanningOutcome>;
|
||||||
@@ -1579,6 +1629,31 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
let cancellationRequested = false;
|
||||||
|
let outcomeSettled = false;
|
||||||
|
let superseded = false;
|
||||||
|
const replacementReason: PlanningReason = {
|
||||||
|
code: 'stale-calculation',
|
||||||
|
message: 'Planning stopped because a newer calculation started.',
|
||||||
|
};
|
||||||
|
const explanationForRun = (value: PlanningExplanation | undefined) => {
|
||||||
|
if (!superseded) return value;
|
||||||
|
if (value?.kind === 'IncompleteExplanation')
|
||||||
|
return { ...value, reason: replacementReason };
|
||||||
|
if (value?.kind === 'PlanExplanation' && value.incomplete)
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
incomplete: { ...value.incomplete, reason: replacementReason },
|
||||||
|
};
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const supersede = () => {
|
||||||
|
if (!outcomeSettled) {
|
||||||
|
superseded = true;
|
||||||
|
cancellationRequested = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
latestWorkerCancels.add(supersede);
|
||||||
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
|
||||||
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
let resolveExplanation!: (explanation: PlanningExplanation | undefined) => void;
|
||||||
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
let resolveExplanationError!: (error: PlanningReason | undefined) => void;
|
||||||
@@ -1600,15 +1675,26 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
|||||||
try {
|
try {
|
||||||
const core = await import('./leave-planner.core');
|
const core = await import('./leave-planner.core');
|
||||||
let completedArtifacts: PlanningArtifacts = {};
|
let completedArtifacts: PlanningArtifacts = {};
|
||||||
const result = core.calculatePlanningInternal(
|
const result = await core.calculatePlanningAsyncInternal(
|
||||||
transport,
|
transport,
|
||||||
undefined,
|
undefined,
|
||||||
(value) => (completedArtifacts = value),
|
(value) => (completedArtifacts = value),
|
||||||
undefined,
|
(value) => {
|
||||||
|
outcomeSettled = true;
|
||||||
|
resolve(
|
||||||
|
superseded && value.kind === 'PlanningIncomplete'
|
||||||
|
? {
|
||||||
|
...value,
|
||||||
|
reasons: [replacementReason],
|
||||||
|
}
|
||||||
|
: value,
|
||||||
|
);
|
||||||
|
},
|
||||||
(value, error) => {
|
(value, error) => {
|
||||||
resolveExplanation(value);
|
resolveExplanation(explanationForRun(value));
|
||||||
resolveExplanationError(error);
|
resolveExplanationError(error);
|
||||||
},
|
},
|
||||||
|
() => cancellationRequested,
|
||||||
);
|
);
|
||||||
if (result.kind === 'InvalidInput') {
|
if (result.kind === 'InvalidInput') {
|
||||||
resolveExplanation(undefined);
|
resolveExplanation(undefined);
|
||||||
@@ -1616,7 +1702,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
|||||||
}
|
}
|
||||||
resolve(result);
|
resolve(result);
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
resolveArtifacts(completedArtifacts);
|
resolveArtifacts({
|
||||||
|
...completedArtifacts,
|
||||||
|
explanation: explanationForRun(completedArtifacts.explanation),
|
||||||
|
});
|
||||||
resolveTimings(completedArtifacts.timings ?? {});
|
resolveTimings(completedArtifacts.timings ?? {});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1636,10 +1725,22 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
|||||||
explanationError: reason,
|
explanationError: reason,
|
||||||
});
|
});
|
||||||
resolveTimings({});
|
resolveTimings({});
|
||||||
|
} finally {
|
||||||
|
outcomeSettled = true;
|
||||||
|
latestWorkerCancels.delete(supersede);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
run = { outcome, explanation, explanationError, artifacts, timings };
|
run = {
|
||||||
|
outcome,
|
||||||
|
explanation,
|
||||||
|
explanationError,
|
||||||
|
artifacts,
|
||||||
|
timings,
|
||||||
|
cancel: () => {
|
||||||
|
if (!outcomeSettled) cancellationRequested = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const retainedArtifacts = run.artifacts.then((artifacts) => ({
|
const retainedArtifacts = run.artifacts.then((artifacts) => ({
|
||||||
@@ -1657,6 +1758,7 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
|
|||||||
const context: PlanningCalculationContext = {
|
const context: PlanningCalculationContext = {
|
||||||
id,
|
id,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
cancel: () => run.cancel?.(),
|
||||||
outcome: run.outcome,
|
outcome: run.outcome,
|
||||||
explanation: run.explanation,
|
explanation: run.explanation,
|
||||||
explanationError: run.explanationError,
|
explanationError: run.explanationError,
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { Worker as NodeWorker } from 'node:worker_threads';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { createPlanningCalculation } from './leave-planner.service';
|
||||||
|
|
||||||
|
describe('real planning worker cancellation', () => {
|
||||||
|
let directory: string;
|
||||||
|
let bundle: string;
|
||||||
|
let afterCheckpoint: (() => void) | undefined;
|
||||||
|
const threads = new Set<NodeWorker>();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
directory = mkdtempSync(join(tmpdir(), 'vac-optimizer-worker-'));
|
||||||
|
bundle = join(directory, 'worker.mjs');
|
||||||
|
const entry = join(directory, 'entry.ts');
|
||||||
|
writeFileSync(
|
||||||
|
entry,
|
||||||
|
`import { parentPort } from 'node:worker_threads';
|
||||||
|
globalThis.self = { postMessage: (message) => parentPort.postMessage(message) };
|
||||||
|
await import(${JSON.stringify(resolve('src/app/services/leave-planner.worker.ts'))});
|
||||||
|
parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await promisify(execFile)('bun', ['build', entry, '--target=node', '--outfile', bundle]);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
afterCheckpoint = undefined;
|
||||||
|
class RealWorker {
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
private thread = new NodeWorker(bundle);
|
||||||
|
constructor() {
|
||||||
|
threads.add(this.thread);
|
||||||
|
this.thread.on('message', (data) => {
|
||||||
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
|
if (data.type === 'checkpoint') afterCheckpoint?.();
|
||||||
|
});
|
||||||
|
this.thread.on('error', () => this.onerror?.());
|
||||||
|
}
|
||||||
|
postMessage(message: unknown) {
|
||||||
|
this.thread.postMessage(message);
|
||||||
|
}
|
||||||
|
terminate() {
|
||||||
|
void this.thread.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('Worker', RealWorker);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all([...threads].map((thread) => thread.terminate()));
|
||||||
|
threads.clear();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
afterAll(() => rmSync(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('stops during calendar preparation without a plan or an obligation conflict', async () => {
|
||||||
|
const stages: string[] = [];
|
||||||
|
let preparationUpdates = 0;
|
||||||
|
const calculation = createPlanningCalculation({
|
||||||
|
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||||
|
balances: [],
|
||||||
|
rules: [],
|
||||||
|
progress: (stage) => {
|
||||||
|
stages.push(stage);
|
||||||
|
if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const outcome = await calculation.outcome;
|
||||||
|
expect(outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
reasons: [{ code: 'user-cancelled' }],
|
||||||
|
});
|
||||||
|
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||||
|
expect(outcome.incumbent).toBeUndefined();
|
||||||
|
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(stages).not.toContain('obligations');
|
||||||
|
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||||
|
expect(await calculation.explanationError).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels before a displayed valid plan without reporting a queued conflict', async () => {
|
||||||
|
const calculation = createPlanningCalculation({
|
||||||
|
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||||
|
balances: [],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'required',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: '2024-01-01',
|
||||||
|
endDate: '2024-12-31',
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
progress: (stage) => {
|
||||||
|
if (stage === 'obligations') calculation.cancel();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const outcome = await calculation.outcome;
|
||||||
|
expect(outcome).toMatchObject({
|
||||||
|
kind: 'PlanningIncomplete',
|
||||||
|
reasons: [{ code: 'user-cancelled' }],
|
||||||
|
});
|
||||||
|
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||||
|
expect(outcome.incumbent).toBeUndefined();
|
||||||
|
expect((await calculation.trace)?.status).toBe('incomplete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops an active obligation search and retains a jointly valid incumbent and its history', async () => {
|
||||||
|
let cancelledAt = 0;
|
||||||
|
afterCheckpoint = () => {
|
||||||
|
afterCheckpoint = undefined;
|
||||||
|
setTimeout(() => {
|
||||||
|
cancelledAt = performance.now();
|
||||||
|
calculation.cancel();
|
||||||
|
}, 10);
|
||||||
|
};
|
||||||
|
const calculation = createPlanningCalculation({
|
||||||
|
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||||
|
balances: [{ id: 'cp', type: 'cp', quantity: 6, expiryDate: '2024-12-31' }],
|
||||||
|
rules: ['first', 'second'].map((id) => ({
|
||||||
|
id,
|
||||||
|
type: 'time_window' as const,
|
||||||
|
force: 'obligation' as const,
|
||||||
|
startDate: '2024-01-01',
|
||||||
|
endDate: '2024-12-31',
|
||||||
|
placeDays: 3,
|
||||||
|
placeType: 'cp' as const,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const outcome = await calculation.outcome;
|
||||||
|
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||||
|
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||||
|
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||||
|
expect(performance.now() - cancelledAt).toBeLessThan(2000);
|
||||||
|
expect(outcome.completedStages).not.toContain('obligations');
|
||||||
|
expect(outcome.incumbent).toBeDefined();
|
||||||
|
const incumbent = outcome.incumbent!;
|
||||||
|
for (const id of ['first', 'second'])
|
||||||
|
expect(
|
||||||
|
incumbent.placements.filter((placement) => placement.ruleIds.includes(id)),
|
||||||
|
).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
incumbent.remainingBalances.reduce((sum, balance) => sum + balance.quantity, 0) +
|
||||||
|
incumbent.placements.length,
|
||||||
|
).toBe(6);
|
||||||
|
const explanation = await calculation.explanation;
|
||||||
|
expect(explanation?.kind).toBe('PlanExplanation');
|
||||||
|
if (explanation?.kind === 'PlanExplanation') {
|
||||||
|
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||||
|
incumbent.placements,
|
||||||
|
);
|
||||||
|
expect(explanation.incomplete?.reason.code).toBe('user-cancelled');
|
||||||
|
}
|
||||||
|
const trace = await calculation.trace;
|
||||||
|
expect(trace?.status).toBe('incomplete');
|
||||||
|
expect(
|
||||||
|
trace?.phases.find((phase) => phase.name === 'obligations')?.decisions.length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(trace?.phases.find((phase) => phase.name === 'dates')?.status).toBe('not-run');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves balances and provenance after a valid checkpoint and drains every produced trace packet', async () => {
|
||||||
|
const calculation = createPlanningCalculation({
|
||||||
|
horizon: { start: '2024-01-01', end: '2025-01-01' },
|
||||||
|
balances: [{ id: 'cp', type: 'cp', quantity: 3, expiryDate: '2024-12-31' }],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
id: 'fixed',
|
||||||
|
type: 'time_window',
|
||||||
|
force: 'obligation',
|
||||||
|
startDate: '2024-01-02',
|
||||||
|
endDate: '2024-01-02',
|
||||||
|
placeDays: 1,
|
||||||
|
placeType: 'cp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
progress: (stage) => {
|
||||||
|
if (stage === 'non-working-day-gain') calculation.cancel();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const outcome = await calculation.outcome;
|
||||||
|
expect(outcome.kind).toBe('PlanningIncomplete');
|
||||||
|
if (outcome.kind !== 'PlanningIncomplete') return;
|
||||||
|
expect(outcome.reasons[0].code).toBe('user-cancelled');
|
||||||
|
expect(outcome.completedStages).toContain('holiday-week-value');
|
||||||
|
expect(outcome.completedStages).not.toContain('non-working-day-gain');
|
||||||
|
const incumbent = outcome.incumbent!;
|
||||||
|
expect(incumbent.placements).toHaveLength(3);
|
||||||
|
expect(incumbent.placements).toContainEqual({
|
||||||
|
date: '2024-01-02',
|
||||||
|
type: 'cp',
|
||||||
|
balanceId: 'cp',
|
||||||
|
ruleIds: ['fixed'],
|
||||||
|
supporting: false,
|
||||||
|
});
|
||||||
|
expect(incumbent.remainingBalances).toEqual([]);
|
||||||
|
const explanation = await calculation.explanation;
|
||||||
|
expect(explanation?.kind).toBe('PlanExplanation');
|
||||||
|
if (explanation?.kind === 'PlanExplanation')
|
||||||
|
expect(explanation.placements.map(({ placement }) => placement)).toEqual(
|
||||||
|
incumbent.placements,
|
||||||
|
);
|
||||||
|
const trace = await calculation.trace;
|
||||||
|
expect(trace?.status).toBe('incomplete');
|
||||||
|
expect(trace?.error?.code).toBe('user-cancelled');
|
||||||
|
expect(trace?.phases.find((phase) => phase.name === 'non-working-day-gain')?.status).toBe(
|
||||||
|
'interrupted',
|
||||||
|
);
|
||||||
|
const frozenOutcome = structuredClone(outcome);
|
||||||
|
calculation.cancel();
|
||||||
|
expect(await calculation.outcome).toEqual(frozenOutcome);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completes the same deterministic plan as the synchronous adapter when not cancelled', async () => {
|
||||||
|
const submitted = {
|
||||||
|
horizon: { start: '2024-01-01', end: '2024-02-01' },
|
||||||
|
balances: [{ id: 'cp', type: 'cp' as const, quantity: 2, expiryDate: '2024-01-31' }],
|
||||||
|
rules: [],
|
||||||
|
};
|
||||||
|
const { calculatePlanningInternal } = await import('./leave-planner.core');
|
||||||
|
const expected = calculatePlanningInternal(submitted);
|
||||||
|
const calculation = createPlanningCalculation(submitted);
|
||||||
|
expect(await calculation.outcome).toEqual(expected);
|
||||||
|
expect((await calculation.trace)?.status).toBe('ready');
|
||||||
|
expect(await calculation.explanationError).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
} from './leave-planner.service';
|
} from './leave-planner.service';
|
||||||
import {
|
import {
|
||||||
analyzeCounterfactualInternal,
|
analyzeCounterfactualInternal,
|
||||||
calculatePlanningInternal,
|
calculatePlanningAsyncInternal,
|
||||||
explainIncompleteCheckpoint,
|
explainIncompleteCheckpoint,
|
||||||
historicalCounterfactualReasons,
|
historicalCounterfactualReasons,
|
||||||
type CounterfactualBaselineOptions,
|
type CounterfactualBaselineOptions,
|
||||||
@@ -38,6 +38,7 @@ type WorkerRequest =
|
|||||||
request: WorkerPlanningRequest;
|
request: WorkerPlanningRequest;
|
||||||
}
|
}
|
||||||
| { type: 'trace-ack'; count: number }
|
| { type: 'trace-ack'; count: number }
|
||||||
|
| { type: 'cancel' }
|
||||||
| {
|
| {
|
||||||
type: 'counterfactual';
|
type: 'counterfactual';
|
||||||
request: PlanningSnapshot;
|
request: PlanningSnapshot;
|
||||||
@@ -120,6 +121,7 @@ interface TraceDelivery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let activeTraceDelivery: TraceDelivery | undefined;
|
let activeTraceDelivery: TraceDelivery | undefined;
|
||||||
|
let activeCalculation: { cancelled: boolean } | undefined;
|
||||||
|
|
||||||
function traceStatusIndex(trace: PlanningTrace): {
|
function traceStatusIndex(trace: PlanningTrace): {
|
||||||
statuses: Map<string, TraceCandidateStatus>;
|
statuses: Map<string, TraceCandidateStatus>;
|
||||||
@@ -272,8 +274,12 @@ function pumpTraceUpdates(delivery: TraceDelivery): void {
|
|||||||
finishTraceDelivery(delivery);
|
finishTraceDelivery(delivery);
|
||||||
}
|
}
|
||||||
|
|
||||||
worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
worker.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||||
try {
|
try {
|
||||||
|
if (event.data?.type === 'cancel') {
|
||||||
|
if (activeCalculation) activeCalculation.cancelled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.data?.type === 'trace-ack') {
|
if (event.data?.type === 'trace-ack') {
|
||||||
const delivery = activeTraceDelivery;
|
const delivery = activeTraceDelivery;
|
||||||
if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return;
|
if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return;
|
||||||
@@ -282,6 +288,8 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.data?.type === 'plan') {
|
if (event.data?.type === 'plan') {
|
||||||
|
const calculation = { cancelled: false };
|
||||||
|
activeCalculation = calculation;
|
||||||
let artifacts: PlanningArtifacts = {};
|
let artifacts: PlanningArtifacts = {};
|
||||||
let outcomePosted = false;
|
let outcomePosted = false;
|
||||||
let outcome: PlanningOutcome;
|
let outcome: PlanningOutcome;
|
||||||
@@ -294,7 +302,7 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|||||||
};
|
};
|
||||||
activeTraceDelivery = traceDelivery;
|
activeTraceDelivery = traceDelivery;
|
||||||
try {
|
try {
|
||||||
outcome = calculatePlanningInternal(
|
outcome = await calculatePlanningAsyncInternal(
|
||||||
{
|
{
|
||||||
...event.data.request,
|
...event.data.request,
|
||||||
progress: (stage: string) =>
|
progress: (stage: string) =>
|
||||||
@@ -331,6 +339,7 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|||||||
explanationError,
|
explanationError,
|
||||||
finalState,
|
finalState,
|
||||||
} satisfies WorkerResponse),
|
} satisfies WorkerResponse),
|
||||||
|
() => calculation.cancelled,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined;
|
if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined;
|
||||||
@@ -341,6 +350,8 @@ worker.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
|||||||
message: error instanceof Error ? error.message : String(error),
|
message: error instanceof Error ? error.message : String(error),
|
||||||
} satisfies WorkerResponse);
|
} satisfies WorkerResponse);
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
if (activeCalculation === calculation) activeCalculation = undefined;
|
||||||
}
|
}
|
||||||
if (!outcomePosted)
|
if (!outcomePosted)
|
||||||
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
|
worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function planningDateInParis(value: Date): string {
|
|||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
}).formatToParts(value);
|
}).formatToParts(value);
|
||||||
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
|
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
|
||||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
return `${get('year').padStart(4, '0')}-${get('month')}-${get('day')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addPlanningYears(key: string, years: number): string {
|
export function addPlanningYears(key: string, years: number): string {
|
||||||
@@ -16,3 +16,15 @@ export function addPlanningYears(key: string, years: number): string {
|
|||||||
if (date.getUTCMonth() !== month) date.setUTCDate(0);
|
if (date.getUTCMonth() !== month) date.setUTCDate(0);
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addPlanningDays(key: string, days: number): string {
|
||||||
|
const date = new Date(`${key}T00:00:00Z`);
|
||||||
|
date.setUTCDate(date.getUTCDate() + days);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlanningDate(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||||
|
const date = new Date(`${value}T00:00:00Z`);
|
||||||
|
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||||
|
}
|
||||||
@@ -57,6 +57,13 @@ export interface AppTranslations {
|
|||||||
dark: string;
|
dark: string;
|
||||||
};
|
};
|
||||||
systemAppearance: string;
|
systemAppearance: string;
|
||||||
|
planningHorizon: string;
|
||||||
|
planningStart: string;
|
||||||
|
lastPlanningDate: string;
|
||||||
|
planningDatesHelp: string;
|
||||||
|
invalidDate: string;
|
||||||
|
horizonOrder: string;
|
||||||
|
invalidHorizon: string;
|
||||||
};
|
};
|
||||||
rules: {
|
rules: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -191,6 +198,10 @@ export interface AppTranslations {
|
|||||||
invalidInput: string;
|
invalidInput: string;
|
||||||
conflict: string;
|
conflict: string;
|
||||||
incomplete: string;
|
incomplete: string;
|
||||||
|
cancel: string;
|
||||||
|
cancelling: string;
|
||||||
|
cancelled: string;
|
||||||
|
cancelledPartial: string;
|
||||||
error: string;
|
error: string;
|
||||||
complete: string;
|
complete: string;
|
||||||
whyPlacement: string;
|
whyPlacement: string;
|
||||||
|
|||||||
+12
-1
@@ -48,7 +48,14 @@
|
|||||||
"selectLanguage": "Sprache auswählen",
|
"selectLanguage": "Sprache auswählen",
|
||||||
"selectTheme": "Design",
|
"selectTheme": "Design",
|
||||||
"themeOptions": { "system": "System", "light": "Hell", "dark": "Dunkel" },
|
"themeOptions": { "system": "System", "light": "Hell", "dark": "Dunkel" },
|
||||||
"systemAppearance": "Aktuelles Systemdesign"
|
"systemAppearance": "Aktuelles Systemdesign",
|
||||||
|
"planningHorizon": "Planungszeitraum",
|
||||||
|
"planningStart": "Startdatum (einschließlich)",
|
||||||
|
"lastPlanningDate": "Letzter Planungstag (einschließlich)",
|
||||||
|
"planningDatesHelp": "Wählen Sie beliebige Daten, auch vergangene. Der Kalender wird aktualisiert, wenn Sie Plan berechnen auswählen.",
|
||||||
|
"invalidDate": "Geben Sie zwei gültige Kalenderdaten ein.",
|
||||||
|
"horizonOrder": "Der letzte Planungstag muss am oder nach dem Startdatum liegen.",
|
||||||
|
"invalidHorizon": "Korrigieren Sie die Planungsdaten in den Einstellungen vor der Berechnung."
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Planungsregeln",
|
"title": "Planungsregeln",
|
||||||
@@ -109,6 +116,10 @@
|
|||||||
"invalidInput": "Die Planungsdaten sind ungültig.",
|
"invalidInput": "Die Planungsdaten sind ungültig.",
|
||||||
"conflict": "Die Verpflichtungen können nicht gemeinsam erfüllt werden.",
|
"conflict": "Die Verpflichtungen können nicht gemeinsam erfüllt werden.",
|
||||||
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
|
"incomplete": "Die Berechnung wurde vor Abschluss aller Qualitätsstufen beendet.",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"cancelling": "Berechnung wird abgebrochen…",
|
||||||
|
"cancelled": "Berechnung abgebrochen",
|
||||||
|
"cancelledPartial": "Berechnung abgebrochen — Teilplan",
|
||||||
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
|
"error": "Bei der Planung ist ein unerwarteter Fehler aufgetreten.",
|
||||||
"complete": "Plan berechnet.",
|
"complete": "Plan berechnet.",
|
||||||
"whyPlacement": "Ihre Urlaubstage verstehen",
|
"whyPlacement": "Ihre Urlaubstage verstehen",
|
||||||
|
|||||||
+12
-1
@@ -48,7 +48,14 @@
|
|||||||
"selectLanguage": "Language",
|
"selectLanguage": "Language",
|
||||||
"selectTheme": "Theme",
|
"selectTheme": "Theme",
|
||||||
"themeOptions": { "system": "System", "light": "Light", "dark": "Dark" },
|
"themeOptions": { "system": "System", "light": "Light", "dark": "Dark" },
|
||||||
"systemAppearance": "Current system appearance"
|
"systemAppearance": "Current system appearance",
|
||||||
|
"planningHorizon": "Planning horizon",
|
||||||
|
"planningStart": "Start date (inclusive)",
|
||||||
|
"lastPlanningDate": "Last planning date (inclusive)",
|
||||||
|
"planningDatesHelp": "Choose any dates, including past dates. Your calendar updates when you select Calculate plan.",
|
||||||
|
"invalidDate": "Enter two valid calendar dates.",
|
||||||
|
"horizonOrder": "The last planning date must be on or after the start date.",
|
||||||
|
"invalidHorizon": "Correct the planning dates in Settings before calculating."
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Planning rules",
|
"title": "Planning rules",
|
||||||
@@ -109,6 +116,10 @@
|
|||||||
"invalidInput": "The planning input is invalid.",
|
"invalidInput": "The planning input is invalid.",
|
||||||
"conflict": "The obligations cannot be satisfied together.",
|
"conflict": "The obligations cannot be satisfied together.",
|
||||||
"incomplete": "Calculation stopped before all quality stages completed.",
|
"incomplete": "Calculation stopped before all quality stages completed.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"cancelling": "Cancelling…",
|
||||||
|
"cancelled": "Calculation cancelled",
|
||||||
|
"cancelledPartial": "Calculation cancelled — partial plan",
|
||||||
"error": "The planner encountered an unexpected error.",
|
"error": "The planner encountered an unexpected error.",
|
||||||
"complete": "Plan calculated.",
|
"complete": "Plan calculated.",
|
||||||
"whyPlacement": "Understand your leave days",
|
"whyPlacement": "Understand your leave days",
|
||||||
|
|||||||
+12
-1
@@ -48,7 +48,14 @@
|
|||||||
"selectLanguage": "Idioma",
|
"selectLanguage": "Idioma",
|
||||||
"selectTheme": "Tema",
|
"selectTheme": "Tema",
|
||||||
"themeOptions": { "system": "Sistema", "light": "Claro", "dark": "Oscuro" },
|
"themeOptions": { "system": "Sistema", "light": "Claro", "dark": "Oscuro" },
|
||||||
"systemAppearance": "Apariencia actual del sistema"
|
"systemAppearance": "Apariencia actual del sistema",
|
||||||
|
"planningHorizon": "Horizonte de planificación",
|
||||||
|
"planningStart": "Fecha de inicio (incluida)",
|
||||||
|
"lastPlanningDate": "Última fecha de planificación (incluida)",
|
||||||
|
"planningDatesHelp": "Elige cualquier fecha, incluso pasada. El calendario se actualiza al seleccionar Calcular plan.",
|
||||||
|
"invalidDate": "Introduce dos fechas válidas.",
|
||||||
|
"horizonOrder": "La última fecha de planificación debe ser igual o posterior a la fecha de inicio.",
|
||||||
|
"invalidHorizon": "Corrige las fechas de planificación en Ajustes antes de calcular."
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Reglas de planificación",
|
"title": "Reglas de planificación",
|
||||||
@@ -109,6 +116,10 @@
|
|||||||
"invalidInput": "Los datos de planificación no son válidos.",
|
"invalidInput": "Los datos de planificación no son válidos.",
|
||||||
"conflict": "Las obligaciones no se pueden satisfacer juntas.",
|
"conflict": "Las obligaciones no se pueden satisfacer juntas.",
|
||||||
"incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.",
|
"incomplete": "El cálculo se detuvo antes de completar todas las etapas de calidad.",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"cancelling": "Cancelando…",
|
||||||
|
"cancelled": "Cálculo cancelado",
|
||||||
|
"cancelledPartial": "Cálculo cancelado — plan parcial",
|
||||||
"error": "El planificador encontró un error inesperado.",
|
"error": "El planificador encontró un error inesperado.",
|
||||||
"complete": "Plan calculado.",
|
"complete": "Plan calculado.",
|
||||||
"whyPlacement": "Entender tus días de permiso",
|
"whyPlacement": "Entender tus días de permiso",
|
||||||
|
|||||||
+12
-1
@@ -48,7 +48,14 @@
|
|||||||
"selectLanguage": "Langue",
|
"selectLanguage": "Langue",
|
||||||
"selectTheme": "Thème",
|
"selectTheme": "Thème",
|
||||||
"themeOptions": { "system": "Système", "light": "Clair", "dark": "Sombre" },
|
"themeOptions": { "system": "Système", "light": "Clair", "dark": "Sombre" },
|
||||||
"systemAppearance": "Apparence actuelle du système"
|
"systemAppearance": "Apparence actuelle du système",
|
||||||
|
"planningHorizon": "Horizon de planification",
|
||||||
|
"planningStart": "Date de début (incluse)",
|
||||||
|
"lastPlanningDate": "Dernière date de planification (incluse)",
|
||||||
|
"planningDatesHelp": "Choisissez vos dates, même passées. Le calendrier est mis à jour lorsque vous cliquez sur Calculer le planning.",
|
||||||
|
"invalidDate": "Saisissez deux dates calendaires valides.",
|
||||||
|
"horizonOrder": "La dernière date de planification doit être égale ou postérieure à la date de début.",
|
||||||
|
"invalidHorizon": "Corrigez les dates de planification dans les paramètres avant de calculer."
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"title": "Règles de planification",
|
"title": "Règles de planification",
|
||||||
@@ -109,6 +116,10 @@
|
|||||||
"invalidInput": "Les données de planification sont invalides.",
|
"invalidInput": "Les données de planification sont invalides.",
|
||||||
"conflict": "Les obligations ne peuvent pas être satisfaites ensemble.",
|
"conflict": "Les obligations ne peuvent pas être satisfaites ensemble.",
|
||||||
"incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.",
|
"incomplete": "Le calcul s’est arrêté avant la fin des critères de qualité.",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"cancelling": "Annulation en cours…",
|
||||||
|
"cancelled": "Calcul annulé",
|
||||||
|
"cancelledPartial": "Calcul annulé — plan partiel",
|
||||||
"error": "Le planificateur a rencontré une erreur inattendue.",
|
"error": "Le planificateur a rencontré une erreur inattendue.",
|
||||||
"complete": "Plan calculé.",
|
"complete": "Plan calculé.",
|
||||||
"whyPlacement": "Comprendre vos jours de congé",
|
"whyPlacement": "Comprendre vos jours de congé",
|
||||||
|
|||||||
Reference in new issue
Block a user