From 2edf2004f56f8a453a691846f3c0e6d960e27911 Mon Sep 17 00:00:00 2001 From: Nessar Date: Mon, 5 Oct 2026 15:34:07 +0200 Subject: [PATCH] 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 --- README.md | 12 +- ...d-horizons-and-cancellable-calculations.md | 2 +- docs/planner-cancellation.md | 51 ++ .../calendar-month/calendar-month.ts | 2 +- .../calendar-year-planning.spec.ts | 264 +++++++ .../calendar-year/calendar-year.html | 17 +- .../calendar-year/calendar-year.scss | 3 + .../components/calendar-year/calendar-year.ts | 177 +++-- src/app/components/user-input/user-input.html | 7 + .../components/user-input/user-input.spec.ts | 21 + src/app/components/user-input/user-input.ts | 3 + .../user-settings/user-settings.html | 47 ++ .../user-settings/user-settings.scss | 23 + .../user-settings/user-settings.spec.ts | 49 ++ .../components/user-settings/user-settings.ts | 5 +- src/app/services/calendar-service.spec.ts | 75 ++ src/app/services/calendar-service.ts | 105 ++- .../calendar-settings-service.spec.ts | 93 +++ src/app/services/calendar-settings-service.ts | 75 +- .../leave-planner-cancellation.spec.ts | 289 ++++++++ src/app/services/leave-planner.core.ts | 685 ++++++++++++++---- .../services/leave-planner.service.spec.ts | 127 +++- src/app/services/leave-planner.service.ts | 118 ++- src/app/services/leave-planner.worker.spec.ts | 235 ++++++ src/app/services/leave-planner.worker.ts | 17 +- src/app/services/planning-date.ts | 14 +- src/app/services/translation-service.ts | 11 + src/i18n/de.json | 13 +- src/i18n/en.json | 13 +- src/i18n/es.json | 13 +- src/i18n/fr.json | 13 +- 31 files changed, 2275 insertions(+), 304 deletions(-) create mode 100644 docs/planner-cancellation.md create mode 100644 src/app/services/leave-planner-cancellation.spec.ts create mode 100644 src/app/services/leave-planner.worker.spec.ts diff --git a/README.md b/README.md index 81246ec..1718099 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ flowchart LR State["Signals and localStorage-backed settings"] Facade["Planning calculation context
immutable snapshot + async artifacts"] Worker["Leave-planner Web Worker"] - Core["Synchronous planner core"] + Core["Resumable planner core"] Holidays["date-holidays
French public calendar"] UI <--> State @@ -40,18 +40,18 @@ Leave calculation stays in the browser. The production Express process serves th ### Main execution path 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. 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. -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 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; - ordered `TIME_WINDOW` and `IF_THEN` rules; - an optional deadline and progress callback. @@ -70,10 +70,12 @@ The outcome is one of: | `InvalidInput` | The request is malformed. | | `PlanningConflict` | Valid obligations cannot all be satisfied together. | | `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. +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 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: diff --git a/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md b/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md index d3f33d0..defd431 100644 --- a/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md +++ b/docs/adr/0006-user-selected-horizons-and-cancellable-calculations.md @@ -1,6 +1,6 @@ # 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. diff --git a/docs/planner-cancellation.md b/docs/planner-cancellation.md new file mode 100644 index 0000000..1a0afd0 --- /dev/null +++ b/docs/planner-cancellation.md @@ -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. diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts index 3ad309c..ffed65c 100644 --- a/src/app/components/calendar-month/calendar-month.ts +++ b/src/app/components/calendar-month/calendar-month.ts @@ -79,7 +79,7 @@ export class CalendarMonth implements OnInit { getDateKey(day: Date): string { return [ - day.getFullYear(), + String(day.getFullYear()).padStart(4, '0'), String(day.getMonth() + 1).padStart(2, '0'), String(day.getDate()).padStart(2, '0'), ].join('-'); diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index 9392371..55dfbc9 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -25,6 +25,8 @@ import { PlanningTrace, } from '../../services/leave-planner.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 { DateRange } from '@angular/material/datepicker'; @@ -182,6 +184,7 @@ function calculationFor( }; return { id: 'test', + cancel: vi.fn(), snapshot: { horizon: plan.horizon, balances: [], @@ -217,6 +220,7 @@ function calendarFor( calculation, calendar: { getSelectedDatesForYear: vi.fn(() => new SelectedDates()), + getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()), getDefaultPlanningHorizon: vi.fn(() => plan.horizon), createPlanningCalculation: vi.fn(() => calculation), selectedDatesForPlan: vi.fn(selectedDatesForPlan), @@ -397,6 +401,265 @@ function jointTrace(): PlanningTrace { } 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(); + const delayedTrace = deferred(); + 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('.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((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((resolve) => setTimeout(resolve, 0)); + fixture.detectChanges(); + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelectorAll('app-calendar-month')).toHaveLength(1); + expect( + root.querySelector('button[data-date="0099-05-14"]')?.disabled, + ).toBe(false); + expect( + root.querySelector('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((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(`button[data-date="${date}"]`)?.disabled, + ).toBe(false); + for (const date of ['2021-03-14', '2024-02-15']) + expect( + root.querySelector(`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(); + const oldExplanation = deferred(); + const oldTrace = deferred(); + 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((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((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( + '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([ ['InvalidInput', invalid], ['PlanningConflict', conflict], @@ -1472,6 +1735,7 @@ describe('CalendarYear planning outcome boundary', () => { it('turns planner rejection into an incomplete error and clears loading', async () => { const calendar = { getSelectedDatesForYear: vi.fn(() => new SelectedDates()), + getSelectedDatesForHorizon: vi.fn(() => new SelectedDates()), getDefaultPlanningHorizon: vi.fn(() => ({ start: '2024-01-01', end: '2025-01-01' })), createPlanningCalculation: vi.fn(() => ({ ...calculationFor(plan), diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 59ebd65..bb6f761 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -14,14 +14,25 @@ >
{{ loadingProgress() }}%
-

{{ tr().loading }}

+

{{ isCancelling() ? tr().planning.cancelling : tr().loading }}

+ }
-

{{ tr().planning.title }}

+

+ {{ tr().planning.title }} +

