fix(planning): complete explanation and trace behavior (#46)

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent 57a39c81ce
commit c9dbe82b79
10 files changed
+925 -140

No files matched your search

@@ -63,6 +63,11 @@
<small class="status-marker" aria-hidden="true">{{ <small class="status-marker" aria-hidden="true">{{
getStatusMarker(day) getStatusMarker(day)
}}</small> }}</small>
@if (status.hypotheticalLabel) {
<small class="hypothetical-marker" aria-hidden="true">{{
getHypotheticalMarker(status)
}}</small>
}
} }
</button> </button>
} }
@@ -75,6 +75,19 @@
border-radius: 999px; 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 { .current {
outline: 3px double var(--today-border-color, #1976d2); outline: 3px double var(--today-border-color, #1976d2);
outline-offset: 1px; outline-offset: 1px;
@@ -9,7 +9,8 @@ export interface CalendarDayStatus {
kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined'; kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined';
label: string; label: string;
reason?: string; reason?: string;
historicalLabel?: string; hypotheticalLabel?: string;
hypotheticalReason?: string;
change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged'; change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged';
changeLabel?: string; 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 { getDayAriaLabel(day: Date): string {
const parts = [ const parts = [
day.toLocaleDateString(this.translationService.locale(), { day.toLocaleDateString(this.translationService.locale(), {
@@ -168,11 +175,15 @@ export class CalendarMonth implements OnInit {
if (status) { if (status) {
tooltips.push(status.label); tooltips.push(status.label);
if (status.changeLabel) tooltips.push(status.changeLabel); 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.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; return tooltips.length > 0 ? tooltips.join(' + ') : null;
} }
@@ -112,8 +112,12 @@ const explanation: PlanningExplanation = {
})), })),
}; };
function selectedDatesForPlan(value: PlanOutcome): SelectedDates { function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates {
const selected = new 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) { for (const placement of value.placements) {
const date = new Date(`${placement.date}T00:00:00`); const date = new Date(`${placement.date}T00:00:00`);
selected.push(new SelectedDate(DayType.CP, new DateRange(date, date))); selected.push(new SelectedDate(DayType.CP, new DateRange(date, date)));
@@ -145,6 +149,18 @@ function calculationFor(
date: '2024-05-04', date: '2024-05-04',
status: 'ineligible', status: 'ineligible',
reason: { code: 'non-working', message: 'Weekend' }, 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); 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); 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({ await TestBed.configureTestingModule({
imports: [CalendarYear], imports: [CalendarYear],
providers: [ providers: [
@@ -292,17 +332,37 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges(); fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement; const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
expect(details.open).toBe(false); expect(details.open).toBe(true);
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(3);
expect(fixture.nativeElement.querySelector('button.current')).toBeNull(); expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
expect(fixture.nativeElement.querySelectorAll('.legend-symbol')).toHaveLength(0); '2024-05-03',
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); );
details.open = true;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]); expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]);
expect(fixture.nativeElement.textContent).toContain('Paid leave'); 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( const next = fixture.nativeElement.querySelector(
'button[data-step="next"]', 'button[data-step="next"]',
@@ -404,6 +464,9 @@ describe('CalendarYear planning outcome boundary', () => {
completedStages: ['at-risk'], completedStages: ['at-risk'],
}; };
setup = calendarFor(plan, explanation, trace); setup = calendarFor(plan, explanation, trace);
setup.calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
);
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [CalendarYear], imports: [CalendarYear],
providers: [ providers: [
@@ -433,6 +496,33 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1); expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1);
expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged'); 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-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]; const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1];
afterButton.click(); afterButton.click();
fixture.detectChanges(); fixture.detectChanges();
@@ -443,7 +533,65 @@ describe('CalendarYear planning outcome boundary', () => {
compareButton.click(); compareButton.click();
await new Promise<void>((resolve) => setTimeout(resolve, 0)); await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges(); 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( const alternativeDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-04"]', 'button[data-date="2024-05-04"]',
) as HTMLButtonElement; ) as HTMLButtonElement;
@@ -451,6 +599,28 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges(); fixture.detectChanges();
expect(alternativeDate.classList).toContain('current'); expect(alternativeDate.classList).toContain('current');
expect(alternativeDate.getAttribute('aria-current')).toBe('date'); 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 () => { it('turns planner rejection into an incomplete error and clears loading', async () => {
@@ -137,7 +137,7 @@
} }
@if (selectedExplanation(); as selected) { @if (selectedExplanation(); as selected) {
<details class="plan-details" (toggle)="togglePlanDetails($event)"> <details class="plan-details" open (toggle)="togglePlanDetails($event)">
<summary>{{ tr().planning.whyPlacement }}</summary> <summary>{{ tr().planning.whyPlacement }}</summary>
<section class="placement-explanation" aria-labelledby="placement-explanation-title"> <section class="placement-explanation" aria-labelledby="placement-explanation-title">
<header class="explanation-heading"> <header class="explanation-heading">
@@ -256,9 +256,7 @@
{{ selected.remainingQuantity }} {{ selected.remainingQuantity }}
@if (selected.remainingBalance) { @if (selected.remainingBalance) {
<br /><small> <br /><small>
{{ {{ formatIdentifier(selected.remainingBalance.classification) }}
formatIdentifier(selected.remainingBalance.classification)
}}
· ·
{{ selected.remainingBalance.reason.message }} {{ selected.remainingBalance.reason.message }}
</small> </small>
@@ -396,12 +394,13 @@
: tr().planning.ineligible : tr().planning.ineligible
}} }}
</p> </p>
@if (result.historicalStatus) { <p>
<p> <strong>{{ tr().planning.historical }}:</strong>
<strong>{{ tr().planning.historical }}:</strong> {{
{{ traceStatusLabel(result.historicalStatus) }} dateStatuses()[result.date]?.label ??
</p> traceStatusLabel(result.historicalStatus ?? 'not-examined')
} }}
</p>
@if (result.reason) { @if (result.reason) {
<p> <p>
<strong>{{ tr().planning.reason }}:</strong> <strong>{{ tr().planning.reason }}:</strong>
@@ -498,15 +497,63 @@
<span class="legend-symbol" aria-hidden="true">◎</span <span class="legend-symbol" aria-hidden="true">◎</span
>{{ tr().planning.currentPlacement }} >{{ tr().planning.currentPlacement }}
</li> </li>
}
@if (counterfactual()) {
<li> <li>
<span class="legend-symbol" aria-hidden="true">✓</span <span class="legend-symbol hypothetical-symbol" aria-hidden="true">~✓</span
>{{ tr().planning.eligible }} >{{ tr().planning.eligible }}
</li> </li>
<li> <li>
<span class="legend-symbol" aria-hidden="true">×</span <span class="legend-symbol hypothetical-symbol" aria-hidden="true">~×</span
>{{ tr().planning.ineligible }} >{{ tr().planning.ineligible }}
</li> </li>
} }
@if (traceReplay()) {
<li>
<span class="legend-symbol" aria-hidden="true">✓</span
>{{ traceStatusLabel('chosen') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceStatusLabel('evaluated-rejected') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">⌁</span
>{{ traceStatusLabel('pruned') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">?</span
>{{ traceStatusLabel('not-examined') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">×</span
>{{ traceStatusLabel('ineligible') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">+</span
>{{ traceChangeLabel('added') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">←</span
>{{ traceChangeLabel('moved-from') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">→</span
>{{ traceChangeLabel('moved-to') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">−</span
>{{ traceChangeLabel('removed') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">≈</span
>{{ traceChangeLabel('shared') }}
</li>
<li>
<span class="legend-symbol" aria-hidden="true">=</span
>{{ traceChangeLabel('unchanged') }}
</li>
}
</ul> </ul>
</div> </div>
@@ -496,6 +496,10 @@ code {
font-weight: 800; font-weight: 800;
} }
.hypothetical-symbol {
width: 1.5rem;
}
.month-grid { .month-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
@@ -6,6 +6,7 @@ import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-mon
import { CalendarSettings } from '../calendar-settings/calendar-settings'; import { CalendarSettings } from '../calendar-settings/calendar-settings';
import { import {
CalendarService, CalendarService,
DayType,
SelectedDates, SelectedDates,
SelectableDayType, SelectableDayType,
VacDay, VacDay,
@@ -61,7 +62,7 @@ export class CalendarYear {
protected readonly traceLoading = signal(false); protected readonly traceLoading = signal(false);
protected readonly traceResolved = signal(false); protected readonly traceResolved = signal(false);
protected readonly selectedPlacementIndex = signal(0); protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(false); protected readonly planDetailsOpen = signal(true);
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null); protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null); protected readonly selectedCounterfactualDate = signal<string | null>(null);
protected readonly counterfactualLoading = signal(false); protected readonly counterfactualLoading = signal(false);
@@ -71,6 +72,7 @@ export class CalendarYear {
protected readonly traceCandidatePage = signal(0); protected readonly traceCandidatePage = signal(0);
protected readonly traceMoment = signal<TraceMoment>('before'); protected readonly traceMoment = signal<TraceMoment>('before');
protected readonly traceReplay = signal(false); protected readonly traceReplay = signal(false);
protected readonly selectedTraceDate = signal<string | null>(null);
protected readonly liveMessage = signal(''); protected readonly liveMessage = signal('');
protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null); protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null);
protected readonly selectedDates = signal(new SelectedDates()); protected readonly selectedDates = signal(new SelectedDates());
@@ -146,14 +148,23 @@ export class CalendarYear {
protected readonly currentPlacementDate = computed(() => { protected readonly currentPlacementDate = computed(() => {
if (this.counterfactual()) if (this.counterfactual())
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date; 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; return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
}); });
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => { protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
const statuses: Record<string, CalendarDayStatus> = {}; const statuses: Record<string, CalendarDayStatus> = {};
const trace = this.planningTrace(); const trace = this.planningTrace();
if (!this.counterfactual() && this.traceReplay() && trace) { const closedDayDates = new Set<string>();
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([ const priority = new Map([
['ineligible', 0], ['ineligible', 0],
['not-examined', 1], ['not-examined', 1],
@@ -173,16 +184,13 @@ export class CalendarYear {
label: this.traceStatusLabel('not-examined'), label: this.traceStatusLabel('not-examined'),
}; };
} }
for (const candidate of trace.phases.flatMap((phase) => for (const date of Object.keys(statuses)) {
phase.decisions.flatMap((decision) => decision.candidates), const reason = this.nonWorkingDateReason(date, closedDayDates);
)) { if (reason)
if (candidate.status !== 'ineligible' || candidate.reason?.code !== 'non-working')
continue;
for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : []))
statuses[date] = { statuses[date] = {
kind: candidate.status, kind: 'ineligible',
label: this.traceStatusLabel(candidate.status), label: this.traceStatusLabel('ineligible'),
reason: candidate.reason.message, reason,
}; };
} }
const explicitPriorities = new Map<string, number>(); const explicitPriorities = new Map<string, number>();
@@ -234,20 +242,39 @@ export class CalendarYear {
if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged'); 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; 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] = { statuses[result.date] = {
kind: result.status, kind: historicalStatus,
label: label: this.traceStatusLabel(historicalStatus),
reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined,
hypotheticalLabel:
result.status === 'eligible' result.status === 'eligible'
? this.tr().planning.eligible ? this.tr().planning.eligible
: this.tr().planning.ineligible, : this.tr().planning.ineligible,
reason: result.reason?.message, hypotheticalReason: result.reason?.message,
historicalLabel: result.historicalStatus
? this.traceStatusLabel(result.historicalStatus)
: undefined,
}; };
}
return statuses; return statuses;
}); });
@@ -357,13 +384,14 @@ export class CalendarYear {
this.traceLoading.set(true); this.traceLoading.set(true);
this.traceResolved.set(false); this.traceResolved.set(false);
this.selectedPlacementIndex.set(0); this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(false); this.planDetailsOpen.set(true);
this.clearCounterfactual(); this.clearCounterfactual();
this.tracePhaseIndex.set(0); this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0); this.traceDecisionIndex.set(0);
this.traceCandidatePage.set(0); this.traceCandidatePage.set(0);
this.traceMoment.set('before'); this.traceMoment.set('before');
this.traceReplay.set(false); this.traceReplay.set(false);
this.selectedTraceDate.set(null);
this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); this.selectedDates.set(this.calendarService.getSelectedDatesForYear());
this.userInput.remainingVacationDaysSignal.set([]); this.userInput.remainingVacationDaysSignal.set([]);
} }
@@ -372,6 +400,8 @@ export class CalendarYear {
void calculation.explanation.then((explanation) => { void calculation.explanation.then((explanation) => {
if (version !== this.calculationVersion) return; if (version !== this.calculationVersion) return;
this.planningExplanation.set(explanation ?? null); this.planningExplanation.set(explanation ?? null);
if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen())
this.selectPlacement(0);
}); });
void calculation.explanationError.then((reason) => { void calculation.explanationError.then((reason) => {
if (version === this.calculationVersion) this.explanationError.set(reason ?? null); if (version === this.calculationVersion) this.explanationError.set(reason ?? null);
@@ -435,6 +465,7 @@ export class CalendarYear {
protected togglePlanDetails(event: Event): void { protected togglePlanDetails(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open; const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.planDetailsOpen()) return;
this.planDetailsOpen.set(open); this.planDetailsOpen.set(open);
if (open) this.selectPlacement(this.selectedPlacementIndex()); if (open) this.selectPlacement(this.selectedPlacementIndex());
else this.clearCounterfactual(); else this.clearCounterfactual();
@@ -464,12 +495,36 @@ export class CalendarYear {
return; 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)) { if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.selectedCounterfactualDate.set(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 { protected selectTracePhase(index: number): void {
const last = (this.planningTrace()?.phases.length ?? 1) - 1; const last = (this.planningTrace()?.phases.length ?? 1) - 1;
index = Math.max(0, Math.min(index, last)); index = Math.max(0, Math.min(index, last));
@@ -478,6 +533,7 @@ export class CalendarYear {
this.traceCandidatePage.set(0); this.traceCandidatePage.set(0);
this.traceMoment.set('before'); this.traceMoment.set('before');
this.traceReplay.set(false); this.traceReplay.set(false);
this.selectedTraceDate.set(null);
this.clearCounterfactual(); this.clearCounterfactual();
this.liveMessage.set( this.liveMessage.set(
`${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`, `${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.traceCandidatePage.set(0);
this.traceMoment.set('before'); this.traceMoment.set('before');
this.traceReplay.set(true); this.traceReplay.set(true);
this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null);
this.clearCounterfactual(); this.clearCounterfactual();
this.liveMessage.set( this.liveMessage.set(
`${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, `${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.clearCounterfactual();
this.traceMoment.set(moment); this.traceMoment.set(moment);
this.traceReplay.set(true); 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 { protected showFinalPlan(): void {
this.traceReplay.set(false); this.traceReplay.set(false);
this.clearCounterfactual(); this.clearCounterfactual();
this.liveMessage.set(this.tr().planning.finalPlan);
} }
protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void { protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void {
@@ -547,9 +608,20 @@ export class CalendarYear {
if (!candidate.placement || !decision) return; if (!candidate.placement || !decision) return;
this.traceMoment.set('before'); this.traceMoment.set('before');
this.traceReplay.set(true); this.traceReplay.set(true);
this.selectedTraceDate.set(candidate.placement.date);
void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement }); void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement });
} }
private nonWorkingDateReason(
date: string,
closedDayDates: ReadonlySet<string>,
): 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<void> { private async analyzeSelection(selection: CounterfactualSelection): Promise<void> {
const calculation = this.activeCalculation; const calculation = this.activeCalculation;
if (!calculation) return; if (!calculation) return;
+130 -64
View File
@@ -512,6 +512,10 @@ class TraceRecorder {
private readonly phases: MutableTracePhase[] = []; private readonly phases: MutableTracePhase[] = [];
private phase: MutableTracePhase | undefined; private phase: MutableTracePhase | undefined;
private sequence = 0; private sequence = 0;
private readonly evaluationDecisions = new WeakMap<
MutableTraceCandidate,
MutableTraceDecision
>();
private previousPlacements = new Map<string, LeavePlacement>(); private previousPlacements = new Map<string, LeavePlacement>();
private previousEarned = new Set<string>(); private previousEarned = new Set<string>();
private previousBalances: ReadonlyMap<string, number>; private previousBalances: ReadonlyMap<string, number>;
@@ -567,23 +571,27 @@ class TraceRecorder {
return latest ?? this.decision(action); return latest ?? this.decision(action);
} }
private latestComparison(placement: LeavePlacement): PlanningObjectiveComparison | undefined { private candidateDecision(dates: readonly string[] = []): MutableTraceDecision {
for (const decision of [...(this.phase?.decisions ?? [])].reverse()) { const latest = this.latestDecision();
const candidate = [...decision.candidates] return latest.deltas.length ? this.decision('choose', dates) : latest;
.reverse() }
.find(
(item) => private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void {
item.placement?.date === placement.date && const alreadyRecorded = decision.candidates.some(
item.placement.balanceId === placement.balanceId && (candidate) =>
item.comparison, candidate.status === 'chosen' &&
); candidate.placement?.date === placement.date &&
if (candidate?.comparison) return candidate.comparison; candidate.placement.balanceId === placement.balanceId,
} );
return undefined; if (!alreadyRecorded)
decision.candidates.push({ status: 'chosen', date: placement.date, placement });
} }
recordCandidate(candidate: PlanningTraceCandidate, copy = true): void { recordCandidate(candidate: PlanningTraceCandidate, copy = true): void {
this.latestDecision().candidates.push( const decision = this.candidateDecision(
candidate.dates ?? (candidate.date ? [candidate.date] : []),
);
decision.candidates.push(
copy copy
? { ? {
...candidate, ...candidate,
@@ -606,14 +614,23 @@ class TraceRecorder {
if (candidate) decision.candidates.push(candidate); if (candidate) decision.candidates.push(candidate);
} }
recordDelta(delta: PlanningTraceDelta): void { recordEvaluation(candidate: MutableTraceCandidate): void {
this.latestDecision().deltas.push({ ...delta }); 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( recordPlan(
placements: readonly LeavePlacement[], placements: readonly LeavePlacement[],
balances: PlanningTraceState['balances'], balances: PlanningTraceState['balances'],
earnedActivations: PlanningTraceState['earnedActivations'], earnedActivations: PlanningTraceState['earnedActivations'],
acceptedCandidate?: MutableTraceCandidate,
): void { ): void {
const current = new Map(placements.map((placement) => [placement.date, placement])); const current = new Map(placements.map((placement) => [placement.date, placement]));
const removed = [...this.previousPlacements.values()].filter( const removed = [...this.previousPlacements.values()].filter(
@@ -640,7 +657,7 @@ class TraceRecorder {
!earnedChanged !earnedChanged
) )
return; return;
this.decision( const action =
removed.length && added.length removed.length && added.length
? 'move' ? 'move'
: added.length : added.length
@@ -649,13 +666,17 @@ class TraceRecorder {
? 'remove' ? 'remove'
: updated.length : updated.length
? 'share' ? 'share'
: 'activate', : 'activate';
[ const dates = [
...removed.map((placement) => placement.date), ...removed.map((placement) => placement.date),
...added.map((placement) => placement.date), ...added.map((placement) => placement.date),
...updated.map(([date]) => 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<string>(); const movedAdded = new Set<string>();
const movedRemoved = new Set<string>(); const movedRemoved = new Set<string>();
for (const previous of removed) { for (const previous of removed) {
@@ -668,7 +689,7 @@ class TraceRecorder {
if (!placement) continue; if (!placement) continue;
movedAdded.add(placement.date); movedAdded.add(placement.date);
movedRemoved.add(previous.date); movedRemoved.add(previous.date);
this.recordDelta({ decision.deltas.push({
kind: 'placement-moved', kind: 'placement-moved',
fromDate: previous.date, fromDate: previous.date,
toDate: placement.date, toDate: placement.date,
@@ -676,16 +697,11 @@ class TraceRecorder {
previousPlacement: previous, previousPlacement: previous,
placement, placement,
}); });
this.recordCandidate({ this.recordChosenCandidate(placement, decision);
status: 'chosen',
date: placement.date,
placement,
comparison: this.latestComparison(placement),
});
} }
for (const placement of removed) { for (const placement of removed) {
if (movedRemoved.has(placement.date)) continue; if (movedRemoved.has(placement.date)) continue;
this.recordDelta({ decision.deltas.push({
kind: 'placement-removed', kind: 'placement-removed',
date: placement.date, date: placement.date,
balanceId: placement.balanceId, balanceId: placement.balanceId,
@@ -694,46 +710,36 @@ class TraceRecorder {
} }
for (const placement of added) { for (const placement of added) {
if (movedAdded.has(placement.date)) continue; if (movedAdded.has(placement.date)) continue;
this.recordDelta({ decision.deltas.push({
kind: 'placement-added', kind: 'placement-added',
date: placement.date, date: placement.date,
balanceId: placement.balanceId, balanceId: placement.balanceId,
placement, placement,
}); });
this.recordCandidate({ this.recordChosenCandidate(placement, decision);
status: 'chosen',
date: placement.date,
placement,
comparison: this.latestComparison(placement),
});
} }
for (const [date, placement] of updated) { for (const [date, placement] of updated) {
const previous = this.previousPlacements.get(date)!; const previous = this.previousPlacements.get(date)!;
this.recordDelta({ decision.deltas.push({
kind: 'placement-updated', kind: 'placement-updated',
date, date,
balanceId: placement.balanceId, balanceId: placement.balanceId,
previousPlacement: previous, previousPlacement: previous,
placement, placement,
}); });
this.recordCandidate({ this.recordChosenCandidate(placement, decision);
status: 'chosen',
date,
placement,
comparison: this.latestComparison(placement),
});
} }
for (const balance of balances) { for (const balance of balances) {
const previousRemaining = this.previousBalances.get(balance.id); const previousRemaining = this.previousBalances.get(balance.id);
if (previousRemaining !== undefined && previousRemaining !== balance.remaining) if (previousRemaining !== undefined && previousRemaining !== balance.remaining)
this.recordDelta({ decision.deltas.push({
kind: 'balance-changed', kind: 'balance-changed',
balanceId: balance.id, balanceId: balance.id,
remainingBefore: previousRemaining, remainingBefore: previousRemaining,
remainingAfter: balance.remaining, remainingAfter: balance.remaining,
}); });
if (previousRemaining === undefined) if (previousRemaining === undefined)
this.recordDelta({ decision.deltas.push({
kind: 'balance-changed', kind: 'balance-changed',
balanceId: balance.id, balanceId: balance.id,
remainingAfter: balance.remaining, remainingAfter: balance.remaining,
@@ -743,7 +749,7 @@ class TraceRecorder {
const activation = earnedActivations.find( const activation = earnedActivations.find(
(candidate) => candidate.id === balance.id, (candidate) => candidate.id === balance.id,
); );
this.recordDelta({ decision.deltas.push({
kind: 'earned-activated', kind: 'earned-activated',
balanceId: balance.id, balanceId: balance.id,
activation: activation 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; if (!context.trace) return;
observeTrace(context, (trace) => { observeTrace(context, (trace) => {
const snapshot = planningTraceState(state, context.horizon); 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, afterMetrics: PlanningMetrics,
dates: readonly string[], dates: readonly string[],
sourceDate?: string, sourceDate?: string,
): void { ): MutableTraceCandidate | undefined {
let evaluation: MutableTraceCandidate | undefined;
observeTrace(context, (trace) => { observeTrace(context, (trace) => {
const date = dates[dates.length - 1]; const date = dates[dates.length - 1];
const placement = date ? afterState.placements.get(date) : undefined; const placement = date ? afterState.placements.get(date) : undefined;
trace.recordCandidate({ evaluation = {
status: 'evaluated-rejected', status: 'evaluated-rejected',
sourceDate, sourceDate,
date, date,
@@ -2595,8 +2612,10 @@ function recordTraceEvaluation(
placement: placement placement: placement
? { ...placement, ruleIds: [...placement.ruleIds].sort() } ? { ...placement, ruleIds: [...placement.ruleIds].sort() }
: undefined, : undefined,
}); };
trace.recordEvaluation(evaluation);
}); });
return context.trace ? evaluation : undefined;
} }
function optimizeMovablePlacements( function optimizeMovablePlacements(
@@ -2678,6 +2697,7 @@ function optimizeMovablePlacements(
let best = result; let best = result;
let bestMetrics = baseline; let bestMetrics = baseline;
let bestDate = original.date; let bestDate = original.date;
let bestEvaluation: MutableTraceCandidate | undefined;
const placementCandidates = const placementCandidates =
current.ruleIds.size > 0 current.ruleIds.size > 0
? context.workingDays.filter((date) => movableWindowDates.has(date)) ? context.workingDays.filter((date) => movableWindowDates.has(date))
@@ -2709,7 +2729,7 @@ function optimizeMovablePlacements(
if (!feasibleCandidate) continue; if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(date)!.supporting = current.supporting; feasibleCandidate.placements.get(date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences); const metrics = qualityMetrics(feasibleCandidate, context, preferences);
recordTraceEvaluation( const evaluation = recordTraceEvaluation(
context, context,
result, result,
feasibleCandidate, feasibleCandidate,
@@ -2753,13 +2773,14 @@ function optimizeMovablePlacements(
best = cloneState(feasibleCandidate); best = cloneState(feasibleCandidate);
bestMetrics = metrics; bestMetrics = metrics;
bestDate = date; bestDate = date;
bestEvaluation = evaluation;
} }
} finally { } finally {
without.placements.delete(date); without.placements.delete(date);
without.balances.find((item) => item.id === current.balanceId)!.remaining++; without.balances.find((item) => item.id === current.balanceId)!.remaining++;
} }
} }
if (best !== result) recordTraceState(context, best); if (best !== result) recordTraceState(context, best, bestEvaluation);
result = best; result = best;
} }
return result; return result;
@@ -2859,6 +2880,7 @@ function optimizeHolidayWeeks(
); );
let best: State | null = null; let best: State | null = null;
let bestMetrics = baseline; let bestMetrics = baseline;
let bestEvaluation: MutableTraceCandidate | undefined;
for (const candidate of candidates) { for (const candidate of candidates) {
context.check(); context.check();
const protectedDates = new Set( const protectedDates = new Set(
@@ -2869,7 +2891,7 @@ function optimizeHolidayWeeks(
const complete = completeHolidayWeek(result, candidate, context, protectedDates); const complete = completeHolidayWeek(result, candidate, context, protectedDates);
if (!complete) continue; if (!complete) continue;
const metrics = qualityMetrics(complete, context, preferences); const metrics = qualityMetrics(complete, context, preferences);
recordTraceEvaluation( const evaluation = recordTraceEvaluation(
context, context,
result, result,
complete, complete,
@@ -2895,10 +2917,11 @@ function optimizeHolidayWeeks(
) { ) {
best = complete; best = complete;
bestMetrics = metrics; bestMetrics = metrics;
bestEvaluation = evaluation;
} }
} }
if (!best) return result; if (!best) return result;
recordTraceState(context, best); recordTraceState(context, best, bestEvaluation);
result = best; result = best;
} }
} }
@@ -2938,6 +2961,7 @@ function optimizeExpiryPlacements(
if (released) released.remaining++; if (released) released.remaining++;
let best = result; let best = result;
let bestExpiry = expiryTieKey(result); let bestExpiry = expiryTieKey(result);
let bestEvaluation: MutableTraceCandidate | undefined;
for (const replacement of without.balances for (const replacement of without.balances
.filter( .filter(
(balance) => (balance) =>
@@ -2961,7 +2985,7 @@ function optimizeExpiryPlacements(
if (!feasibleCandidate) continue; if (!feasibleCandidate) continue;
feasibleCandidate.placements.get(current.date)!.supporting = current.supporting; feasibleCandidate.placements.get(current.date)!.supporting = current.supporting;
const metrics = qualityMetrics(feasibleCandidate, context, preferences); const metrics = qualityMetrics(feasibleCandidate, context, preferences);
recordTraceEvaluation( const evaluation = recordTraceEvaluation(
context, context,
result, result,
feasibleCandidate, feasibleCandidate,
@@ -2975,9 +2999,10 @@ function optimizeExpiryPlacements(
if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) { if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) {
best = feasibleCandidate; best = feasibleCandidate;
bestExpiry = expiry; bestExpiry = expiry;
bestEvaluation = evaluation;
} }
} }
if (best !== result) recordTraceState(context, best); if (best !== result) recordTraceState(context, best, bestEvaluation);
result = best; result = best;
} }
return result; return result;
@@ -3536,6 +3561,7 @@ function supportingPlacements(
const before = qualityMetrics(result, context, preferences); const before = qualityMetrics(result, context, preferences);
let best: State | null = null; let best: State | null = null;
let bestMetrics: PlanningMetrics | null = null; let bestMetrics: PlanningMetrics | null = null;
let bestEvaluation: MutableTraceCandidate | undefined;
for (const balance of result.balances for (const balance of result.balances
.filter( .filter(
(item) => (item) =>
@@ -3556,7 +3582,7 @@ function supportingPlacements(
const feasibleCandidate = finalizeEarnedState(candidate, context); const feasibleCandidate = finalizeEarnedState(candidate, context);
if (!feasibleCandidate) continue; if (!feasibleCandidate) continue;
const metrics = qualityMetrics(feasibleCandidate, context, preferences); const metrics = qualityMetrics(feasibleCandidate, context, preferences);
recordTraceEvaluation( const evaluation = recordTraceEvaluation(
context, context,
result, result,
feasibleCandidate, feasibleCandidate,
@@ -3586,11 +3612,12 @@ function supportingPlacements(
if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) { if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) {
best = feasibleCandidate; best = feasibleCandidate;
bestMetrics = metrics; bestMetrics = metrics;
bestEvaluation = evaluation;
} }
} }
} }
if (!best) return result; if (!best) return result;
recordTraceState(context, best); recordTraceState(context, best, bestEvaluation);
result = best; result = best;
} }
} }
@@ -3613,7 +3640,7 @@ function pruneRedundantSupportingPlacements(
const balance = candidate.balances.find((item) => item.id === placement.balanceId); const balance = candidate.balances.find((item) => item.id === placement.balanceId);
if (balance) balance.remaining++; if (balance) balance.remaining++;
const metrics = qualityMetrics(candidate, context, preferences); const metrics = qualityMetrics(candidate, context, preferences);
recordTraceEvaluation( const evaluation = recordTraceEvaluation(
context, context,
result, result,
candidate, candidate,
@@ -3630,7 +3657,7 @@ function pruneRedundantSupportingPlacements(
metrics.longestWorkSequence <= baseline.longestWorkSequence && metrics.longestWorkSequence <= baseline.longestWorkSequence &&
metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit
) { ) {
recordTraceState(context, candidate); recordTraceState(context, candidate, evaluation);
result = candidate; result = candidate;
removed = true; removed = true;
break; break;
@@ -4128,6 +4155,34 @@ function counterfactualRuleValid(
return true; 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[] { function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] {
return context.rules return context.rules
.filter((rule) => rule.enabled && rule.force === 'preference') .filter((rule) => rule.enabled && rule.force === 'preference')
@@ -4327,6 +4382,17 @@ export function analyzeCounterfactualInternal(
continue; continue;
} }
candidate.placements.get(date)!.supporting = selected.supporting; 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 afterPreferences = preferenceResultsForState(candidate, context);
const afterMetrics = qualityMetrics(candidate, context, afterPreferences); const afterMetrics = qualityMetrics(candidate, context, afterPreferences);
const after = objectiveVector(afterMetrics, afterPreferences, candidate); const after = objectiveVector(afterMetrics, afterPreferences, candidate);
+330 -4
View File
@@ -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 () => { it('preserves supporting provenance in counterfactual placements', async () => {
const browserWindow = globalThis.window; const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined); 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 () => { it('discards counterfactual work from an older calculation', async () => {
const browserWindow = globalThis.window; const browserWindow = globalThis.window;
vi.stubGlobal('window', undefined); vi.stubGlobal('window', undefined);
@@ -1572,6 +1780,89 @@ describe('planning calculation context', () => {
expect( expect(
analysis.results.find((result) => result.date === laterPlacement.date)?.status, analysis.results.find((result) => result.date === laterPlacement.date)?.status,
).toBe('eligible'); ).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 { } finally {
vi.stubGlobal('window', browserWindow); vi.stubGlobal('window', browserWindow);
} }
@@ -1638,10 +1929,8 @@ describe('planning calculation context', () => {
}); });
expect((await calculation.outcome).kind).toBe('Plan'); expect((await calculation.outcome).kind).toBe('Plan');
const trace = await calculation.trace; const trace = await calculation.trace;
const candidates = const decisions = trace?.phases.flatMap((phase) => phase.decisions) ?? [];
trace?.phases.flatMap((phase) => const candidates = decisions.flatMap((decision) => decision.candidates);
phase.decisions.flatMap((decision) => decision.candidates),
) ?? [];
expect( expect(
candidates.some( candidates.some(
(candidate) => (candidate) =>
@@ -1655,6 +1944,43 @@ describe('planning calculation context', () => {
expect( expect(
candidates.some((candidate) => candidate.comparison && candidate.sourceDate), candidates.some((candidate) => candidate.comparison && candidate.sourceDate),
).toBe(true); ).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 { } finally {
vi.stubGlobal('window', browserWindow); vi.stubGlobal('window', browserWindow);
} }
+93 -22
View File
@@ -1,7 +1,6 @@
import type { import type {
CounterfactualAnalysis, CounterfactualAnalysis,
CounterfactualBaselineOptions, CounterfactualBaselineOptions,
CounterfactualDateResult,
CounterfactualSelection, CounterfactualSelection,
InvalidInputOutcome, InvalidInputOutcome,
LeaveBalanceInput, LeaveBalanceInput,
@@ -18,6 +17,7 @@ import type {
PlanningReason, PlanningReason,
PlanningTraceState, PlanningTraceState,
StaleCounterfactualAnalysis, StaleCounterfactualAnalysis,
TraceCandidateStatus,
TraceCounterfactualSelection, TraceCounterfactualSelection,
} from './leave-planner.core'; } from './leave-planner.core';
@@ -281,6 +281,7 @@ type PlannerWorkerResponse =
interface PlanningRun { interface PlanningRun {
outcome: Promise<PlanningOutcome>; outcome: Promise<PlanningOutcome>;
artifacts: Promise<PlanningArtifacts>; artifacts: Promise<PlanningArtifacts>;
cancel?: () => void;
} }
function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState { function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState {
@@ -337,6 +338,8 @@ function interruptedOutcome(
}; };
} }
let latestWorkerCancel: (() => void) | undefined;
function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun { function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun {
let resolveOutcome!: (outcome: PlanningOutcome) => void; let resolveOutcome!: (outcome: PlanningOutcome) => void;
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
@@ -346,6 +349,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
const artifacts = new Promise<PlanningArtifacts>((resolve) => { const artifacts = new Promise<PlanningArtifacts>((resolve) => {
resolveArtifacts = resolve; resolveArtifacts = resolve;
}); });
let cancel: (() => void) | undefined;
try { try {
const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), {
type: 'module', type: 'module',
@@ -375,8 +379,25 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
artifactsSettled = true; artifactsSettled = true;
resolveArtifacts(result); resolveArtifacts(result);
terminate(); 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 fail = (code: string, message: string) => {
const reason = { code, message }; const reason = { code, message };
if (!outcomeSettled) if (!outcomeSettled)
@@ -453,7 +474,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu
explanationError: result.reasons[0], explanationError: result.reasons[0],
}); });
} }
return { outcome, artifacts }; return { outcome, artifacts, cancel };
} }
let calculationSequence = 0; let calculationSequence = 0;
@@ -480,6 +501,62 @@ function traceSelection(
return typeof selection === 'object' && 'decisionId' in selection; return typeof selection === 'object' && 'decisionId' in selection;
} }
const traceStatusPriority = new Map<TraceCandidateStatus, number>([
['ineligible', 0],
['not-examined', 1],
['pruned', 2],
['evaluated-rejected', 3],
['chosen', 4],
]);
const historicalStatusCache = new WeakMap<PlanningTrace, Map<string, TraceCandidateStatus>>();
const historicalNonWorkingCache = new WeakMap<PlanningTrace, Set<string>>();
function historicalStatuses(
trace: PlanningTrace,
decisionId?: string,
): Map<string, TraceCandidateStatus> {
if (!decisionId) {
const cached = historicalStatusCache.get(trace);
if (cached) return cached;
}
const statuses = new Map<string, TraceCandidateStatus>();
const nonWorkingDates = new Set<string>();
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<string> {
const cached = historicalNonWorkingCache.get(trace);
if (cached) return cached;
historicalStatuses(trace);
return historicalNonWorkingCache.get(trace) ?? new Set();
}
function stateBeforeDecision( function stateBeforeDecision(
trace: PlanningTrace, trace: PlanningTrace,
decisionId: string, decisionId: string,
@@ -574,31 +651,19 @@ function withHistoricalStatuses(
decisionId?: string, decisionId?: string,
): CounterfactualAnalysis { ): CounterfactualAnalysis {
if (!trace) return analysis; if (!trace) return analysis;
const priority = new Map([ const statuses = historicalStatuses(trace, decisionId);
['ineligible', 0], const nonWorkingDates = decisionId ? historicalNonWorkingDates(trace) : undefined;
['not-examined', 1],
['pruned', 2],
['evaluated-rejected', 3],
['chosen', 4],
]);
const statuses = new Map<string, CounterfactualDateResult['historicalStatus']>();
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);
}
}
return { return {
...analysis, ...analysis,
results: analysis.results.map((result) => ({ results: analysis.results.map((result) => ({
...result, ...result,
historicalStatus: 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 { export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext {
latestWorkerCancel?.();
latestWorkerCancel = undefined;
const transport = snapshotRequest(request); const transport = snapshotRequest(request);
const snapshot = publicSnapshot(transport); const snapshot = publicSnapshot(transport);
const id = `calculation:${++calculationSequence}`; const id = `calculation:${++calculationSequence}`;
@@ -799,6 +866,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
if (typeof Worker !== 'undefined') { if (typeof Worker !== 'undefined') {
run = planInWorker(transport, deadline); run = planInWorker(transport, deadline);
latestWorkerCancel = run.cancel;
void run.artifacts.then(() => {
if (latestWorkerCancel === run.cancel) latestWorkerCancel = undefined;
});
} else { } else {
let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; let resolveArtifacts!: (artifacts: PlanningArtifacts) => void;
const artifacts = new Promise<PlanningArtifacts>( const artifacts = new Promise<PlanningArtifacts>(