From 45cbc8b1ab5a18725e45a2818d6573aa83964051 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Fri, 3 Jul 2026 03:40:41 +0200 Subject: [PATCH] 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 --- JobTrackerApi.Tests/StageAnalyticsTests.cs | 62 +++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 57 +++++++++++++---- JobTrackerApi/Services/StageAnalytics.cs | 45 ++++++++++++++ 3 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 JobTrackerApi.Tests/StageAnalyticsTests.cs create mode 100644 JobTrackerApi/Services/StageAnalytics.cs diff --git a/JobTrackerApi.Tests/StageAnalyticsTests.cs b/JobTrackerApi.Tests/StageAnalyticsTests.cs new file mode 100644 index 0000000..46dc241 --- /dev/null +++ b/JobTrackerApi.Tests/StageAnalyticsTests.cs @@ -0,0 +1,62 @@ +using JobTrackerApi.Services; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class StageAnalyticsTests +{ + private static readonly DateTime Now = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Computes_median_days_per_active_stage() + { + var jobs = new[] + { + new StageOccupancy("Applied", Now.AddDays(-10)), + new StageOccupancy("Applied", Now.AddDays(-20)), + new StageOccupancy("Applied", Now.AddDays(-30)), + new StageOccupancy("Interview", Now.AddDays(-4)), + }; + + var result = StageAnalytics.TimeInStage(jobs, Now); + + var applied = Assert.Single(result, p => p.Stage == "Applied"); + Assert.Equal(20, applied.MedianDays); + Assert.Equal(3, applied.Count); + + var interview = Assert.Single(result, p => p.Stage == "Interview"); + Assert.Equal(4, interview.MedianDays); + } + + [Fact] + public void Excludes_closed_and_success_stages() + { + var jobs = new[] + { + new StageOccupancy("Offer", Now.AddDays(-5)), + new StageOccupancy("Rejected", Now.AddDays(-5)), + new StageOccupancy("Ghosted", Now.AddDays(-5)), + }; + + Assert.Empty(StageAnalytics.TimeInStage(jobs, Now)); + } + + [Fact] + public void Normalizes_legacy_status_and_orders_by_pipeline() + { + var jobs = new[] + { + new StageOccupancy("Interviewing", Now.AddDays(-3)), + new StageOccupancy("Applied", Now.AddDays(-1)), + new StageOccupancy("Waiting", Now.AddDays(-2)), + }; + + var result = StageAnalytics.TimeInStage(jobs, Now); + + Assert.Equal(new[] { "Applied", "Waiting", "Interview" }, result.Select(p => p.Stage).ToArray()); + } + + [Fact] + public void Empty_input_returns_empty() + => Assert.Empty(StageAnalytics.TimeInStage(Array.Empty(), Now)); +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 2062d54..87a1ec5 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -2023,13 +2023,15 @@ Canonical profile: public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate); public sealed record TagTrendSeries(string Tag, List Counts); public sealed record TagTrendPoint(string Month, List Counts); + public sealed record StageDurationDto(string Stage, double MedianDays, int Count); public sealed record AnalyticsOverviewDto( List Funnel, List ResponseRateBySource, List TopCompanies, double? MedianDaysToFirstResponse, int TotalResponses, - int TotalActive + int TotalActive, + List TimeInStage ); public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason); public sealed record DuplicateCheckResult(bool HasDuplicates, List Matches); @@ -2803,16 +2805,14 @@ Candidate master CV: .Where(j => !j.IsDeleted) .ToListAsync(cancellationToken); - var funnelMap = new Dictionary - { - ["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)), - ["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)), - ["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)), - ["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)), - ["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)), - }; - - var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); + // Funnel = distribution across canonical stages, driven by the pipeline (one source + // of truth, so it includes every stage and normalizes legacy spellings). + var normalizedByStage = activeJobs + .GroupBy(j => JobPipeline.Normalize(j.Status)) + .ToDictionary(g => g.Key, g => g.Count()); + var funnel = JobPipeline.Stages + .Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0)) + .ToList(); var responseRateBySource = activeJobs .GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim()) @@ -2856,13 +2856,46 @@ Candidate master CV: : Math.Round(responseDays[mid], 1); } + // Time-in-stage: for each active job, when did it enter its current stage? Use the most + // recent StatusChanged event into that stage, else its applied date. + var activeIds = activeJobs.Select(j => j.Id).ToList(); + var statusChanges = await _db.JobEvents + .AsNoTracking() + .Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId)) + .Select(e => new { e.JobApplicationId, e.NewValue, e.At }) + .ToListAsync(cancellationToken); + + var lastEntryByJob = statusChanges + .GroupBy(e => e.JobApplicationId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var occupancy = activeJobs.Select(job => + { + var current = JobPipeline.Normalize(job.Status); + DateTime enteredAt = job.DateApplied; + if (lastEntryByJob.TryGetValue(job.Id, out var changes)) + { + var lastIntoCurrent = changes + .Where(e => JobPipeline.Normalize(e.NewValue) == current) + .OrderByDescending(e => e.At) + .FirstOrDefault(); + if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At; + } + return new StageOccupancy(current, enteredAt.ToUniversalTime()); + }); + + var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow) + .Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count)) + .ToList(); + return Ok(new AnalyticsOverviewDto( Funnel: funnel, ResponseRateBySource: responseRateBySource, TopCompanies: topCompanies, MedianDaysToFirstResponse: medianDays, TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null), - TotalActive: activeJobs.Count + TotalActive: activeJobs.Count, + TimeInStage: timeInStage )); } diff --git a/JobTrackerApi/Services/StageAnalytics.cs b/JobTrackerApi/Services/StageAnalytics.cs new file mode 100644 index 0000000..d6a552c --- /dev/null +++ b/JobTrackerApi/Services/StageAnalytics.cs @@ -0,0 +1,45 @@ +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); + } + } +}