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; 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. // DateApplied is null for pre-application stages; those have no "days since applied" // and are filtered out server-side so they can't drag the average toward zero. var activeDates = active == 0 ? new List() : await _db.JobApplications.AsNoTracking() .Where(j => !j.IsDeleted && j.DateApplied != null) .Select(j => j.DateApplied!.Value) .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 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.SavedAt, j.CompanyId, CompanyName = j.Company.Name, CompanySource = j.Company.Source, j.SalaryMin, j.SalaryMax, j.SalaryCurrency, j.SalaryPeriod }) .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(); // "Days to respond" is only meaningful once applied, so rows with no DateApplied // (pre-application stages) are excluded rather than measured from nothing. var responseDays = activeJobs .Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null && j.DateApplied is not null) .Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied!.Value).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); // Fall back to SavedAt when the job has not been applied to: every job has a // saved date, so a stage entry time always exists even before DateApplied does. DateTime enteredAt = job.DateApplied ?? job.SavedAt; 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(); var salaryInsights = activeJobs .Where(j => (j.SalaryMin is not null || j.SalaryMax is not null) && !string.IsNullOrWhiteSpace(j.SalaryCurrency) && !string.IsNullOrWhiteSpace(j.SalaryPeriod)) .GroupBy(j => new { Currency = j.SalaryCurrency!.ToUpperInvariant(), Period = j.SalaryPeriod!.ToLowerInvariant() }) .Select(g => new SalaryInsightDto( g.Key.Currency, g.Key.Period, g.Count(), g.Min(j => j.SalaryMin ?? j.SalaryMax!.Value), g.Max(j => j.SalaryMax ?? j.SalaryMin!.Value), Math.Round(g.Average(j => ((j.SalaryMin ?? j.SalaryMax!.Value) + (j.SalaryMax ?? j.SalaryMin!.Value)) / 2m), 0))) .OrderByDescending(x => x.Count) .ThenBy(x => x.Currency) .ThenBy(x => x.Period) .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, SalaryInsights: salaryInsights ); } } }