perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview #3
@@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers
|
|||||||
private readonly ILogger<JobApplicationsController> _logger;
|
private readonly ILogger<JobApplicationsController> _logger;
|
||||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||||
private readonly ICvPdfExporter _cvPdfExporter;
|
private readonly ICvPdfExporter _cvPdfExporter;
|
||||||
|
private readonly AnalyticsService _analytics;
|
||||||
|
|
||||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_summarizer = summarizer;
|
_summarizer = summarizer;
|
||||||
@@ -33,6 +34,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||||
|
_analytics = analytics ?? new AnalyticsService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||||
@@ -1736,51 +1738,9 @@ Canonical profile:
|
|||||||
return Ok(all);
|
return Ok(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record JobStats(
|
|
||||||
int Total,
|
|
||||||
int Active,
|
|
||||||
int Deleted,
|
|
||||||
Dictionary<string, int> ByStatus,
|
|
||||||
int AppliedLast30Days,
|
|
||||||
double AverageDaysSinceApplied
|
|
||||||
);
|
|
||||||
|
|
||||||
[HttpGet("stats")]
|
[HttpGet("stats")]
|
||||||
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
|
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
|
||||||
{
|
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
|
||||||
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();
|
|
||||||
|
|
||||||
var byStatus = active
|
|
||||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
|
|
||||||
.OrderByDescending(g => g.Count())
|
|
||||||
.ToDictionary(g => g.Key, g => g.Count());
|
|
||||||
|
|
||||||
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
|
|
||||||
|
|
||||||
var avgDays = active.Count == 0
|
|
||||||
? 0
|
|
||||||
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
|
|
||||||
|
|
||||||
return Ok(new JobStats(
|
|
||||||
Total: all.Count,
|
|
||||||
Active: active.Count,
|
|
||||||
Deleted: all.Count - active.Count,
|
|
||||||
ByStatus: byStatus,
|
|
||||||
AppliedLast30Days: appliedLast30Days,
|
|
||||||
AverageDaysSinceApplied: Math.Round(avgDays, 1)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
|
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
|
||||||
|
|
||||||
[HttpGet("analytics")]
|
[HttpGet("analytics")]
|
||||||
@@ -1977,19 +1937,8 @@ Canonical profile:
|
|||||||
return Ok(outList);
|
return Ok(outList);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record FunnelStagePoint(string Label, int Count);
|
|
||||||
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
|
|
||||||
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 TagTrendSeries(string Tag, List<int> Counts);
|
||||||
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||||
public sealed record AnalyticsOverviewDto(
|
|
||||||
List<FunnelStagePoint> Funnel,
|
|
||||||
List<ResponseRatePoint> ResponseRateBySource,
|
|
||||||
List<CompanyActivityPoint> TopCompanies,
|
|
||||||
double? MedianDaysToFirstResponse,
|
|
||||||
int TotalResponses,
|
|
||||||
int TotalActive
|
|
||||||
);
|
|
||||||
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
|
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);
|
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
|
||||||
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
|
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
|
||||||
@@ -2672,86 +2621,7 @@ 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)
|
||||||
{
|
=> Ok(await _analytics.GetAnalyticsOverviewAsync(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()
|
|
||||||
.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<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();
|
|
||||||
|
|
||||||
var responseRateBySource = activeJobs
|
|
||||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
|
|
||||||
.Select(g => new ResponseRatePoint(
|
|
||||||
g.Key,
|
|
||||||
g.Count(),
|
|
||||||
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
|
||||||
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
|
||||||
))
|
|
||||||
.OrderByDescending(x => x.Total)
|
|
||||||
.ThenByDescending(x => x.Rate)
|
|
||||||
.Take(6)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var topCompanies = activeJobs
|
|
||||||
.GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
|
|
||||||
.Select(g => new CompanyActivityPoint(
|
|
||||||
g.Key.CompanyId,
|
|
||||||
g.Key.Name,
|
|
||||||
g.Count(),
|
|
||||||
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
|
||||||
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
|
||||||
))
|
|
||||||
.OrderByDescending(x => x.Count)
|
|
||||||
.ThenByDescending(x => x.ResponseRate)
|
|
||||||
.Take(8)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var responseDays = activeJobs
|
|
||||||
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
|
|
||||||
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
|
|
||||||
.OrderBy(x => x)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
double? medianDays = null;
|
|
||||||
if (responseDays.Count > 0)
|
|
||||||
{
|
|
||||||
var mid = responseDays.Count / 2;
|
|
||||||
medianDays = responseDays.Count % 2 == 0
|
|
||||||
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
|
|
||||||
: Math.Round(responseDays[mid], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("tag-trends")]
|
[HttpGet("tag-trends")]
|
||||||
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
|
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ builder.Services.AddHttpClient("ai-service", client =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
|
builder.Services.AddScoped<AnalyticsService>();
|
||||||
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||||
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only analytics/statistics aggregation extracted from JobApplicationsController.
|
||||||
|
/// Uses the tenant-scoped <see cref="JobTrackerContext"/>, so the global OwnerUserId
|
||||||
|
/// query filters apply automatically. Behaviour is identical to the former inline
|
||||||
|
/// controller methods (GetStats / GetAnalyticsOverview).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AnalyticsService
|
||||||
|
{
|
||||||
|
private readonly JobTrackerContext _db;
|
||||||
|
|
||||||
|
public AnalyticsService(JobTrackerContext db)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
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.
|
||||||
|
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();
|
||||||
|
|
||||||
|
var byStatus = active
|
||||||
|
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
|
||||||
|
.OrderByDescending(g => g.Count())
|
||||||
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
|
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
|
||||||
|
|
||||||
|
var avgDays = active.Count == 0
|
||||||
|
? 0
|
||||||
|
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
|
||||||
|
|
||||||
|
return new JobStats(
|
||||||
|
Total: all.Count,
|
||||||
|
Active: active.Count,
|
||||||
|
Deleted: all.Count - active.Count,
|
||||||
|
ByStatus: byStatus,
|
||||||
|
AppliedLast30Days: appliedLast30Days,
|
||||||
|
AverageDaysSinceApplied: Math.Round(avgDays, 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AnalyticsOverviewDto> GetAnalyticsOverviewAsync(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()
|
||||||
|
.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<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();
|
||||||
|
|
||||||
|
var responseRateBySource = activeJobs
|
||||||
|
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
|
||||||
|
.Select(g => new ResponseRatePoint(
|
||||||
|
g.Key,
|
||||||
|
g.Count(),
|
||||||
|
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
||||||
|
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
||||||
|
))
|
||||||
|
.OrderByDescending(x => x.Total)
|
||||||
|
.ThenByDescending(x => x.Rate)
|
||||||
|
.Take(6)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var topCompanies = activeJobs
|
||||||
|
.GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
|
||||||
|
.Select(g => new CompanyActivityPoint(
|
||||||
|
g.Key.CompanyId,
|
||||||
|
g.Key.Name,
|
||||||
|
g.Count(),
|
||||||
|
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
|
||||||
|
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
|
||||||
|
))
|
||||||
|
.OrderByDescending(x => x.Count)
|
||||||
|
.ThenByDescending(x => x.ResponseRate)
|
||||||
|
.Take(8)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var responseDays = activeJobs
|
||||||
|
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
|
||||||
|
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
|
||||||
|
.OrderBy(x => x)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
double? medianDays = null;
|
||||||
|
if (responseDays.Count > 0)
|
||||||
|
{
|
||||||
|
var mid = responseDays.Count / 2;
|
||||||
|
medianDays = responseDays.Count % 2 == 0
|
||||||
|
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
|
||||||
|
: Math.Round(responseDays[mid], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Models
|
||||||
|
{
|
||||||
|
// Read-only analytics/statistics response DTOs. Extracted from
|
||||||
|
// JobApplicationsController so the aggregation logic can live in AnalyticsService.
|
||||||
|
public sealed record JobStats(
|
||||||
|
int Total,
|
||||||
|
int Active,
|
||||||
|
int Deleted,
|
||||||
|
Dictionary<string, int> ByStatus,
|
||||||
|
int AppliedLast30Days,
|
||||||
|
double AverageDaysSinceApplied
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed record FunnelStagePoint(string Label, int Count);
|
||||||
|
|
||||||
|
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
|
||||||
|
|
||||||
|
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
|
||||||
|
|
||||||
|
public sealed record AnalyticsOverviewDto(
|
||||||
|
List<FunnelStagePoint> Funnel,
|
||||||
|
List<ResponseRatePoint> ResponseRateBySource,
|
||||||
|
List<CompanyActivityPoint> TopCompanies,
|
||||||
|
double? MedianDaysToFirstResponse,
|
||||||
|
int TotalResponses,
|
||||||
|
int TotalActive
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user