CI / policy (push) Successful in 22s
CI / validate (push) Successful in 35s
CI / build (push) Successful in 21s
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 24s
CI / benchmark (push) Successful in 1m28s
CI / security (push) Successful in 1m4s
CI / build (pull_request) Successful in 24s
CI / test (push) Failing after 2m8s
CI / images (push) Skipped
CI / required (push) Failing after 1s
CI / security (pull_request) Successful in 56s
CI / benchmark (pull_request) Successful in 1m32s
CI / test (pull_request) Failing after 1m50s
CI / images (pull_request) Skipped
CI / required (pull_request) Failing after 2s
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>
143 lines
5.2 KiB
TypeScript
143 lines
5.2 KiB
TypeScript
import { computed, effect, inject, Injectable, signal } from '@angular/core';
|
|
import dayjs from 'dayjs';
|
|
import type { PlanningDate, PlanningRequest } from './leave-planner.service';
|
|
import { PlanningCalculationService } from './planning-calculation.service';
|
|
|
|
export interface PlanningDurationRange {
|
|
minimum: number;
|
|
maximum: number;
|
|
unit: 'seconds' | 'minutes';
|
|
}
|
|
|
|
/** 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. */
|
|
remaining: { value: number; unit: PlanningDurationRange['unit'] } | null;
|
|
overrun: boolean;
|
|
}
|
|
|
|
function remainingTime(ms: number): NonNullable<PlanningWait['remaining']> {
|
|
const seconds = ms / 1_000;
|
|
// Rounded up under 10 s so the line never reads "0 s" while time remains.
|
|
if (seconds < 10) return { value: Math.ceil(seconds), unit: 'seconds' };
|
|
const nearestFive = Math.round(seconds / 5) * 5;
|
|
return nearestFive < 60
|
|
? { value: nearestFive, unit: 'seconds' }
|
|
: { value: Math.round(seconds / 60), unit: 'minutes' };
|
|
}
|
|
|
|
function dateTime(value: PlanningDate): number {
|
|
const key =
|
|
value instanceof Date || value.includes('T') ? dayjs(value).format('YYYY-MM-DD') : value;
|
|
return Date.parse(`${key}T00:00:00Z`);
|
|
}
|
|
|
|
function initialDurationMs(request: PlanningRequest): number {
|
|
const start = dateTime(request.horizon.start);
|
|
const end = dateTime(request.horizon.end);
|
|
const days = Math.max(1, (end - start) / 86_400_000);
|
|
let placements = 0;
|
|
for (const balance of request.balances) {
|
|
const available = balance.availableFrom ? dateTime(balance.availableFrom) : start;
|
|
const expiry = dateTime(balance.expiryDate);
|
|
if (available >= end || expiry < start || !Number.isFinite(balance.quantity)) continue;
|
|
const overlap = Math.max(
|
|
0,
|
|
(Math.min(end, expiry + 86_400_000) - Math.max(start, available)) / 86_400_000,
|
|
);
|
|
placements +=
|
|
Math.max(0, Math.min(balance.quantity, (overlap * 5) / 7)) * (expiry < end ? 1 : 0.25);
|
|
}
|
|
let ruleWeight = 1;
|
|
for (const rule of request.rules) {
|
|
if (rule.enabled === false) continue;
|
|
if (
|
|
'startDate' in rule &&
|
|
(dateTime(rule.startDate) >= end || dateTime(rule.endDate) < start)
|
|
)
|
|
continue;
|
|
ruleWeight +=
|
|
(rule.force === 'obligation' ? 0.5 : 0.15) *
|
|
Math.max(1, 'placeDays' in rule ? rule.placeDays / 3 : 1);
|
|
if ('rewardDays' in rule) placements += (Math.max(0, rule.rewardDays) * days) / 365.25;
|
|
}
|
|
// Rough prior fitted to the 2026-10-18 rendered waits in docs/planning-duration.md, not a deadline.
|
|
const duration =
|
|
400 + 250 * (days / 365.25) ** 0.5 * (Math.max(1, placements) / 30) ** 1.5 * ruleWeight;
|
|
return Number.isFinite(duration) ? duration : 1_000;
|
|
}
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export class PlanningDurationService {
|
|
private readonly calculations = inject(PlanningCalculationService);
|
|
private readonly calibration = signal<readonly number[]>([]);
|
|
private readonly clock = signal(0);
|
|
// Equal while the calculation id is unchanged, so e is captured once at Calculate.
|
|
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)),
|
|
}
|
|
);
|
|
},
|
|
{ equal: (a, b) => a?.id === b?.id },
|
|
);
|
|
|
|
readonly estimate = computed<PlanningDurationRange | null>(() => {
|
|
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,
|
|
};
|
|
});
|
|
|
|
readonly wait = computed<PlanningWait | null>(() => {
|
|
const expected = this.expected();
|
|
if (!expected) return null;
|
|
if (this.calculations.pending()?.stage === 'complete')
|
|
return { percent: 100, remaining: null, overrun: false };
|
|
const elapsed = Math.max(0, this.clock() - expected.requestedAt);
|
|
const left = expected.ms - elapsed;
|
|
return {
|
|
percent: Math.floor(95 * Math.min(1, elapsed / expected.ms)),
|
|
remaining: expected.ms >= 3_000 && left > 0 ? remainingTime(left) : null,
|
|
overrun: left <= 0,
|
|
};
|
|
});
|
|
|
|
constructor() {
|
|
// Reading e here captures it at Calculate; stopping the clock on Cancel freezes the percentage.
|
|
effect((onCleanup) => {
|
|
if (!this.expected() || this.calculations.isCancelling()) return;
|
|
const timer = setInterval(() => this.clock.set(performance.now()), 250);
|
|
onCleanup(() => clearInterval(timer));
|
|
});
|
|
}
|
|
|
|
recordCompletion(request: PlanningRequest, elapsedMs: number): void {
|
|
if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return;
|
|
const factor = elapsedMs / initialDurationMs(request);
|
|
this.calibration.update((factors) => [...factors.slice(-7), factor]);
|
|
}
|
|
|
|
private expectedMs(request: PlanningRequest): number {
|
|
const factors = [...this.calibration()].sort((a, b) => a - b);
|
|
const middle = Math.floor(factors.length / 2);
|
|
const factor = factors.length
|
|
? (factors[middle] + factors[Math.floor((factors.length - 1) / 2)]) / 2
|
|
: 1;
|
|
return Math.round(initialDurationMs(request) * factor);
|
|
}
|
|
}
|