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.Status, j.ResponseReceived, j.ResponseDate, j.DateApplied, j.CompanyId, CompanyName = j.Company.Name, CompanySource = j.Company.Source }) .ToListAsync(cancellationToken); var funnelMap = new Dictionary { ["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 ); } } }