merge: reconcile perf/wave1-perf with main (Wave 0 features)
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut: - useViewResource.ts: main'se352aaealready fixes the render loop the same way (load in a ref, dropped from deps) — took main's canonical version. My independent fix is superseded (my branch predatede352aae, which is why the loop reproduced live). - JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays delegated to AnalyticsService. - Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API contract the frontend expects. Build clean; backend suite 135/135 green.
This commit is contained in:
@@ -64,6 +64,7 @@ namespace JobTrackerApi.Services
|
||||
.Where(j => !j.IsDeleted)
|
||||
.Select(j => new
|
||||
{
|
||||
j.Id,
|
||||
j.Status,
|
||||
j.ResponseReceived,
|
||||
j.ResponseDate,
|
||||
@@ -74,16 +75,14 @@ namespace JobTrackerApi.Services
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var funnelMap = new Dictionary<string, int>
|
||||
{
|
||||
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
|
||||
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
|
||||
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
|
||||
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
|
||||
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
|
||||
};
|
||||
|
||||
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
|
||||
// Funnel = distribution across canonical stages, driven by the pipeline (one source
|
||||
// of truth, so it includes every stage and normalizes legacy spellings).
|
||||
var normalizedByStage = activeJobs
|
||||
.GroupBy(j => JobPipeline.Normalize(j.Status))
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var funnel = JobPipeline.Stages
|
||||
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
|
||||
.ToList();
|
||||
|
||||
var responseRateBySource = activeJobs
|
||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
|
||||
@@ -127,13 +126,46 @@ namespace JobTrackerApi.Services
|
||||
: Math.Round(responseDays[mid], 1);
|
||||
}
|
||||
|
||||
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
|
||||
// recent StatusChanged event into that stage, else its applied date.
|
||||
var activeIds = activeJobs.Select(j => j.Id).ToList();
|
||||
var statusChanges = await _db.JobEvents
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
|
||||
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var lastEntryByJob = statusChanges
|
||||
.GroupBy(e => e.JobApplicationId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var occupancy = activeJobs.Select(job =>
|
||||
{
|
||||
var current = JobPipeline.Normalize(job.Status);
|
||||
DateTime enteredAt = job.DateApplied;
|
||||
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
|
||||
{
|
||||
var lastIntoCurrent = changes
|
||||
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
|
||||
.OrderByDescending(e => e.At)
|
||||
.FirstOrDefault();
|
||||
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
|
||||
}
|
||||
return new StageOccupancy(current, enteredAt.ToUniversalTime());
|
||||
});
|
||||
|
||||
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
|
||||
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
|
||||
.ToList();
|
||||
|
||||
return new AnalyticsOverviewDto(
|
||||
Funnel: funnel,
|
||||
ResponseRateBySource: responseRateBySource,
|
||||
TopCompanies: topCompanies,
|
||||
MedianDaysToFirstResponse: medianDays,
|
||||
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
|
||||
TotalActive: activeJobs.Count
|
||||
TotalActive: activeJobs.Count,
|
||||
TimeInStage: timeInStage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed class DatabaseBackupHostedService : BackgroundService
|
||||
{
|
||||
private readonly IDatabaseBackupRunner _runner;
|
||||
private readonly ILogger<DatabaseBackupHostedService> _logger;
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly IStartupReadiness _startupReadiness;
|
||||
|
||||
public DatabaseBackupHostedService(
|
||||
IDatabaseBackupRunner runner,
|
||||
ILogger<DatabaseBackupHostedService> logger,
|
||||
IConfiguration cfg,
|
||||
IStartupReadiness startupReadiness)
|
||||
{
|
||||
_runner = runner;
|
||||
_logger = logger;
|
||||
_cfg = cfg;
|
||||
_startupReadiness = startupReadiness;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await _startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
|
||||
if (!_cfg.GetValue("Backups:Enabled", true))
|
||||
{
|
||||
_logger.LogInformation("Automated database backups disabled (Backups:Enabled=false).");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_runner.IsSupported)
|
||||
{
|
||||
_logger.LogWarning("Automated database backups are unavailable for the configured provider. Configure external backups for MySQL/MariaDB.");
|
||||
return;
|
||||
}
|
||||
|
||||
var hour = _cfg.GetValue("Backups:HourLocal", 3);
|
||||
if (hour < 0 || hour > 23) hour = 3;
|
||||
|
||||
// Catch-up: guarantee at least one recent backup exists even if the
|
||||
// process never stays up long enough to reach the scheduled hour.
|
||||
var latest = _runner.GetLatestBackupUtc();
|
||||
if (latest is null || latest < DateTime.UtcNow.AddHours(-24))
|
||||
{
|
||||
await TryBackupAsync(stoppingToken);
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var next = new DateTime(now.Year, now.Month, now.Day, hour, 0, 0);
|
||||
if (next <= now) next = next.AddDays(1);
|
||||
|
||||
_logger.LogInformation("Next database backup scheduled at {Next}.", next);
|
||||
try
|
||||
{
|
||||
await Task.Delay(next - now, stoppingToken);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await TryBackupAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryBackupAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _runner.RunOnceAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Database backup failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public interface IDatabaseBackupRunner
|
||||
{
|
||||
string BackupsRoot { get; }
|
||||
bool IsSupported { get; }
|
||||
|
||||
/// <summary>Creates one backup file and prunes old ones. Returns the backup path, or null when unsupported.</summary>
|
||||
Task<string?> RunOnceAsync(CancellationToken ct);
|
||||
|
||||
DateTime? GetLatestBackupUtc();
|
||||
}
|
||||
|
||||
public sealed class SqliteDatabaseBackupRunner : IDatabaseBackupRunner
|
||||
{
|
||||
public const string BackupFilePrefix = "jobtracker_backup_";
|
||||
|
||||
private readonly ILogger<SqliteDatabaseBackupRunner> _logger;
|
||||
private readonly string _connectionString;
|
||||
private readonly int _retainCount;
|
||||
|
||||
public string BackupsRoot { get; }
|
||||
public bool IsSupported { get; }
|
||||
|
||||
public SqliteDatabaseBackupRunner(IConfiguration cfg, AppPaths paths, ILogger<SqliteDatabaseBackupRunner> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
|
||||
var cs = cfg.GetConnectionString("JobTracker");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
cs = $"Data Source={paths.GetDbPath()}";
|
||||
provider = "sqlite";
|
||||
}
|
||||
|
||||
_connectionString = cs;
|
||||
IsSupported = provider == "sqlite";
|
||||
BackupsRoot = Path.Combine(paths.DataRoot, "backups");
|
||||
_retainCount = Math.Clamp(cfg.GetValue("Backups:RetainCount", 14), 1, 365);
|
||||
}
|
||||
|
||||
// Test-friendly constructor.
|
||||
public SqliteDatabaseBackupRunner(string connectionString, string backupsRoot, int retainCount, ILogger<SqliteDatabaseBackupRunner> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_connectionString = connectionString;
|
||||
IsSupported = true;
|
||||
BackupsRoot = backupsRoot;
|
||||
_retainCount = Math.Clamp(retainCount, 1, 365);
|
||||
}
|
||||
|
||||
public async Task<string?> RunOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (!IsSupported)
|
||||
{
|
||||
_logger.LogWarning("Automated backups only support the SQLite provider. Configure external backups for MySQL/MariaDB.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(BackupsRoot);
|
||||
|
||||
var target = Path.Combine(BackupsRoot, $"{BackupFilePrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.db");
|
||||
if (File.Exists(target)) File.Delete(target);
|
||||
|
||||
await using (var connection = new SqliteConnection(_connectionString))
|
||||
{
|
||||
await connection.OpenAsync(ct);
|
||||
await using var command = connection.CreateCommand();
|
||||
// VACUUM INTO produces a consistent, compacted snapshot without blocking writers (WAL).
|
||||
command.CommandText = $"VACUUM INTO '{target.Replace("'", "''")}'";
|
||||
await command.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Database backup written: {File}.", target);
|
||||
PruneOldBackups();
|
||||
return target;
|
||||
}
|
||||
|
||||
public DateTime? GetLatestBackupUtc()
|
||||
{
|
||||
if (!Directory.Exists(BackupsRoot)) return null;
|
||||
var latest = ListBackups().FirstOrDefault();
|
||||
return latest?.LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
private void PruneOldBackups()
|
||||
{
|
||||
foreach (var stale in ListBackups().Skip(_retainCount))
|
||||
{
|
||||
try
|
||||
{
|
||||
stale.Delete();
|
||||
_logger.LogInformation("Pruned old database backup: {File}.", stale.Name);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not prune old database backup {File}.", stale.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IOrderedEnumerable<FileInfo> ListBackups()
|
||||
=> new DirectoryInfo(BackupsRoot)
|
||||
.EnumerateFiles($"{BackupFilePrefix}*.db")
|
||||
.OrderByDescending(f => f.LastWriteTimeUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and
|
||||
/// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms).
|
||||
/// Priority matters — a rejection email often still mentions "interview", so rejection wins.
|
||||
/// </summary>
|
||||
public static class EmailStatusClassifier
|
||||
{
|
||||
// Ordered highest-priority first. Each stage lists lowercase phrases to look for.
|
||||
private static readonly (string Status, string Confidence, string[] Phrases)[] Rules =
|
||||
{
|
||||
("Rejected", "high", new[]
|
||||
{
|
||||
"regret to inform", "we regret", "unfortunately, we", "not moving forward",
|
||||
"not be moving forward", "decided not to proceed", "will not be proceeding",
|
||||
"not to proceed", "not been selected", "will not be progressing",
|
||||
"unable to offer", "position has been filled", "no longer being considered",
|
||||
"decided to move forward with other", "pursue other candidates",
|
||||
"not to move forward", "were not successful", "was not successful",
|
||||
}),
|
||||
("Offer", "high", new[]
|
||||
{
|
||||
"pleased to offer", "delighted to offer", "happy to offer", "offer of employment",
|
||||
"job offer", "we would like to offer", "formal offer", "extend an offer",
|
||||
"offer letter", "excited to offer",
|
||||
}),
|
||||
("Interview", "medium", new[]
|
||||
{
|
||||
"invite you to interview", "invite you to an interview", "schedule an interview",
|
||||
"would like to invite you", "phone screen", "phone interview", "video interview",
|
||||
"technical interview", "next steps in the", "your availability for a call",
|
||||
"availability for an interview", "set up a call", "set up an interview",
|
||||
"meet the team", "book a time", "invitation to interview", "interview invitation",
|
||||
"like to speak with you", "move to the interview",
|
||||
}),
|
||||
};
|
||||
|
||||
// Weaker single-word cues only fire when no strong phrase matched (kept low-confidence).
|
||||
private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" };
|
||||
|
||||
public static EmailStatusSuggestion? Classify(string? subject, string? body)
|
||||
{
|
||||
var text = $"{subject}\n{body}".ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
foreach (var (status, confidence, phrases) in Rules)
|
||||
{
|
||||
var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal));
|
||||
if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence);
|
||||
}
|
||||
|
||||
var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal));
|
||||
if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using JobTrackerApi.Services.JobImport;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
/// <summary>One keyword drawn from the job posting and whether the CV covers it.</summary>
|
||||
public sealed record MatchKeyword(string Keyword, int Weight, bool InTitle, bool Matched);
|
||||
|
||||
/// <summary>How many of the matched keywords appear in a given CV section.</summary>
|
||||
public sealed record MatchSectionCoverage(string Section, int Matched, int Total);
|
||||
|
||||
public sealed record JobCvMatchResult(
|
||||
int Score,
|
||||
string Band,
|
||||
int MatchedCount,
|
||||
int TotalKeywords,
|
||||
IReadOnlyList<string> MatchedKeywords,
|
||||
IReadOnlyList<string> MissingKeywords,
|
||||
IReadOnlyList<MatchSectionCoverage> SectionCoverage,
|
||||
bool HasEnoughSignal);
|
||||
|
||||
public interface IJobCvMatchService
|
||||
{
|
||||
JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic CV↔job keyword coverage score. No AI: the same inputs always produce the
|
||||
/// same number so users get a stable, reproducible signal (the Jobscan-style differentiator).
|
||||
/// The AI narrative lives separately in the candidate-fit endpoint.
|
||||
/// </summary>
|
||||
public sealed class JobCvMatchService : IJobCvMatchService
|
||||
{
|
||||
// Weights: curated skill tags are high-signal; salient posting terms are the long tail.
|
||||
private const int CuratedTagWeight = 3;
|
||||
private const int TermWeight = 1;
|
||||
private const int TitleBonus = 2;
|
||||
private const int MaxKeywords = 28;
|
||||
|
||||
private static readonly Regex TokenPattern = new(@"[a-z0-9][a-z0-9+.#-]*", RegexOptions.Compiled);
|
||||
|
||||
private static readonly HashSet<string> StopWords = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"the", "and", "for", "with", "you", "your", "our", "are", "will", "have", "has", "that",
|
||||
"this", "from", "not", "but", "all", "can", "who", "how", "why", "what", "when", "who",
|
||||
"job", "role", "work", "working", "team", "teams", "company", "years", "year", "experience",
|
||||
"experienced", "skills", "skill", "ability", "able", "strong", "good", "great", "excellent",
|
||||
"including", "include", "includes", "well", "using", "use", "used", "within", "across",
|
||||
"into", "onto", "their", "them", "they", "were", "was", "would", "should", "could", "must",
|
||||
"new", "also", "per", "via", "etc", "such", "any", "one", "two", "three", "day", "days",
|
||||
"week", "weeks", "month", "months", "time", "full", "part", "based", "join", "looking",
|
||||
"seeking", "candidate", "candidates", "applicant", "position", "positions", "opportunity",
|
||||
"responsibilities", "requirements", "required", "preferred", "plus", "nice", "want", "need",
|
||||
"needs", "help", "make", "made", "get", "got", "more", "most", "many", "much", "each",
|
||||
"other", "others", "some", "than", "then", "there", "here", "about", "over", "under", "out",
|
||||
"off", "its", "his", "her", "she", "him", "may", "might", "high", "low", "level", "levels",
|
||||
"environment", "environments", "world", "people", "person", "customer", "customers", "client",
|
||||
"clients", "product", "products", "service", "services", "business", "solution", "solutions",
|
||||
"project", "projects", "process", "processes", "development", "develop", "developer",
|
||||
// Seniority / role-title words: noise for CV keyword matching (the hard skills are what count).
|
||||
"senior", "junior", "lead", "principal", "mid", "staff", "engineer", "engineers",
|
||||
"engineering", "manager", "specialist", "analyst", "consultant", "administrator",
|
||||
"coordinator", "associate", "intern", "officer", "director", "professional",
|
||||
};
|
||||
|
||||
public JobCvMatchResult Evaluate(string jobTitle, string jobText, IReadOnlyDictionary<string, string> cvSections)
|
||||
{
|
||||
jobTitle ??= string.Empty;
|
||||
jobText ??= string.Empty;
|
||||
cvSections ??= new Dictionary<string, string>();
|
||||
|
||||
var titleTokens = Tokenize(jobTitle).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var keywords = BuildKeywords(jobTitle, jobText, titleTokens);
|
||||
|
||||
// Combine all CV sections into one searchable corpus, plus keep per-section text for coverage.
|
||||
var sectionCorpora = cvSections
|
||||
.Where(kvp => !string.IsNullOrWhiteSpace(kvp.Value))
|
||||
.ToDictionary(kvp => kvp.Key, kvp => Normalize(kvp.Value), StringComparer.OrdinalIgnoreCase);
|
||||
var fullCorpus = string.Join(" \n ", sectionCorpora.Values);
|
||||
|
||||
var evaluated = keywords
|
||||
.Select(k => k with { Matched = CorpusContains(fullCorpus, k.Keyword) })
|
||||
.ToList();
|
||||
|
||||
var totalWeight = evaluated.Sum(k => k.Weight);
|
||||
var matchedWeight = evaluated.Where(k => k.Matched).Sum(k => k.Weight);
|
||||
var hasEnoughSignal = evaluated.Count >= 3 && sectionCorpora.Count > 0;
|
||||
|
||||
var score = totalWeight == 0 ? 0 : (int)Math.Round(100.0 * matchedWeight / totalWeight, MidpointRounding.AwayFromZero);
|
||||
score = Math.Clamp(score, 0, 100);
|
||||
|
||||
var band = !hasEnoughSignal ? "Unknown" : score >= 75 ? "Strong" : score >= 50 ? "Partial" : "Low";
|
||||
|
||||
var matchedKeywords = evaluated.Where(k => k.Matched)
|
||||
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(k => k.Keyword).ToList();
|
||||
var missingKeywords = evaluated.Where(k => !k.Matched)
|
||||
.OrderByDescending(k => k.Weight).ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(k => k.Keyword).ToList();
|
||||
|
||||
var sectionCoverage = sectionCorpora
|
||||
.Select(section => new MatchSectionCoverage(
|
||||
section.Key,
|
||||
evaluated.Count(k => CorpusContains(section.Value, k.Keyword)),
|
||||
evaluated.Count))
|
||||
.Where(sc => sc.Total > 0)
|
||||
.OrderByDescending(sc => sc.Matched)
|
||||
.ToList();
|
||||
|
||||
return new JobCvMatchResult(
|
||||
Score: score,
|
||||
Band: band,
|
||||
MatchedCount: matchedKeywords.Count,
|
||||
TotalKeywords: evaluated.Count,
|
||||
MatchedKeywords: matchedKeywords,
|
||||
MissingKeywords: missingKeywords,
|
||||
SectionCoverage: sectionCoverage,
|
||||
HasEnoughSignal: hasEnoughSignal);
|
||||
}
|
||||
|
||||
private static List<MatchKeyword> BuildKeywords(string jobTitle, string jobText, HashSet<string> titleTokens)
|
||||
{
|
||||
var combined = $"{jobTitle}\n{jobText}";
|
||||
var byKey = new Dictionary<string, MatchKeyword>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 1) Curated skill tags: high-signal, canonical spelling.
|
||||
foreach (var tag in SkillTagger.Detect(combined))
|
||||
{
|
||||
var inTitle = TitleContains(jobTitle, tag);
|
||||
byKey[tag] = new MatchKeyword(tag, CuratedTagWeight + (inTitle ? TitleBonus : 0), inTitle, false);
|
||||
}
|
||||
|
||||
// 2) Salient posting terms: frequency-ranked content words from the description.
|
||||
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var token in Tokenize(jobText))
|
||||
{
|
||||
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
|
||||
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
|
||||
}
|
||||
|
||||
var rankedTerms = frequencies
|
||||
.Where(kvp => kvp.Value >= 1)
|
||||
.OrderByDescending(kvp => titleTokens.Contains(kvp.Key) ? 1 : 0)
|
||||
.ThenByDescending(kvp => kvp.Value)
|
||||
.ThenBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kvp => kvp.Key);
|
||||
|
||||
foreach (var term in rankedTerms)
|
||||
{
|
||||
if (byKey.Count >= MaxKeywords) break;
|
||||
if (byKey.ContainsKey(term)) continue;
|
||||
var inTitle = titleTokens.Contains(term);
|
||||
byKey[term] = new MatchKeyword(term, TermWeight + (inTitle ? TitleBonus : 0), inTitle, false);
|
||||
}
|
||||
|
||||
return byKey.Values
|
||||
.OrderByDescending(k => k.Weight)
|
||||
.ThenBy(k => k.Keyword, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxKeywords)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool TitleContains(string title, string phrase)
|
||||
=> Normalize(title).Contains(Normalize(phrase), StringComparison.Ordinal);
|
||||
|
||||
private static bool CorpusContains(string normalizedCorpus, string keyword)
|
||||
{
|
||||
var needle = Normalize(keyword);
|
||||
if (needle.Length == 0) return false;
|
||||
// Word-boundary-ish match to avoid "go" matching "goal".
|
||||
var idx = normalizedCorpus.IndexOf(needle, StringComparison.Ordinal);
|
||||
while (idx >= 0)
|
||||
{
|
||||
var beforeOk = idx == 0 || !char.IsLetterOrDigit(normalizedCorpus[idx - 1]);
|
||||
var afterPos = idx + needle.Length;
|
||||
var afterOk = afterPos >= normalizedCorpus.Length || !char.IsLetterOrDigit(normalizedCorpus[afterPos]);
|
||||
if (beforeOk && afterOk) return true;
|
||||
idx = normalizedCorpus.IndexOf(needle, idx + 1, StringComparison.Ordinal);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Tokenize(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) yield break;
|
||||
foreach (Match m in TokenPattern.Matches(text.ToLowerInvariant()))
|
||||
{
|
||||
yield return m.Value.Trim('-', '.', '+', '#');
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsNumeric(string token)
|
||||
=> token.All(c => char.IsDigit(c) || c is '.' or '-' or '+');
|
||||
|
||||
private static string Normalize(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return string.Empty;
|
||||
var sb = new StringBuilder(text.Length);
|
||||
foreach (var ch in text.ToLowerInvariant())
|
||||
{
|
||||
sb.Append(char.IsWhiteSpace(ch) ? ' ' : ch);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ public static class SkillTagger
|
||||
{
|
||||
private static readonly (string Tag, Regex Pattern, int Weight)[] Patterns =
|
||||
{
|
||||
("C#", new Regex(@"\bC#\b|\bcsharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
(".NET", new Regex(@"\b\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
// Symbol skills need punctuation-tolerant boundaries: \b fails next to '#'/'.'
|
||||
// (both non-word chars), which previously left "C#," and ".NET," undetected.
|
||||
("C#", new Regex(@"(?<![A-Za-z0-9#])C#|\bc[-\s]?sharp\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
(".NET", new Regex(@"(?<![A-Za-z0-9.])\.NET\b|\bASP\.NET\b|\bDOTNET\b|\bEntity Framework\b|\bEF Core\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
("Python", new Regex(@"\bPython\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 6),
|
||||
("Java", new Regex(@"\bJava\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||
("JavaScript", new Regex(@"\bJavaScript\b|\bJS\b", RegexOptions.IgnoreCase | RegexOptions.Compiled), 5),
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public enum PipelineCategory
|
||||
{
|
||||
Active,
|
||||
Success,
|
||||
Closed,
|
||||
}
|
||||
|
||||
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
|
||||
|
||||
/// <summary>
|
||||
/// Canonical job-application pipeline: the single source of truth for the ordered set of
|
||||
/// statuses, their grouping, and how free-text/legacy values normalize onto them.
|
||||
/// Status remains a free-text column so custom values are never destroyed; this only
|
||||
/// canonicalizes casing and known synonyms.
|
||||
/// </summary>
|
||||
public static class JobPipeline
|
||||
{
|
||||
public const string DefaultStatus = "Applied";
|
||||
|
||||
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
|
||||
{
|
||||
new("Applied", 1, PipelineCategory.Active),
|
||||
new("Waiting", 2, PipelineCategory.Active),
|
||||
new("Interview", 3, PipelineCategory.Active),
|
||||
new("Offer", 4, PipelineCategory.Success),
|
||||
new("Rejected", 5, PipelineCategory.Closed),
|
||||
new("Ghosted", 6, PipelineCategory.Closed),
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Canonical =
|
||||
Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Legacy/synonym spellings that should collapse onto a canonical stage.
|
||||
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["interviewing"] = "Interview",
|
||||
["interviews"] = "Interview",
|
||||
["interviewed"] = "Interview",
|
||||
["in interview"] = "Interview",
|
||||
["awaiting response"] = "Waiting",
|
||||
["awaiting"] = "Waiting",
|
||||
["in progress"] = "Waiting",
|
||||
["pending"] = "Waiting",
|
||||
["no response"] = "Ghosted",
|
||||
["no reply"] = "Ghosted",
|
||||
["declined"] = "Rejected",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical status for a raw value: trims, matches a stage case-insensitively,
|
||||
/// or maps a known synonym. Unknown non-empty values are preserved (trimmed) so custom
|
||||
/// statuses survive. Empty/whitespace becomes the default stage.
|
||||
/// </summary>
|
||||
public static string Normalize(string? status)
|
||||
{
|
||||
var trimmed = (status ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0) return DefaultStatus;
|
||||
if (Canonical.TryGetValue(trimmed, out var canonical)) return canonical;
|
||||
if (Aliases.TryGetValue(trimmed, out var alias)) return alias;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static bool IsCanonical(string? status)
|
||||
=> !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim());
|
||||
|
||||
public static int OrderOf(string? status)
|
||||
{
|
||||
var normalized = Normalize(status);
|
||||
var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase));
|
||||
return stage?.Order ?? int.MaxValue; // custom statuses sort last
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count);
|
||||
|
||||
/// <summary>One job's position: its canonical stage and when it entered that stage.</summary>
|
||||
public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Pure time-in-stage analytics: for each active pipeline stage, the median number of days
|
||||
/// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and
|
||||
/// the terminal success stage (Offer) are excluded — "how long has this been stuck" only
|
||||
/// makes sense for stages you still act on.
|
||||
/// </summary>
|
||||
public static class StageAnalytics
|
||||
{
|
||||
public static List<StageDurationPoint> TimeInStage(IEnumerable<StageOccupancy> jobs, DateTime nowUtc)
|
||||
{
|
||||
var byStage = jobs
|
||||
.Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays)))
|
||||
.Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active))
|
||||
.GroupBy(x => x.Stage);
|
||||
|
||||
var points = new List<StageDurationPoint>();
|
||||
foreach (var group in byStage)
|
||||
{
|
||||
var days = group.Select(x => x.Days).OrderBy(x => x).ToList();
|
||||
points.Add(new StageDurationPoint(
|
||||
Stage: group.Key,
|
||||
Order: JobPipeline.OrderOf(group.Key),
|
||||
MedianDays: Median(days),
|
||||
Count: days.Count));
|
||||
}
|
||||
|
||||
return points.OrderBy(p => p.Order).ToList();
|
||||
}
|
||||
|
||||
private static double Median(IReadOnlyList<double> sorted)
|
||||
{
|
||||
if (sorted.Count == 0) return 0;
|
||||
var mid = sorted.Count / 2;
|
||||
var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid];
|
||||
return Math.Round(median, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -484,6 +484,12 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
|
||||
|
||||
// Structured salary fields (EF maps decimal to TEXT on SQLite).
|
||||
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
|
||||
|
||||
// Ensure ownership columns exist even on non-legacy DBs.
|
||||
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
|
||||
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
|
||||
@@ -617,6 +623,10 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
|
||||
|
||||
Reference in New Issue
Block a user