refactor(jobs): extract analytics controller

Keep the existing job application analytics routes while moving their queries behind a dedicated authenticated controller and shared tag parser.
This commit is contained in:
cesnimda
2026-08-30 23:26:47 +02:00
parent 32d16089f1
commit 3fa828be47
3 changed files with 200 additions and 261 deletions
@@ -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<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
[HttpGet("analytics")]
public async Task<ActionResult<List<AnalyticsPoint>>> 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<string, int>(StringComparer.Ordinal);
var responses = new Dictionary<string, int>(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<AnalyticsPoint>(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<ActionResult<List<TagPoint>>> GetTags(
[FromQuery] int limit = 10,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
CancellationToken cancellationToken = default)
{
limit = Math.Clamp(limit, 3, 50);
IQueryable<JobApplication> 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<string, (string Display, int Count)>(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<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
=> Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
[HttpGet("tag-trends")]
public async Task<ActionResult<TagTrendResponse>> 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<string, int>(StringComparer.OrdinalIgnoreCase);
var monthKeys = Enumerable.Range(0, months).Select(index => startMonth.AddMonths(index).ToString("yyyy-MM")).ToList();
var countsByTag = new Dictionary<string, Dictionary<string, int>>(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<string, int>(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));
}
}
@@ -30,12 +30,11 @@ namespace JobTrackerApi.Controllers
private readonly UserManager<ApplicationUser> _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<ApplicationUser> 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<ApplicationUser> 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<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
[HttpGet("analytics")]
public async Task<ActionResult<List<AnalyticsPoint>>> 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<string, int>(StringComparer.Ordinal);
var responses = new Dictionary<string, int>(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<AnalyticsPoint>(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<ActionResult<List<TagPoint>>> 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<JobApplication> 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<string> SplitTags(string? s)
{
if (string.IsNullOrWhiteSpace(s)) yield break;
var trimmed = s.Trim();
List<string>? jsonTags = null;
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
try
{
jsonTags = JsonSerializer.Deserialize<List<string>>(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<string, (string Display, int Count)>(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<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
=> Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
[HttpGet("tag-trends")]
public async Task<ActionResult<TagTrendResponse>> 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<string, int>(StringComparer.OrdinalIgnoreCase);
var monthKeys = Enumerable.Range(0, months).Select(i => startMonth.AddMonths(i).ToString("yyyy-MM")).ToList();
var seriesMap = new Dictionary<string, Dictionary<string, int>>(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<string, int>(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<ActionResult<DuplicateCheckResult>> CheckDuplicates(
[FromQuery] int companyId,
+6
View File
@@ -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 1620px 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.