From c9dbe82b79ec7e030c2ef14b824aad1b7a04105f Mon Sep 17 00:00:00 2001
From: Nessar
Date: Fri, 25 Sep 2026 15:13:15 +0200
Subject: [PATCH] fix(planning): complete explanation and trace behavior (#46)
---
.../calendar-month/calendar-month.html | 5 +
.../calendar-month/calendar-month.scss | 13 +
.../calendar-month/calendar-month.ts | 21 +-
.../calendar-year-planning.spec.ts | 194 +++++++++-
.../calendar-year/calendar-year.html | 71 +++-
.../calendar-year/calendar-year.scss | 4 +
.../components/calendar-year/calendar-year.ts | 114 ++++--
src/app/services/leave-planner.core.ts | 194 ++++++----
.../services/leave-planner.service.spec.ts | 334 +++++++++++++++++-
src/app/services/leave-planner.service.ts | 115 ++++--
10 files changed, 925 insertions(+), 140 deletions(-)
diff --git a/src/app/components/calendar-month/calendar-month.html b/src/app/components/calendar-month/calendar-month.html
index d8bd4c8..ea3803f 100644
--- a/src/app/components/calendar-month/calendar-month.html
+++ b/src/app/components/calendar-month/calendar-month.html
@@ -63,6 +63,11 @@
{{
getStatusMarker(day)
}}
+ @if (status.hypotheticalLabel) {
+ {{
+ getHypotheticalMarker(status)
+ }}
+ }
}
}
diff --git a/src/app/components/calendar-month/calendar-month.scss b/src/app/components/calendar-month/calendar-month.scss
index 7848abe..f822774 100644
--- a/src/app/components/calendar-month/calendar-month.scss
+++ b/src/app/components/calendar-month/calendar-month.scss
@@ -75,6 +75,19 @@
border-radius: 999px;
}
+ .hypothetical-marker {
+ position: absolute;
+ inset-block-end: -0.35rem;
+ inset-inline-start: -0.2rem;
+ min-width: 1.25rem;
+ font-size: 0.55rem;
+ font-weight: 800;
+ line-height: 1;
+ background: var(--ui-card-bg, #fff);
+ border: 1px dashed currentColor;
+ border-radius: 999px;
+ }
+
.current {
outline: 3px double var(--today-border-color, #1976d2);
outline-offset: 1px;
diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts
index e247219..79c1d21 100644
--- a/src/app/components/calendar-month/calendar-month.ts
+++ b/src/app/components/calendar-month/calendar-month.ts
@@ -9,7 +9,8 @@ export interface CalendarDayStatus {
kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined';
label: string;
reason?: string;
- historicalLabel?: string;
+ hypotheticalLabel?: string;
+ hypotheticalReason?: string;
change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged';
changeLabel?: string;
}
@@ -114,6 +115,12 @@ export class CalendarMonth implements OnInit {
}
}
+ getHypotheticalMarker(status: CalendarDayStatus): string {
+ return status.hypotheticalLabel === this.translationService.tr().planning.eligible
+ ? '~✓'
+ : '~×';
+ }
+
getDayAriaLabel(day: Date): string {
const parts = [
day.toLocaleDateString(this.translationService.locale(), {
@@ -168,11 +175,15 @@ export class CalendarMonth implements OnInit {
if (status) {
tooltips.push(status.label);
if (status.changeLabel) tooltips.push(status.changeLabel);
- if (status.historicalLabel)
- tooltips.push(
- `${this.translationService.tr().planning.historical}: ${status.historicalLabel}`,
- );
if (status.reason) tooltips.push(status.reason);
+ if (status.hypotheticalLabel)
+ tooltips.push(
+ `${this.translationService.tr().planning.counterfactual}: ${status.hypotheticalLabel}`,
+ );
+ if (status.hypotheticalReason)
+ tooltips.push(
+ `${this.translationService.tr().planning.reason}: ${status.hypotheticalReason}`,
+ );
}
return tooltips.length > 0 ? tooltips.join(' + ') : null;
}
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 620dde8..0af398c 100644
--- a/src/app/components/calendar-year/calendar-year-planning.spec.ts
+++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts
@@ -112,8 +112,12 @@ const explanation: PlanningExplanation = {
})),
};
-function selectedDatesForPlan(value: PlanOutcome): SelectedDates {
+function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates {
const selected = new SelectedDates();
+ if (includeHoliday) {
+ const holiday = new Date('2024-05-08T00:00:00');
+ selected.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday)));
+ }
for (const placement of value.placements) {
const date = new Date(`${placement.date}T00:00:00`);
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
@@ -145,6 +149,18 @@ function calculationFor(
date: '2024-05-04',
status: 'ineligible',
reason: { code: 'non-working', message: 'Weekend' },
+ historicalStatus: 'ineligible',
+ },
+ {
+ date: '2024-05-07',
+ status: 'ineligible',
+ reason: { code: 'rule-invalid', message: 'Hypothetical constraint' },
+ },
+ {
+ date: '2024-05-08',
+ status: 'ineligible',
+ reason: { code: 'non-working', message: 'Public Holiday' },
+ historicalStatus: 'ineligible',
},
],
};
@@ -276,8 +292,32 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
});
- it('keeps the simple result visible and reveals placement details on demand', async () => {
+ it('shows the first plan explanation by default and navigates placements', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
+ calendar.selectedDatesForPlan.mockImplementation((value) =>
+ selectedDatesForPlan(value, true),
+ );
+ const counterfactualWithoutHistoricalStatus: CounterfactualAnalysis = {
+ kind: 'Counterfactual',
+ calculationId: 'test',
+ placement: plan.placements[0],
+ hypothetical: true,
+ baseline: objectiveVector,
+ results: [
+ { date: '2024-05-03', status: 'eligible' },
+ {
+ date: '2024-05-04',
+ status: 'ineligible',
+ },
+ {
+ date: '2024-05-08',
+ status: 'ineligible',
+ },
+ ],
+ };
+ vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue(
+ counterfactualWithoutHistoricalStatus,
+ );
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -292,17 +332,37 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
- expect(details.open).toBe(false);
- expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
- expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
- expect(fixture.nativeElement.querySelectorAll('.legend-symbol')).toHaveLength(0);
- expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
-
- details.open = true;
- details.dispatchEvent(new Event('toggle'));
- fixture.detectChanges();
+ expect(details.open).toBe(true);
+ expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(3);
+ expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
+ '2024-05-03',
+ );
expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]);
expect(fixture.nativeElement.textContent).toContain('Paid leave');
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({
+ kind: 'ineligible',
+ label: 'Ineligible',
+ reason: 'Weekend',
+ });
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({
+ kind: 'ineligible',
+ label: 'Ineligible',
+ reason: 'Public Holiday',
+ });
+ const weekend = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-04"]',
+ ) as HTMLButtonElement;
+ expect(weekend.getAttribute('aria-label')).toContain('Ineligible');
+ expect(weekend.getAttribute('aria-label')).toContain('Weekend');
+ expect(weekend.querySelector('.status-marker')?.textContent).toBe('×');
+ expect(weekend.querySelector('.hypothetical-marker')?.textContent).toBe('~×');
+ const holidayButton = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-08"]',
+ ) as HTMLButtonElement;
+ expect(holidayButton.getAttribute('aria-label')).toContain('Ineligible');
+ expect(holidayButton.getAttribute('aria-label')).toContain('Public Holiday');
+ expect(holidayButton.querySelector('.status-marker')?.textContent).toBe('×');
+ expect(holidayButton.querySelector('.hypothetical-marker')?.textContent).toBe('~×');
const next = fixture.nativeElement.querySelector(
'button[data-step="next"]',
@@ -404,6 +464,9 @@ describe('CalendarYear planning outcome boundary', () => {
completedStages: ['at-risk'],
};
setup = calendarFor(plan, explanation, trace);
+ setup.calendar.selectedDatesForPlan.mockImplementation((value) =>
+ selectedDatesForPlan(value, true),
+ );
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
@@ -433,6 +496,33 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1);
expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged');
expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added');
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({
+ kind: 'ineligible',
+ reason: 'Weekend',
+ });
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({
+ kind: 'ineligible',
+ reason: 'Public Holiday',
+ });
+ const weekend = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-04"]',
+ ) as HTMLButtonElement;
+ expect(weekend.getAttribute('aria-label')).toContain('Ineligible');
+ expect(weekend.getAttribute('aria-label')).toContain('Weekend');
+ const holiday = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-08"]',
+ ) as HTMLButtonElement;
+ expect(holiday.getAttribute('aria-label')).toContain('Public Holiday');
+ const legend = fixture.nativeElement.querySelector('.calendar-legend').textContent;
+ expect(legend).toContain('Evaluated and rejected');
+ expect(legend).toContain('Not examined');
+ const unevaluatedDate = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-07"]',
+ ) as HTMLButtonElement;
+ unevaluatedDate.click();
+ fixture.detectChanges();
+ expect(unevaluatedDate.classList).toContain('current');
+ expect(fixture.componentInstance['liveMessage']()).toContain('Not examined');
const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1];
afterButton.click();
fixture.detectChanges();
@@ -443,7 +533,65 @@ describe('CalendarYear planning outcome boundary', () => {
compareButton.click();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
- expect(fixture.componentInstance['dateStatuses']()['2024-05-04'].kind).toBe('ineligible');
+ expect(fixture.componentInstance['traceReplay']()).toBe(true);
+ expect(fixture.componentInstance['selectedTraceDecision']()?.id).toBe('decision:1');
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({
+ kind: 'ineligible',
+ label: 'Ineligible',
+ reason: 'Weekend',
+ hypotheticalLabel: 'Ineligible',
+ hypotheticalReason: 'Weekend',
+ });
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-03']).toMatchObject({
+ kind: 'chosen',
+ label: 'Chosen',
+ hypotheticalLabel: 'Eligible',
+ });
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-07']).toMatchObject({
+ kind: 'not-examined',
+ label: 'Not examined',
+ hypotheticalLabel: 'Ineligible',
+ });
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({
+ kind: 'ineligible',
+ label: 'Ineligible',
+ reason: 'Public Holiday',
+ hypotheticalLabel: 'Ineligible',
+ });
+ const holidayAlternative = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-08"]',
+ ) as HTMLButtonElement;
+ expect(holidayAlternative.querySelector('.status-marker')?.textContent).toBe('×');
+ expect(holidayAlternative.querySelector('.hypothetical-marker')?.textContent).toBe('~×');
+ const notExaminedAlternative = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-07"]',
+ ) as HTMLButtonElement;
+ expect(notExaminedAlternative.querySelector('.status-marker')?.textContent).toBe('?');
+ expect(notExaminedAlternative.querySelector('.hypothetical-marker')?.textContent).toBe(
+ '~×',
+ );
+ expect(notExaminedAlternative.getAttribute('aria-label')).toContain('Not examined');
+ expect(notExaminedAlternative.getAttribute('aria-label')).toContain(
+ 'Date alternatives: Ineligible',
+ );
+ const counterfactualLegend = fixture.nativeElement.querySelector('.calendar-legend');
+ expect(counterfactualLegend.textContent).toContain('~✓Eligible');
+ expect(counterfactualLegend.textContent).toContain('~×Ineligible');
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added');
+ expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged');
+ const addedDate = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-03"]',
+ ) as HTMLButtonElement;
+ expect(addedDate.querySelector('.status-marker')?.textContent).toBe('+');
+ expect(addedDate.querySelector('.hypothetical-marker')?.textContent).toBe('~✓');
+ expect(addedDate.classList).toContain('change-added');
+ expect(
+ (
+ fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-06"]',
+ ) as HTMLButtonElement
+ ).classList,
+ ).toContain('change-unchanged');
const alternativeDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-04"]',
) as HTMLButtonElement;
@@ -451,6 +599,28 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
expect(alternativeDate.classList).toContain('current');
expect(alternativeDate.getAttribute('aria-current')).toBe('date');
+ expect(alternativeDate.getAttribute('aria-label')).toContain('Ineligible');
+ expect(alternativeDate.getAttribute('aria-label')).toContain(
+ 'Date alternatives: Ineligible',
+ );
+ expect(alternativeDate.getAttribute('aria-label')).toContain('Reason: Weekend');
+ expect(fixture.componentInstance['liveMessage']()).toContain(
+ 'Date alternatives: Ineligible',
+ );
+ const counterfactualResult = fixture.nativeElement.querySelector(
+ '.counterfactual-result',
+ ) as HTMLElement;
+ expect(counterfactualResult.textContent).toContain('Ineligible');
+ expect(counterfactualResult.textContent).toContain('Original search status: Ineligible');
+ const chosenDate = fixture.nativeElement.querySelector(
+ 'button[data-date="2024-05-03"]',
+ ) as HTMLButtonElement;
+ chosenDate.click();
+ fixture.detectChanges();
+ expect(chosenDate.getAttribute('aria-label')).toContain('Chosen');
+ expect(chosenDate.getAttribute('aria-label')).toContain('Date alternatives: Eligible');
+ expect(counterfactualResult.textContent).toContain('Eligible');
+ expect(counterfactualResult.textContent).toContain('Original search status: Chosen');
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html
index 12dc68d..df8f9fa 100644
--- a/src/app/components/calendar-year/calendar-year.html
+++ b/src/app/components/calendar-year/calendar-year.html
@@ -137,7 +137,7 @@
}
@if (selectedExplanation(); as selected) {
-
+
{{ tr().planning.whyPlacement }}
@@ -256,9 +256,7 @@
{{ selected.remainingQuantity }}
@if (selected.remainingBalance) {
- {{
- formatIdentifier(selected.remainingBalance.classification)
- }}
+ {{ formatIdentifier(selected.remainingBalance.classification) }}
·
{{ selected.remainingBalance.reason.message }}
@@ -396,12 +394,13 @@
: tr().planning.ineligible
}}
- @if (result.historicalStatus) {
-
- {{ tr().planning.historical }}:
- {{ traceStatusLabel(result.historicalStatus) }}
-
- }
+
+ {{ tr().planning.historical }}:
+ {{
+ dateStatuses()[result.date]?.label ??
+ traceStatusLabel(result.historicalStatus ?? 'not-examined')
+ }}
+
@if (result.reason) {
{{ tr().planning.reason }}:
@@ -498,15 +497,63 @@
◎{{ tr().planning.currentPlacement }}
+ }
+ @if (counterfactual()) {
- ✓~✓{{ tr().planning.eligible }}
- ×~×{{ tr().planning.ineligible }}
}
+ @if (traceReplay()) {
+
+ ✓{{ traceStatusLabel('chosen') }}
+
+
+ −{{ traceStatusLabel('evaluated-rejected') }}
+
+
+ ⌁{{ traceStatusLabel('pruned') }}
+
+
+ ?{{ traceStatusLabel('not-examined') }}
+
+
+ ×{{ traceStatusLabel('ineligible') }}
+
+
+ +{{ traceChangeLabel('added') }}
+
+
+ ←{{ traceChangeLabel('moved-from') }}
+
+
+ →{{ traceChangeLabel('moved-to') }}
+
+
+ −{{ traceChangeLabel('removed') }}
+
+
+ ≈{{ traceChangeLabel('shared') }}
+
+
+ ={{ traceChangeLabel('unchanged') }}
+
+ }
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss
index d5548a1..588e4fe 100644
--- a/src/app/components/calendar-year/calendar-year.scss
+++ b/src/app/components/calendar-year/calendar-year.scss
@@ -496,6 +496,10 @@ code {
font-weight: 800;
}
+.hypothetical-symbol {
+ width: 1.5rem;
+}
+
.month-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index f07f02d..b46d0be 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -6,6 +6,7 @@ import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-mon
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import {
CalendarService,
+ DayType,
SelectedDates,
SelectableDayType,
VacDay,
@@ -61,7 +62,7 @@ export class CalendarYear {
protected readonly traceLoading = signal(false);
protected readonly traceResolved = signal(false);
protected readonly selectedPlacementIndex = signal(0);
- protected readonly planDetailsOpen = signal(false);
+ protected readonly planDetailsOpen = signal(true);
protected readonly counterfactual = signal(null);
protected readonly selectedCounterfactualDate = signal(null);
protected readonly counterfactualLoading = signal(false);
@@ -71,6 +72,7 @@ export class CalendarYear {
protected readonly traceCandidatePage = signal(0);
protected readonly traceMoment = signal('before');
protected readonly traceReplay = signal(false);
+ protected readonly selectedTraceDate = signal(null);
protected readonly liveMessage = signal('');
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates());
@@ -146,14 +148,23 @@ export class CalendarYear {
protected readonly currentPlacementDate = computed(() => {
if (this.counterfactual())
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
- if (this.traceReplay()) return this.selectedTraceDecision()?.dates[0] ?? null;
+ if (this.traceReplay())
+ return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null;
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
});
protected readonly dateStatuses = computed>>(() => {
const statuses: Record = {};
const trace = this.planningTrace();
- if (!this.counterfactual() && this.traceReplay() && trace) {
+ const closedDayDates = new Set();
+ for (const selected of this.selectedDates().datesSelected) {
+ 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')}`,
+ );
+ }
+ if (this.traceReplay() && trace) {
const priority = new Map([
['ineligible', 0],
['not-examined', 1],
@@ -173,16 +184,13 @@ export class CalendarYear {
label: this.traceStatusLabel('not-examined'),
};
}
- for (const candidate of trace.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) => decision.candidates),
- )) {
- if (candidate.status !== 'ineligible' || candidate.reason?.code !== 'non-working')
- continue;
- for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : []))
+ for (const date of Object.keys(statuses)) {
+ const reason = this.nonWorkingDateReason(date, closedDayDates);
+ if (reason)
statuses[date] = {
- kind: candidate.status,
- label: this.traceStatusLabel(candidate.status),
- reason: candidate.reason.message,
+ kind: 'ineligible',
+ label: this.traceStatusLabel('ineligible'),
+ reason,
};
}
const explicitPriorities = new Map();
@@ -234,20 +242,39 @@ export class CalendarYear {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged');
}
}
+ for (const result of this.counterfactual()?.results ?? []) {
+ const status = statuses[result.date];
+ const historicalStatus =
+ result.historicalStatus ??
+ (status && status.kind !== 'eligible' ? status.kind : 'not-examined');
+ statuses[result.date] = {
+ ...status,
+ kind: historicalStatus,
+ label: this.traceStatusLabel(historicalStatus),
+ hypotheticalLabel:
+ result.status === 'eligible'
+ ? this.tr().planning.eligible
+ : this.tr().planning.ineligible,
+ hypotheticalReason: result.reason?.message,
+ };
+ }
return statuses;
}
- for (const result of this.counterfactual()?.results ?? [])
+ for (const result of this.counterfactual()?.results ?? []) {
+ const nonWorkingReason = this.nonWorkingDateReason(result.date, closedDayDates);
+ const historicalStatus =
+ result.historicalStatus ?? (nonWorkingReason ? 'ineligible' : 'not-examined');
statuses[result.date] = {
- kind: result.status,
- label:
+ kind: historicalStatus,
+ label: this.traceStatusLabel(historicalStatus),
+ reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined,
+ hypotheticalLabel:
result.status === 'eligible'
? this.tr().planning.eligible
: this.tr().planning.ineligible,
- reason: result.reason?.message,
- historicalLabel: result.historicalStatus
- ? this.traceStatusLabel(result.historicalStatus)
- : undefined,
+ hypotheticalReason: result.reason?.message,
};
+ }
return statuses;
});
@@ -357,13 +384,14 @@ export class CalendarYear {
this.traceLoading.set(true);
this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
- this.planDetailsOpen.set(false);
+ this.planDetailsOpen.set(true);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
+ this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.userInput.remainingVacationDaysSignal.set([]);
}
@@ -372,6 +400,8 @@ export class CalendarYear {
void calculation.explanation.then((explanation) => {
if (version !== this.calculationVersion) return;
this.planningExplanation.set(explanation ?? null);
+ if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen())
+ this.selectPlacement(0);
});
void calculation.explanationError.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
@@ -435,6 +465,7 @@ export class CalendarYear {
protected togglePlanDetails(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
+ if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open);
if (open) this.selectPlacement(this.selectedPlacementIndex());
else this.clearCounterfactual();
@@ -464,12 +495,36 @@ export class CalendarYear {
return;
}
}
+ if (this.traceReplay()) {
+ this.selectedTraceDate.set(date);
+ if (this.counterfactual()?.results.some((result) => result.date === date))
+ this.selectedCounterfactualDate.set(date);
+ this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
+ return;
+ }
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.selectedCounterfactualDate.set(date);
- this.liveMessage.set(`${date}: ${this.dateStatuses()[date]?.label ?? ''}`);
+ this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date]));
}
}
+ private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string {
+ return [
+ date,
+ status?.label,
+ status?.reason,
+ status?.hypotheticalLabel
+ ? `${this.tr().planning.counterfactual}: ${status.hypotheticalLabel}`
+ : undefined,
+ status?.hypotheticalReason
+ ? `${this.tr().planning.reason}: ${status.hypotheticalReason}`
+ : undefined,
+ status?.changeLabel,
+ ]
+ .filter(Boolean)
+ .join('. ');
+ }
+
protected selectTracePhase(index: number): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1;
index = Math.max(0, Math.min(index, last));
@@ -478,6 +533,7 @@ export class CalendarYear {
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(false);
+ this.selectedTraceDate.set(null);
this.clearCounterfactual();
this.liveMessage.set(
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`,
@@ -505,6 +561,7 @@ export class CalendarYear {
this.traceCandidatePage.set(0);
this.traceMoment.set('before');
this.traceReplay.set(true);
+ this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
this.clearCounterfactual();
this.liveMessage.set(
`${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
@@ -535,11 +592,15 @@ export class CalendarYear {
this.clearCounterfactual();
this.traceMoment.set(moment);
this.traceReplay.set(true);
+ this.liveMessage.set(
+ `${this.tr().planning[moment]} ${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`,
+ );
}
protected showFinalPlan(): void {
this.traceReplay.set(false);
this.clearCounterfactual();
+ this.liveMessage.set(this.tr().planning.finalPlan);
}
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
@@ -547,9 +608,20 @@ export class CalendarYear {
if (!candidate.placement || !decision) return;
this.traceMoment.set('before');
this.traceReplay.set(true);
+ this.selectedTraceDate.set(candidate.placement.date);
void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
}
+ private nonWorkingDateReason(
+ date: string,
+ closedDayDates: ReadonlySet,
+ ): string | undefined {
+ const day = new Date(`${date}T00:00:00`);
+ if (day.getDay() === 0 || day.getDay() === 6) return this.tr().dayTypes.weekend;
+ if (closedDayDates.has(date)) return this.tr().dayTypes.closedDay;
+ return undefined;
+ }
+
private async analyzeSelection(selection: CounterfactualSelection): Promise {
const calculation = this.activeCalculation;
if (!calculation) return;
diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts
index 8b5c9ff..548cab2 100644
--- a/src/app/services/leave-planner.core.ts
+++ b/src/app/services/leave-planner.core.ts
@@ -512,6 +512,10 @@ class TraceRecorder {
private readonly phases: MutableTracePhase[] = [];
private phase: MutableTracePhase | undefined;
private sequence = 0;
+ private readonly evaluationDecisions = new WeakMap<
+ MutableTraceCandidate,
+ MutableTraceDecision
+ >();
private previousPlacements = new Map();
private previousEarned = new Set();
private previousBalances: ReadonlyMap;
@@ -567,23 +571,27 @@ class TraceRecorder {
return latest ?? this.decision(action);
}
- private latestComparison(placement: LeavePlacement): PlanningObjectiveComparison | undefined {
- for (const decision of [...(this.phase?.decisions ?? [])].reverse()) {
- const candidate = [...decision.candidates]
- .reverse()
- .find(
- (item) =>
- item.placement?.date === placement.date &&
- item.placement.balanceId === placement.balanceId &&
- item.comparison,
- );
- if (candidate?.comparison) return candidate.comparison;
- }
- return undefined;
+ private candidateDecision(dates: readonly string[] = []): MutableTraceDecision {
+ const latest = this.latestDecision();
+ return latest.deltas.length ? this.decision('choose', dates) : latest;
+ }
+
+ private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void {
+ const alreadyRecorded = decision.candidates.some(
+ (candidate) =>
+ candidate.status === 'chosen' &&
+ candidate.placement?.date === placement.date &&
+ candidate.placement.balanceId === placement.balanceId,
+ );
+ if (!alreadyRecorded)
+ decision.candidates.push({ status: 'chosen', date: placement.date, placement });
}
recordCandidate(candidate: PlanningTraceCandidate, copy = true): void {
- this.latestDecision().candidates.push(
+ const decision = this.candidateDecision(
+ candidate.dates ?? (candidate.date ? [candidate.date] : []),
+ );
+ decision.candidates.push(
copy
? {
...candidate,
@@ -606,14 +614,23 @@ class TraceRecorder {
if (candidate) decision.candidates.push(candidate);
}
- recordDelta(delta: PlanningTraceDelta): void {
- this.latestDecision().deltas.push({ ...delta });
+ recordEvaluation(candidate: MutableTraceCandidate): void {
+ const decision = this.candidateDecision(
+ candidate.dates ?? (candidate.date ? [candidate.date] : []),
+ );
+ decision.candidates.push(candidate);
+ this.evaluationDecisions.set(candidate, decision);
+ }
+
+ chooseEvaluation(candidate: MutableTraceCandidate): void {
+ candidate.status = 'chosen';
}
recordPlan(
placements: readonly LeavePlacement[],
balances: PlanningTraceState['balances'],
earnedActivations: PlanningTraceState['earnedActivations'],
+ acceptedCandidate?: MutableTraceCandidate,
): void {
const current = new Map(placements.map((placement) => [placement.date, placement]));
const removed = [...this.previousPlacements.values()].filter(
@@ -640,7 +657,7 @@ class TraceRecorder {
!earnedChanged
)
return;
- this.decision(
+ const action =
removed.length && added.length
? 'move'
: added.length
@@ -649,13 +666,17 @@ class TraceRecorder {
? 'remove'
: updated.length
? 'share'
- : 'activate',
- [
- ...removed.map((placement) => placement.date),
- ...added.map((placement) => placement.date),
- ...updated.map(([date]) => date),
- ],
- );
+ : 'activate';
+ const dates = [
+ ...removed.map((placement) => placement.date),
+ ...added.map((placement) => placement.date),
+ ...updated.map(([date]) => date),
+ ];
+ const decision =
+ (acceptedCandidate && this.evaluationDecisions.get(acceptedCandidate)) ||
+ this.decision(action, dates);
+ decision.action = action;
+ decision.dates = dates;
const movedAdded = new Set();
const movedRemoved = new Set();
for (const previous of removed) {
@@ -668,7 +689,7 @@ class TraceRecorder {
if (!placement) continue;
movedAdded.add(placement.date);
movedRemoved.add(previous.date);
- this.recordDelta({
+ decision.deltas.push({
kind: 'placement-moved',
fromDate: previous.date,
toDate: placement.date,
@@ -676,16 +697,11 @@ class TraceRecorder {
previousPlacement: previous,
placement,
});
- this.recordCandidate({
- status: 'chosen',
- date: placement.date,
- placement,
- comparison: this.latestComparison(placement),
- });
+ this.recordChosenCandidate(placement, decision);
}
for (const placement of removed) {
if (movedRemoved.has(placement.date)) continue;
- this.recordDelta({
+ decision.deltas.push({
kind: 'placement-removed',
date: placement.date,
balanceId: placement.balanceId,
@@ -694,46 +710,36 @@ class TraceRecorder {
}
for (const placement of added) {
if (movedAdded.has(placement.date)) continue;
- this.recordDelta({
+ decision.deltas.push({
kind: 'placement-added',
date: placement.date,
balanceId: placement.balanceId,
placement,
});
- this.recordCandidate({
- status: 'chosen',
- date: placement.date,
- placement,
- comparison: this.latestComparison(placement),
- });
+ this.recordChosenCandidate(placement, decision);
}
for (const [date, placement] of updated) {
const previous = this.previousPlacements.get(date)!;
- this.recordDelta({
+ decision.deltas.push({
kind: 'placement-updated',
date,
balanceId: placement.balanceId,
previousPlacement: previous,
placement,
});
- this.recordCandidate({
- status: 'chosen',
- date,
- placement,
- comparison: this.latestComparison(placement),
- });
+ this.recordChosenCandidate(placement, decision);
}
for (const balance of balances) {
const previousRemaining = this.previousBalances.get(balance.id);
if (previousRemaining !== undefined && previousRemaining !== balance.remaining)
- this.recordDelta({
+ decision.deltas.push({
kind: 'balance-changed',
balanceId: balance.id,
remainingBefore: previousRemaining,
remainingAfter: balance.remaining,
});
if (previousRemaining === undefined)
- this.recordDelta({
+ decision.deltas.push({
kind: 'balance-changed',
balanceId: balance.id,
remainingAfter: balance.remaining,
@@ -743,7 +749,7 @@ class TraceRecorder {
const activation = earnedActivations.find(
(candidate) => candidate.id === balance.id,
);
- this.recordDelta({
+ decision.deltas.push({
kind: 'earned-activated',
balanceId: balance.id,
activation: activation
@@ -1117,11 +1123,21 @@ function planningTraceState(
};
}
-function recordTraceState(context: SearchContext, state: State): void {
+function recordTraceState(
+ context: SearchContext,
+ state: State,
+ acceptedCandidate?: MutableTraceCandidate,
+): void {
if (!context.trace) return;
observeTrace(context, (trace) => {
const snapshot = planningTraceState(state, context.horizon);
- trace.recordPlan(snapshot.placements, snapshot.balances, snapshot.earnedActivations);
+ if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate);
+ trace.recordPlan(
+ snapshot.placements,
+ snapshot.balances,
+ snapshot.earnedActivations,
+ acceptedCandidate,
+ );
});
}
@@ -2579,11 +2595,12 @@ function recordTraceEvaluation(
afterMetrics: PlanningMetrics,
dates: readonly string[],
sourceDate?: string,
-): void {
+): MutableTraceCandidate | undefined {
+ let evaluation: MutableTraceCandidate | undefined;
observeTrace(context, (trace) => {
const date = dates[dates.length - 1];
const placement = date ? afterState.placements.get(date) : undefined;
- trace.recordCandidate({
+ evaluation = {
status: 'evaluated-rejected',
sourceDate,
date,
@@ -2595,8 +2612,10 @@ function recordTraceEvaluation(
placement: placement
? { ...placement, ruleIds: [...placement.ruleIds].sort() }
: undefined,
- });
+ };
+ trace.recordEvaluation(evaluation);
});
+ return context.trace ? evaluation : undefined;
}
function optimizeMovablePlacements(
@@ -2678,6 +2697,7 @@ function optimizeMovablePlacements(
let best = result;
let bestMetrics = baseline;
let bestDate = original.date;
+ let bestEvaluation: MutableTraceCandidate | undefined;
const placementCandidates =
current.ruleIds.size > 0
? context.workingDays.filter((date) => movableWindowDates.has(date))
@@ -2709,7 +2729,7 @@ function optimizeMovablePlacements(
if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
- recordTraceEvaluation(
+ const evaluation = recordTraceEvaluation(
context,
result,
feasibleCandidate,
@@ -2753,13 +2773,14 @@ function optimizeMovablePlacements(
best = cloneState(feasibleCandidate);
bestMetrics = metrics;
bestDate = date;
+ bestEvaluation = evaluation;
}
} finally {
without.placements.delete(date);
without.balances.find((item) => item.id === current.balanceId)!.remaining++;
}
}
- if (best !== result) recordTraceState(context, best);
+ if (best !== result) recordTraceState(context, best, bestEvaluation);
result = best;
}
return result;
@@ -2859,6 +2880,7 @@ function optimizeHolidayWeeks(
);
let best: State | null = null;
let bestMetrics = baseline;
+ let bestEvaluation: MutableTraceCandidate | undefined;
for (const candidate of candidates) {
context.check();
const protectedDates = new Set(
@@ -2869,7 +2891,7 @@ function optimizeHolidayWeeks(
const complete = completeHolidayWeek(result, candidate, context, protectedDates);
if (!complete) continue;
const metrics = qualityMetrics(complete, context, preferences);
- recordTraceEvaluation(
+ const evaluation = recordTraceEvaluation(
context,
result,
complete,
@@ -2895,10 +2917,11 @@ function optimizeHolidayWeeks(
) {
best = complete;
bestMetrics = metrics;
+ bestEvaluation = evaluation;
}
}
if (!best) return result;
- recordTraceState(context, best);
+ recordTraceState(context, best, bestEvaluation);
result = best;
}
}
@@ -2938,6 +2961,7 @@ function optimizeExpiryPlacements(
if (released) released.remaining++;
let best = result;
let bestExpiry = expiryTieKey(result);
+ let bestEvaluation: MutableTraceCandidate | undefined;
for (const replacement of without.balances
.filter(
(balance) =>
@@ -2961,7 +2985,7 @@ function optimizeExpiryPlacements(
if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(current.date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
- recordTraceEvaluation(
+ const evaluation = recordTraceEvaluation(
context,
result,
feasibleCandidate,
@@ -2975,9 +2999,10 @@ function optimizeExpiryPlacements(
if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) {
best = feasibleCandidate;
bestExpiry = expiry;
+ bestEvaluation = evaluation;
}
}
- if (best !== result) recordTraceState(context, best);
+ if (best !== result) recordTraceState(context, best, bestEvaluation);
result = best;
}
return result;
@@ -3536,6 +3561,7 @@ function supportingPlacements(
const before = qualityMetrics(result, context, preferences);
let best: State | null = null;
let bestMetrics: PlanningMetrics | null = null;
+ let bestEvaluation: MutableTraceCandidate | undefined;
for (const balance of result.balances
.filter(
(item) =>
@@ -3556,7 +3582,7 @@ function supportingPlacements(
const feasibleCandidate = finalizeEarnedState(candidate, context);
if (!feasibleCandidate) continue;
const metrics = qualityMetrics(feasibleCandidate, context, preferences);
- recordTraceEvaluation(
+ const evaluation = recordTraceEvaluation(
context,
result,
feasibleCandidate,
@@ -3586,11 +3612,12 @@ function supportingPlacements(
if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) {
best = feasibleCandidate;
bestMetrics = metrics;
+ bestEvaluation = evaluation;
}
}
}
if (!best) return result;
- recordTraceState(context, best);
+ recordTraceState(context, best, bestEvaluation);
result = best;
}
}
@@ -3613,7 +3640,7 @@ function pruneRedundantSupportingPlacements(
const balance = candidate.balances.find((item) => item.id === placement.balanceId);
if (balance) balance.remaining++;
const metrics = qualityMetrics(candidate, context, preferences);
- recordTraceEvaluation(
+ const evaluation = recordTraceEvaluation(
context,
result,
candidate,
@@ -3630,7 +3657,7 @@ function pruneRedundantSupportingPlacements(
metrics.longestWorkSequence <= baseline.longestWorkSequence &&
metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
) {
- recordTraceState(context, candidate);
+ recordTraceState(context, candidate, evaluation);
result = candidate;
removed = true;
break;
@@ -4128,6 +4155,34 @@ function counterfactualRuleValid(
return true;
}
+function counterfactualObligationsValid(
+ state: State,
+ context: SearchContext,
+ previous: State,
+): boolean {
+ return context.rules
+ .filter((rule) => rule.enabled && rule.force === 'obligation')
+ .every((rule) => {
+ if (rule.type === 'time_window') {
+ const count = (candidate: State) =>
+ [...candidate.placements.values()].filter((placement) =>
+ placement.ruleIds.has(rule.id),
+ ).length;
+ return count(previous) !== rule.placeDays || count(state) === rule.placeDays;
+ }
+ return occurrences(rule, context).every((occurrence) => {
+ const patterns = patternsFor(rule, occurrence, context);
+ const complete = (candidate: State) =>
+ patterns.some((pattern) =>
+ pattern.every(
+ (date) => candidate.placements.get(date)?.ruleIds.has(rule.id) === true,
+ ),
+ );
+ return !complete(previous) || complete(state);
+ });
+ });
+}
+
function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] {
return context.rules
.filter((rule) => rule.enabled && rule.force === 'preference')
@@ -4327,6 +4382,17 @@ export function analyzeCounterfactualInternal(
continue;
}
candidate.placements.get(date)!.supporting = selected.supporting;
+ if (!counterfactualObligationsValid(candidate, context, comparisonState)) {
+ results.push({
+ date,
+ status: 'ineligible',
+ reason: counterfactualReason(
+ 'rule-invalid',
+ 'The date would leave an obligation unsatisfied.',
+ ),
+ });
+ continue;
+ }
const afterPreferences = preferenceResultsForState(candidate, context);
const afterMetrics = qualityMetrics(candidate, context, afterPreferences);
const after = objectiveVector(afterMetrics, afterPreferences, candidate);
diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts
index 073ec45..834f811 100644
--- a/src/app/services/leave-planner.service.spec.ts
+++ b/src/app/services/leave-planner.service.spec.ts
@@ -1410,6 +1410,123 @@ describe('planning calculation context', () => {
}
});
+ it('keeps every mandatory IF_THEN occurrence satisfied in counterfactuals', async () => {
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', undefined);
+ try {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-04-25', end: '2024-05-15' },
+ balances: [balance('cp', 'cp', 2, '2024-12-31')],
+ rules: [
+ {
+ id: 'wednesday-holidays',
+ type: 'if_then',
+ force: 'obligation',
+ dayOfWeek: 3,
+ placeDays: 1,
+ placeType: 'cp',
+ placement: 'auto',
+ rewardDays: 0,
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan') return;
+ const firstOccurrence = outcome.placements.find(
+ (placement) => placement.date < '2024-05-05',
+ );
+ const secondOccurrence = outcome.placements.find(
+ (placement) => placement.date > '2024-05-05',
+ );
+ expect(firstOccurrence).toBeDefined();
+ expect(secondOccurrence).toBeDefined();
+ if (!firstOccurrence || !secondOccurrence) return;
+
+ const alternateSecondDate =
+ secondOccurrence.date === '2024-05-07' ? '2024-05-09' : '2024-05-07';
+ const analysis = await calculation.analyzeCounterfactual(firstOccurrence);
+ expect(analysis.kind).toBe('Counterfactual');
+ if (analysis.kind !== 'Counterfactual') return;
+ expect(
+ analysis.results.find((result) => result.date === alternateSecondDate),
+ ).toMatchObject({
+ status: 'ineligible',
+ reason: { code: 'rule-invalid' },
+ });
+ } finally {
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
+ it('allows trace counterfactuals while later obligations are still pending', async () => {
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', undefined);
+ try {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-05-01', end: '2024-05-10' },
+ balances: [balance('cp', 'cp', 2, '2024-12-31')],
+ rules: [
+ {
+ id: 'early-window',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-02',
+ endDate: '2024-05-03',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ {
+ id: 'late-window',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-05-06',
+ endDate: '2024-05-07',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ const trace = await calculation.trace;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan' || !trace) return;
+ const earlyPlacement = outcome.placements.find((placement) =>
+ placement.ruleIds.includes('early-window'),
+ );
+ expect(earlyPlacement).toBeDefined();
+ if (!earlyPlacement) return;
+ const decision = trace.phases
+ .flatMap((phase) => phase.decisions)
+ .find((candidate) =>
+ candidate.deltas.some(
+ (delta) =>
+ delta.kind === 'placement-added' &&
+ delta.placement?.ruleIds.includes('early-window'),
+ ),
+ );
+ expect(decision).toBeDefined();
+ if (!decision) return;
+
+ const analysis = await calculation.analyzeCounterfactual({
+ decisionId: decision.id,
+ placement: earlyPlacement,
+ });
+ expect(analysis.kind).toBe('Counterfactual');
+ if (analysis.kind !== 'Counterfactual') return;
+ expect(
+ ['2024-05-02', '2024-05-03'].map(
+ (date) => analysis.results.find((result) => result.date === date)?.status,
+ ),
+ ).toEqual(['eligible', 'eligible']);
+ expect(
+ analysis.results.find((result) => result.date === earlyPlacement.date)?.status,
+ ).toBe('eligible');
+ } finally {
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
it('preserves supporting provenance in counterfactual placements', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -1440,6 +1557,97 @@ describe('planning calculation context', () => {
}
});
+ it('cancels superseded planning and trace workers without changing the latest result', async () => {
+ const browserWindow = globalThis.window;
+ const expected = isPlan(
+ await runInNonBrowser({
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ }),
+ );
+ const trace = {
+ status: 'ready' as const,
+ initialState: {
+ horizon: expected.horizon,
+ balances: [],
+ placements: [],
+ earnedActivations: [],
+ },
+ phases: [],
+ completedStages: [],
+ };
+ const workers: Array<{ terminated: boolean }> = [];
+ class SupersededWorker {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: (() => void) | null = null;
+ terminated = false;
+
+ constructor() {
+ workers.push(this);
+ }
+
+ postMessage(): void {
+ if (workers.length === 2) {
+ this.onmessage?.({
+ data: { type: 'result', outcome: expected },
+ } as MessageEvent);
+ return;
+ }
+ if (workers.length !== 3) return;
+ queueMicrotask(() => {
+ this.onmessage?.({
+ data: { type: 'result', outcome: expected },
+ } as MessageEvent);
+ this.onmessage?.({
+ data: { type: 'artifacts', artifacts: { trace } },
+ } as MessageEvent);
+ });
+ }
+
+ terminate(): void {
+ this.terminated = true;
+ }
+ }
+ vi.stubGlobal('window', {});
+ vi.stubGlobal('Worker', SupersededWorker);
+ try {
+ const pending = createPlanningCalculation({
+ horizon: expected.horizon,
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ });
+ const materializing = createPlanningCalculation({
+ horizon: expected.horizon,
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ });
+ const latest = createPlanningCalculation({
+ horizon: expected.horizon,
+ balances: [balance('cp', 'cp', 1)],
+ rules: [],
+ });
+
+ expect(workers).toHaveLength(3);
+ expect(workers[0].terminated).toBe(true);
+ expect(workers[1].terminated).toBe(true);
+ const pendingOutcome = await pending.outcome;
+ expect(pendingOutcome.kind).toBe('PlanningIncomplete');
+ if (pendingOutcome.kind === 'PlanningIncomplete')
+ expect(pendingOutcome.reasons[0].code).toBe('stale-calculation');
+ expect((await pending.trace)?.error?.code).toBe('stale-calculation');
+ expect(await materializing.outcome).toEqual(expected);
+ expect((await materializing.trace)?.error?.code).toBe('stale-calculation');
+ expect(await materializing.explanationError).toBeUndefined();
+ expect(await latest.outcome).toEqual(expected);
+ expect((await latest.trace)?.status).toBe('ready');
+ expect(workers[2].terminated).toBe(true);
+ } finally {
+ vi.unstubAllGlobals();
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
it('discards counterfactual work from an older calculation', async () => {
const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined);
@@ -1572,6 +1780,89 @@ describe('planning calculation context', () => {
expect(
analysis.results.find((result) => result.date === laterPlacement.date)?.status,
).toBe('eligible');
+ expect(
+ analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
+ ).toBe('ineligible');
+ } finally {
+ vi.stubGlobal('window', browserWindow);
+ }
+ });
+
+ it('does not inherit unrelated rule-invalid history into a trace decision', async () => {
+ const browserWindow = globalThis.window;
+ vi.stubGlobal('window', undefined);
+ try {
+ const calculation = createPlanningCalculation({
+ horizon: { start: '2024-01-01', end: '2024-01-10' },
+ balances: [balance('cp', 'cp', 1, '2024-01-31')],
+ rules: [
+ {
+ id: 'early-window',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-01-02',
+ endDate: '2024-01-04',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ {
+ id: 'late-window',
+ type: 'time_window',
+ force: 'obligation',
+ startDate: '2024-01-03',
+ endDate: '2024-01-05',
+ placeDays: 1,
+ placeType: 'cp',
+ },
+ ],
+ });
+ const outcome = await calculation.outcome;
+ const trace = await calculation.trace;
+ expect(outcome.kind).toBe('Plan');
+ if (outcome.kind !== 'Plan' || !trace) return;
+ const decision = trace.phases
+ .flatMap((phase) => phase.decisions)
+ .find((candidate) =>
+ candidate.deltas.some((delta) => delta.kind === 'placement-added'),
+ );
+ const placement = decision?.deltas.find(
+ (delta) => delta.kind === 'placement-added',
+ )?.placement;
+ expect(decision).toBeDefined();
+ expect(placement).toBeDefined();
+ if (!decision || !placement) return;
+
+ const candidates = trace.phases.flatMap((phase) =>
+ phase.decisions.flatMap((item) => item.candidates),
+ );
+ const decisionDates = new Set(
+ decision.candidates.flatMap(
+ (candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []),
+ ),
+ );
+ const unrelatedIneligible = candidates.find(
+ (candidate) =>
+ candidate.status === 'ineligible' &&
+ candidate.reason?.code === 'rule-invalid' &&
+ candidate.date &&
+ !decisionDates.has(candidate.date),
+ );
+ expect(unrelatedIneligible).toBeDefined();
+ if (!unrelatedIneligible?.date) return;
+
+ const analysis = await calculation.analyzeCounterfactual({
+ decisionId: decision.id,
+ placement,
+ });
+ expect(analysis.kind).toBe('Counterfactual');
+ if (analysis.kind !== 'Counterfactual') return;
+ expect(
+ analysis.results.find((result) => result.date === unrelatedIneligible.date)
+ ?.historicalStatus,
+ ).toBe('not-examined');
+ expect(
+ analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus,
+ ).toBe('ineligible');
} finally {
vi.stubGlobal('window', browserWindow);
}
@@ -1638,10 +1929,8 @@ describe('planning calculation context', () => {
});
expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace;
- const candidates =
- trace?.phases.flatMap((phase) =>
- phase.decisions.flatMap((decision) => decision.candidates),
- ) ?? [];
+ const decisions = trace?.phases.flatMap((phase) => phase.decisions) ?? [];
+ const candidates = decisions.flatMap((decision) => decision.candidates);
expect(
candidates.some(
(candidate) =>
@@ -1655,6 +1944,43 @@ describe('planning calculation context', () => {
expect(
candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
).toBe(true);
+ const chosenComparisons = decisions.flatMap((decision) =>
+ decision.candidates
+ .filter((candidate) => candidate.status === 'chosen' && candidate.comparison)
+ .map((candidate) => ({ decision, candidate })),
+ );
+ expect(chosenComparisons.length).toBeGreaterThan(0);
+ for (const { decision, candidate: chosen } of chosenComparisons) {
+ expect(
+ decision.deltas.some(
+ (delta) =>
+ (delta.kind === 'placement-moved' &&
+ delta.toDate === chosen.date &&
+ delta.balanceId === chosen.placement?.balanceId) ||
+ (delta.kind === 'placement-added' &&
+ delta.date === chosen.date &&
+ delta.balanceId === chosen.placement?.balanceId) ||
+ (delta.kind === 'placement-removed' && delta.date === chosen.date),
+ ),
+ ).toBe(true);
+ }
+ const phasesWithMultipleAcceptedMutations = trace?.phases.filter((phase) => {
+ const acceptedMutations = phase.decisions.filter(
+ (decision) =>
+ decision.candidates.some(
+ (candidate) => candidate.status === 'chosen' && candidate.comparison,
+ ) &&
+ decision.deltas.some(
+ (delta) =>
+ delta.kind === 'placement-moved' ||
+ delta.kind === 'placement-added' ||
+ delta.kind === 'placement-removed' ||
+ delta.kind === 'placement-updated',
+ ),
+ );
+ return acceptedMutations.length > 1;
+ });
+ expect(phasesWithMultipleAcceptedMutations?.length).toBeGreaterThan(0);
} finally {
vi.stubGlobal('window', browserWindow);
}
diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts
index 4c39906..eb4a5ad 100644
--- a/src/app/services/leave-planner.service.ts
+++ b/src/app/services/leave-planner.service.ts
@@ -1,7 +1,6 @@
import type {
CounterfactualAnalysis,
CounterfactualBaselineOptions,
- CounterfactualDateResult,
CounterfactualSelection,
InvalidInputOutcome,
LeaveBalanceInput,
@@ -18,6 +17,7 @@ import type {
PlanningReason,
PlanningTraceState,
StaleCounterfactualAnalysis,
+ TraceCandidateStatus,
TraceCounterfactualSelection,
} from './leave-planner.core';
@@ -281,6 +281,7 @@ type PlannerWorkerResponse =
interface PlanningRun {
outcome: Promise;
artifacts: Promise;
+ cancel?: () => void;
}
function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
@@ -337,6 +338,8 @@ function interruptedOutcome(
};
}
+let latestWorkerCancel: (() => void) | undefined;
+
function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun {
let resolveOutcome!: (outcome: PlanningOutcome) => void;
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
@@ -346,6 +349,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
const artifacts = new Promise((resolve) => {
resolveArtifacts = resolve;
});
+ let cancel: (() => void) | undefined;
try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module',
@@ -375,8 +379,25 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
artifactsSettled = true;
resolveArtifacts(result);
terminate();
+ if (latestWorkerCancel === cancel) latestWorkerCancel = undefined;
}
};
+ cancel = () => {
+ if (artifactsSettled) return;
+ const hadOutcome = outcomeSettled;
+ const reason = {
+ code: 'stale-calculation',
+ message: 'Planning stopped because a newer calculation started.',
+ };
+ if (!hadOutcome)
+ finishOutcome(
+ interruptedOutcome(checkpoint, deadline, reason.code, reason.message),
+ );
+ finishArtifacts({
+ trace: fallbackTrace(snapshot, 'failed', reason),
+ explanationError: hadOutcome ? undefined : reason,
+ });
+ };
const fail = (code: string, message: string) => {
const reason = { code, message };
if (!outcomeSettled)
@@ -453,7 +474,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
explanationError: result.reasons[0],
});
}
- return { outcome, artifacts };
+ return { outcome, artifacts, cancel };
}
let calculationSequence = 0;
@@ -480,6 +501,62 @@ function traceSelection(
return typeof selection === 'object' && 'decisionId' in selection;
}
+const traceStatusPriority = new Map([
+ ['ineligible', 0],
+ ['not-examined', 1],
+ ['pruned', 2],
+ ['evaluated-rejected', 3],
+ ['chosen', 4],
+]);
+const historicalStatusCache = new WeakMap>();
+const historicalNonWorkingCache = new WeakMap>();
+
+function historicalStatuses(
+ trace: PlanningTrace,
+ decisionId?: string,
+): Map {
+ if (!decisionId) {
+ const cached = historicalStatusCache.get(trace);
+ if (cached) return cached;
+ }
+ const statuses = new Map();
+ const nonWorkingDates = new Set();
+ for (const phase of trace.phases) {
+ for (const decision of phase.decisions) {
+ if (decisionId && decision.id !== decisionId) continue;
+ for (const candidate of decision.candidates) {
+ for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
+ if (
+ !decisionId &&
+ candidate.status === 'ineligible' &&
+ candidate.reason?.code === 'non-working'
+ )
+ nonWorkingDates.add(date);
+ const previous = statuses.get(date);
+ if (
+ !previous ||
+ traceStatusPriority.get(candidate.status)! >
+ traceStatusPriority.get(previous)!
+ )
+ statuses.set(date, candidate.status);
+ }
+ }
+ }
+ }
+ if (!decisionId) {
+ historicalStatusCache.set(trace, statuses);
+ historicalNonWorkingCache.set(trace, nonWorkingDates);
+ }
+ return statuses;
+}
+
+function historicalNonWorkingDates(trace: PlanningTrace): ReadonlySet {
+ const cached = historicalNonWorkingCache.get(trace);
+ if (cached) return cached;
+ historicalStatuses(trace);
+ return historicalNonWorkingCache.get(trace) ?? new Set();
+}
+
function stateBeforeDecision(
trace: PlanningTrace,
decisionId: string,
@@ -574,31 +651,19 @@ function withHistoricalStatuses(
decisionId?: string,
): CounterfactualAnalysis {
if (!trace) return analysis;
- const priority = new Map([
- ['ineligible', 0],
- ['not-examined', 1],
- ['pruned', 2],
- ['evaluated-rejected', 3],
- ['chosen', 4],
- ]);
- const statuses = new Map();
- for (const candidate of trace.phases.flatMap((phase) =>
- phase.decisions
- .filter((decision) => !decisionId || decision.id === decisionId)
- .flatMap((decision) => decision.candidates),
- )) {
- for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) {
- const previous = statuses.get(date);
- if (!previous || priority.get(candidate.status)! > priority.get(previous)!)
- statuses.set(date, candidate.status);
- }
- }
+ const statuses = historicalStatuses(trace, decisionId);
+ const nonWorkingDates = decisionId ? historicalNonWorkingDates(trace) : undefined;
return {
...analysis,
results: analysis.results.map((result) => ({
...result,
historicalStatus:
- statuses.get(result.date) ?? (decisionId ? 'not-examined' : undefined),
+ statuses.get(result.date) ??
+ (decisionId
+ ? nonWorkingDates?.has(result.date)
+ ? 'ineligible'
+ : 'not-examined'
+ : undefined),
})),
};
}
@@ -783,6 +848,8 @@ export interface PlanningCalculationContext {
}
export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
+ latestWorkerCancel?.();
+ latestWorkerCancel = undefined;
const transport = snapshotRequest(request);
const snapshot = publicSnapshot(transport);
const id = `calculation:${++calculationSequence}`;
@@ -799,6 +866,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
if (typeof window !== 'undefined') {
if (typeof Worker !== 'undefined') {
run = planInWorker(transport, deadline);
+ latestWorkerCancel = run.cancel;
+ void run.artifacts.then(() => {
+ if (latestWorkerCancel === run.cancel) latestWorkerCancel = undefined;
+ });
} else {
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
const artifacts = new Promise(