ea6c3650f3
- Move inline DTOs to JobApplicationDtos.cs, pure static helpers to JobApplicationHelpers.cs - GetStats aggregates server-side (COUNT/GROUP BY) instead of loading the full table - Cache RuleSettings via IMemoryCache, keyed per-user (RulesEngine.GetSettings falls back to per-user UserRuleSettings overrides, so a single global cache key would leak settings across users) - Add missing AsNoTracking() to read-only GET endpoints (GetAll, GetById, GetBoard, GetReminders, GetStatusSuggestion, GetMatchScore, GetCandidateFit, GetFocusPlan, GetInterviewPrep, GetReadiness) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
184 lines
8.2 KiB
C#
184 lines
8.2 KiB
C#
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;
|
|
var last30 = now.AddDays(-30);
|
|
|
|
// Aggregate server-side (COUNT/GROUP BY) instead of pulling every row into memory.
|
|
var total = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken);
|
|
var active = await _db.JobApplications.AsNoTracking().CountAsync(j => !j.IsDeleted, cancellationToken);
|
|
var appliedLast30Days = await _db.JobApplications.AsNoTracking()
|
|
.CountAsync(j => !j.IsDeleted && j.DateApplied >= last30, cancellationToken);
|
|
|
|
var byStatus = await _db.JobApplications
|
|
.AsNoTracking()
|
|
.Where(j => !j.IsDeleted)
|
|
.GroupBy(j => j.Status)
|
|
.Select(g => new { Status = g.Key, Count = g.Count() })
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var byStatusDict = byStatus
|
|
.GroupBy(x => string.IsNullOrWhiteSpace(x.Status) ? "Unknown" : x.Status)
|
|
.OrderByDescending(g => g.Sum(x => x.Count))
|
|
.ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
|
|
|
|
// ponytail: average age needs a per-row day-diff that doesn't translate identically
|
|
// across the SQLite/MySQL providers this app runs on, so pull just the DateApplied
|
|
// column (no wide blob columns) for active rows and average client-side.
|
|
var activeDates = active == 0
|
|
? new List<DateTime>()
|
|
: await _db.JobApplications.AsNoTracking()
|
|
.Where(j => !j.IsDeleted)
|
|
.Select(j => j.DateApplied)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var avgDays = activeDates.Count == 0
|
|
? 0
|
|
: activeDates.Average(d => Math.Max(0, (now - d).TotalDays));
|
|
|
|
return new JobStats(
|
|
Total: total,
|
|
Active: active,
|
|
Deleted: total - active,
|
|
ByStatus: byStatusDict,
|
|
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.Id,
|
|
j.Status,
|
|
j.ResponseReceived,
|
|
j.ResponseDate,
|
|
j.DateApplied,
|
|
j.CompanyId,
|
|
CompanyName = j.Company.Name,
|
|
CompanySource = j.Company.Source
|
|
})
|
|
.ToListAsync(cancellationToken);
|
|
|
|
// 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())
|
|
.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);
|
|
}
|
|
|
|
// 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,
|
|
TimeInStage: timeInStage
|
|
);
|
|
}
|
|
}
|
|
}
|