From f128af68af4ccf4428d675f3d10327e16642af96 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Mon, 19 Oct 2026 01:12:35 +0200
Subject: [PATCH] feat(planning): warn of an overrun only past the displayed
range
The expected wait sits near the middle of recent waits, so about half of
all runs pass it. Between the expected wait and the top of the range
shown before Calculate, the percentage now holds at 95% with no
remaining-time line; "Taking longer than expected" appears and is
announced only past that top.
Refs #300
Co-Authored-By: Claude Opus 5.5
---
docs/planning-duration.md | 11 +++--
.../calendar-year-planning.spec.ts | 9 ++--
.../planning-duration.service.spec.ts | 19 +++++++-
src/app/services/planning-duration.service.ts | 43 +++++++++++--------
4 files changed, 55 insertions(+), 27 deletions(-)
diff --git a/docs/planning-duration.md b/docs/planning-duration.md
index 14bbfcc..4a8a63d 100644
--- a/docs/planning-duration.md
+++ b/docs/planning-duration.md
@@ -96,10 +96,13 @@ 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.
+10 seconds, 5-second steps under a minute and whole minutes after that. From
+`e` to the top of the range shown before Calculate, the percentage holds at 95%
+and that line is hidden. Because `e` sits near the middle of recent waits, about
+half of all runs pass it, so it is no reason to warn the user. Past the top of
+the range, for any `e`, the line reads "Taking longer than expected" and the
+live status announces it once. Both lines are visible text, not live regions,
+and are hidden after Cancel.
## What is timed
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 3661eef..e6b3854 100644
--- a/src/app/components/calendar-year/calendar-year-planning.spec.ts
+++ b/src/app/components/calendar-year/calendar-year-planning.spec.ts
@@ -2062,7 +2062,7 @@ describe('CalendarYear planning outcome boundary', () => {
});
expect(text('.calculation-remaining')).toBe(remaining);
}
- at(20_000);
+ at(60_000);
for (const [language, , , overrun] of localized) {
TestBed.inject(TranslationService).language.set(language);
fixture.detectChanges();
@@ -2073,18 +2073,21 @@ describe('CalendarYear planning outcome boundary', () => {
}
});
- it('announces an overrun once while the overlay status keeps loading', async () => {
+ it('announces an overrun once past the displayed range 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(root.querySelector('.calculation-remaining')).toBeNull();
+ expect(text('.sr-only[role="status"]')).toBe('Calculating...');
+ at(60_000);
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);
+ at(120_000);
expect(text('.spinner-text')).toBe('95%');
expect(text('.sr-only[role="status"]')).toBe('');
expect(root.querySelector('.cancel-calculation')).not.toBeNull();
diff --git a/src/app/services/planning-duration.service.spec.ts b/src/app/services/planning-duration.service.spec.ts
index 4b2faf1..bf6bc00 100644
--- a/src/app/services/planning-duration.service.spec.ts
+++ b/src/app/services/planning-duration.service.spec.ts
@@ -220,7 +220,7 @@ describe('Indicative wait progress', () => {
});
expect(at(10_000)).toMatchObject({ percent: 47, overrun: false });
expect(at(19_999)).toMatchObject({ percent: 94, overrun: false });
- expect(at(20_000)).toEqual({ percent: 95, remaining: null, overrun: true });
+ expect(at(20_000)).toEqual({ percent: 95, remaining: null, overrun: false });
expect(at(600_000)).toEqual({ percent: 95, remaining: null, overrun: true });
progress('complete');
expect(duration.wait()).toEqual({ percent: 100, remaining: null, overrun: false });
@@ -229,10 +229,25 @@ describe('Indicative wait progress', () => {
it('shows the remaining time only for expected waits of at least 3 seconds', () => {
start(2_999);
expect(at(1_000)).toEqual({ percent: 31, remaining: null, overrun: false });
- expect(at(2_999)).toEqual({ percent: 95, remaining: null, overrun: true });
+ expect(at(2_999)).toEqual({ percent: 95, remaining: null, overrun: false });
resolve({ kind: 'PlanningIncomplete', completedStages: [], reasons: [] });
});
+ it.each([
+ [2_999, { minimum: 1, maximum: 9, unit: 'seconds' }, 9_000],
+ [20_000, { minimum: 10, maximum: 60, unit: 'seconds' }, 60_000],
+ [300_000, { minimum: 2, maximum: 15, unit: 'minutes' }, 900_000],
+ ])(
+ 'warns of an overrun of a %i ms expected wait only past the top of the range shown before Calculate',
+ (expectedMs, shown, topMs) => {
+ start(expectedMs);
+ expect(duration.estimate()).toEqual(shown);
+ expect(at(expectedMs)).toEqual({ percent: 95, remaining: null, overrun: false });
+ expect(at(topMs - 1)!.overrun).toBe(false);
+ expect(at(topMs)!.overrun).toBe(true);
+ },
+ );
+
it('rounds the remaining time to seconds, then 5-second steps, then minutes', () => {
start(3_000);
expect(at(0)!.remaining).toEqual({ value: 3, unit: 'seconds' });
diff --git a/src/app/services/planning-duration.service.ts b/src/app/services/planning-duration.service.ts
index cdac826..b71520c 100644
--- a/src/app/services/planning-duration.service.ts
+++ b/src/app/services/planning-duration.service.ts
@@ -9,11 +9,24 @@ export interface PlanningDurationRange {
unit: 'seconds' | 'minutes';
}
+const UNIT_MS = { seconds: 1_000, minutes: 60_000 } as const;
+
+function durationRange(duration: number): PlanningDurationRange {
+ const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds';
+ const minimum = Math.max(1, Math.floor(duration / 2 / UNIT_MS[unit]));
+ return {
+ minimum,
+ maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / UNIT_MS[unit])),
+ unit,
+ };
+}
+
/** Progress through the indicative wait of the pending calculation. */
export interface PlanningWait {
percent: number;
- /** Approximate remaining time; null when the expected wait is short, overrun or complete. */
+ /** Approximate remaining time; null when the expected wait is short, elapsed or complete. */
remaining: { value: number; unit: PlanningDurationRange['unit'] } | null;
+ /** True past the top of the range shown before Calculate. */
overrun: boolean;
}
@@ -77,13 +90,15 @@ export class PlanningDurationService {
private readonly expected = computed(
() => {
const pending = this.calculations.pending();
- return (
- pending && {
- id: pending.id,
- requestedAt: pending.requestedAt,
- ms: Math.max(1, this.expectedMs(pending.request)),
- }
- );
+ if (!pending) return null;
+ const ms = Math.max(1, this.expectedMs(pending.request));
+ const { maximum, unit } = durationRange(ms);
+ return {
+ id: pending.id,
+ requestedAt: pending.requestedAt,
+ ms,
+ overrunMs: maximum * UNIT_MS[unit],
+ };
},
{ equal: (a, b) => a?.id === b?.id },
);
@@ -91,15 +106,7 @@ export class PlanningDurationService {
readonly estimate = computed(() => {
const request = this.calculations.request();
if (!request) return null;
- const duration = this.expectedMs(request);
- const unit = duration / 2 >= 60_000 ? 'minutes' : 'seconds';
- const divisor = unit === 'minutes' ? 60_000 : 1_000;
- const minimum = Math.max(1, Math.floor(duration / 2 / divisor));
- return {
- minimum,
- maximum: Math.max(minimum + 1, Math.ceil((duration * 3) / divisor)),
- unit,
- };
+ return durationRange(this.expectedMs(request));
});
readonly wait = computed(() => {
@@ -112,7 +119,7 @@ export class PlanningDurationService {
return {
percent: Math.floor(95 * Math.min(1, elapsed / expected.ms)),
remaining: expected.ms >= 3_000 && left > 0 ? remainingTime(left) : null,
- overrun: left <= 0,
+ overrun: elapsed >= expected.overrunMs,
};
});