diff --git a/src/app/components/calendar-month/calendar-month.html b/src/app/components/calendar-month/calendar-month.html index d8bd4c8..ea3803f 100644 --- a/src/app/components/calendar-month/calendar-month.html +++ b/src/app/components/calendar-month/calendar-month.html @@ -63,6 +63,11 @@ + @if (status.hypotheticalLabel) { + + } } } diff --git a/src/app/components/calendar-month/calendar-month.scss b/src/app/components/calendar-month/calendar-month.scss index 7848abe..f822774 100644 --- a/src/app/components/calendar-month/calendar-month.scss +++ b/src/app/components/calendar-month/calendar-month.scss @@ -75,6 +75,19 @@ border-radius: 999px; } + .hypothetical-marker { + position: absolute; + inset-block-end: -0.35rem; + inset-inline-start: -0.2rem; + min-width: 1.25rem; + font-size: 0.55rem; + font-weight: 800; + line-height: 1; + background: var(--ui-card-bg, #fff); + border: 1px dashed currentColor; + border-radius: 999px; + } + .current { outline: 3px double var(--today-border-color, #1976d2); outline-offset: 1px; diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts index e247219..79c1d21 100644 --- a/src/app/components/calendar-month/calendar-month.ts +++ b/src/app/components/calendar-month/calendar-month.ts @@ -9,7 +9,8 @@ export interface CalendarDayStatus { kind: 'eligible' | 'ineligible' | 'chosen' | 'evaluated-rejected' | 'pruned' | 'not-examined'; label: string; reason?: string; - historicalLabel?: string; + hypotheticalLabel?: string; + hypotheticalReason?: string; change?: 'added' | 'moved-from' | 'moved-to' | 'removed' | 'shared' | 'unchanged'; changeLabel?: string; } @@ -114,6 +115,12 @@ export class CalendarMonth implements OnInit { } } + getHypotheticalMarker(status: CalendarDayStatus): string { + return status.hypotheticalLabel === this.translationService.tr().planning.eligible + ? '~✓' + : '~×'; + } + getDayAriaLabel(day: Date): string { const parts = [ day.toLocaleDateString(this.translationService.locale(), { @@ -168,11 +175,15 @@ export class CalendarMonth implements OnInit { if (status) { tooltips.push(status.label); if (status.changeLabel) tooltips.push(status.changeLabel); - if (status.historicalLabel) - tooltips.push( - `${this.translationService.tr().planning.historical}: ${status.historicalLabel}`, - ); if (status.reason) tooltips.push(status.reason); + if (status.hypotheticalLabel) + tooltips.push( + `${this.translationService.tr().planning.counterfactual}: ${status.hypotheticalLabel}`, + ); + if (status.hypotheticalReason) + tooltips.push( + `${this.translationService.tr().planning.reason}: ${status.hypotheticalReason}`, + ); } return tooltips.length > 0 ? tooltips.join(' + ') : null; } diff --git a/src/app/components/calendar-year/calendar-year-planning.spec.ts b/src/app/components/calendar-year/calendar-year-planning.spec.ts index 620dde8..0af398c 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -112,8 +112,12 @@ const explanation: PlanningExplanation = { })), }; -function selectedDatesForPlan(value: PlanOutcome): SelectedDates { +function selectedDatesForPlan(value: PlanOutcome, includeHoliday = false): SelectedDates { const selected = new SelectedDates(); + if (includeHoliday) { + const holiday = new Date('2024-05-08T00:00:00'); + selected.push(new SelectedDate(DayType.CLOSED_DAY, new DateRange(holiday, holiday))); + } for (const placement of value.placements) { const date = new Date(`${placement.date}T00:00:00`); selected.push(new SelectedDate(DayType.CP, new DateRange(date, date))); @@ -145,6 +149,18 @@ function calculationFor( date: '2024-05-04', status: 'ineligible', reason: { code: 'non-working', message: 'Weekend' }, + historicalStatus: 'ineligible', + }, + { + date: '2024-05-07', + status: 'ineligible', + reason: { code: 'rule-invalid', message: 'Hypothetical constraint' }, + }, + { + date: '2024-05-08', + status: 'ineligible', + reason: { code: 'non-working', message: 'Public Holiday' }, + historicalStatus: 'ineligible', }, ], }; @@ -276,8 +292,32 @@ describe('CalendarYear planning outcome boundary', () => { expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); }); - it('keeps the simple result visible and reveals placement details on demand', async () => { + it('shows the first plan explanation by default and navigates placements', async () => { const { calendar, calculation } = calendarFor(plan, explanation); + calendar.selectedDatesForPlan.mockImplementation((value) => + selectedDatesForPlan(value, true), + ); + const counterfactualWithoutHistoricalStatus: CounterfactualAnalysis = { + kind: 'Counterfactual', + calculationId: 'test', + placement: plan.placements[0], + hypothetical: true, + baseline: objectiveVector, + results: [ + { date: '2024-05-03', status: 'eligible' }, + { + date: '2024-05-04', + status: 'ineligible', + }, + { + date: '2024-05-08', + status: 'ineligible', + }, + ], + }; + vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue( + counterfactualWithoutHistoricalStatus, + ); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ @@ -292,17 +332,37 @@ describe('CalendarYear planning outcome boundary', () => { fixture.detectChanges(); const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement; - expect(details.open).toBe(false); - expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2); - expect(fixture.nativeElement.querySelector('button.current')).toBeNull(); - expect(fixture.nativeElement.querySelectorAll('.legend-symbol')).toHaveLength(0); - expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled(); - - details.open = true; - details.dispatchEvent(new Event('toggle')); - fixture.detectChanges(); + expect(details.open).toBe(true); + expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(3); + expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe( + '2024-05-03', + ); expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[0]); expect(fixture.nativeElement.textContent).toContain('Paid leave'); + expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({ + kind: 'ineligible', + label: 'Ineligible', + reason: 'Weekend', + }); + expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({ + kind: 'ineligible', + label: 'Ineligible', + reason: 'Public Holiday', + }); + const weekend = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-04"]', + ) as HTMLButtonElement; + expect(weekend.getAttribute('aria-label')).toContain('Ineligible'); + expect(weekend.getAttribute('aria-label')).toContain('Weekend'); + expect(weekend.querySelector('.status-marker')?.textContent).toBe('×'); + expect(weekend.querySelector('.hypothetical-marker')?.textContent).toBe('~×'); + const holidayButton = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-08"]', + ) as HTMLButtonElement; + expect(holidayButton.getAttribute('aria-label')).toContain('Ineligible'); + expect(holidayButton.getAttribute('aria-label')).toContain('Public Holiday'); + expect(holidayButton.querySelector('.status-marker')?.textContent).toBe('×'); + expect(holidayButton.querySelector('.hypothetical-marker')?.textContent).toBe('~×'); const next = fixture.nativeElement.querySelector( 'button[data-step="next"]', @@ -404,6 +464,9 @@ describe('CalendarYear planning outcome boundary', () => { completedStages: ['at-risk'], }; setup = calendarFor(plan, explanation, trace); + setup.calendar.selectedDatesForPlan.mockImplementation((value) => + selectedDatesForPlan(value, true), + ); await TestBed.configureTestingModule({ imports: [CalendarYear], providers: [ @@ -433,6 +496,33 @@ describe('CalendarYear planning outcome boundary', () => { expect(fixture.componentInstance['traceState']()?.placements).toHaveLength(1); expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged'); expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added'); + expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({ + kind: 'ineligible', + reason: 'Weekend', + }); + expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({ + kind: 'ineligible', + reason: 'Public Holiday', + }); + const weekend = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-04"]', + ) as HTMLButtonElement; + expect(weekend.getAttribute('aria-label')).toContain('Ineligible'); + expect(weekend.getAttribute('aria-label')).toContain('Weekend'); + const holiday = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-08"]', + ) as HTMLButtonElement; + expect(holiday.getAttribute('aria-label')).toContain('Public Holiday'); + const legend = fixture.nativeElement.querySelector('.calendar-legend').textContent; + expect(legend).toContain('Evaluated and rejected'); + expect(legend).toContain('Not examined'); + const unevaluatedDate = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-07"]', + ) as HTMLButtonElement; + unevaluatedDate.click(); + fixture.detectChanges(); + expect(unevaluatedDate.classList).toContain('current'); + expect(fixture.componentInstance['liveMessage']()).toContain('Not examined'); const afterButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[1]; afterButton.click(); fixture.detectChanges(); @@ -443,7 +533,65 @@ describe('CalendarYear planning outcome boundary', () => { compareButton.click(); await new Promise((resolve) => setTimeout(resolve, 0)); fixture.detectChanges(); - expect(fixture.componentInstance['dateStatuses']()['2024-05-04'].kind).toBe('ineligible'); + expect(fixture.componentInstance['traceReplay']()).toBe(true); + expect(fixture.componentInstance['selectedTraceDecision']()?.id).toBe('decision:1'); + expect(fixture.componentInstance['dateStatuses']()['2024-05-04']).toMatchObject({ + kind: 'ineligible', + label: 'Ineligible', + reason: 'Weekend', + hypotheticalLabel: 'Ineligible', + hypotheticalReason: 'Weekend', + }); + expect(fixture.componentInstance['dateStatuses']()['2024-05-03']).toMatchObject({ + kind: 'chosen', + label: 'Chosen', + hypotheticalLabel: 'Eligible', + }); + expect(fixture.componentInstance['dateStatuses']()['2024-05-07']).toMatchObject({ + kind: 'not-examined', + label: 'Not examined', + hypotheticalLabel: 'Ineligible', + }); + expect(fixture.componentInstance['dateStatuses']()['2024-05-08']).toMatchObject({ + kind: 'ineligible', + label: 'Ineligible', + reason: 'Public Holiday', + hypotheticalLabel: 'Ineligible', + }); + const holidayAlternative = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-08"]', + ) as HTMLButtonElement; + expect(holidayAlternative.querySelector('.status-marker')?.textContent).toBe('×'); + expect(holidayAlternative.querySelector('.hypothetical-marker')?.textContent).toBe('~×'); + const notExaminedAlternative = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-07"]', + ) as HTMLButtonElement; + expect(notExaminedAlternative.querySelector('.status-marker')?.textContent).toBe('?'); + expect(notExaminedAlternative.querySelector('.hypothetical-marker')?.textContent).toBe( + '~×', + ); + expect(notExaminedAlternative.getAttribute('aria-label')).toContain('Not examined'); + expect(notExaminedAlternative.getAttribute('aria-label')).toContain( + 'Date alternatives: Ineligible', + ); + const counterfactualLegend = fixture.nativeElement.querySelector('.calendar-legend'); + expect(counterfactualLegend.textContent).toContain('~✓Eligible'); + expect(counterfactualLegend.textContent).toContain('~×Ineligible'); + expect(fixture.componentInstance['dateStatuses']()['2024-05-03'].change).toBe('added'); + expect(fixture.componentInstance['dateStatuses']()['2024-05-06'].change).toBe('unchanged'); + const addedDate = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-03"]', + ) as HTMLButtonElement; + expect(addedDate.querySelector('.status-marker')?.textContent).toBe('+'); + expect(addedDate.querySelector('.hypothetical-marker')?.textContent).toBe('~✓'); + expect(addedDate.classList).toContain('change-added'); + expect( + ( + fixture.nativeElement.querySelector( + 'button[data-date="2024-05-06"]', + ) as HTMLButtonElement + ).classList, + ).toContain('change-unchanged'); const alternativeDate = fixture.nativeElement.querySelector( 'button[data-date="2024-05-04"]', ) as HTMLButtonElement; @@ -451,6 +599,28 @@ describe('CalendarYear planning outcome boundary', () => { fixture.detectChanges(); expect(alternativeDate.classList).toContain('current'); expect(alternativeDate.getAttribute('aria-current')).toBe('date'); + expect(alternativeDate.getAttribute('aria-label')).toContain('Ineligible'); + expect(alternativeDate.getAttribute('aria-label')).toContain( + 'Date alternatives: Ineligible', + ); + expect(alternativeDate.getAttribute('aria-label')).toContain('Reason: Weekend'); + expect(fixture.componentInstance['liveMessage']()).toContain( + 'Date alternatives: Ineligible', + ); + const counterfactualResult = fixture.nativeElement.querySelector( + '.counterfactual-result', + ) as HTMLElement; + expect(counterfactualResult.textContent).toContain('Ineligible'); + expect(counterfactualResult.textContent).toContain('Original search status: Ineligible'); + const chosenDate = fixture.nativeElement.querySelector( + 'button[data-date="2024-05-03"]', + ) as HTMLButtonElement; + chosenDate.click(); + fixture.detectChanges(); + expect(chosenDate.getAttribute('aria-label')).toContain('Chosen'); + expect(chosenDate.getAttribute('aria-label')).toContain('Date alternatives: Eligible'); + expect(counterfactualResult.textContent).toContain('Eligible'); + expect(counterfactualResult.textContent).toContain('Original search status: Chosen'); }); it('turns planner rejection into an incomplete error and clears loading', async () => { diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 12dc68d..df8f9fa 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -137,7 +137,7 @@ } @if (selectedExplanation(); as selected) { -
+
{{ tr().planning.whyPlacement }}
@@ -256,9 +256,7 @@ {{ selected.remainingQuantity }} @if (selected.remainingBalance) {
- {{ - formatIdentifier(selected.remainingBalance.classification) - }} + {{ formatIdentifier(selected.remainingBalance.classification) }} · {{ selected.remainingBalance.reason.message }} @@ -396,12 +394,13 @@ : tr().planning.ineligible }}

- @if (result.historicalStatus) { -

- {{ tr().planning.historical }}: - {{ traceStatusLabel(result.historicalStatus) }} -

- } +

+ {{ tr().planning.historical }}: + {{ + dateStatuses()[result.date]?.label ?? + traceStatusLabel(result.historicalStatus ?? 'not-examined') + }} +

@if (result.reason) {

{{ tr().planning.reason }}: @@ -498,15 +497,63 @@ {{ tr().planning.currentPlacement }} + } + @if (counterfactual()) {

  • - ~✓{{ tr().planning.eligible }}
  • - ~×{{ tr().planning.ineligible }}
  • } + @if (traceReplay()) { +
  • + {{ traceStatusLabel('chosen') }} +
  • +
  • + {{ traceStatusLabel('evaluated-rejected') }} +
  • +
  • + {{ traceStatusLabel('pruned') }} +
  • +
  • + {{ traceStatusLabel('not-examined') }} +
  • +
  • + {{ traceStatusLabel('ineligible') }} +
  • +
  • + {{ traceChangeLabel('added') }} +
  • +
  • + {{ traceChangeLabel('moved-from') }} +
  • +
  • + {{ traceChangeLabel('moved-to') }} +
  • +
  • + {{ traceChangeLabel('removed') }} +
  • +
  • + {{ traceChangeLabel('shared') }} +
  • +
  • + {{ traceChangeLabel('unchanged') }} +
  • + } diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss index d5548a1..588e4fe 100644 --- a/src/app/components/calendar-year/calendar-year.scss +++ b/src/app/components/calendar-year/calendar-year.scss @@ -496,6 +496,10 @@ code { font-weight: 800; } +.hypothetical-symbol { + width: 1.5rem; +} + .month-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr)); diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts index f07f02d..b46d0be 100644 --- a/src/app/components/calendar-year/calendar-year.ts +++ b/src/app/components/calendar-year/calendar-year.ts @@ -6,6 +6,7 @@ import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-mon import { CalendarSettings } from '../calendar-settings/calendar-settings'; import { CalendarService, + DayType, SelectedDates, SelectableDayType, VacDay, @@ -61,7 +62,7 @@ export class CalendarYear { protected readonly traceLoading = signal(false); protected readonly traceResolved = signal(false); protected readonly selectedPlacementIndex = signal(0); - protected readonly planDetailsOpen = signal(false); + protected readonly planDetailsOpen = signal(true); protected readonly counterfactual = signal(null); protected readonly selectedCounterfactualDate = signal(null); protected readonly counterfactualLoading = signal(false); @@ -71,6 +72,7 @@ export class CalendarYear { protected readonly traceCandidatePage = signal(0); protected readonly traceMoment = signal('before'); protected readonly traceReplay = signal(false); + protected readonly selectedTraceDate = signal(null); protected readonly liveMessage = signal(''); protected readonly planningHorizon = signal<{ start: string; end: string } | null>(null); protected readonly selectedDates = signal(new SelectedDates()); @@ -146,14 +148,23 @@ export class CalendarYear { protected readonly currentPlacementDate = computed(() => { if (this.counterfactual()) return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date; - if (this.traceReplay()) return this.selectedTraceDecision()?.dates[0] ?? null; + if (this.traceReplay()) + return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null; return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null; }); protected readonly dateStatuses = computed>>(() => { const statuses: Record = {}; const trace = this.planningTrace(); - if (!this.counterfactual() && this.traceReplay() && trace) { + const closedDayDates = new Set(); + for (const selected of this.selectedDates().datesSelected) { + if (selected.type !== DayType.CLOSED_DAY || !selected.range.start) continue; + const day = selected.range.start; + closedDayDates.add( + `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`, + ); + } + if (this.traceReplay() && trace) { const priority = new Map([ ['ineligible', 0], ['not-examined', 1], @@ -173,16 +184,13 @@ export class CalendarYear { label: this.traceStatusLabel('not-examined'), }; } - for (const candidate of trace.phases.flatMap((phase) => - phase.decisions.flatMap((decision) => decision.candidates), - )) { - if (candidate.status !== 'ineligible' || candidate.reason?.code !== 'non-working') - continue; - for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) + for (const date of Object.keys(statuses)) { + const reason = this.nonWorkingDateReason(date, closedDayDates); + if (reason) statuses[date] = { - kind: candidate.status, - label: this.traceStatusLabel(candidate.status), - reason: candidate.reason.message, + kind: 'ineligible', + label: this.traceStatusLabel('ineligible'), + reason, }; } const explicitPriorities = new Map(); @@ -234,20 +242,39 @@ export class CalendarYear { if (!changedDates.has(placement.date)) setChange(placement.date, 'unchanged'); } } + for (const result of this.counterfactual()?.results ?? []) { + const status = statuses[result.date]; + const historicalStatus = + result.historicalStatus ?? + (status && status.kind !== 'eligible' ? status.kind : 'not-examined'); + statuses[result.date] = { + ...status, + kind: historicalStatus, + label: this.traceStatusLabel(historicalStatus), + hypotheticalLabel: + result.status === 'eligible' + ? this.tr().planning.eligible + : this.tr().planning.ineligible, + hypotheticalReason: result.reason?.message, + }; + } return statuses; } - for (const result of this.counterfactual()?.results ?? []) + for (const result of this.counterfactual()?.results ?? []) { + const nonWorkingReason = this.nonWorkingDateReason(result.date, closedDayDates); + const historicalStatus = + result.historicalStatus ?? (nonWorkingReason ? 'ineligible' : 'not-examined'); statuses[result.date] = { - kind: result.status, - label: + kind: historicalStatus, + label: this.traceStatusLabel(historicalStatus), + reason: historicalStatus === 'ineligible' ? nonWorkingReason : undefined, + hypotheticalLabel: result.status === 'eligible' ? this.tr().planning.eligible : this.tr().planning.ineligible, - reason: result.reason?.message, - historicalLabel: result.historicalStatus - ? this.traceStatusLabel(result.historicalStatus) - : undefined, + hypotheticalReason: result.reason?.message, }; + } return statuses; }); @@ -357,13 +384,14 @@ export class CalendarYear { this.traceLoading.set(true); this.traceResolved.set(false); this.selectedPlacementIndex.set(0); - this.planDetailsOpen.set(false); + this.planDetailsOpen.set(true); this.clearCounterfactual(); this.tracePhaseIndex.set(0); this.traceDecisionIndex.set(0); this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(false); + this.selectedTraceDate.set(null); this.selectedDates.set(this.calendarService.getSelectedDatesForYear()); this.userInput.remainingVacationDaysSignal.set([]); } @@ -372,6 +400,8 @@ export class CalendarYear { void calculation.explanation.then((explanation) => { if (version !== this.calculationVersion) return; this.planningExplanation.set(explanation ?? null); + if (explanation?.kind === 'PlanExplanation' && this.planDetailsOpen()) + this.selectPlacement(0); }); void calculation.explanationError.then((reason) => { if (version === this.calculationVersion) this.explanationError.set(reason ?? null); @@ -435,6 +465,7 @@ export class CalendarYear { protected togglePlanDetails(event: Event): void { const open = (event.currentTarget as HTMLDetailsElement).open; + if (open === this.planDetailsOpen()) return; this.planDetailsOpen.set(open); if (open) this.selectPlacement(this.selectedPlacementIndex()); else this.clearCounterfactual(); @@ -464,12 +495,36 @@ export class CalendarYear { return; } } + if (this.traceReplay()) { + this.selectedTraceDate.set(date); + if (this.counterfactual()?.results.some((result) => result.date === date)) + this.selectedCounterfactualDate.set(date); + this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date])); + return; + } if (this.counterfactual()?.results.some((result) => result.date === date)) { this.selectedCounterfactualDate.set(date); - this.liveMessage.set(`${date}: ${this.dateStatuses()[date]?.label ?? ''}`); + this.liveMessage.set(this.dateStatusAnnouncement(date, this.dateStatuses()[date])); } } + private dateStatusAnnouncement(date: string, status?: CalendarDayStatus): string { + return [ + date, + status?.label, + status?.reason, + status?.hypotheticalLabel + ? `${this.tr().planning.counterfactual}: ${status.hypotheticalLabel}` + : undefined, + status?.hypotheticalReason + ? `${this.tr().planning.reason}: ${status.hypotheticalReason}` + : undefined, + status?.changeLabel, + ] + .filter(Boolean) + .join('. '); + } + protected selectTracePhase(index: number): void { const last = (this.planningTrace()?.phases.length ?? 1) - 1; index = Math.max(0, Math.min(index, last)); @@ -478,6 +533,7 @@ export class CalendarYear { this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(false); + this.selectedTraceDate.set(null); this.clearCounterfactual(); this.liveMessage.set( `${this.tr().planning.phases} ${index + 1} ${this.tr().planning.of} ${this.planningTrace()?.phases.length ?? 0}`, @@ -505,6 +561,7 @@ export class CalendarYear { this.traceCandidatePage.set(0); this.traceMoment.set('before'); this.traceReplay.set(true); + this.selectedTraceDate.set(this.selectedTraceDecision()?.dates[0] ?? null); this.clearCounterfactual(); this.liveMessage.set( `${this.tr().planning.decision} ${index + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, @@ -535,11 +592,15 @@ export class CalendarYear { this.clearCounterfactual(); this.traceMoment.set(moment); this.traceReplay.set(true); + this.liveMessage.set( + `${this.tr().planning[moment]} ${this.tr().planning.decision} ${this.traceDecisionIndex() + 1} ${this.tr().planning.of} ${this.selectedTracePhase()?.decisions.length ?? 0}`, + ); } protected showFinalPlan(): void { this.traceReplay.set(false); this.clearCounterfactual(); + this.liveMessage.set(this.tr().planning.finalPlan); } protected analyzeTraceCandidate(candidate: PlanningTraceCandidate): void { @@ -547,9 +608,20 @@ export class CalendarYear { if (!candidate.placement || !decision) return; this.traceMoment.set('before'); this.traceReplay.set(true); + this.selectedTraceDate.set(candidate.placement.date); void this.analyzeSelection({ decisionId: decision.id, placement: candidate.placement }); } + private nonWorkingDateReason( + date: string, + closedDayDates: ReadonlySet, + ): string | undefined { + const day = new Date(`${date}T00:00:00`); + if (day.getDay() === 0 || day.getDay() === 6) return this.tr().dayTypes.weekend; + if (closedDayDates.has(date)) return this.tr().dayTypes.closedDay; + return undefined; + } + private async analyzeSelection(selection: CounterfactualSelection): Promise { const calculation = this.activeCalculation; if (!calculation) return; diff --git a/src/app/services/leave-planner.core.ts b/src/app/services/leave-planner.core.ts index 8b5c9ff..548cab2 100644 --- a/src/app/services/leave-planner.core.ts +++ b/src/app/services/leave-planner.core.ts @@ -512,6 +512,10 @@ class TraceRecorder { private readonly phases: MutableTracePhase[] = []; private phase: MutableTracePhase | undefined; private sequence = 0; + private readonly evaluationDecisions = new WeakMap< + MutableTraceCandidate, + MutableTraceDecision + >(); private previousPlacements = new Map(); private previousEarned = new Set(); private previousBalances: ReadonlyMap; @@ -567,23 +571,27 @@ class TraceRecorder { return latest ?? this.decision(action); } - private latestComparison(placement: LeavePlacement): PlanningObjectiveComparison | undefined { - for (const decision of [...(this.phase?.decisions ?? [])].reverse()) { - const candidate = [...decision.candidates] - .reverse() - .find( - (item) => - item.placement?.date === placement.date && - item.placement.balanceId === placement.balanceId && - item.comparison, - ); - if (candidate?.comparison) return candidate.comparison; - } - return undefined; + private candidateDecision(dates: readonly string[] = []): MutableTraceDecision { + const latest = this.latestDecision(); + return latest.deltas.length ? this.decision('choose', dates) : latest; + } + + private recordChosenCandidate(placement: LeavePlacement, decision: MutableTraceDecision): void { + const alreadyRecorded = decision.candidates.some( + (candidate) => + candidate.status === 'chosen' && + candidate.placement?.date === placement.date && + candidate.placement.balanceId === placement.balanceId, + ); + if (!alreadyRecorded) + decision.candidates.push({ status: 'chosen', date: placement.date, placement }); } recordCandidate(candidate: PlanningTraceCandidate, copy = true): void { - this.latestDecision().candidates.push( + const decision = this.candidateDecision( + candidate.dates ?? (candidate.date ? [candidate.date] : []), + ); + decision.candidates.push( copy ? { ...candidate, @@ -606,14 +614,23 @@ class TraceRecorder { if (candidate) decision.candidates.push(candidate); } - recordDelta(delta: PlanningTraceDelta): void { - this.latestDecision().deltas.push({ ...delta }); + recordEvaluation(candidate: MutableTraceCandidate): void { + const decision = this.candidateDecision( + candidate.dates ?? (candidate.date ? [candidate.date] : []), + ); + decision.candidates.push(candidate); + this.evaluationDecisions.set(candidate, decision); + } + + chooseEvaluation(candidate: MutableTraceCandidate): void { + candidate.status = 'chosen'; } recordPlan( placements: readonly LeavePlacement[], balances: PlanningTraceState['balances'], earnedActivations: PlanningTraceState['earnedActivations'], + acceptedCandidate?: MutableTraceCandidate, ): void { const current = new Map(placements.map((placement) => [placement.date, placement])); const removed = [...this.previousPlacements.values()].filter( @@ -640,7 +657,7 @@ class TraceRecorder { !earnedChanged ) return; - this.decision( + const action = removed.length && added.length ? 'move' : added.length @@ -649,13 +666,17 @@ class TraceRecorder { ? 'remove' : updated.length ? 'share' - : 'activate', - [ - ...removed.map((placement) => placement.date), - ...added.map((placement) => placement.date), - ...updated.map(([date]) => date), - ], - ); + : 'activate'; + const dates = [ + ...removed.map((placement) => placement.date), + ...added.map((placement) => placement.date), + ...updated.map(([date]) => date), + ]; + const decision = + (acceptedCandidate && this.evaluationDecisions.get(acceptedCandidate)) || + this.decision(action, dates); + decision.action = action; + decision.dates = dates; const movedAdded = new Set(); const movedRemoved = new Set(); for (const previous of removed) { @@ -668,7 +689,7 @@ class TraceRecorder { if (!placement) continue; movedAdded.add(placement.date); movedRemoved.add(previous.date); - this.recordDelta({ + decision.deltas.push({ kind: 'placement-moved', fromDate: previous.date, toDate: placement.date, @@ -676,16 +697,11 @@ class TraceRecorder { previousPlacement: previous, placement, }); - this.recordCandidate({ - status: 'chosen', - date: placement.date, - placement, - comparison: this.latestComparison(placement), - }); + this.recordChosenCandidate(placement, decision); } for (const placement of removed) { if (movedRemoved.has(placement.date)) continue; - this.recordDelta({ + decision.deltas.push({ kind: 'placement-removed', date: placement.date, balanceId: placement.balanceId, @@ -694,46 +710,36 @@ class TraceRecorder { } for (const placement of added) { if (movedAdded.has(placement.date)) continue; - this.recordDelta({ + decision.deltas.push({ kind: 'placement-added', date: placement.date, balanceId: placement.balanceId, placement, }); - this.recordCandidate({ - status: 'chosen', - date: placement.date, - placement, - comparison: this.latestComparison(placement), - }); + this.recordChosenCandidate(placement, decision); } for (const [date, placement] of updated) { const previous = this.previousPlacements.get(date)!; - this.recordDelta({ + decision.deltas.push({ kind: 'placement-updated', date, balanceId: placement.balanceId, previousPlacement: previous, placement, }); - this.recordCandidate({ - status: 'chosen', - date, - placement, - comparison: this.latestComparison(placement), - }); + this.recordChosenCandidate(placement, decision); } for (const balance of balances) { const previousRemaining = this.previousBalances.get(balance.id); if (previousRemaining !== undefined && previousRemaining !== balance.remaining) - this.recordDelta({ + decision.deltas.push({ kind: 'balance-changed', balanceId: balance.id, remainingBefore: previousRemaining, remainingAfter: balance.remaining, }); if (previousRemaining === undefined) - this.recordDelta({ + decision.deltas.push({ kind: 'balance-changed', balanceId: balance.id, remainingAfter: balance.remaining, @@ -743,7 +749,7 @@ class TraceRecorder { const activation = earnedActivations.find( (candidate) => candidate.id === balance.id, ); - this.recordDelta({ + decision.deltas.push({ kind: 'earned-activated', balanceId: balance.id, activation: activation @@ -1117,11 +1123,21 @@ function planningTraceState( }; } -function recordTraceState(context: SearchContext, state: State): void { +function recordTraceState( + context: SearchContext, + state: State, + acceptedCandidate?: MutableTraceCandidate, +): void { if (!context.trace) return; observeTrace(context, (trace) => { const snapshot = planningTraceState(state, context.horizon); - trace.recordPlan(snapshot.placements, snapshot.balances, snapshot.earnedActivations); + if (acceptedCandidate) trace.chooseEvaluation(acceptedCandidate); + trace.recordPlan( + snapshot.placements, + snapshot.balances, + snapshot.earnedActivations, + acceptedCandidate, + ); }); } @@ -2579,11 +2595,12 @@ function recordTraceEvaluation( afterMetrics: PlanningMetrics, dates: readonly string[], sourceDate?: string, -): void { +): MutableTraceCandidate | undefined { + let evaluation: MutableTraceCandidate | undefined; observeTrace(context, (trace) => { const date = dates[dates.length - 1]; const placement = date ? afterState.placements.get(date) : undefined; - trace.recordCandidate({ + evaluation = { status: 'evaluated-rejected', sourceDate, date, @@ -2595,8 +2612,10 @@ function recordTraceEvaluation( placement: placement ? { ...placement, ruleIds: [...placement.ruleIds].sort() } : undefined, - }); + }; + trace.recordEvaluation(evaluation); }); + return context.trace ? evaluation : undefined; } function optimizeMovablePlacements( @@ -2678,6 +2697,7 @@ function optimizeMovablePlacements( let best = result; let bestMetrics = baseline; let bestDate = original.date; + let bestEvaluation: MutableTraceCandidate | undefined; const placementCandidates = current.ruleIds.size > 0 ? context.workingDays.filter((date) => movableWindowDates.has(date)) @@ -2709,7 +2729,7 @@ function optimizeMovablePlacements( if (!feasibleCandidate) continue; feasibleCandidate.placements.get(date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); - recordTraceEvaluation( + const evaluation = recordTraceEvaluation( context, result, feasibleCandidate, @@ -2753,13 +2773,14 @@ function optimizeMovablePlacements( best = cloneState(feasibleCandidate); bestMetrics = metrics; bestDate = date; + bestEvaluation = evaluation; } } finally { without.placements.delete(date); without.balances.find((item) => item.id === current.balanceId)!.remaining++; } } - if (best !== result) recordTraceState(context, best); + if (best !== result) recordTraceState(context, best, bestEvaluation); result = best; } return result; @@ -2859,6 +2880,7 @@ function optimizeHolidayWeeks( ); let best: State | null = null; let bestMetrics = baseline; + let bestEvaluation: MutableTraceCandidate | undefined; for (const candidate of candidates) { context.check(); const protectedDates = new Set( @@ -2869,7 +2891,7 @@ function optimizeHolidayWeeks( const complete = completeHolidayWeek(result, candidate, context, protectedDates); if (!complete) continue; const metrics = qualityMetrics(complete, context, preferences); - recordTraceEvaluation( + const evaluation = recordTraceEvaluation( context, result, complete, @@ -2895,10 +2917,11 @@ function optimizeHolidayWeeks( ) { best = complete; bestMetrics = metrics; + bestEvaluation = evaluation; } } if (!best) return result; - recordTraceState(context, best); + recordTraceState(context, best, bestEvaluation); result = best; } } @@ -2938,6 +2961,7 @@ function optimizeExpiryPlacements( if (released) released.remaining++; let best = result; let bestExpiry = expiryTieKey(result); + let bestEvaluation: MutableTraceCandidate | undefined; for (const replacement of without.balances .filter( (balance) => @@ -2961,7 +2985,7 @@ function optimizeExpiryPlacements( if (!feasibleCandidate) continue; feasibleCandidate.placements.get(current.date)!.supporting = current.supporting; const metrics = qualityMetrics(feasibleCandidate, context, preferences); - recordTraceEvaluation( + const evaluation = recordTraceEvaluation( context, result, feasibleCandidate, @@ -2975,9 +2999,10 @@ function optimizeExpiryPlacements( if (sameHigherQuality(metrics, baseline) && expiry < bestExpiry) { best = feasibleCandidate; bestExpiry = expiry; + bestEvaluation = evaluation; } } - if (best !== result) recordTraceState(context, best); + if (best !== result) recordTraceState(context, best, bestEvaluation); result = best; } return result; @@ -3536,6 +3561,7 @@ function supportingPlacements( const before = qualityMetrics(result, context, preferences); let best: State | null = null; let bestMetrics: PlanningMetrics | null = null; + let bestEvaluation: MutableTraceCandidate | undefined; for (const balance of result.balances .filter( (item) => @@ -3556,7 +3582,7 @@ function supportingPlacements( const feasibleCandidate = finalizeEarnedState(candidate, context); if (!feasibleCandidate) continue; const metrics = qualityMetrics(feasibleCandidate, context, preferences); - recordTraceEvaluation( + const evaluation = recordTraceEvaluation( context, result, feasibleCandidate, @@ -3586,11 +3612,12 @@ function supportingPlacements( if (!bestMetrics || compareMetrics(metrics, bestMetrics) < 0) { best = feasibleCandidate; bestMetrics = metrics; + bestEvaluation = evaluation; } } } if (!best) return result; - recordTraceState(context, best); + recordTraceState(context, best, bestEvaluation); result = best; } } @@ -3613,7 +3640,7 @@ function pruneRedundantSupportingPlacements( const balance = candidate.balances.find((item) => item.id === placement.balanceId); if (balance) balance.remaining++; const metrics = qualityMetrics(candidate, context, preferences); - recordTraceEvaluation( + const evaluation = recordTraceEvaluation( context, result, candidate, @@ -3630,7 +3657,7 @@ function pruneRedundantSupportingPlacements( metrics.longestWorkSequence <= baseline.longestWorkSequence && metrics.shortSequenceDeficit <= baseline.shortSequenceDeficit ) { - recordTraceState(context, candidate); + recordTraceState(context, candidate, evaluation); result = candidate; removed = true; break; @@ -4128,6 +4155,34 @@ function counterfactualRuleValid( return true; } +function counterfactualObligationsValid( + state: State, + context: SearchContext, + previous: State, +): boolean { + return context.rules + .filter((rule) => rule.enabled && rule.force === 'obligation') + .every((rule) => { + if (rule.type === 'time_window') { + const count = (candidate: State) => + [...candidate.placements.values()].filter((placement) => + placement.ruleIds.has(rule.id), + ).length; + return count(previous) !== rule.placeDays || count(state) === rule.placeDays; + } + return occurrences(rule, context).every((occurrence) => { + const patterns = patternsFor(rule, occurrence, context); + const complete = (candidate: State) => + patterns.some((pattern) => + pattern.every( + (date) => candidate.placements.get(date)?.ruleIds.has(rule.id) === true, + ), + ); + return !complete(previous) || complete(state); + }); + }); +} + function preferenceResultsForState(state: State, context: SearchContext): PreferenceResult[] { return context.rules .filter((rule) => rule.enabled && rule.force === 'preference') @@ -4327,6 +4382,17 @@ export function analyzeCounterfactualInternal( continue; } candidate.placements.get(date)!.supporting = selected.supporting; + if (!counterfactualObligationsValid(candidate, context, comparisonState)) { + results.push({ + date, + status: 'ineligible', + reason: counterfactualReason( + 'rule-invalid', + 'The date would leave an obligation unsatisfied.', + ), + }); + continue; + } const afterPreferences = preferenceResultsForState(candidate, context); const afterMetrics = qualityMetrics(candidate, context, afterPreferences); const after = objectiveVector(afterMetrics, afterPreferences, candidate); diff --git a/src/app/services/leave-planner.service.spec.ts b/src/app/services/leave-planner.service.spec.ts index 073ec45..834f811 100644 --- a/src/app/services/leave-planner.service.spec.ts +++ b/src/app/services/leave-planner.service.spec.ts @@ -1410,6 +1410,123 @@ describe('planning calculation context', () => { } }); + it('keeps every mandatory IF_THEN occurrence satisfied in counterfactuals', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-04-25', end: '2024-05-15' }, + balances: [balance('cp', 'cp', 2, '2024-12-31')], + rules: [ + { + id: 'wednesday-holidays', + type: 'if_then', + force: 'obligation', + dayOfWeek: 3, + placeDays: 1, + placeType: 'cp', + placement: 'auto', + rewardDays: 0, + }, + ], + }); + const outcome = await calculation.outcome; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan') return; + const firstOccurrence = outcome.placements.find( + (placement) => placement.date < '2024-05-05', + ); + const secondOccurrence = outcome.placements.find( + (placement) => placement.date > '2024-05-05', + ); + expect(firstOccurrence).toBeDefined(); + expect(secondOccurrence).toBeDefined(); + if (!firstOccurrence || !secondOccurrence) return; + + const alternateSecondDate = + secondOccurrence.date === '2024-05-07' ? '2024-05-09' : '2024-05-07'; + const analysis = await calculation.analyzeCounterfactual(firstOccurrence); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect( + analysis.results.find((result) => result.date === alternateSecondDate), + ).toMatchObject({ + status: 'ineligible', + reason: { code: 'rule-invalid' }, + }); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('allows trace counterfactuals while later obligations are still pending', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-05-01', end: '2024-05-10' }, + balances: [balance('cp', 'cp', 2, '2024-12-31')], + rules: [ + { + id: 'early-window', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-02', + endDate: '2024-05-03', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'late-window', + type: 'time_window', + force: 'obligation', + startDate: '2024-05-06', + endDate: '2024-05-07', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + const earlyPlacement = outcome.placements.find((placement) => + placement.ruleIds.includes('early-window'), + ); + expect(earlyPlacement).toBeDefined(); + if (!earlyPlacement) return; + const decision = trace.phases + .flatMap((phase) => phase.decisions) + .find((candidate) => + candidate.deltas.some( + (delta) => + delta.kind === 'placement-added' && + delta.placement?.ruleIds.includes('early-window'), + ), + ); + expect(decision).toBeDefined(); + if (!decision) return; + + const analysis = await calculation.analyzeCounterfactual({ + decisionId: decision.id, + placement: earlyPlacement, + }); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect( + ['2024-05-02', '2024-05-03'].map( + (date) => analysis.results.find((result) => result.date === date)?.status, + ), + ).toEqual(['eligible', 'eligible']); + expect( + analysis.results.find((result) => result.date === earlyPlacement.date)?.status, + ).toBe('eligible'); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + it('preserves supporting provenance in counterfactual placements', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); @@ -1440,6 +1557,97 @@ describe('planning calculation context', () => { } }); + it('cancels superseded planning and trace workers without changing the latest result', async () => { + const browserWindow = globalThis.window; + const expected = isPlan( + await runInNonBrowser({ + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1)], + rules: [], + }), + ); + const trace = { + status: 'ready' as const, + initialState: { + horizon: expected.horizon, + balances: [], + placements: [], + earnedActivations: [], + }, + phases: [], + completedStages: [], + }; + const workers: Array<{ terminated: boolean }> = []; + class SupersededWorker { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + terminated = false; + + constructor() { + workers.push(this); + } + + postMessage(): void { + if (workers.length === 2) { + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent); + return; + } + if (workers.length !== 3) return; + queueMicrotask(() => { + this.onmessage?.({ + data: { type: 'result', outcome: expected }, + } as MessageEvent); + this.onmessage?.({ + data: { type: 'artifacts', artifacts: { trace } }, + } as MessageEvent); + }); + } + + terminate(): void { + this.terminated = true; + } + } + vi.stubGlobal('window', {}); + vi.stubGlobal('Worker', SupersededWorker); + try { + const pending = createPlanningCalculation({ + horizon: expected.horizon, + balances: [balance('cp', 'cp', 1)], + rules: [], + }); + const materializing = createPlanningCalculation({ + horizon: expected.horizon, + balances: [balance('cp', 'cp', 1)], + rules: [], + }); + const latest = createPlanningCalculation({ + horizon: expected.horizon, + balances: [balance('cp', 'cp', 1)], + rules: [], + }); + + expect(workers).toHaveLength(3); + expect(workers[0].terminated).toBe(true); + expect(workers[1].terminated).toBe(true); + const pendingOutcome = await pending.outcome; + expect(pendingOutcome.kind).toBe('PlanningIncomplete'); + if (pendingOutcome.kind === 'PlanningIncomplete') + expect(pendingOutcome.reasons[0].code).toBe('stale-calculation'); + expect((await pending.trace)?.error?.code).toBe('stale-calculation'); + expect(await materializing.outcome).toEqual(expected); + expect((await materializing.trace)?.error?.code).toBe('stale-calculation'); + expect(await materializing.explanationError).toBeUndefined(); + expect(await latest.outcome).toEqual(expected); + expect((await latest.trace)?.status).toBe('ready'); + expect(workers[2].terminated).toBe(true); + } finally { + vi.unstubAllGlobals(); + vi.stubGlobal('window', browserWindow); + } + }); + it('discards counterfactual work from an older calculation', async () => { const browserWindow = globalThis.window; vi.stubGlobal('window', undefined); @@ -1572,6 +1780,89 @@ describe('planning calculation context', () => { expect( analysis.results.find((result) => result.date === laterPlacement.date)?.status, ).toBe('eligible'); + expect( + analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus, + ).toBe('ineligible'); + } finally { + vi.stubGlobal('window', browserWindow); + } + }); + + it('does not inherit unrelated rule-invalid history into a trace decision', async () => { + const browserWindow = globalThis.window; + vi.stubGlobal('window', undefined); + try { + const calculation = createPlanningCalculation({ + horizon: { start: '2024-01-01', end: '2024-01-10' }, + balances: [balance('cp', 'cp', 1, '2024-01-31')], + rules: [ + { + id: 'early-window', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-02', + endDate: '2024-01-04', + placeDays: 1, + placeType: 'cp', + }, + { + id: 'late-window', + type: 'time_window', + force: 'obligation', + startDate: '2024-01-03', + endDate: '2024-01-05', + placeDays: 1, + placeType: 'cp', + }, + ], + }); + const outcome = await calculation.outcome; + const trace = await calculation.trace; + expect(outcome.kind).toBe('Plan'); + if (outcome.kind !== 'Plan' || !trace) return; + const decision = trace.phases + .flatMap((phase) => phase.decisions) + .find((candidate) => + candidate.deltas.some((delta) => delta.kind === 'placement-added'), + ); + const placement = decision?.deltas.find( + (delta) => delta.kind === 'placement-added', + )?.placement; + expect(decision).toBeDefined(); + expect(placement).toBeDefined(); + if (!decision || !placement) return; + + const candidates = trace.phases.flatMap((phase) => + phase.decisions.flatMap((item) => item.candidates), + ); + const decisionDates = new Set( + decision.candidates.flatMap( + (candidate) => candidate.dates ?? (candidate.date ? [candidate.date] : []), + ), + ); + const unrelatedIneligible = candidates.find( + (candidate) => + candidate.status === 'ineligible' && + candidate.reason?.code === 'rule-invalid' && + candidate.date && + !decisionDates.has(candidate.date), + ); + expect(unrelatedIneligible).toBeDefined(); + if (!unrelatedIneligible?.date) return; + + const analysis = await calculation.analyzeCounterfactual({ + decisionId: decision.id, + placement, + }); + expect(analysis.kind).toBe('Counterfactual'); + if (analysis.kind !== 'Counterfactual') return; + expect( + analysis.results.find((result) => result.date === unrelatedIneligible.date) + ?.historicalStatus, + ).toBe('not-examined'); + expect( + analysis.results.find((result) => result.date === '2024-01-06')?.historicalStatus, + ).toBe('ineligible'); } finally { vi.stubGlobal('window', browserWindow); } @@ -1638,10 +1929,8 @@ describe('planning calculation context', () => { }); expect((await calculation.outcome).kind).toBe('Plan'); const trace = await calculation.trace; - const candidates = - trace?.phases.flatMap((phase) => - phase.decisions.flatMap((decision) => decision.candidates), - ) ?? []; + const decisions = trace?.phases.flatMap((phase) => phase.decisions) ?? []; + const candidates = decisions.flatMap((decision) => decision.candidates); expect( candidates.some( (candidate) => @@ -1655,6 +1944,43 @@ describe('planning calculation context', () => { expect( candidates.some((candidate) => candidate.comparison && candidate.sourceDate), ).toBe(true); + const chosenComparisons = decisions.flatMap((decision) => + decision.candidates + .filter((candidate) => candidate.status === 'chosen' && candidate.comparison) + .map((candidate) => ({ decision, candidate })), + ); + expect(chosenComparisons.length).toBeGreaterThan(0); + for (const { decision, candidate: chosen } of chosenComparisons) { + expect( + decision.deltas.some( + (delta) => + (delta.kind === 'placement-moved' && + delta.toDate === chosen.date && + delta.balanceId === chosen.placement?.balanceId) || + (delta.kind === 'placement-added' && + delta.date === chosen.date && + delta.balanceId === chosen.placement?.balanceId) || + (delta.kind === 'placement-removed' && delta.date === chosen.date), + ), + ).toBe(true); + } + const phasesWithMultipleAcceptedMutations = trace?.phases.filter((phase) => { + const acceptedMutations = phase.decisions.filter( + (decision) => + decision.candidates.some( + (candidate) => candidate.status === 'chosen' && candidate.comparison, + ) && + decision.deltas.some( + (delta) => + delta.kind === 'placement-moved' || + delta.kind === 'placement-added' || + delta.kind === 'placement-removed' || + delta.kind === 'placement-updated', + ), + ); + return acceptedMutations.length > 1; + }); + expect(phasesWithMultipleAcceptedMutations?.length).toBeGreaterThan(0); } finally { vi.stubGlobal('window', browserWindow); } diff --git a/src/app/services/leave-planner.service.ts b/src/app/services/leave-planner.service.ts index 4c39906..eb4a5ad 100644 --- a/src/app/services/leave-planner.service.ts +++ b/src/app/services/leave-planner.service.ts @@ -1,7 +1,6 @@ import type { CounterfactualAnalysis, CounterfactualBaselineOptions, - CounterfactualDateResult, CounterfactualSelection, InvalidInputOutcome, LeaveBalanceInput, @@ -18,6 +17,7 @@ import type { PlanningReason, PlanningTraceState, StaleCounterfactualAnalysis, + TraceCandidateStatus, TraceCounterfactualSelection, } from './leave-planner.core'; @@ -281,6 +281,7 @@ type PlannerWorkerResponse = interface PlanningRun { outcome: Promise; artifacts: Promise; + cancel?: () => void; } function traceStateFor(snapshot: TransportSnapshot): PlanningTraceState { @@ -337,6 +338,8 @@ function interruptedOutcome( }; } +let latestWorkerCancel: (() => void) | undefined; + function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRun { let resolveOutcome!: (outcome: PlanningOutcome) => void; let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; @@ -346,6 +349,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu const artifacts = new Promise((resolve) => { resolveArtifacts = resolve; }); + let cancel: (() => void) | undefined; try { const worker = new Worker(new URL('./leave-planner.worker', import.meta.url), { type: 'module', @@ -375,8 +379,25 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu artifactsSettled = true; resolveArtifacts(result); terminate(); + if (latestWorkerCancel === cancel) latestWorkerCancel = undefined; } }; + cancel = () => { + if (artifactsSettled) return; + const hadOutcome = outcomeSettled; + const reason = { + code: 'stale-calculation', + message: 'Planning stopped because a newer calculation started.', + }; + if (!hadOutcome) + finishOutcome( + interruptedOutcome(checkpoint, deadline, reason.code, reason.message), + ); + finishArtifacts({ + trace: fallbackTrace(snapshot, 'failed', reason), + explanationError: hadOutcome ? undefined : reason, + }); + }; const fail = (code: string, message: string) => { const reason = { code, message }; if (!outcomeSettled) @@ -453,7 +474,7 @@ function planInWorker(snapshot: TransportSnapshot, deadline: number): PlanningRu explanationError: result.reasons[0], }); } - return { outcome, artifacts }; + return { outcome, artifacts, cancel }; } let calculationSequence = 0; @@ -480,6 +501,62 @@ function traceSelection( return typeof selection === 'object' && 'decisionId' in selection; } +const traceStatusPriority = new Map([ + ['ineligible', 0], + ['not-examined', 1], + ['pruned', 2], + ['evaluated-rejected', 3], + ['chosen', 4], +]); +const historicalStatusCache = new WeakMap>(); +const historicalNonWorkingCache = new WeakMap>(); + +function historicalStatuses( + trace: PlanningTrace, + decisionId?: string, +): Map { + if (!decisionId) { + const cached = historicalStatusCache.get(trace); + if (cached) return cached; + } + const statuses = new Map(); + const nonWorkingDates = new Set(); + for (const phase of trace.phases) { + for (const decision of phase.decisions) { + if (decisionId && decision.id !== decisionId) continue; + for (const candidate of decision.candidates) { + for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) { + if ( + !decisionId && + candidate.status === 'ineligible' && + candidate.reason?.code === 'non-working' + ) + nonWorkingDates.add(date); + const previous = statuses.get(date); + if ( + !previous || + traceStatusPriority.get(candidate.status)! > + traceStatusPriority.get(previous)! + ) + statuses.set(date, candidate.status); + } + } + } + } + if (!decisionId) { + historicalStatusCache.set(trace, statuses); + historicalNonWorkingCache.set(trace, nonWorkingDates); + } + return statuses; +} + +function historicalNonWorkingDates(trace: PlanningTrace): ReadonlySet { + const cached = historicalNonWorkingCache.get(trace); + if (cached) return cached; + historicalStatuses(trace); + return historicalNonWorkingCache.get(trace) ?? new Set(); +} + function stateBeforeDecision( trace: PlanningTrace, decisionId: string, @@ -574,31 +651,19 @@ function withHistoricalStatuses( decisionId?: string, ): CounterfactualAnalysis { if (!trace) return analysis; - const priority = new Map([ - ['ineligible', 0], - ['not-examined', 1], - ['pruned', 2], - ['evaluated-rejected', 3], - ['chosen', 4], - ]); - const statuses = new Map(); - for (const candidate of trace.phases.flatMap((phase) => - phase.decisions - .filter((decision) => !decisionId || decision.id === decisionId) - .flatMap((decision) => decision.candidates), - )) { - for (const date of candidate.dates ?? (candidate.date ? [candidate.date] : [])) { - const previous = statuses.get(date); - if (!previous || priority.get(candidate.status)! > priority.get(previous)!) - statuses.set(date, candidate.status); - } - } + const statuses = historicalStatuses(trace, decisionId); + const nonWorkingDates = decisionId ? historicalNonWorkingDates(trace) : undefined; return { ...analysis, results: analysis.results.map((result) => ({ ...result, historicalStatus: - statuses.get(result.date) ?? (decisionId ? 'not-examined' : undefined), + statuses.get(result.date) ?? + (decisionId + ? nonWorkingDates?.has(result.date) + ? 'ineligible' + : 'not-examined' + : undefined), })), }; } @@ -783,6 +848,8 @@ export interface PlanningCalculationContext { } export function createPlanningCalculation(request: PlanningRequest): PlanningCalculationContext { + latestWorkerCancel?.(); + latestWorkerCancel = undefined; const transport = snapshotRequest(request); const snapshot = publicSnapshot(transport); const id = `calculation:${++calculationSequence}`; @@ -799,6 +866,10 @@ export function createPlanningCalculation(request: PlanningRequest): PlanningCal if (typeof window !== 'undefined') { if (typeof Worker !== 'undefined') { run = planInWorker(transport, deadline); + latestWorkerCancel = run.cancel; + void run.artifacts.then(() => { + if (latestWorkerCancel === run.cancel) latestWorkerCancel = undefined; + }); } else { let resolveArtifacts!: (artifacts: PlanningArtifacts) => void; const artifacts = new Promise(