fix(planning): complete explanation and trace behavior (#46)
This commit is contained in:
1 parent
57a39c81ce
commit
c9dbe82b79
10 files changed
+925
-140
No files matched your search
@@ -63,6 +63,11 @@
|
||||
<small class="status-marker" aria-hidden="true">{{
|
||||
getStatusMarker(day)
|
||||
}}</small>
|
||||
@if (status.hypotheticalLabel) {
|
||||
<small class="hypothetical-marker" aria-hidden="true">{{
|
||||
getHypotheticalMarker(status)
|
||||
}}</small>
|
||||
}
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<void>((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 () => {
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
}
|
||||
|
||||
@if (selectedExplanation(); as selected) {
|
||||
<details class="plan-details" (toggle)="togglePlanDetails($event)">
|
||||
<details class="plan-details" open (toggle)="togglePlanDetails($event)">
|
||||
<summary>{{ tr().planning.whyPlacement }}</summary>
|
||||
<section class="placement-explanation" aria-labelledby="placement-explanation-title">
|
||||
<header class="explanation-heading">
|
||||
@@ -256,9 +256,7 @@
|
||||
{{ selected.remainingQuantity }}
|
||||
@if (selected.remainingBalance) {
|
||||
<br /><small>
|
||||
{{
|
||||
formatIdentifier(selected.remainingBalance.classification)
|
||||
}}
|
||||
{{ formatIdentifier(selected.remainingBalance.classification) }}
|
||||
·
|
||||
{{ selected.remainingBalance.reason.message }}
|
||||
</small>
|
||||
@@ -396,12 +394,13 @@
|
||||
: tr().planning.ineligible
|
||||
}}
|
||||
</p>
|
||||
@if (result.historicalStatus) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.historical }}:</strong>
|
||||
{{ traceStatusLabel(result.historicalStatus) }}
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
<strong>{{ tr().planning.historical }}:</strong>
|
||||
{{
|
||||
dateStatuses()[result.date]?.label ??
|
||||
traceStatusLabel(result.historicalStatus ?? 'not-examined')
|
||||
}}
|
||||
</p>
|
||||
@if (result.reason) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.reason }}:</strong>
|
||||
@@ -498,15 +497,63 @@
|
||||
<span class="legend-symbol" aria-hidden="true">◎</span
|
||||
>{{ tr().planning.currentPlacement }}
|
||||
</li>
|
||||
}
|
||||
@if (counterfactual()) {
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">✓</span
|
||||
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~✓</span
|
||||
>{{ tr().planning.eligible }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">×</span
|
||||
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~×</span
|
||||
>{{ tr().planning.ineligible }}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<CounterfactualAnalysis | null>(null);
|
||||
protected readonly selectedCounterfactualDate = signal<string | null>(null);
|
||||
protected readonly counterfactualLoading = signal(false);
|
||||
@@ -71,6 +72,7 @@ export class CalendarYear {
|
||||
protected readonly traceCandidatePage = signal(0);
|
||||
protected readonly traceMoment = signal<TraceMoment>('before');
|
||||
protected readonly traceReplay = signal(false);
|
||||
protected readonly selectedTraceDate = signal<string | null>(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<Readonly<Record<string, CalendarDayStatus>>>(() => {
|
||||
const statuses: Record<string, CalendarDayStatus> = {};
|
||||
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([
|
||||
['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<string, number>();
|
||||
@@ -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>,
|
||||
): 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> {
|
||||
const calculation = this.activeCalculation;
|
||||
if (!calculation) return;
|
||||
|
||||
Reference in new issue
Block a user