fix(planning): complete explanation and trace behavior (#46)
This commit is contained in:
1 parent
57a39c81ce
commit
c9dbe82b79
10 files changed
+920
-135
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 ??
|
||||||
|
traceStatusLabel(result.historicalStatus ?? 'not-examined')
|
||||||
|
}}
|
||||||
</p>
|
</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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return statuses;
|
for (const result of this.counterfactual()?.results ?? []) {
|
||||||
}
|
const status = statuses[result.date];
|
||||||
for (const result of this.counterfactual()?.results ?? [])
|
const historicalStatus =
|
||||||
|
result.historicalStatus ??
|
||||||
|
(status && status.kind !== 'eligible' ? status.kind : 'not-examined');
|
||||||
statuses[result.date] = {
|
statuses[result.date] = {
|
||||||
kind: result.status,
|
...status,
|
||||||
label:
|
kind: historicalStatus,
|
||||||
|
label: this.traceStatusLabel(historicalStatus),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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: historicalStatus,
|
||||||
|
label: this.traceStatusLabel(historicalStatus),
|
||||||
|
reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined,
|
||||||
|
hypotheticalLabel:
|
||||||
|
result.status === 'eligible'
|
||||||
|
? this.tr().planning.eligible
|
||||||
|
: this.tr().planning.ineligible,
|
||||||
|
hypotheticalReason: result.reason?.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
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;
|
||||||
|
|||||||
@@ -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) =>
|
|
||||||
item.placement?.date === placement.date &&
|
|
||||||
item.placement.balanceId === placement.balanceId &&
|
|
||||||
item.comparison,
|
|
||||||
);
|
|
||||||
if (candidate?.comparison) return candidate.comparison;
|
|
||||||
}
|
}
|
||||||
return undefined;
|
|
||||||
|
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 {
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
Reference in new issue
Block a user