namespace JobTrackerApi.Services { public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count); /// One job's position: its canonical stage and when it entered that stage. public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc); /// /// Pure time-in-stage analytics: for each active pipeline stage, the median number of days /// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and /// the terminal success stage (Offer) are excluded — "how long has this been stuck" only /// makes sense for stages you still act on. /// public static class StageAnalytics { public static List TimeInStage(IEnumerable jobs, DateTime nowUtc) { var byStage = jobs .Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays))) .Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active)) .GroupBy(x => x.Stage); var points = new List(); foreach (var group in byStage) { var days = group.Select(x => x.Days).OrderBy(x => x).ToList(); points.Add(new StageDurationPoint( Stage: group.Key, Order: JobPipeline.OrderOf(group.Key), MedianDays: Median(days), Count: days.Count)); } return points.OrderBy(p => p.Order).ToList(); } private static double Median(IReadOnlyList sorted) { if (sorted.Count == 0) return 0; var mid = sorted.Count / 2; var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid]; return Math.Round(median, 1); } } }