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:
nessarandClaude Opus 5.5 committed 2026-10-19 10:45:28 +02:00
1 parent 35a63d24c4
commit 58be749949
14 files changed
+529 -45

No files matched your search

@@ -8,6 +8,7 @@ import type {
PlanningExplanation,
PlanningOutcome,
PlanningReason,
PlanningRequest,
PlanningSnapshot,
} from './leave-planner.service';
import { RuleService } from './rule-service';
@@ -16,10 +17,14 @@ import { UserInputService } from './user-input-service';
type Horizon = { start: string; end: string };
interface PendingCalculation {
export interface PendingCalculation {
id: number;
horizon: Horizon;
progress: number;
/** The configuration submitted at Calculate, unaffected by later form edits. */
request: PlanningRequest;
requestedAt: number;
/** The latest planning stage reported by the planner, or 'complete'. */
stage: string;
cancelling: boolean;
}
@@ -34,22 +39,6 @@ export interface AcceptedCalculation {
successful: boolean;
}
function stageProgress(stage: string, current: number): number {
if (stage === 'complete') return 100;
if (stage === 'validation') return 5;
if (stage === 'obligations') return 20;
if (stage === 'at-risk') return 35;
if (stage.startsWith('preference:') || stage.startsWith('optimization:')) return 50;
if (stage === 'holiday-week-value') return 58;
if (stage === 'non-working-day-gain') return 65;
if (stage === 'longest-work-sequence') return 72;
if (stage === 'short-sequence-deficit') return 79;
if (stage === 'supporting-placements') return 86;
if (stage === 'expiry') return 93;
if (stage === 'dates') return 98;
return current;
}
function plannerError(error: unknown): PlanningReason {
return {
code: 'planner-error',
@@ -64,7 +53,7 @@ function explanationFailure(error: unknown): PlanningReason {
};
}
/** Single owner of planning calculations: submission, progress, cancellation and the accepted result. */
/** Single owner of planning calculations: submission, planning stages, cancellation and the accepted result. */
@Injectable({ providedIn: 'root' })
export class PlanningCalculationService {
private readonly settings = inject(CalendarSettingsService);
@@ -107,7 +96,7 @@ export class PlanningCalculationService {
readonly pendingId = computed(() => this.pendingState()?.id ?? null);
readonly isCalculating = computed(() => this.pendingState() !== null);
readonly isCancelling = computed(() => this.pendingState()?.cancelling ?? false);
readonly progress = computed(() => this.pendingState()?.progress ?? 0);
readonly pending = this.pendingState.asReadonly();
readonly outcome = computed(() => this.acceptedState()?.outcome ?? null);
readonly snapshot = computed(() => this.acceptedState()?.snapshot ?? null);
readonly displayedPlan = computed(() => {
@@ -131,7 +120,9 @@ export class PlanningCalculationService {
this.pendingState.set({
id: version,
horizon: request.horizon,
progress: 0,
request,
requestedAt,
stage: 'preparation',
cancelling: false,
});
let calculation: PlanningCalculationContext;
@@ -147,12 +138,8 @@ export class PlanningCalculationService {
),
progress: (stage) => {
if (version === this.version)
this.pendingState.update(
(pending) =>
pending && {
...pending,
progress: stageProgress(stage, pending.progress),
},
this.pendingState.update((pending) =>
pending && !pending.cancelling ? { ...pending, stage } : pending,
);
},
});