feat: time-in-stage analytics + pipeline-driven funnel
- 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>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user