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()) { - - } - @if (counterfactualError()) { - - }

{{ 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…",