merge: reconcile perf/wave1-perf with main (Wave 0 features)
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut: - useViewResource.ts: main'se352aaealready fixes the render loop the same way (load in a ref, dropped from deps) — took main's canonical version. My independent fix is superseded (my branch predatede352aae, which is why the loop reproduced live). - JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays delegated to AnalyticsService. - Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API contract the frontend expects. Build clean; backend suite 135/135 green.
This commit is contained in:
@@ -64,6 +64,7 @@ namespace JobTrackerApi.Services
|
||||
.Where(j => !j.IsDeleted)
|
||||
.Select(j => new
|
||||
{
|
||||
j.Id,
|
||||
j.Status,
|
||||
j.ResponseReceived,
|
||||
j.ResponseDate,
|
||||
@@ -74,16 +75,14 @@ namespace JobTrackerApi.Services
|
||||
})
|
||||
.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.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
|
||||
@@ -127,13 +126,46 @@ namespace JobTrackerApi.Services
|
||||
: 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 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