diff --git a/docs/planning-duration.md b/docs/planning-duration.md index 62b4e9a..14bbfcc 100644 --- a/docs/planning-duration.md +++ b/docs/planning-duration.md @@ -82,6 +82,25 @@ duration rounding to whole milliseconds prevents floating-point noise at unit boundaries. Very fast runs can finish before the displayed lower bound. The range is explicitly indicative, not a confidence interval or a promise. +## During a calculation + +The loading overlay's percentage follows the indicative wait, not the planning +stages completed. At Calculate, `PlanningDurationService` freezes the expected +duration `e` for the submitted snapshot, including the local refinement. On a +250 ms tick, it measures elapsed time from the same timestamp the calibration +uses. Progress rises linearly to 95% at `e`, holds there and shows 100% only +when the planner reports completion. Per-stage weights are not used, because +the costly stage depends on the configuration. Form edits and repeated stages +cannot move it backwards. On Cancel, the tick stops and the percentage freezes. + +Below the status, one line names the current planning stage. For an +optimization or preference stage, it shows the rule's name. When `e` is at least +3 seconds, a second line shows "About N s left". This is in whole seconds under +10 seconds, 5-second steps under a minute and whole minutes after that. Once +the elapsed time reaches `e`, the line reads "Taking longer than expected" for +any `e`, and the live status announces it once. Both lines are visible text, +not live regions, and are hidden after Cancel. + ## What is timed Every request, manual or initial, captures `performance.now()` inside 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 fefddd7..3661eef 100644 --- a/src/app/components/calendar-year/calendar-year-planning.spec.ts +++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts @@ -1935,6 +1935,183 @@ describe('CalendarYear planning outcome boundary', () => { } }); + async function waitingCalendar() { + vi.useFakeTimers(); + const clock = { now: 1_000 }; + vi.spyOn(performance, 'now').mockImplementation(() => clock.now); + const { calendar, calculation } = calendarFor(plan); + calendar.createPlanningCalculation.mockReturnValue({ + ...calculation, + outcome: new Promise(() => undefined), + }); + await TestBed.configureTestingModule({ + imports: [CalendarYear], + providers: [ + provideZonelessChangeDetection(), + { provide: CalendarService, useValue: calendar }, + ], + }).compileComponents(); + TestBed.inject(CalendarSettingsService).setPlanningDates('2024-01-01', '2024-12-31'); + TestBed.inject(RuleService).addRule({ + id: 'spring', + name: 'Spring break', + type: RuleType.TIME_WINDOW, + enabled: true, + force: 'preference', + startDate: new Date('2024-04-01T00:00:00'), + endDate: new Date('2024-04-30T00:00:00'), + placeDays: 5, + placeType: SelectableDayType.CP, + }); + TestBed.inject(PlanningDurationService).recordCompletion( + TestBed.inject(PlanningCalculationService).request()!, + 20_000, + ); + const fixture = TestBed.createComponent(CalendarYear); + fixture.detectChanges(); + const [request] = calendar.createPlanningCalculation.mock.calls[0] as unknown as [ + { progress: (stage: string) => void }, + ]; + const root = fixture.nativeElement as HTMLElement; + const text = (selector: string) => root.querySelector(selector)?.textContent?.trim(); + const at = (elapsedMs: number) => { + clock.now = 1_000 + elapsedMs; + vi.advanceTimersByTime(250); + fixture.detectChanges(); + }; + return { fixture, root, text, at, progress: request.progress }; + } + + it('names the planning stage and remaining wait as visible text in every supported language', async () => { + try { + const { fixture, root, text, at, progress } = await waitingCalendar(); + at(0); + expect(text('.spinner-text')).toBe('0%'); + expect(text('.calculation-stage')).toBe('Preparing the calendar'); + expect(text('.calculation-remaining')).toBe('About 20 s left'); + expect( + root.querySelector('.calculation-detail')!.closest('[role], [aria-live]'), + ).toBeNull(); + for (const [stage, expected] of [ + ['validation', 'Checking your inputs'], + ['obligations', 'Satisfying obligations'], + ['optimization:default-holiday-week-value', 'Applying “Semaines fériées”'], + ['preference:spring', 'Applying “Spring break”'], + ['expiry', 'Using balances that expire first'], + ['dates', 'Settling the final dates'], + ]) { + progress(stage); + fixture.detectChanges(); + expect(text('.calculation-stage')).toBe(expected); + } + const stages = [ + 'preparation', + 'validation', + 'obligations', + 'preference:spring', + 'expiry', + 'dates', + ]; + const localized = [ + [ + 'fr', + [ + 'Préparation du calendrier', + 'Vérification de vos saisies', + 'Respect des obligations', + 'Application de « Spring break »', + 'Utilisation des soldes qui expirent en premier', + 'Fixation des dates définitives', + ], + 'Encore environ 20 s', + 'Plus long que prévu', + ], + [ + 'es', + [ + 'Preparando el calendario', + 'Comprobando tus datos', + 'Cumpliendo las obligaciones', + 'Aplicando «Spring break»', + 'Usando primero los saldos que vencen antes', + 'Fijando las fechas definitivas', + ], + 'Quedan unos 20 s', + 'Está tardando más de lo previsto', + ], + [ + 'de', + [ + 'Kalender wird vorbereitet', + 'Eingaben werden geprüft', + 'Verpflichtungen werden erfüllt', + '„Spring break“ wird angewendet', + 'Zuerst verfallende Guthaben werden verwendet', + 'Endgültige Daten werden festgelegt', + ], + 'Noch etwa 20 s', + 'Dauert länger als erwartet', + ], + ] as const; + for (const [language, texts, remaining] of localized) { + TestBed.inject(TranslationService).language.set(language); + stages.forEach((stage, index) => { + progress(stage); + fixture.detectChanges(); + expect(text('.calculation-stage')).toBe(texts[index]); + }); + expect(text('.calculation-remaining')).toBe(remaining); + } + at(20_000); + for (const [language, , , overrun] of localized) { + TestBed.inject(TranslationService).language.set(language); + fixture.detectChanges(); + expect(text('.calculation-remaining')).toBe(overrun); + } + } finally { + vi.restoreAllMocks(); + } + }); + + it('announces an overrun once while the overlay status keeps loading', async () => { + try { + const { fixture, root, text, at } = await waitingCalendar(); + at(19_000); + expect(text('.calculation-remaining')).toBe('About 1 s left'); + at(20_000); + expect(text('.spinner-text')).toBe('95%'); + expect(text('.calculation-remaining')).toBe('Taking longer than expected'); + expect(text('.loading-overlay [role="status"]')).toBe('Calculating...'); + expect(text('.sr-only[role="status"]')).toBe('Taking longer than expected'); + fixture.componentInstance['liveMessage'].set(''); + at(60_000); + expect(text('.spinner-text')).toBe('95%'); + expect(text('.sr-only[role="status"]')).toBe(''); + expect(root.querySelector('.cancel-calculation')).not.toBeNull(); + } finally { + vi.restoreAllMocks(); + } + }); + + it('freezes the percentage and hides the stage and remaining wait after Cancel', async () => { + try { + const { root, text, at, progress } = await waitingCalendar(); + at(10_000); + expect(text('.spinner-text')).toBe('47%'); + root.querySelector('.cancel-calculation')!.click(); + progress('complete'); + at(30_000); + expect(text('.spinner-text')).toBe('47%'); + expect(root.querySelector('.calculation-detail')).toBeNull(); + expect(text('.loading-overlay [role="status"]')).toBe('Cancelling…'); + expect(root.querySelector('.cancel-calculation')!.disabled).toBe( + true, + ); + } finally { + vi.restoreAllMocks(); + } + }); + it.each([false, true])( 'cancels accessibly with a valid incumbent: %s, then allows a new calculation', async (withIncumbent) => { diff --git a/src/app/components/calendar-year/calendar-year.html b/src/app/components/calendar-year/calendar-year.html index 72a3988..0605a45 100644 --- a/src/app/components/calendar-year/calendar-year.html +++ b/src/app/components/calendar-year/calendar-year.html @@ -10,11 +10,21 @@ -
{{ loadingProgress() }}%
+
{{ wait()?.percent ?? 0 }}%

{{ isCancelling() ? tr().planning.cancelling : tr().loading }}

+ @if (!isCancelling()) { +
+ @if (calculationStage()) { +

{{ calculationStage() }}

+ } + @if (remainingWait()) { +

{{ remainingWait() }}

+ } +
+ }