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:
@@ -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<int> Counts);
|
||||
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
|
||||
public sealed record AnalyticsOverviewDto(
|
||||
List<FunnelStagePoint> Funnel,
|
||||
List<ResponseRatePoint> ResponseRateBySource,
|
||||
List<CompanyActivityPoint> TopCompanies,
|
||||
double? MedianDaysToFirstResponse,
|
||||
int TotalResponses,
|
||||
int TotalActive
|
||||
int TotalActive,
|
||||
List<StageDurationDto> 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<DuplicateCandidateDto> Matches);
|
||||
@@ -2803,16 +2805,14 @@ Candidate master CV:
|
||||
.Where(j => !j.IsDeleted)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var funnelMap = new Dictionary<string, int>
|
||||
{
|
||||
["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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user