feat(calendar): add explained period feature and update translations
This commit is contained in:
1 parent
01c87d1e44
commit
dd16db8c87
12 files changed
+134
-123
No files matched your search
@@ -38,6 +38,7 @@
|
||||
[class.end]="selectedDates.isEnd(day)"
|
||||
[class.selected]="selectedDates.isSelected(day)"
|
||||
[class.current]="dateKey === currentDate"
|
||||
[class.explained-period]="isInExplainedPeriod(day)"
|
||||
[class.context-only]="!isInHorizon(day)"
|
||||
[class.eligible]="status?.kind === 'eligible'"
|
||||
[class.ineligible]="status?.kind === 'ineligible'"
|
||||
|
||||
@@ -111,8 +111,12 @@
|
||||
}
|
||||
|
||||
.current {
|
||||
outline: 3px double var(--today-border-color, #1976d2);
|
||||
outline-offset: 1px;
|
||||
outline: 2px solid var(--today-border-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.explained-period {
|
||||
box-shadow: inset 0 -3px var(--today-border-color);
|
||||
}
|
||||
|
||||
.eligible,
|
||||
|
||||
@@ -68,6 +68,9 @@ export class CalendarMonth implements OnInit {
|
||||
@Input()
|
||||
currentDate: string | null = null;
|
||||
|
||||
@Input()
|
||||
explainedPeriod?: { start: string; end: string };
|
||||
|
||||
@Input()
|
||||
dateStatuses: Readonly<Record<string, CalendarDayStatus>> = {};
|
||||
|
||||
@@ -94,6 +97,15 @@ export class CalendarMonth implements OnInit {
|
||||
return this.dateStatuses[this.getDateKey(day)];
|
||||
}
|
||||
|
||||
isInExplainedPeriod(day: Date): boolean {
|
||||
const date = this.getDateKey(day);
|
||||
return (
|
||||
!!this.explainedPeriod &&
|
||||
date >= this.explainedPeriod.start &&
|
||||
date <= this.explainedPeriod.end
|
||||
);
|
||||
}
|
||||
|
||||
getStatusMarker(day: Date): string {
|
||||
switch (this.getStatus(day)?.change) {
|
||||
case 'added':
|
||||
@@ -145,6 +157,10 @@ export class CalendarMonth implements OnInit {
|
||||
}
|
||||
const parts = [this.dayFormatter.format(day)];
|
||||
if (tooltip) parts.push(tooltip);
|
||||
if (this.getDateKey(day) === this.currentDate)
|
||||
parts.push(this.translationService.tr().planning.currentPlacement);
|
||||
if (this.isInExplainedPeriod(day))
|
||||
parts.push(this.translationService.tr().planning.explainedPeriod);
|
||||
if (!this.isInHorizon(day))
|
||||
parts.push(this.translationService.tr().planning.outsideHorizon);
|
||||
return parts.join('. ');
|
||||
|
||||
@@ -498,6 +498,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
)!;
|
||||
expect(contextDate.classList).toContain('context-only');
|
||||
expect(contextDate.disabled).toBe(true);
|
||||
expect(contextDate.classList).toContain('explained-period');
|
||||
expect(contextDate.getAttribute('aria-label')).toContain('Explained time-off period');
|
||||
expect(root.querySelectorAll('button.explained-period')).toHaveLength(4);
|
||||
expect(contextDate.getAttribute('aria-label')).toContain(
|
||||
'Context date outside the planning horizon',
|
||||
);
|
||||
@@ -614,6 +617,12 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
);
|
||||
expect(root.querySelectorAll('button[data-date="2024-05-03"].selected')).toHaveLength(1);
|
||||
expect(root.querySelectorAll('button[data-date="2024-05-06"].selected')).toHaveLength(1);
|
||||
expect(
|
||||
Array.from(
|
||||
root.querySelectorAll<HTMLButtonElement>('button.explained-period'),
|
||||
(button) => button.dataset['date'],
|
||||
),
|
||||
).toEqual(['2024-05-03', '2024-05-04', '2024-05-05', '2024-05-06']);
|
||||
expect(root.querySelector('.technical-details')?.textContent).toContain(
|
||||
'The planner’s priorities',
|
||||
);
|
||||
@@ -635,8 +644,9 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(document.activeElement).toBe(next);
|
||||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledWith(plan.placements[1]);
|
||||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||||
expect(root.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(root.querySelectorAll('button.explained-period')).toHaveLength(0);
|
||||
|
||||
const firstButton = root.querySelector<HTMLButtonElement>('button[data-step="first"]')!;
|
||||
activateWithKeyboard(firstButton);
|
||||
@@ -702,33 +712,8 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
expect(fixture.nativeElement.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows the initial placement and starts alternatives on selection without opening the dialog', async () => {
|
||||
it('keeps explanation selection quiet and analyzes alternatives only from Compare dates', 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').mockImplementation(async (selection) => ({
|
||||
...counterfactualWithoutHistoricalStatus,
|
||||
placement: placementForSelection(selection),
|
||||
}));
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CalendarYear],
|
||||
providers: [
|
||||
@@ -742,77 +727,65 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
const details = fixture.nativeElement.querySelector('.plan-details') as HTMLDetailsElement;
|
||||
const root = fixture.nativeElement as HTMLElement;
|
||||
const details = root.querySelector<HTMLDetailsElement>('.plan-details')!;
|
||||
expect(details.open).toBe(true);
|
||||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
|
||||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||||
'2024-05-03',
|
||||
);
|
||||
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).toHaveBeenCalledWith(plan.placements[0]);
|
||||
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
|
||||
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',
|
||||
);
|
||||
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"]',
|
||||
) as HTMLButtonElement;
|
||||
next.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
|
||||
'2024-05-06',
|
||||
);
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
|
||||
'2024-05-06',
|
||||
);
|
||||
details.open = false;
|
||||
details.dispatchEvent(new Event('toggle'));
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
|
||||
expect(root.querySelector('button.current')).toBeNull();
|
||||
details.open = true;
|
||||
details.dispatchEvent(new Event('toggle'));
|
||||
fixture.detectChanges();
|
||||
root.querySelector<HTMLButtonElement>('button[data-date="2024-05-06"]')!.click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||||
'2024-05-06',
|
||||
);
|
||||
expect(root.querySelectorAll('.status-marker, .hypothetical-marker')).toHaveLength(0);
|
||||
|
||||
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledExactlyOnceWith(
|
||||
plan.placements[1],
|
||||
);
|
||||
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
|
||||
expect(root.querySelectorAll('button.selected')).toHaveLength(2);
|
||||
expect(root.querySelectorAll('.status-marker, .hypothetical-marker')).toHaveLength(0);
|
||||
expect(root.querySelector('.calendar-legend')?.textContent).not.toContain('Eligible');
|
||||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||||
'2024-05-06',
|
||||
);
|
||||
const input = document.querySelector<HTMLInputElement>('.compare-date-field input')!;
|
||||
input.value = '04/05/2024';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
expect(document.querySelector('.unavailable-date')?.textContent).toContain(
|
||||
'No leave is needed.',
|
||||
);
|
||||
expect(root.querySelector('button[data-date="2024-05-04"]')?.classList).not.toContain(
|
||||
'ineligible',
|
||||
);
|
||||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||||
'2024-05-06',
|
||||
);
|
||||
|
||||
document.querySelector<HTMLButtonElement>('.comparison-close')!.click();
|
||||
await fixture.whenStable();
|
||||
await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
|
||||
root.querySelector<HTMLButtonElement>('button[data-date="2024-05-03"]')!.click();
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector<HTMLButtonElement>('button.current')?.dataset['date']).toBe(
|
||||
'2024-05-03',
|
||||
);
|
||||
expect(calculation.analyzeCounterfactual).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('explains a whole break and compares another date against the current plan', async () => {
|
||||
@@ -1185,7 +1158,7 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
expect(root.querySelector('.plan-summary')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('announces background counterfactual failure and retries from explicit comparison', async () => {
|
||||
it('announces an explicit comparison failure and retries inside the dialog', async () => {
|
||||
const { calendar, calculation } = calendarFor(plan, explanation);
|
||||
const firstAttempt = deferred<CounterfactualAnalysis>();
|
||||
const retryAttempt = deferred<CounterfactualAnalysis>();
|
||||
@@ -1212,26 +1185,30 @@ describe('CalendarYear planning outcome boundary', () => {
|
||||
)!;
|
||||
activateWithKeyboard(plannedDate);
|
||||
fixture.detectChanges();
|
||||
expect(root.querySelector('.counterfactual-progress')?.textContent).toContain(
|
||||
expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(plannedDate);
|
||||
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||||
fixture.detectChanges();
|
||||
expect(document.querySelector('.comparison-loading')?.textContent).toContain(
|
||||
'Analyzing every date',
|
||||
);
|
||||
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
|
||||
'Analyzing every date',
|
||||
);
|
||||
expect(document.activeElement).toBe(plannedDate);
|
||||
|
||||
firstAttempt.reject(new Error('worker unavailable'));
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
root.querySelector('.counterfactual-inline-error[role="alert"]')?.textContent,
|
||||
document.querySelector(
|
||||
'.counterfactual-panel .counterfactual-inline-error[role="alert"]',
|
||||
)?.textContent,
|
||||
).toContain('worker unavailable');
|
||||
expect(root.querySelector('.calendar-year > p[aria-live]')?.textContent).toContain(
|
||||
'Date alternatives could not be calculated.',
|
||||
);
|
||||
expect(document.activeElement).toBe(plannedDate);
|
||||
expect(root.querySelector('.counterfactual-inline-error')).toBeNull();
|
||||
|
||||
root.querySelector<HTMLButtonElement>('.compare-dates-button')!.click();
|
||||
document.querySelector<HTMLButtonElement>('.comparison-done')!.click();
|
||||
fixture.detectChanges();
|
||||
expect(document.querySelector('.comparison-loading')).not.toBeNull();
|
||||
const focusedBeforeResult = document.activeElement;
|
||||
|
||||
@@ -888,16 +888,6 @@
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (counterfactualLoading()) {
|
||||
<p class="counterfactual-progress" aria-hidden="true">
|
||||
{{ tr().planning.counterfactualLoading }}
|
||||
</p>
|
||||
}
|
||||
@if (counterfactualError()) {
|
||||
<div class="counterfactual-inline-error" role="alert">
|
||||
<p>{{ tr().planning.counterfactualError }} {{ counterfactualError() }}</p>
|
||||
</div>
|
||||
}
|
||||
<p class="calendar-help">
|
||||
<mat-icon aria-hidden="true">touch_app</mat-icon>{{ tr().planning.calendarHelp }}
|
||||
</p>
|
||||
@@ -926,11 +916,17 @@
|
||||
</li>
|
||||
@if (planDetailsOpen() || traceReplay()) {
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">◎</span
|
||||
<span class="legend-swatch current" aria-hidden="true"></span
|
||||
>{{ tr().planning.currentPlacement }}
|
||||
</li>
|
||||
}
|
||||
@if (counterfactual()) {
|
||||
@if (explainedPeriod()) {
|
||||
<li>
|
||||
<span class="legend-swatch explained-period" aria-hidden="true"></span
|
||||
>{{ tr().planning.explainedPeriod }}
|
||||
</li>
|
||||
}
|
||||
@if (traceReplay() && counterfactual()) {
|
||||
<li>
|
||||
<span class="legend-symbol hypothetical-symbol" aria-hidden="true">~✓</span
|
||||
>{{ tr().planning.eligible }}
|
||||
@@ -1000,7 +996,8 @@
|
||||
[horizonStart]="planningHorizon()?.start"
|
||||
[horizonEnd]="planningHorizon()?.end"
|
||||
[currentDate]="currentPlacementDate()"
|
||||
[dateStatuses]="dateStatuses()"
|
||||
[explainedPeriod]="explainedPeriod()"
|
||||
[dateStatuses]="traceReplay() ? dateStatuses() : {}"
|
||||
(dateSelected)="selectCalendarDate($event)"
|
||||
></app-calendar-month>
|
||||
</mat-card-content>
|
||||
|
||||
@@ -369,10 +369,6 @@ output {
|
||||
justify-items: start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.counterfactual-progress {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.decisive-summary {
|
||||
padding: 0.75rem;
|
||||
border-inline-start: 3px solid var(--mat-sys-primary);
|
||||
@@ -442,6 +438,13 @@ output {
|
||||
&.today {
|
||||
border: 2px dashed var(--today-border-color);
|
||||
}
|
||||
&.current {
|
||||
outline: 2px solid var(--today-border-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
&.explained-period {
|
||||
box-shadow: inset 0 -3px var(--today-border-color);
|
||||
}
|
||||
}
|
||||
.legend-symbol,
|
||||
.status-symbol {
|
||||
|
||||
@@ -260,13 +260,22 @@ export class CalendarYear {
|
||||
});
|
||||
|
||||
protected readonly currentPlacementDate = computed(() => {
|
||||
if (this.counterfactual())
|
||||
return this.selectedCounterfactualDate() ?? this.counterfactual()!.placement.date;
|
||||
if (this.traceReplay())
|
||||
return this.selectedTraceDate() ?? this.selectedTraceDecision()?.dates[0] ?? null;
|
||||
return this.selectedExplanation()?.placement.date ?? null;
|
||||
return (
|
||||
this.selectedCounterfactualDate() ??
|
||||
this.selectedTraceDate() ??
|
||||
this.selectedTraceDecision()?.dates[0] ??
|
||||
null
|
||||
);
|
||||
return this.planDetailsOpen() ? (this.selectedExplanation()?.placement.date ?? null) : null;
|
||||
});
|
||||
|
||||
protected readonly explainedPeriod = computed(() =>
|
||||
this.planDetailsOpen() && !this.traceReplay()
|
||||
? this.selectedExplanation()?.timeOffPeriod
|
||||
: undefined,
|
||||
);
|
||||
|
||||
protected readonly dateStatuses = computed<Readonly<Record<string, CalendarDayStatus>>>(() => {
|
||||
const statuses: Record<string, CalendarDayStatus> = {};
|
||||
const trace = this.planningTrace();
|
||||
@@ -543,7 +552,7 @@ export class CalendarYear {
|
||||
this.planningExplanation.set(explanation ?? null);
|
||||
if (explanation?.kind === 'PlanExplanation') {
|
||||
this.planDetailsOpen.set(true);
|
||||
this.selectPlacement(0, false, false);
|
||||
this.selectPlacement(0, false);
|
||||
this.liveMessage.set(
|
||||
`${this.planningOutcome() ? this.outcomeLabel(this.planningOutcome()!) : ''} ${this.placementAnnouncement()}`.trim(),
|
||||
);
|
||||
@@ -621,7 +630,7 @@ export class CalendarYear {
|
||||
this.userInput.remainingVacationDaysSignal.set([]);
|
||||
}
|
||||
|
||||
protected selectPlacement(index: number, analyze = true, announce = true): void {
|
||||
protected selectPlacement(index: number, announce = true): void {
|
||||
const explanation = this.planExplanation();
|
||||
if (!explanation?.placements.length) return;
|
||||
const selected = Math.max(0, Math.min(index, explanation.placements.length - 1));
|
||||
@@ -630,7 +639,6 @@ export class CalendarYear {
|
||||
this.clearCounterfactual();
|
||||
this.selectedCounterfactualDate.set(explanation.placements[selected].placement.date);
|
||||
if (announce) this.liveMessage.set(this.placementAnnouncement());
|
||||
if (analyze) void this.analyzeSelection(explanation.placements[selected].placement);
|
||||
}
|
||||
|
||||
protected compareDates(): void {
|
||||
|
||||
Reference in new issue
Block a user