Files
jobtrackingapp/JobTrackerApi/Controllers/JobApplicationAnalyticsController.cs
T
cesnimda 3fa828be47 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.
2026-08-30 23:26:47 +02:00

194 lines
7.9 KiB
C#

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));
}
}