@if (planningOutcome()?.kind === 'InvalidInput') { } @else if (planningOutcome()?.kind === 'Plan') {

diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index e2aec7f..522267e 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -526,6 +526,9 @@ output { justify-items: center; gap: 1rem; } +.cancel-calculation { + min-height: 44px; +} .spinner-container { position: relative; } diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index 0ed78e1..8fb9eed 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -17,6 +17,7 @@ import { DateAdapter } from '@angular/material/core'; import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; +import { MatButton } from '@angular/material/button'; import dayjs from 'dayjs'; import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; @@ -32,6 +33,7 @@ import { import { RuleService } from '../../services/rule-service'; import { TranslationService } from '../../services/translation-service'; import { UserInputService } from '../../services/user-input-service'; +import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { applyReplayDeltas, CounterfactualAnalysis, @@ -64,6 +66,7 @@ type TraceTransition = Readonly<{ before: PlanningTraceState; after: PlanningTra MatDatepickerModule, MatFormFieldModule, MatInputModule, + MatButton, MatCard, CalendarMonth, MatCardContent, @@ -78,6 +81,7 @@ export class CalendarYear { private readonly userInput = inject(UserInputService); private readonly calendarService = inject(CalendarService); private readonly ruleService = inject(RuleService); + private readonly calendarSettings = inject(CalendarSettingsService); private readonly dialog = inject(MatDialog); private readonly dateAdapter = inject>(DateAdapter); private readonly destroyRef = inject(DestroyRef); @@ -86,7 +90,9 @@ export class CalendarYear { private comparisonDialog: MatDialogRef | null = null; protected readonly translationService = inject(TranslationService); - protected readonly isLoading = signal(true); + protected readonly isLoading = signal(false); + protected readonly isCancelling = signal(false); + private readonly resultsTitle = viewChild>('resultsTitle'); protected readonly loadingProgress = signal(0); protected readonly planningOutcome = signal(null); protected readonly planningExplanation = signal(null); @@ -201,7 +207,7 @@ export class CalendarYear { // Material returns local midnight; converting to UTC can change the calendar day. const date = selected ? [ - selected.getFullYear(), + String(selected.getFullYear()).padStart(4, '0'), String(selected.getMonth() + 1).padStart(2, '0'), String(selected.getDate()).padStart(2, '0'), ].join('-') @@ -283,7 +289,7 @@ export class CalendarYear { if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue; const day = selected.range.start; 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) { @@ -428,7 +434,8 @@ export class CalendarYear { } } 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) { result.push({ monthIndex: cursor.getMonth(), year: cursor.getFullYear() }); cursor.setMonth(cursor.getMonth() + 1); @@ -441,74 +448,87 @@ export class CalendarYear { this.destroyRef.onDestroy(() => this.clearCounterfactual()); effect(() => { this.userInput.computeTriggerSignal(); - this.resetCalculationState(); - 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); - }); - }); + untracked(() => this.startCalculation()); }); } + 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 { if (version !== this.calculationVersion) return; const reason = { @@ -523,7 +543,7 @@ export class CalendarYear { this.isLoading.set(false); } - private resetCalculationState(): void { + private resetCalculationState(horizon: { start: string; end: string }): void { this.activeCalculation = null; this.planningOutcome.set(null); this.planningExplanation.set(null); @@ -542,7 +562,7 @@ export class CalendarYear { this.traceReplay.set(false); this.clearHistoricalEligibility(); this.selectedTraceDate.set(null); - this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); + this.selectedDates.set(this.calendarService.getSelectedDatesForHorizon(horizon)); this.userInput.remainingVacationDaysSignal.set([]); } @@ -626,7 +646,9 @@ export class CalendarYear { this.userInput.remainingVacationDaysSignal.set(this.toLegacyBalances(plan)); return; } - this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); + this.selectedDates.set( + this.calendarService.getSelectedDatesForHorizon(this.planningHorizon()!), + ); this.userInput.remainingVacationDaysSignal.set([]); } @@ -1114,7 +1136,10 @@ export class CalendarYear { protected hasPlannerError(): boolean { const outcome = this.planningOutcome(); 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(':', ': '); } - private outcomeLabel(outcome: PlanningOutcome): string { + protected outcomeLabel(outcome: PlanningOutcome): string { if (outcome.kind === 'Plan') return this.tr().planning.complete; if (outcome.kind === 'InvalidInput') return this.tr().planning.invalidInput; 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; } diff --git a/src/app/components/user-input/user-input.html b/src/app/components/user-input/user-input.html index 065cb36..84ae6fb 100644 --- a/src/app/components/user-input/user-input.html +++ b/src/app/components/user-input/user-input.html @@ -30,11 +30,18 @@ class="calculate-button" color="primary" type="button" + [disabled]="!!calendarSettings.planningHorizonError()" + [attr.aria-describedby]=" + calendarSettings.planningHorizonError() ? 'invalid-planning-horizon' : null + " (click)="triggerComputation()" > {{ tr().userInput.calculate }} + @if (calendarSettings.planningHorizonError()) { +

+ } @if (hasRemainingDays()) { diff --git a/src/app/components/user-input/user-input.spec.ts b/src/app/components/user-input/user-input.spec.ts index 3f5ddbd..6c72367 100644 --- a/src/app/components/user-input/user-input.spec.ts +++ b/src/app/components/user-input/user-input.spec.ts @@ -10,6 +10,7 @@ import { vi } from 'vitest'; import { UserInput } from './user-input'; import { UserInputService } from '../../services/user-input-service'; import { UserSettings } from '../user-settings/user-settings'; +import { CalendarSettingsService } from '../../services/calendar-settings-service'; describe('UserInput', () => { let component: UserInput; @@ -82,6 +83,26 @@ describe('UserInput', () => { 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('.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', () => { const vacationDays = userInputService.vacationNumberSignal(); expect(vacationDays).toBeDefined(); diff --git a/src/app/components/user-input/user-input.ts b/src/app/components/user-input/user-input.ts index 61d5758..9c9c83d 100644 --- a/src/app/components/user-input/user-input.ts +++ b/src/app/components/user-input/user-input.ts @@ -8,6 +8,7 @@ import { DayInput } from '../userInput/day-input/day-input'; import { MatButtonModule } from '@angular/material/button'; import { VacDay } from '../../services/calendar-service'; import { TranslationService } from '../../services/translation-service'; +import { CalendarSettingsService } from '../../services/calendar-settings-service'; @Component({ selector: 'app-user-input', @@ -26,6 +27,7 @@ import { TranslationService } from '../../services/translation-service'; }) export class UserInput { private userInputService: UserInputService = inject(UserInputService); + protected readonly calendarSettings = inject(CalendarSettingsService); protected readonly translationService = inject(TranslationService); get tr() { @@ -99,6 +101,7 @@ export class UserInput { } public triggerComputation() { + if (this.calendarSettings.planningHorizonError()) return; this.userInputService.requestComputation(); } } diff --git a/src/app/components/user-settings/user-settings.html b/src/app/components/user-settings/user-settings.html index f976c58..73dba40 100644 --- a/src/app/components/user-settings/user-settings.html +++ b/src/app/components/user-settings/user-settings.html @@ -2,6 +2,53 @@

{{ tr().settings.description }}

+
+ {{ tr().settings.planningHorizon }} +

{{ tr().settings.planningDatesHelp }}

+ + {{ tr().settings.planningStart }} + + + + {{ tr().settings.lastPlanningDate }} + + + @if (calendarSettings.planningHorizonError(); as error) { + + } +
{{ tr().settings.selectTheme }} p { mat-form-field { 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; + } +} diff --git a/src/app/components/user-settings/user-settings.spec.ts b/src/app/components/user-settings/user-settings.spec.ts index 7a2fea9..0ca2d9b 100644 --- a/src/app/components/user-settings/user-settings.spec.ts +++ b/src/app/components/user-settings/user-settings.spec.ts @@ -1,22 +1,71 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { UserSettings } from './user-settings'; +import { CalendarSettingsService } from '../../services/calendar-settings-service'; +import { TranslationService } from '../../services/translation-service'; describe('UserSettings', () => { let component: UserSettings; let fixture: ComponentFixture; beforeEach(async () => { + localStorage.clear(); await TestBed.configureTestingModule({ imports: [UserSettings], providers: [provideZonelessChangeDetection()], }).compileComponents(); + TestBed.inject(TranslationService).setLanguage('en'); + TestBed.inject(CalendarSettingsService).setPlanningDates('2024-02-29', '2027-02-28'); fixture = TestBed.createComponent(UserSettings); component = fixture.componentInstance; 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('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('input[name="planningStart"]')!; + const last = root.querySelector('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', () => { expect(component).toBeTruthy(); }); diff --git a/src/app/components/user-settings/user-settings.ts b/src/app/components/user-settings/user-settings.ts index b985dba..a7f3772 100644 --- a/src/app/components/user-settings/user-settings.ts +++ b/src/app/components/user-settings/user-settings.ts @@ -9,6 +9,7 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { MatInputModule } from '@angular/material/input'; import { CalendarSettingsService } from '../../services/calendar-settings-service'; import { ThemeService } from '../../services/theme-service'; import { TranslationService } from '../../services/translation-service'; @@ -24,6 +25,7 @@ import { TranslationService } from '../../services/translation-service'; MatFormFieldModule, MatSelectModule, MatSlideToggle, + MatInputModule, ], templateUrl: './user-settings.html', styleUrl: './user-settings.scss', @@ -32,7 +34,8 @@ import { TranslationService } from '../../services/translation-service'; export class UserSettings { protected readonly themeService = inject(ThemeService); 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 themePreferences = ['system', 'light', 'dark'] as const; } diff --git a/src/app/services/calendar-service.spec.ts b/src/app/services/calendar-service.spec.ts index 0dd2f94..db9aeb7 100644 --- a/src/app/services/calendar-service.spec.ts +++ b/src/app/services/calendar-service.spec.ts @@ -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', () => { it('should return an array of holidays', () => { const holidays = service.getHolidays(); @@ -115,6 +171,25 @@ describe('CalendarService', () => { 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', () => { diff --git a/src/app/services/calendar-service.ts b/src/app/services/calendar-service.ts index 8790dd6..a234ed4 100644 --- a/src/app/services/calendar-service.ts +++ b/src/app/services/calendar-service.ts @@ -13,7 +13,7 @@ import { PlanningReason, RemainingClassification, } from './leave-planner.service'; -import { addPlanningYears, planningDateInParis } from './planning-date'; +import { addPlanningDays, addPlanningYears, planningDateInParis } from './planning-date'; dayjs.extend(weekOfYear); dayjs.extend(isoWeek); @@ -39,10 +39,21 @@ export class CalendarService { } selectedDatesForPlan( - plan: Pick & { horizon?: { start: string; end: string } }, + plan: Pick & { + horizon?: { start: string; end: string }; + timeOffPeriods?: PlanOutcome['timeOffPeriods']; + }, ): SelectedDates { - const selected = plan.horizon - ? this.getSelectedDatesForHorizon(plan.horizon) + const projectionHorizon = plan.horizon ? { ...plan.horizon } : undefined; + 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(); for (const placement of plan.placements) { selected.push( @@ -58,9 +69,7 @@ export class CalendarService { return selected; } - // Cache for holiday timestamps - private holidayTimestampsCache: Set | null = null; - private holidayCacheYear: number | null = null; + private readonly holidaysByYear = new Map(); // Get selected dates from current date to the next year getSelectedDatesFromNow(): SelectedDates { @@ -78,9 +87,10 @@ export class CalendarService { const start = new Date(`${horizon.start}T00:00:00`); const last = new Date(`${horizon.end}T00:00:00`); last.setDate(last.getDate() - 1); - const holidays = this.getHolidays(start.getFullYear()); + const holidays = this.getHolidays(start.getFullYear(), last.getFullYear()); const monthInHorizon: Year = []; - const cursor = new Date(start.getFullYear(), start.getMonth(), 1); + const cursor = new Date(start); + cursor.setDate(1); while (cursor <= last) { monthInHorizon.push( CalendarService.weeksInMonth(cursor.getMonth(), cursor.getFullYear()), @@ -128,59 +138,47 @@ export class CalendarService { setWeekendSelection(date: Date, selectedDates: SelectedDates) { if (date.getDay() === 6) { + const end = new Date(date); + end.setDate(end.getDate() + 1); selectedDates._datesSelected.push( - new SelectedDate( - DayType.WEEKEND, - new DateRange( - date, - new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1), - ), - ), + new SelectedDate(DayType.WEEKEND, new DateRange(date, end)), ); } if (date.getDate() === 1 && date.getDay() === 0) { + const start = new Date(date); + start.setDate(start.getDate() - 1); selectedDates._datesSelected.push( - new SelectedDate( - DayType.WEEKEND, - new DateRange( - new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1), - date, - ), - ), + new SelectedDate(DayType.WEEKEND, new DateRange(start, date)), ); } } - getHolidays(startYear = this.dateCache.getCurrentYear()): Date[] { - // Cache holidays to avoid recalculation - if (this.holidayCacheYear === startYear && this.holidayTimestampsCache) { - // Return timestamps directly for efficient lookup - return Array.from(this.holidayTimestampsCache).map((ts) => new Date(ts)); + getHolidays(startYear = this.dateCache.getCurrentYear(), lastYear = startYear + 1): Date[] { + const dates: Date[] = []; + for (let year = startYear; year <= lastYear; year++) { + let timestamps = this.holidaysByYear.get(year); + 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))); } - - // 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; + return dates; } 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); let offset = firstDayOfMonth.getDay() - 1; // Adjust to make Monday the first day of the week if (offset < 0) { @@ -357,8 +355,7 @@ export class SelectedDate implements SelectedDateInterface { getLengthInDays(): number { if (this.range.start && this.range.end) { - const timeDiff = this.range.end.getTime() - this.range.start.getTime(); - return Math.floor(timeDiff / (1000 * 3600 * 24)) + 1; + return dayjs(this.range.end).diff(dayjs(this.range.start), 'day') + 1; } return 0; } @@ -409,8 +406,6 @@ export class SelectedDates implements SelectedDateInterface { return; } - const oneDayInMs = 24 * 60 * 60 * 1000; - // Sort dates by start date const sorted = [...this._datesSelected].sort((a, b) => { const aStart = a.range.start?.getTime() ?? 0; @@ -429,7 +424,9 @@ export class SelectedDates implements SelectedDateInterface { const currEnd = current.range.end?.getTime() ?? 0; // 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); currentGroupMaxEnd = Math.max(currentGroupMaxEnd, currEnd); } else { diff --git a/src/app/services/calendar-settings-service.spec.ts b/src/app/services/calendar-settings-service.spec.ts index 01f343c..cfe8db9 100644 --- a/src/app/services/calendar-settings-service.spec.ts +++ b/src/app/services/calendar-settings-service.spec.ts @@ -1,21 +1,114 @@ import { TestBed } from '@angular/core/testing'; import { provideZonelessChangeDetection } from '@angular/core'; import { CalendarSettingsService } from './calendar-settings-service'; +import { vi } from 'vitest'; describe('CalendarSettingsService', () => { let service: CalendarSettingsService; beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-04T22:30:00Z')); TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()], }); service = TestBed.inject(CalendarSettingsService); }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + localStorage.clear(); + }); + it('should be created', () => { 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', () => { expect(service.get('showWeekNumbers')()).toBe(false); }); diff --git a/src/app/services/calendar-settings-service.ts b/src/app/services/calendar-settings-service.ts index e4f1a5e..7084943 100644 --- a/src/app/services/calendar-settings-service.ts +++ b/src/app/services/calendar-settings-service.ts @@ -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 { + 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 { defaultValue: boolean; @@ -11,6 +28,62 @@ interface SettingDefinition { }) export class CalendarSettingsService { 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([ [ diff --git a/src/app/services/leave-planner-cancellation.spec.ts b/src/app/services/leave-planner-cancellation.spec.ts new file mode 100644 index 0000000..06bfe97 --- /dev/null +++ b/src/app/services/leave-planner-cancellation.spec.ts @@ -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); + }); +}); diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 44822ab..b5ceb9f 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -1,5 +1,5 @@ import Holidays from 'date-holidays'; -import { addPlanningYears, planningDateInParis } from './planning-date'; +import { addPlanningYears, isPlanningDate, planningDateInParis } from './planning-date'; /** Date-only values are serialized as YYYY-MM-DD at the planning boundary. */ export type PlanningDate = string | Date; @@ -550,6 +550,7 @@ interface Requirement { } const obligationRequirementCache = new WeakMap(); +const preferencePatternCache = new WeakMap>(); type MutableTraceCandidate = PlanningTraceCandidate & { candidates?: never }; type MutableTraceDecision = { @@ -1109,8 +1110,20 @@ interface SearchContext { traceBranchParentId?: string; traceBranchActive?: boolean; traceError?: PlanningReason; + /** Supplied metrics were evaluated after reconciling earned entitlement dates. */ + retain?: (state: State, finalizedMetrics?: PlanningMetrics) => void; } +type PlanningCalendar = Pick< + SearchContext, + | 'holidays' + | 'workingDays' + | 'calendarDays' + | 'calendarIndex' + | 'workingCalendar' + | 'workingPrefix' +>; + interface ObligationBranchReference { candidate: MutableTraceCandidate; decisionId: string; @@ -1216,6 +1229,10 @@ function recordTraceIneligible( } class DeadlineReached extends Error {} +class UserCancelled extends Error {} + +// A true value asks the asynchronous driver to pause immediately at a phase boundary. +type PlanningSteps = Generator; const DAY_MS = 86_400_000; const MAX_PLANNING_MS = 10_000; @@ -1225,7 +1242,6 @@ const CORE_DEADLINE_MARGIN_MS = 50; // that this ceiling misses a better lexicographic assignment. const OBLIGATION_SOLUTION_LIMIT = 4096; const EXACT_OBLIGATION_BRANCH_LIMIT = 10_000; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; const VALID_TYPES = new Set(['cp', 'rtt', 'other']); const VALID_SELECTORS = new Set(['cp', 'rtt', 'other', 'any']); const VALID_MODES = new Set(['before', 'after', 'around', 'auto']); @@ -1237,10 +1253,7 @@ function dateKey(value: unknown): string | null { // leading calendar date as data, rather than letting the host timezone // shift it across a midnight boundary. const dateOnly = value.match(/^(\d{4}-\d{2}-\d{2})(?:$|\s|T)/)?.[1]; - if (dateOnly && DATE_RE.test(dateOnly)) { - const date = utcDate(dateOnly); - return date.toISOString().slice(0, 10) === dateOnly ? dateOnly : null; - } + if (dateOnly) return isPlanningDate(dateOnly) ? dateOnly : null; const parsed = new Date(value); return Number.isNaN(parsed.getTime()) ? null : planningDateInParis(parsed); } @@ -1560,7 +1573,7 @@ function recordTraceIfThenEvaluation( return evaluation; } -function recordTraceBranchMutation( +function retainAndRecordBranchMutation( context: SearchContext, before: State, after: State, @@ -1577,10 +1590,11 @@ function recordTraceBranchMutation( candidate, ); }); + context.retain?.(after); return decisionId; } -function recordTraceAtRiskBranchMutation( +function retainAndRecordAtRiskBranchMutation( context: SearchContext, after: State, parentDecisionId: string | undefined, @@ -1612,15 +1626,15 @@ function recordTraceAtRiskBranchMutation( }); decisionId = trace.recordBranchTransition([date], parentDecisionId, deltas, candidate); }); + context.retain?.(after); return decisionId; } -function recordTraceState( +function retainAndRecordState( context: SearchContext, state: State, acceptedCandidate?: MutableTraceCandidate, ): void { - if (!context.trace) return; observeTrace(context, (trace) => { const snapshot = planningTraceState(state, context.horizon); if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate); @@ -1631,6 +1645,7 @@ function recordTraceState( acceptedCandidate, ); }); + context.retain?.(state); } function normalizeType(value: unknown): LeaveType | null { @@ -1906,11 +1921,6 @@ function normalizeRequest(request: unknown): { if (rawHorizon && compareDate(start, end) >= 0) { reasons.push(reason('horizon-order', 'The planning horizon must have start before end.')); } - if (compareDate(end, addPlanningYears(start, 1)) > 0) { - reasons.push( - reason('horizon-length', 'The planning horizon may not exceed twelve months.'), - ); - } const configuredDeadline = source.deadlineMs; if ( configuredDeadline !== undefined && @@ -2537,12 +2547,12 @@ function finalizeEarnedState(state: State, context: SearchContext): State | null return result; } -function assignDatesVariants( +function* assignDatesVariants( state: State, dates: string[], rule: NormalizedRule, context: SearchContext, -): State[] { +): PlanningSteps { if (!dates.length) return [state]; const [date, ...rest] = dates; if ( @@ -2553,12 +2563,13 @@ function assignDatesVariants( return []; const result: State[] = []; for (const next of assignDateVariants(state, date, rule.placeType, rule.id, context)) { - result.push(...assignDatesVariants(next, rest, rule, context)); + yield; + result.push(...(yield* assignDatesVariants(next, rest, rule, context))); } return result; } -function satisfyObligations( +function* satisfyObligations( requirements: Requirement[], state: State, context: SearchContext, @@ -2567,7 +2578,8 @@ function satisfyObligations( solutionLimit = OBLIGATION_SOLUTION_LIMIT, branchPath: readonly ObligationBranchReference[] = [], parentDecisionId?: string, -): State | null { +): PlanningSteps { + yield; context.check(); if (index >= requirements.length) { const finalized = finalizeEarnedState(state, context); @@ -2598,12 +2610,20 @@ function satisfyObligations( if (path.length) context.obligationSolutionBranches.set(finalized, path); solutions.push(finalized); } + context.retain?.(finalized); return solutions ? null : finalized; } const requirement = requirements[index]; if (requirement.patterns) { for (const pattern of requirement.patterns) { - for (const assigned of assignDatesVariants(state, pattern, requirement.rule, context)) { + yield; + for (const assigned of yield* assignDatesVariants( + state, + pattern, + requirement.rule, + context, + )) { + yield; const candidate = addEarnedBalance( assigned, requirement.rule, @@ -2618,7 +2638,7 @@ function satisfyObligations( parentDecisionId, pattern, ); - const complete = satisfyObligations( + const complete = yield* satisfyObligations( requirements, candidate, context, @@ -2645,7 +2665,12 @@ function satisfyObligations( } const candidates = requirement.candidates ?? []; const needed = requirement.rule.placeDays; - const choose = (position: number, picked: string[], current: State): State | null => { + const choose = function* ( + position: number, + picked: string[], + current: State, + ): PlanningSteps { + yield; context.check(); if (picked.length === needed) { const branch = recordTraceBranchDecision( @@ -2655,7 +2680,7 @@ function satisfyObligations( parentDecisionId, picked, ); - const complete = satisfyObligations( + const complete = yield* satisfyObligations( requirements, current, context, @@ -2681,6 +2706,7 @@ function satisfyObligations( return null; } for (let i = position; i < candidates.length; i++) { + yield; for (const next of assignDateVariants( current, candidates[i], @@ -2688,7 +2714,8 @@ function satisfyObligations( requirement.rule.id, context, )) { - const complete = choose(i + 1, [...picked, candidates[i]], next); + yield; + const complete = yield* choose(i + 1, [...picked, candidates[i]], next); if (complete && !solutions) return complete; if (solutions && solutionLimit > 0 && solutions.length >= solutionLimit) { recordTraceCandidate(context, { @@ -2704,17 +2731,17 @@ function satisfyObligations( } return null; }; - return choose(0, [], state); + return yield* choose(0, [], state); } function chooseObligationBranches(state: State, context: SearchContext): void { for (const branch of context.obligationSolutionBranches.get(state) ?? []) { observeTrace(context, (trace) => trace.chooseEvaluation(branch.candidate)); - recordTraceState(context, branch.stateAfter, branch.candidate); + retainAndRecordState(context, branch.stateAfter, branch.candidate); } } -function placeAtRisk(state: State, context: SearchContext): State { +function* placeAtRisk(state: State, context: SearchContext): PlanningSteps { // At-risk placement is a greedy pass over one state. Clone it once, then // mutate that private copy; cloning the whole placement map for every day is // quadratic for a loaded year and does not change the observable result. @@ -2728,18 +2755,21 @@ function placeAtRisk(state: State, context: SearchContext): State { ) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id)); for (const balance of balances) { + yield; const availableDates = [...context.workingDays].sort( (a, b) => preferenceDateConflictScore(balance.type, a, context) - preferenceDateConflictScore(balance.type, b, context) || compareDate(a, b), ); while (true) { + yield; context.check(); const current = result.balances.find((item) => item.id === balance.id)!; if (current.remaining <= 0) break; let nextState: State | null = null; let nextEvaluation: MutableTraceCandidate | undefined; for (const [candidateIndex, candidate] of availableDates.entries()) { + yield; context.check(); if ( !result.placements.has(candidate) && @@ -2757,6 +2787,7 @@ function placeAtRisk(state: State, context: SearchContext): State { const selectionFacts = nextDate ? atRiskDateSelectionFacts(balance.type, candidate, nextDate, context) : undefined; + recordTraceDecision(context, [candidate]); let remainingBeforeTransition = current.remaining; let evaluation: MutableTraceCandidate | undefined; const assigned = canMutate @@ -2790,7 +2821,7 @@ function placeAtRisk(state: State, context: SearchContext): State { nextEvaluation = evaluation; if (context.traceBranchActive) { const parent = canMutate - ? recordTraceAtRiskBranchMutation( + ? retainAndRecordAtRiskBranchMutation( context, feasible, context.traceBranchParentId, @@ -2799,7 +2830,7 @@ function placeAtRisk(state: State, context: SearchContext): State { current.id, remainingBeforeTransition, ) - : recordTraceBranchMutation( + : retainAndRecordBranchMutation( context, result, feasible, @@ -2815,17 +2846,17 @@ function placeAtRisk(state: State, context: SearchContext): State { } if (!nextState) break; result = nextState; - if (!context.traceBranchActive) recordTraceState(context, result, nextEvaluation); + if (!context.traceBranchActive) retainAndRecordState(context, result, nextEvaluation); } } return result; } -function preferenceTimeWindow( +function* preferenceTimeWindow( state: State, rule: NormalizedRule, context: SearchContext, -): { state: State; count: number } { +): PlanningSteps<{ state: State; count: number }> { const start = compareDate(rule.startDate!, context.horizon.start) < 0 ? context.horizon.start @@ -2853,8 +2884,10 @@ function preferenceTimeWindow( ); }); for (const date of candidates) { + yield; context.check(); if (count >= rule.placeDays) break; + recordTraceDecision(context, [date]); const before = result; let evaluation: MutableTraceCandidate | undefined; const next = assignDate( @@ -2871,7 +2904,7 @@ function preferenceTimeWindow( const feasible = next ? finalizeEarnedState(next, context) : null; if (feasible) { if (context.traceBranchActive) { - const parent = recordTraceBranchMutation( + const parent = retainAndRecordBranchMutation( context, before, feasible, @@ -2880,7 +2913,7 @@ function preferenceTimeWindow( [date], ); context.traceBranchParentId = parent ?? context.traceBranchParentId; - } else recordTraceState(context, feasible, evaluation); + } else retainAndRecordState(context, feasible, evaluation); result = feasible; count++; } @@ -2888,11 +2921,11 @@ function preferenceTimeWindow( return { state: result, count }; } -function preferenceIfThen( +function* preferenceIfThen( state: State, rule: NormalizedRule, context: SearchContext, -): { state: State; count: number } { +): PlanningSteps<{ state: State; count: number }> { const matchingOccurrences = occurrences(rule, context); let bestState = state; let bestCount = 0; @@ -2900,7 +2933,13 @@ function preferenceIfThen( let bestMetrics: PlanningMetrics | undefined; let bestBranch: ObligationBranchReference | undefined; let bestEvaluation: MutableTraceCandidate | undefined; - const search = (index: number, current: State, count: number, futureScore: number): void => { + const search = function* ( + index: number, + current: State, + count: number, + futureScore: number, + ): PlanningSteps { + yield; context.check(); const maximumCount = count + matchingOccurrences.length - index; if (maximumCount < bestCount) { @@ -2938,6 +2977,7 @@ function preferenceIfThen( return; } const metrics = qualityMetrics(finalized, context, []); + context.retain?.(finalized, metrics); const betterTie = futureScore < bestFutureScore || (futureScore === bestFutureScore && @@ -3002,9 +3042,11 @@ function preferenceIfThen( patternConflictScore(rule.placeType, b, context) || compareDateLists(a, b), ); for (const pattern of patterns) { - const variants = assignDatesVariants(current, pattern, rule, context); + yield; + const variants = yield* assignDatesVariants(current, pattern, rule, context); for (const variant of variants) { - search( + yield; + yield* search( index + 1, addEarnedBalance(variant, rule, occurrence, pattern, context), count + 1, @@ -3015,14 +3057,14 @@ function preferenceIfThen( // A preference may leave an occurrence unplaced. Exploring this branch after // complete patterns keeps the result deterministic while still maximizing the // number of complete occurrences. - search(index + 1, current, count, futureScore); + yield* search(index + 1, current, count, futureScore); }; - search(0, state, 0, 0); + yield* search(0, state, 0, 0); if (bestBranch) { observeTrace(context, (trace) => trace.chooseEvaluation(bestBranch!.candidate)); context.traceBranchParentId = bestBranch.decisionId; } else if (!context.traceBranchActive) { - recordTraceState(context, bestState, bestEvaluation); + retainAndRecordState(context, bestState, bestEvaluation); } return { state: bestState, count: bestCount }; } @@ -3329,15 +3371,16 @@ function preferenceReason( return reason(code, `Preference "${rule.name}" could not be completely satisfied.`, [rule.id]); } -function applyPreferenceRules( +function* applyPreferenceRules( state: State, preferenceRules: NormalizedRule[], context: SearchContext, -): { state: State; preferences: PreferenceResult[] } { +): PlanningSteps<{ state: State; preferences: PreferenceResult[] }> { let result = state; const preferences: PreferenceResult[] = []; for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { - result = applyPreferenceStep( + yield; + result = yield* applyPreferenceStep( result, preferenceRules, preferenceIndex, @@ -3348,15 +3391,15 @@ function applyPreferenceRules( return { state: result, preferences }; } -function applyPreferenceStep( +function* applyPreferenceStep( state: State, preferenceRules: NormalizedRule[], preferenceIndex: number, preferences: PreferenceResult[], context: SearchContext, -): State { +): PlanningSteps { let result = state; - const applied = applyPreferenceRule( + const applied = yield* applyPreferenceRule( result, preferenceRules[preferenceIndex], preferenceRules, @@ -3372,13 +3415,15 @@ function applyPreferenceStep( .slice(0, preferenceIndex + 1) .reduce((total, preference) => total + preference.requested, 0); while (remainingImprovements > 0) { + yield; context.check(); let improved = false; for (let retryIndex = 0; retryIndex <= preferenceIndex; retryIndex++) { + yield; context.check(); const previous = preferences[retryIndex]; if (previous.satisfied >= previous.requested) continue; - const retried = applyPreferenceRule( + const retried = yield* applyPreferenceRule( result, preferenceRules[retryIndex], preferenceRules, @@ -3396,13 +3441,14 @@ function applyPreferenceStep( return result; } -function applyPreferenceRule( +function* applyPreferenceRule( state: State, rule: NormalizedRule, preferenceRules: NormalizedRule[], preferenceIndex: number, context: SearchContext, -): { state: State; preference: PreferenceResult } { +): PlanningSteps<{ state: State; preference: PreferenceResult }> { + yield; context.check(); context.reservedTypes = new Set( preferenceRules @@ -3417,8 +3463,8 @@ function applyPreferenceRule( ); const applied = rule.type === 'time_window' - ? preferenceTimeWindow(state, rule, context) - : preferenceIfThen(state, rule, context); + ? yield* preferenceTimeWindow(state, rule, context) + : yield* preferenceIfThen(state, rule, context); const requested = rule.type === 'time_window' ? rule.placeDays : occurrences(rule, context).length; return { @@ -3577,6 +3623,46 @@ function addQualityComparator( return before === after; } +function recordTraceIncumbentBound( + context: SearchContext, + incumbentMetrics: PlanningMetrics, + unplacedAtRisk: number, + preferences: readonly PreferenceResult[] = [], +): void { + observeTrace(context, (trace) => { + const values: QualityTraceComparator[] = []; + let equal = addQualityComparator( + values, + 'unplaced-at-risk', + 'minimize', + incumbentMetrics.unplacedAtRisk, + unplacedAtRisk, + ); + for (let index = 0; equal && index < preferences.length; index++) { + const preference = preferences[index]; + equal = addQualityComparator( + values, + `preference:${preference.ruleId}`, + 'maximize', + incumbentMetrics.preferenceSatisfaction[index] ?? 0, + preference.satisfied, + ); + } + trace.recordCandidate( + { + status: 'pruned', + technicalComparison: values, + firstDecisiveComparator: values[values.length - 1].name, + reason: reason( + 'incumbent-bound', + 'This state cannot improve the incumbent on the first differing planning objective.', + ), + }, + false, + ); + }); +} + function movableProtectionTraceDetails( baseline: PlanningMetrics, candidate: PlanningMetrics, @@ -3740,7 +3826,7 @@ function updateTraceEvaluationReason( }); } -function recordTraceEvaluation( +function retainAndRecordEvaluation( context: SearchContext, beforeState: State, afterState: State, @@ -3774,6 +3860,7 @@ function recordTraceEvaluation( }; trace.recordEvaluation(evaluation); }); + context.retain?.(afterState, afterMetrics); return context.trace ? evaluation : undefined; } @@ -3889,12 +3976,12 @@ function qualityMetricTieTraceDetails( }; } -function optimizeMovablePlacements( +function* optimizeMovablePlacements( state: State, context: SearchContext, preferences: PreferenceResult[], stage: 'non-working-day-gain' | 'longest-work-sequence' | 'short-sequence-deficit' | 'dates', -): State { +): PlanningSteps { let result = state; // Small plans are common and can be searched exhaustively. Keep the fixed // sparse calendar candidates for loaded years so quality work stays within @@ -3936,6 +4023,7 @@ function optimizeMovablePlacements( if (!exhaustive) { const sampled = new Set(candidateDates); for (const date of context.workingDays) { + yield; if (sampled.has(date)) continue; recordTraceCandidate(context, { status: 'not-examined', @@ -3952,6 +4040,7 @@ function optimizeMovablePlacements( (placement) => placement.ruleIds.size === 0 || isMovableTimeWindow(placement, context), ) .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))) { + yield; const current = result.placements.get(original.date); if (!current || (current.ruleIds.size > 0 && !isMovableTimeWindow(current, context))) continue; @@ -3975,6 +4064,7 @@ function optimizeMovablePlacements( ? context.workingDays.filter((date) => movableWindowDates.has(date)) : candidateDates; for (const date of placementCandidates) { + yield; if (date === original.date) continue; if (!compatibleWithRules(date, current.type, current.ruleIds, context)) { recordTraceIneligible( @@ -4002,7 +4092,7 @@ function optimizeMovablePlacements( feasibleCandidate.placements.get(date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); if (metrics.unplacedAtRisk !== baseline.unplacedAtRisk) { - recordTraceEvaluation( + retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -4016,7 +4106,7 @@ function optimizeMovablePlacements( continue; } if (metrics.holidayWeekValue < baseline.holidayWeekValue) { - recordTraceEvaluation( + retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -4036,7 +4126,7 @@ function optimizeMovablePlacements( (value, index) => value !== baseline.preferenceSatisfaction[index], ) ) { - recordTraceEvaluation( + retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -4069,7 +4159,7 @@ function optimizeMovablePlacements( metrics.shortSequenceDeficit === bestMetrics.shortSequenceDeficit && date < bestDate)); - const evaluation = recordTraceEvaluation( + const evaluation = retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -4129,7 +4219,7 @@ function optimizeMovablePlacements( without.balances.find((item) => item.id === current.balanceId)!.remaining++; } } - if (best !== result) recordTraceState(context, best, bestEvaluation); + if (best !== result) retainAndRecordState(context, best, bestEvaluation); result = best; } return result; @@ -4137,12 +4227,12 @@ function optimizeMovablePlacements( // ponytail: regroup only flexible placements and time-window rules; add wider // rule reshaping only if a real plan needs coordinated if-then moves. -function completeHolidayWeek( +function* completeHolidayWeek( state: State, candidate: HolidayWeekCandidate, context: SearchContext, protectedDates: Set, -): State | null { +): PlanningSteps { const targetDates = new Set(candidate.dates); const missing = candidate.dates.filter((date) => !state.placements.has(date)); if (!missing.length) return state; @@ -4158,7 +4248,8 @@ function completeHolidayWeek( : isAtRisk(balance, context)) ); }; - const search = (index: number, current: State): State | null => { + const search = function* (index: number, current: State): PlanningSteps { + yield; context.check(); if (index >= missing.length) return finalizeEarnedState(current, context); const date = missing[index]; @@ -4174,6 +4265,7 @@ function completeHolidayWeek( compareDate(a.date, b.date), ); for (const source of movable) { + yield; if (!compatibleWithRules(date, source.type, source.ruleIds, context)) { recordTraceIneligible( context, @@ -4197,31 +4289,33 @@ function completeHolidayWeek( source.ruleIds, ); if (!reassigned) continue; - const complete = search(index + 1, reassigned); + const complete = yield* search(index + 1, reassigned); if (complete) return complete; } for (const balance of current.balances .filter((item) => item.remaining > 0) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { + yield; const assigned = assignDateToBalance(current, date, balance.id, context); if (!assigned) continue; assigned.placements.get(date)!.supporting = !isAtRisk(balance, context); - const complete = search(index + 1, assigned); + const complete = yield* search(index + 1, assigned); if (complete) return complete; } return null; }; - return search(0, state); + return yield* search(0, state); } -function optimizeHolidayWeeks( +function* optimizeHolidayWeeks( state: State, context: SearchContext, preferences: PreferenceResult[], -): State { +): PlanningSteps { let result = state; const candidates = context.holidayWeeks; while (true) { + yield; context.check(); if (candidates.length) recordTraceDecision(context, []); const baseline = qualityMetrics(result, context, preferences); @@ -4232,13 +4326,14 @@ function optimizeHolidayWeeks( let bestMetrics = baseline; let bestEvaluation: MutableTraceCandidate | undefined; for (const candidate of candidates) { + yield; context.check(); const protectedDates = new Set( completed .filter((week) => week.value >= candidate.value) .flatMap((week) => week.dates), ); - const complete = completeHolidayWeek(result, candidate, context, protectedDates); + const complete = yield* completeHolidayWeek(result, candidate, context, protectedDates); if (!complete) continue; const metrics = qualityMetrics(complete, context, preferences); if ( @@ -4249,7 +4344,7 @@ function optimizeHolidayWeeks( ) !== 0 || metrics.holidayWeekValue <= baseline.holidayWeekValue ) { - recordTraceEvaluation( + retainAndRecordEvaluation( context, result, complete, @@ -4267,7 +4362,7 @@ function optimizeHolidayWeeks( compareMetrics(metrics, bestMetrics) < 0 || (compareMetrics(metrics, bestMetrics) === 0 && placementTieKey(complete) < placementTieKey(best)); - const evaluation = recordTraceEvaluation( + const evaluation = retainAndRecordEvaluation( context, result, complete, @@ -4318,7 +4413,7 @@ function optimizeHolidayWeeks( } } if (!best) return result; - recordTraceState(context, best, bestEvaluation); + retainAndRecordState(context, best, bestEvaluation); result = best; } } @@ -4410,17 +4505,18 @@ function expiryProtectionTraceDetails( return { technicalComparison: values }; } -function optimizeExpiryPlacements( +function* optimizeExpiryPlacements( state: State, context: SearchContext, preferences: PreferenceResult[], -): State { +): PlanningSteps { let result = state; for (const original of [...result.placements.values()] .filter( (placement) => placement.ruleIds.size === 0 || isMovableTimeWindow(placement, context), ) .sort((a, b) => compareDate(a.date, b.date) || compareText(a.balanceId, b.balanceId))) { + yield; context.check(); const current = result.placements.get(original.date); if (!current) continue; @@ -4446,6 +4542,7 @@ function optimizeExpiryPlacements( compareDate(current.date, balance.expiryDate) <= 0, ) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { + yield; const candidate = assignDateToBalance( without, current.date, @@ -4461,7 +4558,7 @@ function optimizeExpiryPlacements( const expiry = expiryTieKey(feasibleCandidate); const preservesQuality = sameHigherQuality(metrics, baseline); const better = preservesQuality && expiry < bestExpiry; - const evaluation = recordTraceEvaluation( + const evaluation = retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -4516,7 +4613,7 @@ function optimizeExpiryPlacements( bestEvaluation = evaluation; } } - if (best !== result) recordTraceState(context, best, bestEvaluation); + if (best !== result) retainAndRecordState(context, best, bestEvaluation); result = best; } return result; @@ -4602,6 +4699,13 @@ function periodData(state: State, context: SearchContext): TimeOffPeriod[] { }); } +function unplacedAtRiskQuantity(state: State, context: SearchContext): number { + return state.balances.reduce( + (total, balance) => total + (isAtRisk(balance, context) ? balance.remaining : 0), + 0, + ); +} + function qualityMetrics( state: State, context: SearchContext, @@ -4626,27 +4730,8 @@ function qualityMetrics( (sum, length) => sum + (length > 0 && length < 5 ? 5 - length : 0), 0, ); - const atRiskBalances = state.balances.filter( - (balance) => - compareDate(balance.expiryDate, context.horizon.start) >= 0 && - compareDate(balance.expiryDate, context.horizon.end) <= 0, - ); - const atRisk = atRiskBalances - .filter((balance) => balance.quantity > 0) - .reduce( - (sum, balance) => - sum + - Math.max( - 0, - balance.quantity - - (state.balances.find((item) => item.id === balance.id)?.remaining ?? 0), - ), - 0, - ); - const totalRisk = atRiskBalances.reduce((sum, balance) => sum + balance.quantity, 0); - const unplacedAtRisk = totalRisk - atRisk; return { - unplacedAtRisk, + unplacedAtRisk: unplacedAtRiskQuantity(state, context), holidayWeekValue: holidayWeekValue(state, context), nonWorkingDayGain: periods.reduce((sum, period) => sum + period.nonWorkingDayGain, 0), longestWorkSequence: sequences.length ? Math.max(...sequences) : 0, @@ -4977,7 +5062,7 @@ function makePlan( completedStages: [...stages], lastCompletedStage: stages[stages.length - 1] ?? 'validation', }; - recordTraceState(context, state); + retainAndRecordState(context, state); return plan; } @@ -5260,13 +5345,14 @@ function supportingRemovalTraceDetails( return { technicalComparison: values }; } -function supportingPlacements( +function* supportingPlacements( state: State, context: SearchContext, preferences: PreferenceResult[], -): State { +): PlanningSteps { let result = state; while (true) { + yield; context.check(); const before = qualityMetrics(result, context, preferences); let best: State | null = null; @@ -5278,7 +5364,9 @@ function supportingPlacements( compareDate(item.expiryDate, context.horizon.end) > 0 && item.remaining > 0, ) .sort((a, b) => compareDate(a.expiryDate, b.expiryDate) || compareText(a.id, b.id))) { + yield; for (const date of context.workingDays) { + yield; context.check(); if ( result.placements.has(date) || @@ -5304,7 +5392,7 @@ function supportingPlacements( metrics.longestWorkSequence <= before.longestWorkSequence && metrics.shortSequenceDeficit <= before.shortSequenceDeficit; if (!preservesHigherMetrics) { - recordTraceEvaluation( + retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -5323,7 +5411,7 @@ function supportingPlacements( metrics.longestWorkSequence < before.longestWorkSequence || metrics.shortSequenceDeficit < before.shortSequenceDeficit; if (!useful) { - recordTraceEvaluation( + retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -5343,7 +5431,7 @@ function supportingPlacements( continue; } const better = !bestMetrics || compareMetrics(metrics, bestMetrics) < 0; - const evaluation = recordTraceEvaluation( + const evaluation = retainAndRecordEvaluation( context, result, feasibleCandidate, @@ -5393,40 +5481,54 @@ function supportingPlacements( } } if (!best) return result; - recordTraceState(context, best, bestEvaluation); + retainAndRecordState(context, best, bestEvaluation); result = best; } } -function pruneRedundantSupportingPlacements( +function* pruneRedundantSupportingPlacements( state: State, context: SearchContext, preferences: PreferenceResult[], -): State { +): PlanningSteps { let result = state; let removed = true; while (removed) { + yield; context.check(); removed = false; const baseline = qualityMetrics(result, context, preferences); for (const placement of [...result.placements.values()].filter((item) => item.supporting)) { + yield; context.check(); recordTraceDecision(context, [placement.date]); const candidate = cloneState(result); candidate.placements.delete(placement.date); const balance = candidate.balances.find((item) => item.id === placement.balanceId); if (balance) balance.remaining++; - const metrics = qualityMetrics(candidate, context, preferences); + const feasibleCandidate = finalizeEarnedState(candidate, context); + if (!feasibleCandidate) { + recordTraceCandidate(context, { + status: 'pruned', + date: placement.date, + reason: reason( + 'earned-entitlement-order', + 'Removing this supporting placement would invalidate earned entitlement dates.', + ), + }); + continue; + } + const metrics = qualityMetrics(feasibleCandidate, context, preferences); const removesWithoutQualityLoss = metrics.unplacedAtRisk === baseline.unplacedAtRisk && metrics.holidayWeekValue >= baseline.holidayWeekValue && metrics.nonWorkingDayGain >= baseline.nonWorkingDayGain && metrics.longestWorkSequence <= baseline.longestWorkSequence && metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit; - const evaluation = recordTraceEvaluation( + const evaluation = retainAndRecordEvaluation( context, result, - candidate, + feasibleCandidate, preferences, baseline, metrics, @@ -5441,8 +5543,8 @@ function pruneRedundantSupportingPlacements( }, ); if (removesWithoutQualityLoss) { - recordTraceState(context, candidate, evaluation); - result = candidate; + retainAndRecordState(context, feasibleCandidate, evaluation); + result = feasibleCandidate; removed = true; break; } @@ -5479,7 +5581,7 @@ export type PlanningExplanationCallback = ( finalState: PlanningTraceState | undefined, ) => void; -/** @internal Worker-only synchronous core. Application code uses the public calculation context. */ +/** @internal Synchronous adapter retained for non-browser callers and deterministic checks. */ export function calculatePlanningInternal( request: unknown, checkpointCallback?: PlanningCheckpoint, @@ -5487,6 +5589,96 @@ export function calculatePlanningInternal( outcomeCallback?: PlanningOutcomeCallback, explanationCallback?: PlanningExplanationCallback, ): PlanningOutcome { + const steps = calculatePlanningSteps( + request, + checkpointCallback, + artifactsCallback, + outcomeCallback, + explanationCallback, + ); + let next = steps.next(); + while (!next.done) next = steps.next(); + return next.value; +} + +/** @internal Runs the same calculation while allowing worker messages between CPU slices. */ +export async function calculatePlanningAsyncInternal( + request: unknown, + checkpointCallback?: PlanningCheckpoint, + artifactsCallback?: PlanningArtifactsCallback, + outcomeCallback?: PlanningOutcomeCallback, + explanationCallback?: PlanningExplanationCallback, + isCancelled: () => boolean = () => false, +): Promise { + const steps = calculatePlanningSteps( + request, + checkpointCallback, + artifactsCallback, + outcomeCallback, + explanationCallback, + true, + ); + let next = steps.next(); + let pauseAt = now() + 8; + while (!next.done) { + if (next.value || now() >= pauseAt) { + await new Promise((resolve) => setTimeout(resolve, 0)); + pauseAt = now() + 8; + } + next = isCancelled() ? steps.throw(new UserCancelled()) : steps.next(); + } + return next.value; +} + +function* prepareCalendar( + horizon: { + start: string; + end: string; + }, + progress: () => void, +): PlanningSteps { + const holidays = new Set(); + for ( + let year = Number(horizon.start.slice(0, 4)) - 1; + year <= Number(horizon.end.slice(0, 4)) + 1; + year++ + ) { + yield true; + for (const holiday of holidaysForYear(year)) holidays.add(holiday); + progress(); + } + const workingDays: string[] = []; + const calendarDays: string[] = []; + const calendarIndex = new Map(); + const workingCalendar = new Set(); + const workingPrefix = [0]; + const end = addDays(horizon.end, 400); + for ( + let date = addDays(horizon.start, -400); + compareDate(date, end) < 0; + date = addDays(date, 1) + ) { + yield; + calendarIndex.set(date, calendarDays.length); + calendarDays.push(date); + const working = isWorking(date, holidays); + if (working) { + workingCalendar.add(date); + if (date >= horizon.start && date < horizon.end) workingDays.push(date); + } + workingPrefix.push(workingPrefix[workingPrefix.length - 1] + Number(working)); + } + return { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix }; +} + +function* calculatePlanningSteps( + request: unknown, + checkpointCallback?: PlanningCheckpoint, + artifactsCallback?: PlanningArtifactsCallback, + outcomeCallback?: PlanningOutcomeCallback, + explanationCallback?: PlanningExplanationCallback, + retainIncumbents = false, +): PlanningSteps { const source = runtimeRequest(request); const normalized = normalizeRequest(source); if (normalized.reasons.length) { @@ -5496,18 +5688,51 @@ export function calculatePlanningInternal( return outcome; } const horizon = normalized.horizon!; - const holidays = holidaySet(horizon); - const workingDays = dateRange(horizon.start, horizon.end).filter((date) => - isWorking(date, holidays), - ); - const calendarDays = dateRange(addDays(horizon.start, -400), addDays(horizon.end, 400)); - const calendarIndex = new Map(calendarDays.map((date, index) => [date, index])); - const workingCalendar = new Set(calendarDays.filter((date) => isWorking(date, holidays))); - const workingPrefix = [0]; - for (const date of calendarDays) - workingPrefix.push( - workingPrefix[workingPrefix.length - 1] + Number(workingCalendar.has(date)), - ); + let calendar: PlanningCalendar; + try { + if (typeof source.progress === 'function') source.progress('preparation'); + yield true; + calendar = yield* prepareCalendar(horizon, () => { + if (typeof source.progress === 'function') source.progress('preparation'); + }); + } catch (error) { + if (!(error instanceof UserCancelled)) throw error; + const outcome: PlanningIncompleteOutcome = { + kind: 'PlanningIncomplete', + completedStages: ['validation'], + lastCompletedStage: 'validation', + reasons: [reason('user-cancelled', 'The calculation was cancelled by the user.')], + }; + const explanation: IncompleteExplanation = { + kind: 'IncompleteExplanation', + reason: outcome.reasons[0], + completedStages: outcome.completedStages, + uncompletedStages: ['preparation', 'obligations'], + }; + outcomeCallback?.(outcome); + explanationCallback?.(explanation, undefined, undefined); + artifactsCallback?.({ + explanation, + trace: { + status: 'incomplete', + initialState: { + horizon, + balances: normalized.balances.map((balance) => ({ + ...balance, + remaining: balance.quantity, + })), + placements: [], + earnedActivations: [], + }, + phases: [], + completedStages: outcome.completedStages, + error: outcome.reasons[0], + }, + }); + return outcome; + } + const { holidays, workingDays, calendarDays, calendarIndex, workingCalendar, workingPrefix } = + calendar; const started = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now(); const configuredLimit = @@ -5593,6 +5818,97 @@ export function calculatePlanningInternal( const preferences: PreferenceResult[] = []; let checkpoint: PlanOutcome | undefined; let checkpointState: State | undefined; + let retained: Pick | undefined; + if (retainIncumbents) + context.retain = (candidate, finalizedMetrics) => { + const check = context.check; + context.check = () => undefined; + try { + const finalized = finalizedMetrics + ? candidate + : finalizeEarnedState(candidate, context); + if (!finalized) return; + const unplacedAtRisk = + finalizedMetrics?.unplacedAtRisk ?? unplacedAtRiskQuantity(finalized, context); + if (retained && unplacedAtRisk > retained.metrics.unplacedAtRisk) { + recordTraceIncumbentBound(context, retained.metrics, unplacedAtRisk); + return; + } + const candidatePreferences = preferenceResultsForState(finalized, context); + const preferenceSatisfaction = candidatePreferences.map( + ({ satisfied }) => satisfied, + ); + if ( + retained && + unplacedAtRisk === retained.metrics.unplacedAtRisk && + comparePreferenceVectors( + preferenceSatisfaction, + retained.metrics.preferenceSatisfaction, + ) > 0 + ) { + recordTraceIncumbentBound( + context, + retained.metrics, + unplacedAtRisk, + candidatePreferences, + ); + return; + } + if (unsatisfiedObligationCount(finalized, context)) return; + if ( + retained && + !planningTraceDeltas(retained.state, finalized, [ + ...retained.state.placements.keys(), + ...finalized.placements.keys(), + ]).length + ) + return; + const scored = { + state: finalized, + preferences: candidatePreferences, + metrics: finalizedMetrics + ? { + ...finalizedMetrics, + preferenceSatisfaction, + } + : qualityMetrics(finalized, context, candidatePreferences), + }; + const compared = retained ? compareObligationPreviews(scored, retained) : -1; + if (retained) { + observeTrace(context, (trace) => { + const facts = obligationPreviewComparisonFacts(scored, retained!); + trace.recordCandidate( + { + status: compared < 0 ? 'chosen' : 'evaluated-rejected', + dates: [...finalized.placements.keys()], + technicalComparison: facts.values, + firstDecisiveComparator: facts.firstDecisiveComparator, + reason: reason( + 'incumbent-comparison', + compared < 0 + ? 'This valid candidate became the best available partial plan.' + : 'This valid candidate did not improve the best available partial plan.', + ), + }, + false, + ); + }); + } + if (compared < 0) { + const firstValidCandidate = !retained; + retained = { ...scored, state: cloneState(finalized) }; + if (firstValidCandidate && checkpointCallback) + observeCallback(context, () => + checkpointCallback( + makePlan(retained!.state, context, retained!.preferences, stages), + planningTraceState(retained!.state, horizon), + ), + ); + } + } finally { + context.check = check; + } + }; const finish = (outcome: PlanningOutcome, explanationState?: State): PlanningOutcome => { const planningFinishedAt = now(); const planningExcludedTime = context.traceTime + context.observerTime; @@ -5764,6 +6080,7 @@ export function calculatePlanningInternal( return outcome; }; const publishCheckpoint = (): void => { + context.retain?.(state); checkpoint = makePlan(state, context, preferences, stages); checkpointState = cloneState(state); if (checkpointCallback) { @@ -5781,10 +6098,12 @@ export function calculatePlanningInternal( }; try { markProgress(context, 'validation'); + yield true; // At-risk quantity is the first objective, but equivalent placements can // still preserve later preferences by sharing their compatible dates. context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); markProgress(context, 'obligations'); + yield true; const obligationRequirements = buildRequirements(normalized.rules, context, 'obligation'); context.reservedTypes = new Set( obligationRequirements @@ -5792,6 +6111,7 @@ export function calculatePlanningInternal( .filter((selector): selector is LeaveType => selector !== 'any'), ); for (const requirement of obligationRequirements) { + yield; if ( !requirement.candidates || requirement.candidates.length !== 1 || @@ -5812,7 +6132,7 @@ export function calculatePlanningInternal( obligationBranchEstimate(obligationRequirements) <= EXACT_OBLIGATION_BRANCH_LIMIT ? 0 : OBLIGATION_SOLUTION_LIMIT; - satisfyObligations( + yield* satisfyObligations( obligationRequirements, state, context, @@ -5829,6 +6149,7 @@ export function calculatePlanningInternal( ); let bestPreview: ObligationPreview | null = null; for (const obligationSolution of obligationSolutions) { + yield; context.check(); const obligationPath = context.obligationSolutionBranches.get(obligationSolution) ?? []; const previousBranchActive = context.traceBranchActive; @@ -5836,8 +6157,8 @@ export function calculatePlanningInternal( context.traceBranchActive = true; context.traceBranchParentId = obligationPath.at(-1)?.decisionId; try { - const atRiskState = placeAtRisk(cloneState(obligationSolution), context); - const preview = applyPreferenceRules(atRiskState, preferenceRules, context); + const atRiskState = yield* placeAtRisk(cloneState(obligationSolution), context); + const preview = yield* applyPreferenceRules(atRiskState, preferenceRules, context); const previewState = finalizeEarnedState(preview.state, context); if (!previewState) continue; const candidate = { @@ -5883,19 +6204,22 @@ export function calculatePlanningInternal( stages.push('obligations'); publishCheckpoint(); markProgress(context, 'at-risk'); + yield true; context.protectedTypesByDate = futurePreferenceProtection(normalized.rules, 0, context); context.reservedTypes = new Set( obligationRequirements .map((requirement) => requirement.rule.placeType) .filter((selector): selector is LeaveType => selector !== 'any'), ); - state = placeAtRisk(state, context); + state = yield* placeAtRisk(state, context); stages.push('at-risk'); publishCheckpoint(); for (let preferenceIndex = 0; preferenceIndex < preferenceRules.length; preferenceIndex++) { + yield; const rule = preferenceRules[preferenceIndex]; markProgress(context, `preference:${rule.id}`); - state = applyPreferenceStep( + yield true; + state = yield* applyPreferenceStep( state, preferenceRules, preferenceIndex, @@ -5906,38 +6230,60 @@ export function calculatePlanningInternal( publishCheckpoint(); } markProgress(context, 'holiday-week-value'); + yield true; context.check(); - state = optimizeHolidayWeeks(state, context, preferences); + state = yield* optimizeHolidayWeeks(state, context, preferences); stages.push('holiday-week-value'); publishCheckpoint(); markProgress(context, 'non-working-day-gain'); + yield true; context.check(); - state = optimizeMovablePlacements(state, context, preferences, 'non-working-day-gain'); + state = yield* optimizeMovablePlacements( + state, + context, + preferences, + 'non-working-day-gain', + ); stages.push('non-working-day-gain'); publishCheckpoint(); markProgress(context, 'longest-work-sequence'); + yield true; context.check(); - state = optimizeMovablePlacements(state, context, preferences, 'longest-work-sequence'); + state = yield* optimizeMovablePlacements( + state, + context, + preferences, + 'longest-work-sequence', + ); stages.push('longest-work-sequence'); publishCheckpoint(); markProgress(context, 'short-sequence-deficit'); + yield true; context.check(); - state = optimizeMovablePlacements(state, context, preferences, 'short-sequence-deficit'); + state = yield* optimizeMovablePlacements( + state, + context, + preferences, + 'short-sequence-deficit', + ); stages.push('short-sequence-deficit'); publishCheckpoint(); markProgress(context, 'supporting-placements'); - state = supportingPlacements(state, context, preferences); - state = pruneRedundantSupportingPlacements(state, context, preferences); + yield true; + state = yield* supportingPlacements(state, context, preferences); + state = yield* pruneRedundantSupportingPlacements(state, context, preferences); stages.push('supporting-placements'); publishCheckpoint(); markProgress(context, 'expiry'); + yield true; context.check(); - state = optimizeExpiryPlacements(state, context, preferences); + state = yield* optimizeExpiryPlacements(state, context, preferences); stages.push('expiry'); publishCheckpoint(); markProgress(context, 'dates'); + yield true; context.check(); - state = optimizeMovablePlacements(state, context, preferences, 'dates'); + state = yield* optimizeMovablePlacements(state, context, preferences, 'dates'); stages.push('dates'); const finalized = finalizeEarnedState(state, context); if (!finalized) { @@ -5955,15 +6301,42 @@ export function calculatePlanningInternal( state = finalized; publishCheckpoint(); markProgress(context, 'complete'); + yield true; return finish(checkpoint!, state); } catch (error) { + if (error instanceof UserCancelled && retained) { + // Freeze the chosen valid candidate, rather than exposing a temporary search state. + context.check = () => undefined; + context.retain = undefined; + checkpointState = retained.state; + const partialPreferences = retained.preferences.map((preference) => ({ + ...preference, + reason: preferenceReason( + normalized.rules.find((rule) => rule.id === preference.ruleId)!, + preference.satisfied, + context, + retained!.state, + ), + })); + checkpoint = makePlan(checkpointState, context, partialPreferences, stages); + recordTraceDecision( + context, + checkpoint.placements.map(({ date }) => date), + ); + retainAndRecordState(context, checkpointState); + } const interruption = - error instanceof DeadlineReached - ? reason( - 'deadline', - `Planning stopped after ${context.deadline}ms before all quality stages completed.`, - ) - : reason('planner-error', error instanceof Error ? error.message : String(error)); + error instanceof UserCancelled + ? reason('user-cancelled', 'The calculation was cancelled by the user.') + : error instanceof DeadlineReached + ? reason( + 'deadline', + `Planning stopped after ${context.deadline}ms before all quality stages completed.`, + ) + : reason( + 'planner-error', + error instanceof Error ? error.message : String(error), + ); return finish( { kind: 'PlanningIncomplete', @@ -6320,18 +6693,36 @@ function counterfactualObligationsValid( } function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] { + let patternsByRule = preferencePatternCache.get(context); + if (!patternsByRule) { + patternsByRule = new Map( + context.rules + .filter( + (rule) => + rule.enabled && rule.force === 'preference' && rule.type === 'if_then', + ) + .map((rule) => [ + rule.id, + occurrences(rule, context).map((occurrence) => + patternsFor(rule, occurrence, context), + ), + ]), + ); + preferencePatternCache.set(context, patternsByRule); + } return context.rules .filter((rule) => rule.enabled && rule.force === 'preference') .map((rule) => { + const occurrencePatterns = patternsByRule.get(rule.id) ?? []; const requested = - rule.type === 'time_window' ? rule.placeDays : occurrences(rule, context).length; + rule.type === 'time_window' ? rule.placeDays : occurrencePatterns.length; const satisfied = rule.type === 'time_window' ? [...state.placements.values()].filter((placement) => placement.ruleIds.has(rule.id), ).length - : occurrences(rule, context).filter((occurrence) => - patternsFor(rule, occurrence, context).some((pattern) => + : occurrencePatterns.filter((patterns) => + patterns.some((pattern) => pattern.every( (date) => state.placements.get(date)?.ruleIds.has(rule.id) === true, diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts index ede3de5..3031194 100644 --- a/src/app/services/leave-planner.service.spec.ts +++ b/src/app/services/leave-planner.service.spec.ts @@ -46,6 +46,68 @@ async function runInNonBrowser(request: Parameters[0]) { } 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 () => { const result = await planLeave({ 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'); }); + 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([ [ 'invalid balance quantity', @@ -620,6 +694,7 @@ describe('planLeave', () => { 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 () => { const result = isPlan( await runInNonBrowser({ @@ -677,7 +752,7 @@ describe('planLeave', () => { '2027-07-16', ]), ); - }); + }, 15_000); it('moves a satisfied time-window preference to complete a holiday week', async () => { const result = isPlan( @@ -1411,6 +1486,56 @@ describe('planLeave transport', () => { }); 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', () => { const state = { balances: [ diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts index 716b39b..b7f46ad 100644 --- a/src/app/services/leave-planner.service.ts +++ b/src/app/services/leave-planner.service.ts @@ -434,6 +434,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu const timings = new Promise((resolve) => { resolveTimings = resolve; }); + let supersede: (() => void) | undefined; let cancel: (() => void) | undefined; try { 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 cancellationRequested = false; + let cancelledRace: PlanningIncompleteOutcome | undefined; let explanationSettled = false; let artifactsSettled = false; let checkpoint: PlanOutcome | undefined; @@ -483,17 +486,44 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu }; const finishOutcome = (result: PlanningOutcome) => { 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; clearTimeout(livenessWatchdog); 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 = ( result: PlanningExplanation | undefined, error: PlanningReason | undefined, finalState?: PlanningTraceState, ) => { if (!explanationSettled) { + if (!error) result = cancelledExplanation(result); explanationSettled = true; receivedExplanation = result; receivedExplanationError = error; @@ -511,6 +541,16 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu explanationError: result.explanationError ?? receivedExplanationError, 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) finishExplanation( complete.explanation, @@ -529,7 +569,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu traceTransferMs, }); terminate(); - if (cancel) latestWorkerCancels.delete(cancel); + if (supersede) latestWorkerCancels.delete(supersede); } }; const partialArtifacts = ( @@ -601,7 +641,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu deltas.push(operation.delta); } }; - cancel = () => { + supersede = () => { if (artifactsSettled) return; const hadOutcome = outcomeSettled; 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 }) => { if (!explanationSettled && checkpoint && checkpointState) { receivedFinalState ??= checkpointState; @@ -757,6 +797,15 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu }; 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) => { const message = event.data; if (!message) return; @@ -1489,6 +1538,7 @@ function explainCheckpointInWorker( } export interface PlanningCalculationContext { + readonly cancel: () => void; readonly id: string; readonly snapshot: PlanningSnapshot; readonly outcome: Promise; @@ -1579,6 +1629,31 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal }; } } 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 resolveExplanation!: (explanation: PlanningExplanation | undefined) => void; let resolveExplanationError!: (error: PlanningReason | undefined) => void; @@ -1600,15 +1675,26 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal try { const core = await import('./leave-planner.core'); let completedArtifacts: PlanningArtifacts = {}; - const result = core.calculatePlanningInternal( + const result = await core.calculatePlanningAsyncInternal( transport, undefined, (value) => (completedArtifacts = value), - undefined, + (value) => { + outcomeSettled = true; + resolve( + superseded && value.kind === 'PlanningIncomplete' + ? { + ...value, + reasons: [replacementReason], + } + : value, + ); + }, (value, error) => { - resolveExplanation(value); + resolveExplanation(explanationForRun(value)); resolveExplanationError(error); }, + () => cancellationRequested, ); if (result.kind === 'InvalidInput') { resolveExplanation(undefined); @@ -1616,7 +1702,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal } resolve(result); queueMicrotask(() => { - resolveArtifacts(completedArtifacts); + resolveArtifacts({ + ...completedArtifacts, + explanation: explanationForRun(completedArtifacts.explanation), + }); resolveTimings(completedArtifacts.timings ?? {}); }); } catch (error) { @@ -1636,10 +1725,22 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal explanationError: reason, }); 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) => ({ @@ -1657,6 +1758,7 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal const context: PlanningCalculationContext = { id, snapshot, + cancel: () => run.cancel?.(), outcome: run.outcome, explanation: run.explanation, explanationError: run.explanationError, diff --git a/src/app/services/leave-planner.worker.spec.ts b/src/app/services/leave-planner.worker.spec.ts new file mode 100644 index 0000000..f7cf90d --- /dev/null +++ b/src/app/services/leave-planner.worker.spec.ts @@ -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(); + + 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(); + }); +}); diff --git a/src/app/services/leave-planner.worker.ts b/src/app/services/leave-planner.worker.ts index 617be9d..17eb0f5 100644 --- a/src/app/services/leave-planner.worker.ts +++ b/src/app/services/leave-planner.worker.ts @@ -15,7 +15,7 @@ import type { } from './leave-planner.service'; import { analyzeCounterfactualInternal, - calculatePlanningInternal, + calculatePlanningAsyncInternal, explainIncompleteCheckpoint, historicalCounterfactualReasons, type CounterfactualBaselineOptions, @@ -38,6 +38,7 @@ type WorkerRequest = request: WorkerPlanningRequest; } | { type: 'trace-ack'; count: number } + | { type: 'cancel' } | { type: 'counterfactual'; request: PlanningSnapshot; @@ -120,6 +121,7 @@ interface TraceDelivery { } let activeTraceDelivery: TraceDelivery | undefined; +let activeCalculation: { cancelled: boolean } | undefined; function traceStatusIndex(trace: PlanningTrace): { statuses: Map; @@ -272,8 +274,12 @@ function pumpTraceUpdates(delivery: TraceDelivery): void { finishTraceDelivery(delivery); } -worker.onmessage = (event: MessageEvent) => { +worker.onmessage = async (event: MessageEvent) => { try { + if (event.data?.type === 'cancel') { + if (activeCalculation) activeCalculation.cancelled = true; + return; + } if (event.data?.type === 'trace-ack') { const delivery = activeTraceDelivery; if (!delivery || !Number.isInteger(event.data.count) || event.data.count <= 0) return; @@ -282,6 +288,8 @@ worker.onmessage = (event: MessageEvent) => { return; } if (event.data?.type === 'plan') { + const calculation = { cancelled: false }; + activeCalculation = calculation; let artifacts: PlanningArtifacts = {}; let outcomePosted = false; let outcome: PlanningOutcome; @@ -294,7 +302,7 @@ worker.onmessage = (event: MessageEvent) => { }; activeTraceDelivery = traceDelivery; try { - outcome = calculatePlanningInternal( + outcome = await calculatePlanningAsyncInternal( { ...event.data.request, progress: (stage: string) => @@ -331,6 +339,7 @@ worker.onmessage = (event: MessageEvent) => { explanationError, finalState, } satisfies WorkerResponse), + () => calculation.cancelled, ); } catch (error) { if (activeTraceDelivery === traceDelivery) activeTraceDelivery = undefined; @@ -341,6 +350,8 @@ worker.onmessage = (event: MessageEvent) => { message: error instanceof Error ? error.message : String(error), } satisfies WorkerResponse); return; + } finally { + if (activeCalculation === calculation) activeCalculation = undefined; } if (!outcomePosted) worker.postMessage({ type: 'result', outcome } satisfies WorkerResponse); diff --git a/src/app/services/planning-date.ts b/src/app/services/planning-date.ts index 099b7e9..4bec958 100644 --- a/src/app/services/planning-date.ts +++ b/src/app/services/planning-date.ts @@ -6,7 +6,7 @@ export function planningDateInParis(value: Date): string { day: '2-digit', }).formatToParts(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 { @@ -16,3 +16,15 @@ export function addPlanningYears(key: string, years: number): string { if (date.getUTCMonth() !== month) date.setUTCDate(0); 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; +} diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts index bf18f27..e44eb5a 100644 --- a/src/app/services/translation-service.ts +++ b/src/app/services/translation-service.ts @@ -57,6 +57,13 @@ export interface AppTranslations { dark: string; }; systemAppearance: string; + planningHorizon: string; + planningStart: string; + lastPlanningDate: string; + planningDatesHelp: string; + invalidDate: string; + horizonOrder: string; + invalidHorizon: string; }; rules: { title: string; @@ -191,6 +198,10 @@ export interface AppTranslations { invalidInput: string; conflict: string; incomplete: string; + cancel: string; + cancelling: string; + cancelled: string; + cancelledPartial: string; error: string; complete: string; whyPlacement: string; diff --git a/src/i18n/de.json b/src/i18n/de.json index 2d1772d..4a5ddae 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -48,7 +48,14 @@ "selectLanguage": "Sprache auswählen", "selectTheme": "Design", "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": { "title": "Planungsregeln", @@ -109,6 +116,10 @@ "invalidInput": "Die Planungsdaten sind ungültig.", "conflict": "Die Verpflichtungen können nicht gemeinsam erfüllt werden.", "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.", "complete": "Plan berechnet.", "whyPlacement": "Ihre Urlaubstage verstehen", diff --git a/src/i18n/en.json b/src/i18n/en.json index 327d5a4..6643f17 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -48,7 +48,14 @@ "selectLanguage": "Language", "selectTheme": "Theme", "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": { "title": "Planning rules", @@ -109,6 +116,10 @@ "invalidInput": "The planning input is invalid.", "conflict": "The obligations cannot be satisfied together.", "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.", "complete": "Plan calculated.", "whyPlacement": "Understand your leave days", diff --git a/src/i18n/es.json b/src/i18n/es.json index 7586f12..4ab37e8 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -48,7 +48,14 @@ "selectLanguage": "Idioma", "selectTheme": "Tema", "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": { "title": "Reglas de planificación", @@ -109,6 +116,10 @@ "invalidInput": "Los datos de planificación no son válidos.", "conflict": "Las obligaciones no se pueden satisfacer juntas.", "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.", "complete": "Plan calculado.", "whyPlacement": "Entender tus días de permiso", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 617a6b4..6ff731a 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -48,7 +48,14 @@ "selectLanguage": "Langue", "selectTheme": "Thème", "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": { "title": "Règles de planification", @@ -109,6 +116,10 @@ "invalidInput": "Les données de planification sont invalides.", "conflict": "Les obligations ne peuvent pas être satisfaites ensemble.", "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.", "complete": "Plan calculé.", "whyPlacement": "Comprendre vos jours de congé",