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 estimate = computed(() => { 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]); } }