45cbc8b1ab
- New pure StageAnalytics.TimeInStage: median days jobs have spent in each active pipeline stage (entry time from the last StatusChanged event into that stage, else applied date). Closed/success stages excluded since 'how long stuck' only applies to actionable stages. - analytics-overview now derives the funnel from JobPipeline (includes the previously-omitted Waiting stage, normalizes legacy spellings) and returns TimeInStage. - 4 unit tests; full backend suite green (124). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
46 lines
2.0 KiB
C#
46 lines
2.0 KiB
C#
namespace JobTrackerApi.Services
|
|
{
|
|
public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count);
|
|
|
|
/// <summary>One job's position: its canonical stage and when it entered that stage.</summary>
|
|
public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public static class StageAnalytics
|
|
{
|
|
public static List<StageDurationPoint> TimeInStage(IEnumerable<StageOccupancy> 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<StageDurationPoint>();
|
|
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<double> 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);
|
|
}
|
|
}
|
|
}
|