Files
vac-optimizer/src/app/services/planning-duration.service.ts
T
nessarandClaude Opus 5.5 31cb4a499c
CI / policy (push) Successful in 20s
CI / validate (push) Successful in 36s
CI / build (push) Successful in 23s
CI / policy (pull_request) Successful in 8s
CI / validate (pull_request) Successful in 25s
CI / benchmark (push) Successful in 1m29s
CI / security (push) Successful in 1m10s
CI / build (pull_request) Successful in 23s
CI / test (push) Successful in 2m16s
CI / benchmark (pull_request) Successful in 1m27s
CI / security (pull_request) Successful in 1m0s
CI / images (push) Successful in 59s
CI / required (push) Successful in 1s
CI / test (pull_request) Successful in 2m6s
CI / images (pull_request) Successful in 53s
CI / required (pull_request) Successful in 1s
fix(planning): recalibrate the duration estimate to the faster engine
Rendered waits after #302, in throttled headless Chromium, were well
below the shown ranges: two years waited 1.9 s against 3-24 s, three
years 4.5 s against 11-68 s, and the loaded year 4.0 s against 4-26 s.
The prior keeps its structure with refitted, rounded constants,
400 + 250 x (D/365.25)^0.5 x (P/30)^1.5 x R, which stays within about
1.2 times of all six measured configurations. Five years with 30 days
per year now shows 6-37 s instead of 44-265 s. Local refinement is
unchanged.

The 4x CPU throttle slows only the main thread, not the planning
worker, so phones can still wait longer than these desktop samples.

Part of #302.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:31:28 +02:00

83 lines
3.1 KiB
TypeScript

import { computed, 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';
}
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[]>([]);
readonly estimate = computed<PlanningDurationRange | null>(() => {
const request = this.calculations.request();
if (!request) return null;
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;
const duration = Math.round(initialDurationMs(request) * factor);
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,
};
});
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]);
}
}