From dd16db8c87648a0096a51f912eced96771a5a32f Mon Sep 17 00:00:00 2001
From: Nessar
Date: Sat, 3 Oct 2026 17:40:42 +0200
Subject: [PATCH] feat(calendar): add explained period feature and update
translations
---
.../calendar-month/calendar-month.html | 1 +
.../calendar-month/calendar-month.scss | 8 +-
.../calendar-month/calendar-month.ts | 16 ++
.../calendar-year-planning.spec.ts | 171 ++++++++----------
.../calendar-year/calendar-year.html | 23 +--
.../calendar-year/calendar-year.scss | 11 +-
.../components/calendar-year/calendar-year.ts | 22 ++-
src/app/services/translation-service.ts | 1 +
src/i18n/de.json | 1 +
src/i18n/en.json | 1 +
src/i18n/es.json | 1 +
src/i18n/fr.json | 1 +
12 files changed, 134 insertions(+), 123 deletions(-)
diff --git a/src/app/components/calendar-month/calendar-month.html b/src/app/components/calendar-month/calendar-month.html
index e56ea26..1c962d9 100644
--- a/src/app/components/calendar-month/calendar-month.html
+++ b/src/app/components/calendar-month/calendar-month.html
@@ -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'"
diff --git a/src/app/components/calendar-month/calendar-month.scss b/src/app/components/calendar-month/calendar-month.scss
index ed8dca4..9975b9e 100644
--- a/src/app/components/calendar-month/calendar-month.scss
+++ b/src/app/components/calendar-month/calendar-month.scss
@@ -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,
diff --git a/src/app/components/calendar-month/calendar-month.ts b/src/app/components/calendar-month/calendar-month.ts
index 4a598e4..3ad309c 100644
--- a/src/app/components/calendar-month/calendar-month.ts
+++ b/src/app/components/calendar-month/calendar-month.ts
@@ -68,6 +68,9 @@ export class CalendarMonth implements OnInit {
@Input()
currentDate: string | null = null;
+ @Input()
+ explainedPeriod?: { start: string; end: string };
+
@Input()
dateStatuses: Readonly> = {};
@@ -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('. ');
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 96ddf99..5fa992d 100644
--- a/src/app/components/calendar-year/calendar-year-planning.spec.ts
+++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts
@@ -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('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('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((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('.plan-details')!;
expect(details.open).toBe(true);
- expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
- expect(fixture.nativeElement.querySelector('button.current')?.dataset.date).toBe(
+ expect(root.querySelector('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('button[data-date="2024-05-06"]')!.click();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ expect(calculation.analyzeCounterfactual).not.toHaveBeenCalled();
+ expect(root.querySelector('button.current')?.dataset['date']).toBe(
'2024-05-06',
);
+ expect(root.querySelectorAll('.status-marker, .hypothetical-marker')).toHaveLength(0);
+
+ root.querySelector('.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('button.current')?.dataset['date']).toBe(
+ '2024-05-06',
+ );
+ const input = document.querySelector('.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('button.current')?.dataset['date']).toBe(
+ '2024-05-06',
+ );
+
+ document.querySelector('.comparison-close')!.click();
+ await fixture.whenStable();
+ await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull());
+ root.querySelector('button[data-date="2024-05-03"]')!.click();
+ fixture.detectChanges();
+ expect(root.querySelector('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();
const retryAttempt = deferred();
@@ -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('.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('.compare-dates-button')!.click();
+ document.querySelector('.comparison-done')!.click();
fixture.detectChanges();
expect(document.querySelector('.comparison-loading')).not.toBeNull();
const focusedBeforeResult = document.activeElement;
diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html
index 88c5eb3..08cfa34 100644
--- a/src/app/components/calendar-year/calendar-year.html
+++ b/src/app/components/calendar-year/calendar-year.html
@@ -888,16 +888,6 @@
}
- @if (counterfactualLoading()) {
-
- {{ tr().planning.counterfactualLoading }}
-
- }
- @if (counterfactualError()) {
-
-
{{ tr().planning.counterfactualError }} {{ counterfactualError() }}
-
- }
touch_app{{ tr().planning.calendarHelp }}
@@ -926,11 +916,17 @@
@if (planDetailsOpen() || traceReplay()) {
- ◎{{ tr().planning.currentPlacement }}
}
- @if (counterfactual()) {
+ @if (explainedPeriod()) {
+
+ {{ tr().planning.explainedPeriod }}
+
+ }
+ @if (traceReplay() && counterfactual()) {
~✓{{ 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)"
>
diff --git a/src/app/components/calendar-year/calendar-year.scss b/src/app/components/calendar-year/calendar-year.scss
index f743b0c..e2aec7f 100644
--- a/src/app/components/calendar-year/calendar-year.scss
+++ b/src/app/components/calendar-year/calendar-year.scss
@@ -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 {
diff --git a/src/app/components/calendar-year/calendar-year.ts b/src/app/components/calendar-year/calendar-year.ts
index 0c82ba2..d019f6d 100644
--- a/src/app/components/calendar-year/calendar-year.ts
+++ b/src/app/components/calendar-year/calendar-year.ts
@@ -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>>(() => {
const statuses: Record = {};
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 {
diff --git a/src/app/services/translation-service.ts b/src/app/services/translation-service.ts
index 6fff8b1..b1f6c73 100644
--- a/src/app/services/translation-service.ts
+++ b/src/app/services/translation-service.ts
@@ -221,6 +221,7 @@ export interface AppTranslations {
uncompletedStages: string;
none: string;
currentPlacement: string;
+ explainedPeriod: string;
outsideHorizon: string;
traceTitle: string;
traceLoading: string;
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 1746450..f1bd760 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -143,6 +143,7 @@
"uncompletedStages": "Unterbrochene oder nicht gestartete Schritte",
"none": "Keine",
"currentPlacement": "Aktuell erklärte Platzierung",
+ "explainedPeriod": "Erklärter Freizeitzeitraum",
"outsideHorizon": "Kontextdatum außerhalb des Planungshorizonts",
"traceTitle": "Erweitert: ursprüngliche Entscheidungsspur",
"traceLoading": "Der Plan ist fertig; seine Entscheidungsspur wird noch geladen…",
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 7076d33..3611a93 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -143,6 +143,7 @@
"uncompletedStages": "Interrupted or unstarted stages",
"none": "None",
"currentPlacement": "Current explained placement",
+ "explainedPeriod": "Explained time-off period",
"outsideHorizon": "Context date outside the planning horizon",
"traceTitle": "Advanced: original decision trace",
"traceLoading": "The plan is ready; its decision trace is still loading…",
diff --git a/src/i18n/es.json b/src/i18n/es.json
index 5dd14fd..10a7dc4 100644
--- a/src/i18n/es.json
+++ b/src/i18n/es.json
@@ -143,6 +143,7 @@
"uncompletedStages": "Etapas interrumpidas o no iniciadas",
"none": "Ninguno",
"currentPlacement": "Asignación explicada actualmente",
+ "explainedPeriod": "Período de descanso explicado",
"outsideHorizon": "Fecha de contexto fuera del horizonte de planificación",
"traceTitle": "Avanzado: traza de decisiones original",
"traceLoading": "El plan está listo; su traza de decisiones aún se está cargando…",
diff --git a/src/i18n/fr.json b/src/i18n/fr.json
index 63640c6..25c20a8 100644
--- a/src/i18n/fr.json
+++ b/src/i18n/fr.json
@@ -143,6 +143,7 @@
"uncompletedStages": "Étapes interrompues ou non démarrées",
"none": "Aucun",
"currentPlacement": "Placement expliqué actuellement",
+ "explainedPeriod": "Période de repos expliquée",
"outsideHorizon": "Date de contexte hors de l’horizon de planification",
"traceTitle": "Avancé : trace des décisions originales",
"traceLoading": "Le plan est prêt ; sa trace de décision est encore en cours de chargement…",