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 @@
>