UI improvments

This commit is contained in:
nessar committed 2026-10-02 15:05:30 +02:00
1 parent c9dbe82b79
commit 0ce33cc9d6
106 files changed
+72934 -1675

No files matched your search

@@ -18,9 +18,11 @@ import {
PlanningIncompleteOutcome,
InvalidInputOutcome,
PlanningOutcome,
PlanningObjectiveVector,
PlanningTrace,
} from '../../services/leave-planner.service';
import { UserInputService } from '../../services/user-input-service';
import { TranslationService } from '../../services/translation-service';
import { DateRange } from '@angular/material/datepicker';
const invalid: InvalidInputOutcome = {
@@ -292,7 +294,7 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
});
it('shows the first plan explanation by default and navigates placements', async () => {
it('keeps the calendar clear until a placement is selected and alternatives are requested', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
calendar.selectedDatesForPlan.mockImplementation((value) =>
selectedDatesForPlan(value, true),
@@ -332,7 +334,23 @@ describe('CalendarYear planning outcome boundary', () => {
fixture.detectChanges();
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
const placement = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
) as HTMLButtonElement;
placement.click();
fixture.detectChanges();
expect(details.open).toBe(true);
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('.technical-details').open).toBe(false);
const compare = fixture.nativeElement.querySelector(
'.compare-dates-button',
) as HTMLButtonElement;
compare.click();
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(3);
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
'2024-05-03',
@@ -373,12 +391,314 @@ describe('CalendarYear planning outcome boundary', () => {
'2024-05-06',
);
expect(fixture.componentInstance['counterfactual']()).toBeNull();
details.open = false;
details.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('button.current')).toBeNull();
});
it('explains a whole break and compares another date against the current plan', async () => {
const before: PlanningObjectiveVector = {
objectives: [
{ id: 'unplaced-at-risk', direction: 'minimize', value: 1 },
{ id: 'preference:spring', direction: 'maximize', value: 1 },
{ id: 'non-working-day-gain', direction: 'maximize', value: 0 },
{ id: 'longest-work-sequence', direction: 'minimize', value: 12 },
{ id: 'placement-date', direction: 'minimize', value: 'removed' },
],
};
const current: PlanningObjectiveVector = {
objectives: [
{ id: 'unplaced-at-risk', direction: 'minimize', value: 0 },
{ id: 'preference:spring', direction: 'maximize', value: 2 },
{ id: 'non-working-day-gain', direction: 'maximize', value: 4 },
{ id: 'longest-work-sequence', direction: 'minimize', value: 6 },
{ id: 'placement-date', direction: 'minimize', value: '2024-05-03' },
],
};
const alternative: PlanningObjectiveVector = {
objectives: [
{ id: 'unplaced-at-risk', direction: 'minimize', value: 0 },
{ id: 'preference:spring', direction: 'maximize', value: 1 },
{ id: 'non-working-day-gain', direction: 'maximize', value: 2 },
{ id: 'longest-work-sequence', direction: 'minimize', value: 8 },
{ id: 'placement-date', direction: 'minimize', value: '2024-05-07' },
],
};
const readable: PlanningExplanation = {
...explanation,
placements: explanation.placements.map((selected) => ({
...selected,
timeOffPeriod: {
start: '2024-05-03',
end: '2024-05-06',
duration: 4,
placementDates: ['2024-05-03', '2024-05-06'],
nonWorkingDates: ['2024-05-04', '2024-05-05'],
nonWorkingDayGain: 2,
},
objectiveVector: current,
comparison: {
before,
after: current,
delta: [],
firstDecisiveObjective: 'unplaced-at-risk',
},
})),
};
const { calendar, calculation } = calendarFor(plan, readable);
calculation.snapshot.rules = [
{
id: 'spring',
name: 'Spring break',
type: 'time_window',
force: 'preference',
startDate: '2024-05-01',
endDate: '2024-05-06',
placeDays: 2,
placeType: 'cp',
},
];
const analysis: CounterfactualAnalysis = {
kind: 'Counterfactual',
calculationId: 'test',
placement: plan.placements[0],
hypothetical: true,
baseline: before,
results: [
{
date: '2024-05-03',
status: 'eligible',
comparison: { before, after: current, delta: [] },
},
{
date: '2024-05-04',
status: 'ineligible',
reason: { code: 'non-working', message: 'Weekend' },
},
{
date: '2024-05-07',
status: 'eligible',
comparison: { before, after: alternative, delta: [] },
},
{
date: '2024-05-09',
status: 'eligible',
comparison: { before, after: current, delta: [] },
},
],
};
vi.spyOn(calculation, 'analyzeCounterfactual').mockResolvedValue(analysis);
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const element: HTMLElement = document.body;
element.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
fixture.detectChanges();
expect(
Array.from(element.querySelectorAll('.break-equation strong'), (node) =>
node.textContent?.trim(),
),
).toEqual(['2', '2', '4']);
expect(element.querySelector('.leave-story')?.textContent).toContain(
'This date uses 1 day from Paid leave.',
);
expect(
Array.from(element.querySelectorAll('.day-impact td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['1', '0', '1', '2', '0', '4', '12', '6']);
expect(element.querySelector('.day-impact')?.textContent).toContain(
'Preference: Spring break',
);
expect(element.querySelector('.day-impact')?.textContent).toContain(
'Leave days in this preferred period.',
);
expect(element.querySelector('.day-impact')?.textContent).not.toContain('placement-date');
const details = element.querySelector<HTMLDetailsElement>('.technical-details')!;
details.open = true;
expect(details.querySelectorAll('.criteria-table')).toHaveLength(1);
expect(details.querySelector('.decisive-row')?.textContent).toContain(
'Leave left at risk of expiry',
);
expect(details.querySelector('.decisive-label')?.textContent).toContain('First difference');
expect(details.querySelector('.balance-ledger')?.textContent).toContain(
'Unused days in the final plan',
);
expect(details.querySelectorAll('.criteria-table tbody tr')).toHaveLength(4);
expect(details.querySelector('.criteria-detail')?.textContent).not.toContain('2024-05-03');
expect(details.querySelector('.tie-breaks')?.textContent).toContain('Earlier dates first');
expect(details.querySelector<HTMLDetailsElement>('.criterion-help')?.open).toBe(false);
expect(details.querySelector<HTMLDetailsElement>('.audit-data')?.open).toBe(false);
expect(JSON.parse(details.querySelector('pre')!.textContent!).objectiveVector).toEqual(
current,
);
element.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
await fixture.whenStable();
fixture.detectChanges();
const input = element.querySelector<HTMLInputElement>('.compare-date-field input')!;
expect(input.type).toBe('text');
expect(input.value).toBe('07/05/2024');
element
.querySelector<HTMLButtonElement>('.compare-date-field mat-datepicker-toggle button')!
.click();
await fixture.whenStable();
fixture.detectChanges();
expect(document.querySelector('mat-calendar')).not.toBeNull();
const chosenDay = document.querySelector<HTMLButtonElement>(
'mat-calendar button[aria-label="3 May 2024"]',
)!;
chosenDay.click();
await fixture.whenStable();
fixture.detectChanges();
expect(input.value).toBe('03/05/2024');
expect(fixture.componentInstance['selectedCounterfactualDate']()).toBe('2024-05-03');
expect(fixture.nativeElement.querySelector('.counterfactual-panel')).toBeNull();
expect(element.querySelector('[role="dialog"]')).not.toBeNull();
expect(
fixture.componentInstance['nearbyComparisonDates']().map((result) => result.date),
).toEqual(['2024-05-07', '2024-05-09']);
input.value = '03/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'This is the planned date.',
);
input.value = '07/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
// Both alternatives share the removed-day baseline, but moving must compare
// current vs alternative (4 -> 2, 6 -> 8), not removed vs alternative (0 -> 2).
expect(
Array.from(
element.querySelectorAll('.counterfactual-result > .impact-table td'),
(node) => node.textContent?.trim(),
),
).toEqual(['2', '1', '4', '2', '6', '8']);
const dateDetails = element.querySelector('.date-calculation-details')!;
expect(
Array.from(dateDetails.querySelectorAll('.criteria-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['0', '0', '2', '1', '4', '2', '6', '8']);
expect(dateDetails.querySelector('.decisive-row')?.textContent).toContain(
'Preference: Spring break',
);
expect(dateDetails.querySelector('thead')?.textContent).toContain('Current plan');
expect(element.querySelectorAll('button.selected')).toHaveLength(2);
input.value = '';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(input.value).toBe('');
expect(element.querySelector('.counterfactual-result')).toBeNull();
expect(element.querySelector('.comparison-empty')?.textContent).toContain('Choose a date');
input.value = '01/01/2025';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(input.value).toBe('01/01/2025');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(element.querySelector('#comparison-date-error')).not.toBeNull();
element.querySelector<HTMLButtonElement>('.nearby-dates button')!.click();
fixture.detectChanges();
expect(input.value).toBe('07/05/2024');
expect(input.getAttribute('aria-invalid')).toBe('false');
expect(input.getAttribute('aria-describedby')).toContain('comparison-range');
input.value = '09/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'These totals are unchanged.',
);
expect(element.querySelector('.counterfactual-result > .impact-table')).toBeNull();
input.value = '04/05/2024';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'No leave is needed.',
);
TestBed.inject(TranslationService).setLanguage('fr');
fixture.detectChanges();
expect(element.querySelector('.day-impact')?.textContent).toContain(
'Congés risquant d’expirer',
);
expect(element.querySelector('.counterfactual-result')?.textContent).toContain(
'Aucun congé à poser.',
);
TestBed.inject(TranslationService).setLanguage('en');
fixture.detectChanges();
await fixture.whenStable();
});
it('dismisses a pending comparison, ignores its late result, and keeps errors inside the dialog', async () => {
const { calendar, calculation } = calendarFor(plan, explanation);
let finish!: (result: CounterfactualAnalysis) => void;
vi.spyOn(calculation, 'analyzeCounterfactual')
.mockImplementationOnce(
() =>
new Promise<CounterfactualAnalysis>((resolve) => {
finish = resolve;
}),
)
.mockRejectedValueOnce(new Error('worker unavailable'));
await TestBed.configureTestingModule({
imports: [CalendarYear],
providers: [
provideZonelessChangeDetection(),
{ provide: CalendarService, useValue: calendar },
],
}).compileComponents();
const fixture = TestBed.createComponent(CalendarYear);
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
const element: HTMLElement = fixture.nativeElement;
element.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
fixture.detectChanges();
const compare = element.querySelector<HTMLButtonElement>('.compare-dates-button')!;
compare.click();
fixture.detectChanges();
expect(document.querySelector('.comparison-loading')).not.toBeNull();
document.querySelector<HTMLButtonElement>('.comparison-close')!.click();
await fixture.whenStable();
finish({
kind: 'Counterfactual',
calculationId: 'test',
placement: plan.placements[0],
hypothetical: true,
baseline: objectiveVector,
results: [{ date: '2024-05-03', status: 'eligible' }],
});
await fixture.whenStable();
fixture.detectChanges();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
expect(fixture.componentInstance['counterfactual']()).toBeNull();
expect(fixture.componentInstance['counterfactualLoading']()).toBe(false);
expect(element.querySelectorAll('button.selected')).toHaveLength(2);
compare.click();
await fixture.whenStable();
fixture.detectChanges();
expect(
document.querySelector('.counterfactual-panel .inline-error')?.textContent,
).toContain('Date alternatives could not be calculated.');
expect(document.querySelector('.comparison-done')).not.toBeNull();
document.querySelector<HTMLButtonElement>('.comparison-done')!.click();
await fixture.whenStable();
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
});
it('renders conflict causes and replays an atomic trace decision', async () => {
const conflictExplanation: PlanningExplanation = {
kind: 'ConflictExplanation',
@@ -440,6 +760,7 @@ describe('CalendarYear planning outcome boundary', () => {
status: 'chosen',
date: '2024-05-03',
placement: plan.placements[0],
comparison: explanation.placements[0].comparison,
},
],
deltas: [
@@ -490,6 +811,14 @@ describe('CalendarYear planning outcome boundary', () => {
) as HTMLButtonElement;
firstPhase.click();
fixture.detectChanges();
expect(
Array.from(
fixture.nativeElement.querySelectorAll(
'.candidate-list .criteria-table td',
) as NodeListOf<HTMLElement>,
(node) => node.textContent?.trim(),
),
).toEqual(['3', '4']);
const beforeButton = fixture.nativeElement.querySelectorAll('.trace-toolbar button')[0];
beforeButton.click();
fixture.detectChanges();
@@ -607,10 +936,10 @@ describe('CalendarYear planning outcome boundary', () => {
expect(fixture.componentInstance['liveMessage']()).toContain(
'Date alternatives: Ineligible',
);
const counterfactualResult = fixture.nativeElement.querySelector(
const counterfactualResult = document.querySelector(
'.counterfactual-result',
) as HTMLElement;
expect(counterfactualResult.textContent).toContain('Ineligible');
expect(counterfactualResult.textContent).toContain('This date can’t be used');
expect(counterfactualResult.textContent).toContain('Original search status: Ineligible');
const chosenDate = fixture.nativeElement.querySelector(
'button[data-date="2024-05-03"]',
@@ -619,8 +948,34 @@ describe('CalendarYear planning outcome boundary', () => {
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('Available date');
expect(counterfactualResult.textContent).toContain('Original search status: Chosen');
expect(counterfactualResult.textContent).toContain('Before this decision');
expect(
Array.from(counterfactualResult.querySelectorAll(':scope > .impact-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['3', '4']);
expect(
Array.from(counterfactualResult.querySelectorAll('.criteria-table td'), (node) =>
node.textContent?.trim(),
),
).toEqual(['3', '4']);
// Closing the trace restores the actual plan and clears hypothetical markers.
const traceDetails = fixture.nativeElement.querySelector(
'.trace-explorer',
) as HTMLDetailsElement;
traceDetails.open = true;
traceDetails.dispatchEvent(new Event('toggle'));
beforeButton.click();
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(true);
traceDetails.open = false;
traceDetails.dispatchEvent(new Event('toggle'));
fixture.detectChanges();
expect(fixture.componentInstance['traceReplay']()).toBe(false);
expect(fixture.componentInstance['counterfactual']()).toBeNull();
expect(fixture.componentInstance['dateStatuses']()).toEqual({});
});
it('turns planner rejection into an incomplete error and clears loading', async () => {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+243 -10
View File
@@ -1,7 +1,25 @@
import { Component, computed, effect, inject, signal, untracked } from '@angular/core';
import {
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
signal,
TemplateRef,
untracked,
viewChild,
} from '@angular/core';
import { JsonPipe, NgTemplateOutlet } from '@angular/common';
import { MatCard, MatCardContent } from '@angular/material/card';
import { MatIcon } from '@angular/material/icon';
import { DateAdapter } from '@angular/material/core';
import { MatDatepickerModule, MatDatepickerInputEvent } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { DAY_FIRST_DATE_PROVIDERS } from '../../shared/day-first-date-adapter';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogContent, MatDialogRef } from '@angular/material/dialog';
import { CalendarMonth, CalendarDayStatus } from '../calendar-month/calendar-month';
import { CalendarSettings } from '../calendar-settings/calendar-settings';
import {
@@ -23,6 +41,7 @@ import {
PlanningCalculationContext,
PlanningExplanation,
PlanningObjective,
PlanningObjectiveVector,
PlanningOutcome,
PlanningReason,
PlanningTrace,
@@ -37,6 +56,12 @@ type TraceMoment = 'before' | 'after';
@Component({
selector: 'app-calendar-year',
imports: [
JsonPipe,
NgTemplateOutlet,
MatDialogContent,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
MatCard,
CalendarMonth,
MatCardContent,
@@ -46,11 +71,18 @@ type TraceMoment = 'before' | 'after';
],
templateUrl: './calendar-year.html',
styleUrl: './calendar-year.scss',
providers: DAY_FIRST_DATE_PROVIDERS,
})
export class CalendarYear {
private readonly userInput = inject(UserInputService);
private readonly calendarService = inject(CalendarService);
private readonly ruleService = inject(RuleService);
private readonly dialog = inject(MatDialog);
private readonly dateAdapter = inject<DateAdapter<Date>>(DateAdapter);
private readonly destroyRef = inject(DestroyRef);
private readonly comparisonTemplate =
viewChild.required<TemplateRef<unknown>>('dateComparisonDialog');
private comparisonDialog: MatDialogRef<unknown> | null = null;
protected readonly translationService = inject(TranslationService);
protected readonly isLoading = signal(true);
@@ -62,11 +94,15 @@ export class CalendarYear {
protected readonly traceLoading = signal(false);
protected readonly traceResolved = signal(false);
protected readonly selectedPlacementIndex = signal(0);
protected readonly planDetailsOpen = signal(true);
protected readonly planDetailsOpen = signal(false);
protected readonly traceOpen = signal(false);
private readonly planDetails = viewChild<ElementRef<HTMLDetailsElement>>('planDetails');
protected readonly counterfactual = signal<CounterfactualAnalysis | null>(null);
protected readonly selectedCounterfactualDate = signal<string | null>(null);
protected readonly counterfactualLoading = signal(false);
protected readonly counterfactualError = signal<string | null>(null);
protected readonly invalidComparisonDate = signal(false);
protected readonly comparisonDateInput = signal<Date | null>(null);
protected readonly tracePhaseIndex = signal(0);
protected readonly traceDecisionIndex = signal(0);
protected readonly traceCandidatePage = signal(0);
@@ -126,6 +162,70 @@ export class CalendarYear {
),
);
protected readonly nearbyComparisonDates = computed(() => {
const analysis = this.counterfactual();
if (!analysis) return [];
const original = Date.parse(analysis.placement.date);
return analysis.results
.filter(
(result) => result.status === 'eligible' && result.date !== analysis.placement.date,
)
.sort(
(a, b) =>
Math.abs(Date.parse(a.date) - original) -
Math.abs(Date.parse(b.date) - original) || a.date.localeCompare(b.date),
)
.slice(0, 3);
});
protected changeComparisonDate(event: MatDatepickerInputEvent<Date>): void {
const selected = event.value;
// Material returns local midnight; converting to UTC can change the calendar day.
const date = selected
? [
selected.getFullYear(),
String(selected.getMonth() + 1).padStart(2, '0'),
String(selected.getDate()).padStart(2, '0'),
].join('-')
: null;
const valid = !!this.counterfactual()?.results.some((result) => result.date === date);
this.invalidComparisonDate.set(!!(event.targetElement as HTMLInputElement).value && !valid);
if (valid && date) this.selectCalendarDate(date);
else this.selectedCounterfactualDate.set(null);
}
protected readonly placementChanges = computed(() => {
const comparison = this.selectedExplanation()?.comparison;
return this.numericChanges(comparison?.before, comparison?.after);
});
protected readonly dateComparison = computed(() => {
const analysis = this.counterfactual();
const comparison = this.selectedCounterfactualResult()?.comparison;
if (!analysis || !comparison) return undefined;
// Final-plan alternatives share a removed-day baseline. Compare their restored
// plans; a trace candidate instead uses the exact state before its decision.
const before = this.traceReplay()
? comparison.before
: analysis.results.find((result) => result.date === analysis.placement.date)?.comparison
?.after;
return before
? {
before,
after: comparison.after,
firstDecisiveObjective: comparison.after.objectives.find(
(objective) =>
before.objectives.find((item) => item.id === objective.id)?.value !==
objective.value,
)?.id,
}
: undefined;
});
protected readonly dateChanges = computed(() =>
this.numericChanges(this.dateComparison()?.before, this.dateComparison()?.after),
);
protected readonly traceState = computed(() => {
if (!this.traceReplay()) return undefined;
return this.replayTraceState(
@@ -294,6 +394,8 @@ export class CalendarYear {
});
constructor() {
effect(() => this.dateAdapter.setLocale(this.translationService.locale()));
this.destroyRef.onDestroy(() => this.clearCounterfactual());
effect(() => {
this.userInput.computeTriggerSignal();
this.resetCalculationState();
@@ -384,7 +486,8 @@ export class CalendarYear {
this.traceLoading.set(true);
this.traceResolved.set(false);
this.selectedPlacementIndex.set(0);
this.planDetailsOpen.set(true);
this.planDetailsOpen.set(false);
this.traceOpen.set(false);
this.clearCounterfactual();
this.tracePhaseIndex.set(0);
this.traceDecisionIndex.set(0);
@@ -456,19 +559,34 @@ export class CalendarYear {
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
this.selectedPlacementIndex.set(selected);
this.traceReplay.set(false);
this.clearCounterfactual();
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
this.liveMessage.set(
`${this.tr().planning.placement} ${selected + 1} ${this.tr().planning.of} ${explanation.placements.length}`,
);
void this.analyzeSelection(explanation.placements[selected].placement);
}
protected compareDates(): void {
const placement = this.selectedExplanation()?.placement;
if (placement) void this.analyzeSelection(placement);
}
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();
if (open) {
this.traceOpen.set(false);
this.selectPlacement(this.selectedPlacementIndex());
} else this.clearCounterfactual();
}
protected toggleTrace(event: Event): void {
const open = (event.currentTarget as HTMLDetailsElement).open;
if (open === this.traceOpen()) return;
this.traceOpen.set(open);
if (open) this.planDetailsOpen.set(false);
this.showFinalPlan();
}
protected stepPlacement(step: 'first' | 'previous' | 'next' | 'last'): void {
@@ -486,12 +604,22 @@ export class CalendarYear {
}
protected selectCalendarDate(date: string): void {
if (!this.traceReplay() && this.planDetailsOpen()) {
if (this.counterfactual()?.results.some((result) => result.date === date)) {
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(new Date(`${date}T00:00:00`));
}
if (!this.traceReplay() && !this.counterfactual() && !this.counterfactualLoading()) {
const index = this.planExplanation()?.placements.findIndex(
(explanation) => explanation.placement.date === date,
);
if (index !== undefined && index >= 0) {
this.planDetailsOpen.set(true);
this.traceOpen.set(false);
this.selectPlacement(index);
this.planDetails()?.nativeElement.scrollIntoView?.({ block: 'start' });
this.planDetails()
?.nativeElement.querySelector('summary')
?.focus({ preventScroll: true });
return;
}
}
@@ -629,13 +757,35 @@ export class CalendarYear {
this.counterfactual.set(null);
this.counterfactualError.set(null);
this.counterfactualLoading.set(true);
this.invalidComparisonDate.set(false);
if (!this.comparisonDialog) {
const dialog = this.dialog.open(this.comparisonTemplate(), {
width: '48rem',
maxWidth: 'calc(100vw - 2rem)',
maxHeight: 'calc(100dvh - 2rem)',
autoFocus: 'first-heading',
ariaLabelledBy: 'counterfactual-title',
ariaDescribedBy: 'comparison-intro',
});
this.comparisonDialog = dialog;
dialog.beforeClosed().subscribe(() => {
if (this.comparisonDialog === dialog) {
this.comparisonDialog = null;
this.clearCounterfactual();
}
});
}
try {
const result = await calculation.analyzeCounterfactual(selection);
if (version !== this.counterfactualVersion || calculation !== this.activeCalculation)
return;
if (result.kind === 'Counterfactual') {
this.counterfactual.set(result);
this.selectedCounterfactualDate.set(result.placement.date);
this.selectCalendarDate(
this.traceReplay()
? result.placement.date
: (this.nearbyComparisonDates()[0]?.date ?? result.placement.date),
);
this.liveMessage.set(this.tr().planning.counterfactualReady);
}
} catch (error) {
@@ -647,12 +797,17 @@ export class CalendarYear {
}
}
private clearCounterfactual(): void {
protected clearCounterfactual(): void {
const dialog = this.comparisonDialog;
this.comparisonDialog = null;
dialog?.close();
this.counterfactualVersion++;
this.counterfactual.set(null);
this.selectedCounterfactualDate.set(null);
this.counterfactualLoading.set(false);
this.counterfactualError.set(null);
this.invalidComparisonDate.set(false);
this.comparisonDateInput.set(null);
}
private replayTraceState(
@@ -744,7 +899,85 @@ export class CalendarYear {
}
protected objectiveLabel(objective: PlanningObjective): string {
return objective.label ?? objective.id;
return this.objectiveCopy(objective.id)?.label ?? objective.label ?? objective.id;
}
protected objectiveRows(before: PlanningObjectiveVector, after: PlanningObjectiveVector) {
return after.objectives.map((objective) => ({
...objective,
label: this.objectiveLabel(objective),
help: this.objectiveCopy(objective.id)?.help,
before: before.objectives.find((item) => item.id === objective.id)?.value,
numeric: typeof objective.value === 'number',
}));
}
protected hasTieBreaks(vector: PlanningObjectiveVector): boolean {
return vector.objectives.some((objective) => typeof objective.value === 'string');
}
protected objectiveCopy(id: string) {
const key = id.startsWith('preference:') ? 'preference' : id;
const copy = this.tr().planning.explanation.objectives[key];
const rule =
key === 'preference'
? this.activeCalculation?.snapshot.rules.find(
(candidate) => candidate.id === id.slice('preference:'.length),
)
: undefined;
return (
copy && {
...copy,
label: copy.label.replace('{rule}', this.ruleLabel(id.slice('preference:'.length))),
help:
(rule &&
this.tr().planning.explanation.preferenceHelp[rule.type.toLowerCase()]) ??
copy.help,
}
);
}
protected phaseLabel(name: string): string {
return (
this.tr().planning.explanation.stages[name] ??
this.objectiveCopy(name)?.label ??
this.formatIdentifier(name)
);
}
protected leaveUsed(explanation: PlacementExplanation): string {
return this.tr().planning.explanation.leaveUsed.replace(
'{balance}',
this.balanceLabel(explanation),
);
}
private numericChanges(before?: PlanningObjectiveVector, after?: PlanningObjectiveVector) {
return (after?.objectives ?? []).flatMap((objective) => {
const previous = before?.objectives.find((item) => item.id === objective.id)?.value;
if (
typeof previous !== 'number' ||
typeof objective.value !== 'number' ||
previous === objective.value
)
return [];
return [
{
id: objective.id,
label: this.objectiveLabel(objective),
help: this.objectiveCopy(objective.id)?.help,
before: previous,
after: objective.value,
},
];
});
}
protected displayDate(value: string): string {
return new Intl.DateTimeFormat(this.translationService.locale(), {
dateStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
}
protected planningDate(value: string | Date | undefined): string {