diff --git a/JobTrackerApi/Controllers/JobApplicationAnalyticsController.cs b/JobTrackerApi/Controllers/JobApplicationAnalyticsController.cs new file mode 100644 index 0000000..8a6e5f8 --- /dev/null +++ b/JobTrackerApi/Controllers/JobApplicationAnalyticsController.cs @@ -0,0 +1,193 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using static JobTrackerApi.Services.JobApplicationHelpers; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/jobapplications")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class JobApplicationAnalyticsController : ControllerBase +{ + private readonly JobTrackerContext _db; + private readonly AnalyticsService _analytics; + + public JobApplicationAnalyticsController(JobTrackerContext db, AnalyticsService analytics) + { + _db = db; + _analytics = analytics; + } + + [HttpGet("stats")] + public async Task> GetStats(CancellationToken cancellationToken) + => Ok(await _analytics.GetStatsAsync(cancellationToken)); + + [HttpGet("analytics")] + public async Task>> GetAnalytics( + [FromQuery] int months = 12, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + CancellationToken cancellationToken = default) + { + months = Math.Clamp(months, 3, 36); + var now = DateTime.Now; + DateTime startMonth; + DateTime endMonth; + + if (from is not null || to is not null) + { + var toValue = to ?? now; + var fromValue = from ?? toValue.AddMonths(-months); + if (toValue < fromValue) (fromValue, toValue) = (toValue, fromValue); + + startMonth = new DateTime(fromValue.Year, fromValue.Month, 1); + endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1); + var spanMonths = ((endMonth.Year - startMonth.Year) * 12) + endMonth.Month - startMonth.Month; + spanMonths = Math.Clamp(spanMonths, 3, 36); + startMonth = endMonth.AddMonths(-spanMonths); + months = spanMonths; + } + else + { + endMonth = new DateTime(now.Year, now.Month, 1).AddMonths(1); + startMonth = endMonth.AddMonths(-months); + } + + var jobs = await _db.JobApplications + .AsNoTracking() + .Where(job => !job.IsDeleted + && job.DateApplied != null + && job.DateApplied >= startMonth + && job.DateApplied < endMonth) + .Select(job => new { job.DateApplied, job.ResponseDate }) + .ToListAsync(cancellationToken); + + var applied = new Dictionary(StringComparer.Ordinal); + var responses = new Dictionary(StringComparer.Ordinal); + static string Key(DateTime value) => $"{value:yyyy-MM}"; + + foreach (var job in jobs) + { + var appliedKey = Key(job.DateApplied!.Value); + applied[appliedKey] = (applied.TryGetValue(appliedKey, out var appliedCount) ? appliedCount : 0) + 1; + if (job.ResponseDate is not null) + { + var responseKey = Key(job.ResponseDate.Value); + responses[responseKey] = (responses.TryGetValue(responseKey, out var responseCount) ? responseCount : 0) + 1; + } + } + + var result = new List(months); + for (var index = 0; index < months; index++) + { + var key = Key(startMonth.AddMonths(index)); + applied.TryGetValue(key, out var appliedCount); + responses.TryGetValue(key, out var responseCount); + result.Add(new AnalyticsPoint(key, appliedCount, responseCount)); + } + + return Ok(result); + } + + [HttpGet("tags")] + public async Task>> GetTags( + [FromQuery] int limit = 10, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + CancellationToken cancellationToken = default) + { + limit = Math.Clamp(limit, 3, 50); + IQueryable query = _db.JobApplications.AsNoTracking().Where(job => !job.IsDeleted); + + if (from is not null || to is not null) + { + var toValue = to ?? DateTime.Now; + var fromValue = from ?? DateTime.MinValue; + if (toValue < fromValue) (fromValue, toValue) = (toValue, fromValue); + + var startMonth = fromValue == DateTime.MinValue + ? (DateTime?)null + : new DateTime(fromValue.Year, fromValue.Month, 1); + var endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1); + if (startMonth is not null) query = query.Where(job => job.DateApplied >= startMonth.Value); + query = query.Where(job => job.DateApplied < endMonth); + } + + var tagStrings = await query.Select(job => job.Tags).ToListAsync(cancellationToken); + var counts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var tagString in tagStrings) + { + foreach (var tag in SplitTags(tagString)) + { + counts[tag] = counts.TryGetValue(tag, out var value) + ? (value.Display, value.Count + 1) + : (tag, 1); + } + } + + return Ok(counts.Values + .OrderByDescending(value => value.Count) + .ThenBy(value => value.Display, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .Select(value => new TagPoint(value.Display, value.Count)) + .ToList()); + } + + [HttpGet("analytics-overview")] + public async Task> GetAnalyticsOverview(CancellationToken cancellationToken) + => Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken)); + + [HttpGet("tag-trends")] + public async Task> GetTagTrends( + [FromQuery] int months = 6, + [FromQuery] int limit = 5, + CancellationToken cancellationToken = default) + { + months = Math.Clamp(months, 3, 24); + limit = Math.Clamp(limit, 3, 10); + var endMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).AddMonths(1); + var startMonth = endMonth.AddMonths(-months); + var jobs = await _db.JobApplications + .AsNoTracking() + .Where(job => !job.IsDeleted && job.DateApplied >= startMonth && job.DateApplied < endMonth) + .Select(job => new { job.DateApplied, job.Tags }) + .ToListAsync(cancellationToken); + + var totals = new Dictionary(StringComparer.OrdinalIgnoreCase); + var monthKeys = Enumerable.Range(0, months).Select(index => startMonth.AddMonths(index).ToString("yyyy-MM")).ToList(); + var countsByTag = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var job in jobs) + { + var monthKey = $"{job.DateApplied:yyyy-MM}"; + foreach (var tag in SplitTags(job.Tags)) + { + totals[tag] = (totals.TryGetValue(tag, out var total) ? total : 0) + 1; + if (!countsByTag.TryGetValue(tag, out var countsByMonth)) + { + countsByMonth = new Dictionary(StringComparer.Ordinal); + countsByTag[tag] = countsByMonth; + } + countsByMonth[monthKey] = (countsByMonth.TryGetValue(monthKey, out var count) ? count : 0) + 1; + } + } + + var topTags = totals + .OrderByDescending(value => value.Value) + .ThenBy(value => value.Key, StringComparer.OrdinalIgnoreCase) + .Take(limit) + .Select(value => value.Key) + .ToList(); + var series = topTags + .Select(tag => new TagTrendSeries( + tag, + monthKeys.Select(month => countsByTag.TryGetValue(tag, out var byMonth) + && byMonth.TryGetValue(month, out var count) ? count : 0).ToList())) + .ToList(); + + return Ok(new TagTrendResponse(monthKeys, series)); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 166e748..4631f1a 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -30,12 +30,11 @@ namespace JobTrackerApi.Controllers private readonly UserManager _users; private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvPdfExporter _cvPdfExporter; - private readonly AnalyticsService _analytics; private readonly IJobCvMatchService _matchService; private readonly IMemoryCache _cache; private readonly IApplicationChecklistService _checklist; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, UserManager users, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, UserManager users, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null) { _checklist = checklist ?? new ApplicationChecklistService(db); _db = db; @@ -43,7 +42,6 @@ namespace JobTrackerApi.Controllers _users = users; _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); - _analytics = analytics ?? new AnalyticsService(db); _matchService = matchService ?? new JobCvMatchService(); _cache = cache ?? new MemoryCache(new MemoryCacheOptions()); } @@ -1177,204 +1175,6 @@ Canonical profile: return Ok(items); } - [HttpGet("stats")] - public async Task> GetStats(CancellationToken cancellationToken) - => Ok(await _analytics.GetStatsAsync(cancellationToken)); - - [HttpGet("analytics")] - public async Task>> GetAnalytics( - [FromQuery] int months = 12, - [FromQuery] DateTime? from = null, - [FromQuery] DateTime? to = null, - CancellationToken cancellationToken = default - ) - { - if (months < 3) months = 3; - if (months > 36) months = 36; - - var now = DateTime.Now; - - DateTime startMonth; - DateTime endMonth; - - if (from is not null || to is not null) - { - var toValue = to ?? now; - var fromValue = from ?? toValue.AddMonths(-months); - - if (toValue < fromValue) - { - (fromValue, toValue) = (toValue, fromValue); - } - - startMonth = new DateTime(fromValue.Year, fromValue.Month, 1); - endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1); - - var spanMonths = ((endMonth.Year - startMonth.Year) * 12) + (endMonth.Month - startMonth.Month); - if (spanMonths < 3) - { - spanMonths = 3; - startMonth = endMonth.AddMonths(-spanMonths); - } - - if (spanMonths > 36) - { - spanMonths = 36; - startMonth = endMonth.AddMonths(-spanMonths); - } - - months = spanMonths; - } - else - { - endMonth = new DateTime(now.Year, now.Month, 1).AddMonths(1); - startMonth = endMonth.AddMonths(-months); - } - - // DateApplied != null is explicit rather than implied by the range comparison: this is - // applied-volume-per-month, so jobs that have not been applied to must not appear. - var jobs = await _db.JobApplications - .AsNoTracking() - .Where(j => !j.IsDeleted && j.DateApplied != null && j.DateApplied >= startMonth && j.DateApplied < endMonth) - .Select(j => new { j.DateApplied, j.ResponseDate }) - .ToListAsync(cancellationToken); - - var applied = new Dictionary(StringComparer.Ordinal); - var responses = new Dictionary(StringComparer.Ordinal); - - static string Key(DateTime d) => $"{d:yyyy-MM}"; - - foreach (var j in jobs) - { - var ak = Key(j.DateApplied!.Value); - applied[ak] = (applied.TryGetValue(ak, out var av) ? av : 0) + 1; - - if (j.ResponseDate is not null) - { - var rk = Key(j.ResponseDate.Value); - responses[rk] = (responses.TryGetValue(rk, out var rv) ? rv : 0) + 1; - } - } - - var outList = new List(months); - for (var i = 0; i < months; i++) - { - var m = startMonth.AddMonths(i); - var k = Key(m); - applied.TryGetValue(k, out var a); - responses.TryGetValue(k, out var r); - outList.Add(new AnalyticsPoint(k, a, r)); - } - - return Ok(outList); - } - - [HttpGet("tags")] - public async Task>> GetTags( - [FromQuery] int limit = 10, - [FromQuery] DateTime? from = null, - [FromQuery] DateTime? to = null, - CancellationToken cancellationToken = default - ) - { - if (limit < 3) limit = 3; - if (limit > 50) limit = 50; - - IQueryable query = _db.JobApplications - .AsNoTracking() - .Where(j => !j.IsDeleted); - - if (from is not null || to is not null) - { - var now = DateTime.Now; - var toValue = to ?? now; - var fromValue = from ?? DateTime.MinValue; - - if (toValue < fromValue) - { - (fromValue, toValue) = (toValue, fromValue); - } - - var startMonth = fromValue == DateTime.MinValue - ? (DateTime?)null - : new DateTime(fromValue.Year, fromValue.Month, 1); - var endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1); - - if (startMonth is not null) - { - query = query.Where(j => j.DateApplied >= startMonth.Value); - } - query = query.Where(j => j.DateApplied < endMonth); - } - - var tagStrings = await query - .Select(j => j.Tags) - .ToListAsync(cancellationToken); - - static IEnumerable SplitTags(string? s) - { - if (string.IsNullOrWhiteSpace(s)) yield break; - - var trimmed = s.Trim(); - - List? jsonTags = null; - if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) - { - try - { - jsonTags = JsonSerializer.Deserialize>(trimmed); - } - catch - { - jsonTags = null; - } - } - - if (jsonTags is not null) - { - foreach (var x in jsonTags) - { - var t = (x ?? string.Empty).Trim(); - if (t.Length == 0) continue; - yield return t; - } - yield break; - } - - foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)) - { - var t = raw.Trim(); - if (t.Length == 0) continue; - yield return t; - } - } - - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var s in tagStrings) - { - foreach (var t in SplitTags(s)) - { - if (map.TryGetValue(t, out var v)) - { - map[t] = (v.Display, v.Count + 1); - } - else - { - map[t] = (t, 1); - } - } - } - - var outList = map.Values - .OrderByDescending(x => x.Count) - .ThenBy(x => x.Display, StringComparer.OrdinalIgnoreCase) - .Take(limit) - .Select(x => new TagPoint(x.Display, x.Count)) - .ToList(); - - return Ok(outList); - } - private static string BuildPackageModeInstruction(string? mode) { return (mode ?? string.Empty).Trim().ToLowerInvariant() switch @@ -2080,66 +1880,6 @@ Candidate master CV: RecruiterMessageVariants: recruiterMessageVariants)); } - [HttpGet("analytics-overview")] - public async Task> GetAnalyticsOverview(CancellationToken cancellationToken) - => Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken)); - - [HttpGet("tag-trends")] - public async Task> GetTagTrends( - [FromQuery] int months = 6, - [FromQuery] int limit = 5, - CancellationToken cancellationToken = default) - { - if (months < 3) months = 3; - if (months > 24) months = 24; - if (limit < 3) limit = 3; - if (limit > 10) limit = 10; - - var endMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).AddMonths(1); - var startMonth = endMonth.AddMonths(-months); - - var jobs = await _db.JobApplications - .AsNoTracking() - .Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth) - .Select(j => new { j.DateApplied, j.Tags }) - .ToListAsync(cancellationToken); - - var overall = new Dictionary(StringComparer.OrdinalIgnoreCase); - var monthKeys = Enumerable.Range(0, months).Select(i => startMonth.AddMonths(i).ToString("yyyy-MM")).ToList(); - var seriesMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - foreach (var job in jobs) - { - var key = $"{job.DateApplied:yyyy-MM}"; - foreach (var tag in SplitTags(job.Tags)) - { - overall[tag] = (overall.TryGetValue(tag, out var count) ? count : 0) + 1; - if (!seriesMap.TryGetValue(tag, out var byMonth)) - { - byMonth = new Dictionary(StringComparer.Ordinal); - seriesMap[tag] = byMonth; - } - byMonth[key] = (byMonth.TryGetValue(key, out var monthCount) ? monthCount : 0) + 1; - } - } - - var topTags = overall - .OrderByDescending(x => x.Value) - .ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase) - .Take(limit) - .Select(x => x.Key) - .ToList(); - - var series = topTags - .Select(tag => new TagTrendSeries( - tag, - monthKeys.Select(month => seriesMap.TryGetValue(tag, out var byMonth) && byMonth.TryGetValue(month, out var count) ? count : 0).ToList() - )) - .ToList(); - - return Ok(new TagTrendResponse(monthKeys, series)); - } - [HttpGet("duplicate-check")] public async Task> CheckDuplicates( [FromQuery] int companyId, diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 5c5a7a3..5568539 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -6,6 +6,9 @@ Updated: 2026-08-30 ### Completed +- Diagnosed Gitea Actions run 696 from its authenticated job log: application tests passed, but repeated truncated npm/Playwright downloads were followed by a native exit 139 before browser assertions ran. CI now invokes the lockfile-installed Playwright CLI directly, extends the bounded download timeout, retries only SIGSEGV, and does not redownload the same dependency tree after a transient runner crash. +- Removed the unreachable legacy `JobDetailsDialog`, its duplicate insight tabs/panels, and tests coupled only to that retired surface. Live correspondence and routed-workspace contracts retain focused coverage; the cleanup removed 2,239 lines without changing the active Job Workspace. +- Began the controller-responsibility split by moving job statistics, monthly analytics, tag counts, overview analytics, and tag trends into one dedicated authenticated analytics controller. Existing `api/jobapplications/*` routes and response contracts are unchanged; the shared tag parser replaces a byte-for-byte duplicate local parser. - Introduced shared application spacing tokens for 16px mobile gutters, 24px tablet/desktop gutters, 32px wide-screen gutters, 24px section rhythm, and 16–20px card padding. `AppShell` and the first redesigned surfaces consume these tokens. - Reworked the Dashboard around today's prioritized actions. Empty accounts no longer render zero-value metrics, ten empty funnel stages, empty time-in-stage, company, skill, or activity panels. The activity SVG now scales to its container instead of requiring a clipped fixed-width mobile canvas. - Made onboarding dismissible per account/browser while preserving the checklist until it is completed or dismissed. @@ -86,6 +89,9 @@ Updated: 2026-08-30 ### Verification +- Gitea run 696 evidence: all backend/frontend unit stages passed; failure isolated to Playwright runtime bootstrap/execution after truncated archives and exit 139. The revised shell branch preserves ordinary non-139 failures and uses the installed CLI without `npx` fallback installation. +- Post-cleanup frontend: ESLint passed with zero warnings, all 60 suites and 260/260 tests passed, optimized Next production build and integrated TypeScript passed, and Playwright passed 10/10 including public and long searchable multi-page CV PDFs. +- Analytics-controller extraction: Release build passed with 0 warnings/errors and the complete backend suite passed 736/736. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed.