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>
83 lines
3.1 KiB
TypeScript
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]);
|
|
}
|
|
}
|