merge: reconcile perf/wave1-perf with main (Wave 0 features)
CI and Deploy / test (pull_request) Successful in 2m13s
CI and Deploy / deploy (pull_request) Has been skipped

Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut:

- useViewResource.ts: main's e352aae already 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 predated e352aae, 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:
cesnimda
2026-07-05 20:16:40 +02:00
67 changed files with 3254 additions and 498 deletions
+43 -11
View File
@@ -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
);
}
}