3fa828be47
Keep the existing job application analytics routes while moving their queries behind a dedicated authenticated controller and shared tag parser.
194 lines
7.9 KiB
C#
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));
|
|
}
|
|
}
|