feat(planning): follow the indicative wait in calculation progress
The overlay percentage now rises linearly to 95% at the expected wait captured at Calculate, holds there, and shows 100% only when the planner completes. Beneath the status, one line names the current planning stage (with the rule name for rule stages) and another shows the approximate remaining time, or "Taking longer than expected" once the wait is exceeded, announced once through the live status. Cancel freezes the percentage and hides both lines. PlanningCalculationService reports the stage and submitted request instead of fixed per-stage percentages; PlanningDurationService owns the wait progress on a 250 ms tick. Closes #300 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
35a63d24c4
commit
58be749949
14 files changed
+529
-45
No files matched your search
@@ -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<PlanningOutcome>(() => 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<HTMLButtonElement>('.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<HTMLButtonElement>('.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) => {
|
||||
|
||||
@@ -10,11 +10,21 @@
|
||||
<mat-spinner
|
||||
[diameter]="72"
|
||||
[mode]="'determinate'"
|
||||
[value]="loadingProgress()"
|
||||
[value]="wait()?.percent ?? 0"
|
||||
></mat-spinner>
|
||||
<div class="spinner-text">{{ loadingProgress() }}%</div>
|
||||
<div class="spinner-text">{{ wait()?.percent ?? 0 }}%</div>
|
||||
</div>
|
||||
<p role="status">{{ isCancelling() ? tr().planning.cancelling : tr().loading }}</p>
|
||||
@if (!isCancelling()) {
|
||||
<div class="calculation-detail">
|
||||
@if (calculationStage()) {
|
||||
<p class="calculation-stage">{{ calculationStage() }}</p>
|
||||
}
|
||||
@if (remainingWait()) {
|
||||
<p class="calculation-remaining">{{ remainingWait() }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
|
||||
@@ -510,6 +510,15 @@ output {
|
||||
.cancel-calculation {
|
||||
min-height: 44px;
|
||||
}
|
||||
.calculation-detail {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.25rem;
|
||||
text-align: center;
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.spinner-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,26 @@ export class CalendarYear {
|
||||
protected readonly isLoading = this.calculations.isCalculating;
|
||||
protected readonly isCancelling = this.calculations.isCancelling;
|
||||
private readonly resultsTitle = viewChild<ElementRef<HTMLElement>>('resultsTitle');
|
||||
protected readonly loadingProgress = this.calculations.progress;
|
||||
protected readonly wait = this.duration.wait;
|
||||
private readonly overrun = computed(() => this.wait()?.overrun ?? false);
|
||||
protected readonly calculationStage = computed(() => {
|
||||
const pending = this.calculations.pending();
|
||||
if (!pending) return '';
|
||||
const progress = this.tr().planning.progress;
|
||||
const ruleId = /^(?:optimization|preference):(.*)$/.exec(pending.stage)?.[1];
|
||||
if (ruleId === undefined) return progress.stages[pending.stage] ?? '';
|
||||
const rule = pending.request.rules.find(({ id }) => id === ruleId);
|
||||
return progress.rule.replace('{rule}', rule?.name ?? ruleId);
|
||||
});
|
||||
protected readonly remainingWait = computed(() => {
|
||||
const wait = this.wait();
|
||||
const progress = this.tr().planning.progress;
|
||||
if (wait?.overrun) return progress.overrun;
|
||||
if (!wait?.remaining) return '';
|
||||
return progress.remaining
|
||||
.replace('{value}', String(wait.remaining.value))
|
||||
.replace('{unit}', this.tr().userInput.durationUnits[wait.remaining.unit]);
|
||||
});
|
||||
protected readonly planningOutcome = this.calculations.outcome;
|
||||
protected readonly planningExplanation = this.calculations.explanation;
|
||||
protected readonly explanationError = this.calculations.explanationError;
|
||||
@@ -319,6 +338,10 @@ export class CalendarYear {
|
||||
if (this.calculations.pendingId() !== null)
|
||||
untracked(() => this.liveMessage.set(this.tr().loading));
|
||||
});
|
||||
effect(() => {
|
||||
if (this.overrun())
|
||||
untracked(() => this.liveMessage.set(this.tr().planning.progress.overrun));
|
||||
});
|
||||
effect(() => {
|
||||
const pending = this.calculations.pendingId() !== null;
|
||||
const cancelling = this.isCancelling();
|
||||
|
||||
Reference in new issue
Block a user