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