Files
vac-optimizer/src/app/services/planning-duration.service.ts
T
nessarandClaude Opus 5.5 5cb0b93339 refactor(planning): give planning calculations one state owner
PlanningCalculationService now builds the submitted request, owns the
pending and accepted calculation state, and delivers explanations for
the accepted calculation only. The duration estimator reads the same
request. The calendar and leave-balance summary consume the accepted
outcome instead of coordinating writable state on the input service.

Refs #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:11:37 +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 from the browser references in docs/planning-duration.md, not a deadline.
const duration =
300 + 600 * (days / 365.25) ** 1.2 * (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]);
}
}