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