From 6cb593ab5c253073a513af9d942ed77aa8c2a7eb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 10:20:58 +0200 Subject: [PATCH] perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/JobApplicationsController.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 58f6884..2795bf5 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1750,8 +1750,13 @@ Canonical profile: { 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 .AsNoTracking() + .Select(j => new { j.IsDeleted, j.Status, j.DateApplied }) .ToListAsync(cancellationToken); var active = all.Where(j => !j.IsDeleted).ToList(); @@ -2668,10 +2673,21 @@ Candidate master CV: [HttpGet("analytics-overview")] public async Task> 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 .AsNoTracking() - .Include(j => j.Company) .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); var funnelMap = new Dictionary @@ -2686,7 +2702,7 @@ Candidate master CV: var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList(); 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( g.Key, g.Count(), @@ -2699,7 +2715,7 @@ Candidate master CV: .ToList(); var topCompanies = activeJobs - .GroupBy(j => new { j.CompanyId, Name = j.Company.Name }) + .GroupBy(j => new { j.CompanyId, Name = j.CompanyName }) .Select(g => new CompanyActivityPoint( g.Key.CompanyId, g.Key.Name,