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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user