perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview

Both endpoints materialised full JobApplication rows (GetAnalyticsOverview also
Include-d full Company) purely to aggregate a few fields, dragging the large
Description/TranslatedDescription/TailoredCvText/Notes/CoverLetter blobs over
the wire on every dashboard load. Project to only the columns each aggregation
needs (mirrors the existing GetTagTrends pattern). Behaviour is identical;
aggregation stays in memory over a small per-tenant set.

Backend suite: 92/92 green.

Note: the planned hot-path *index* migration is deferred — the committed EF
ModelSnapshot is stale (21 lines, no entities), so `migrations add` cannot
produce a clean incremental diff. Resyncing the snapshot is a prerequisite and
is tracked as its own task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-05 10:20:58 +02:00
parent 657cb95a48
commit 6cb593ab5c
@@ -1750,8 +1750,13 @@ Canonical profile:
{ {
var now = DateTime.Now; var now = DateTime.Now;
// Project to only the columns the stats need instead of materialising full
// JobApplication rows (which drag large Description/TranslatedDescription/
// TailoredCvText/Notes blobs). Aggregation stays in memory over a small
// per-tenant set. Mirrors the projection pattern used by GetTagTrends.
var all = await _db.JobApplications var all = await _db.JobApplications
.AsNoTracking() .AsNoTracking()
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList(); var active = all.Where(j => !j.IsDeleted).ToList();
@@ -2668,10 +2673,21 @@ Candidate master CV:
[HttpGet("analytics-overview")] [HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken) public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
{ {
// Project to only the fields the overview needs instead of Include-ing full
// Company + JobApplication rows (avoids loading large description/CV blobs).
var activeJobs = await _db.JobApplications var activeJobs = await _db.JobApplications
.AsNoTracking() .AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted) .Where(j => !j.IsDeleted)
.Select(j => new
{
j.Status,
j.ResponseReceived,
j.ResponseDate,
j.DateApplied,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
})
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int> var funnelMap = new Dictionary<string, int>
@@ -2686,7 +2702,7 @@ Candidate master CV:
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
var responseRateBySource = activeJobs var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim()) .GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
.Select(g => new ResponseRatePoint( .Select(g => new ResponseRatePoint(
g.Key, g.Key,
g.Count(), g.Count(),
@@ -2699,7 +2715,7 @@ Candidate master CV:
.ToList(); .ToList();
var topCompanies = activeJobs var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.Company.Name }) .GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
.Select(g => new CompanyActivityPoint( .Select(g => new CompanyActivityPoint(
g.Key.CompanyId, g.Key.CompanyId,
g.Key.Name, g.Key.Name,