using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; using JobTrackerApi.Models; namespace JobTrackerApi.Services { /// /// Read-only analytics/statistics aggregation extracted from JobApplicationsController. /// Uses the tenant-scoped , so the global OwnerUserId /// query filters apply automatically. Behaviour is identical to the former inline /// controller methods (GetStats / GetAnalyticsOverview). /// public sealed class AnalyticsService { private readonly JobTrackerContext _db; public AnalyticsService(JobTrackerContext db) { _db = db; } public async Task 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 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 ); } } }