feat(workspace): add application intelligence
CI and Deploy / test (push) Failing after 1m11s
CI and Deploy / deploy (push) Has been skipped

Phase 5.3. Three read-only reads that answer "how suitable is this job", "how
does my experience match", "what am I missing", "what happened previously".

Timeline (GET /{id}/timeline) is an interpretation layer over JobEvent, which
stays the source of historical truth. Each row gains a readable summary, a
category and a milestone flag; events group by day. Milestones are returned
unfiltered, because narrowing the detail must not hide what actually happened.

Job analysis (GET /{id}/analysis) extracts role, company, location, employment
type, seniority, salary, technologies, skills, responsibilities and keywords
from the advert, reusing the existing SkillTagger so the vocabulary matches the
job importer. It also reports what the advert does NOT say, which is usually the
more useful half.

Career matching (GET /{id}/match) feeds the master CareerProfile into the same
JobCvMatchService the CV builder uses, so one application scores identically
whichever surface asks. It returns the score, matched and missing skills, and
which experience and project entries are the evidence for each match.

All three are deterministic and own no data — no new table, no new column, and
nothing writes to the CareerProfile, a CvVariant, or the JobApplication. The AI
narrative stays where it already was, in AiWorkspaceService's job-analysis and
career-match modules, generated only when the user asks and versioned by the
append-only AiInteraction history. Opening a section costs nothing and changes
nothing.

Frontend adds Timeline, Analysis and Match sections to the workspace, sharing
one loader so loading, empty and error states are consistent. The deterministic
answer renders first, with the AI panel below it.

345 backend tests, 104 frontend tests, type check, production build all pass
locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-19 13:52:37 +02:00
parent 7f426e255c
commit a7cecce13d
10 changed files with 1632 additions and 3 deletions
@@ -0,0 +1,63 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
// Phase 5.3 — Application Intelligence. Three read-only endpoints on the application:
// timeline (interprets JobEvent), analysis (reads the advert), match (reads the master profile).
//
// None of them write anything. The AI narrative for analysis and match stays on the existing
// /api/jobapplications/{id}/ai routes, which are suggestion-only and versioned by AiInteraction.
// docs/architecture/application-workspace.md.
[ApiController]
[Route("api/jobapplications/{jobId:int}")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class ApplicationIntelligenceController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly IApplicationTimelineService _timeline;
private readonly IApplicationIntelligenceService _intelligence;
public ApplicationIntelligenceController(
UserManager<ApplicationUser> users,
IApplicationTimelineService timeline,
IApplicationIntelligenceService intelligence)
{
_users = users;
_timeline = timeline;
_intelligence = intelligence;
}
[HttpGet("timeline")]
public async Task<ActionResult<TimelineDto>> GetTimeline(
int jobId, [FromQuery] string? category, [FromQuery] bool milestonesOnly, CancellationToken ct)
{
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
var result = await _timeline.GetAsync(userId, jobId, category, milestonesOnly, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("analysis")]
public async Task<ActionResult<JobAnalysisDto>> GetAnalysis(int jobId, CancellationToken ct)
{
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
var result = await _intelligence.AnalyzeAsync(userId, jobId, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("match")]
public async Task<ActionResult<CareerMatchDto>> GetMatch(int jobId, CancellationToken ct)
{
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
var result = await _intelligence.MatchAsync(userId, jobId, ct);
return result is null ? NotFound() : Ok(result);
}
private async Task<string?> CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id;
}
+2
View File
@@ -43,6 +43,8 @@ builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistService>();
builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>();
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
@@ -0,0 +1,365 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services.JobImport;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Phase 5.3 Milestones 2 and 3 — job analysis and career matching.
//
// DETERMINISTIC and READ-ONLY. Both endpoints derive their answer from data the user already owns
// (the advert on the JobApplication, the master CareerProfile) using the existing SkillTagger and
// JobCvMatchService. The same input always gives the same number, so the score is something a user
// can trust and re-check.
//
// The AI narrative is deliberately NOT here: it stays in AiWorkspaceService's "job-analysis" and
// "career-match" modules, which are suggestion-only, append-only (AiInteraction is the version
// history), and require the user to act on them. Nothing in this file writes to the CareerProfile,
// a CvVariant, or the JobApplication. docs/architecture/application-workspace.md.
public sealed record JobAnalysisDto(
string? Role,
string? Company,
string? Location,
string? EmploymentType,
string? Seniority,
string? Salary,
IReadOnlyList<string> Technologies,
IReadOnlyList<string> Skills,
IReadOnlyList<string> Responsibilities,
IReadOnlyList<string> Keywords,
string Summary,
IReadOnlyList<string> ImportantRequirements,
IReadOnlyList<string> InterviewTopics,
IReadOnlyList<string> MissingInformation,
bool HasJobDescription,
int AiSuggestionCount);
public sealed record MatchEvidenceDto(string Title, string? Subtitle, IReadOnlyList<string> Matched);
public sealed record CareerMatchDto(
int Score,
string Band,
bool HasEnoughSignal,
bool HasCareerProfile,
IReadOnlyList<string> MatchedSkills,
IReadOnlyList<string> MissingSkills,
IReadOnlyList<MatchEvidenceDto> RelevantExperience,
IReadOnlyList<MatchEvidenceDto> RelevantProjects,
IReadOnlyList<string> Suggestions,
int AiSuggestionCount);
public interface IApplicationIntelligenceService
{
Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
}
public sealed class ApplicationIntelligenceService : IApplicationIntelligenceService
{
private const int MaxEvidence = 5;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
// Bullet lines in an advert: "-", "*", "•", or "1." at the start of a line.
private static readonly Regex BulletRx = new(@"^\s*(?:[-*•·–]|\d+[.)])\s+(?<text>.+)$",
RegexOptions.Multiline | RegexOptions.Compiled);
private static readonly (string Label, Regex Pattern)[] EmploymentTypes =
{
("Full-time", new Regex(@"\bfull[-\s]?time\b|\bfast stilling\b|\bheltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Part-time", new Regex(@"\bpart[-\s]?time\b|\bdeltid\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Contract", new Regex(@"\bcontract\b|\bfreelance\b|\bconsultan(t|cy)\b|\bengasjement\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Internship", new Regex(@"\bintern(ship)?\b|\btrainee\b|\bpraktikant\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Temporary", new Regex(@"\btemporary\b|\bfixed[-\s]?term\b|\bvikariat\b|\bmidlertidig\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
};
private static readonly (string Label, Regex Pattern)[] Seniorities =
{
("Lead / Principal", new Regex(@"\b(lead|principal|staff|head of|director)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Senior", new Regex(@"\bsenior\b|\bsr\.?\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Junior / Entry", new Regex(@"\bjunior\b|\bjr\.?\b|\bentry[-\s]?level\b|\bgraduate\b|\bnyutdannet\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
("Mid-level", new Regex(@"\bmid[-\s]?level\b|\bintermediate\b", RegexOptions.IgnoreCase | RegexOptions.Compiled)),
};
// A salary line: a currency figure, or an explicit salary/lønn mention with numbers nearby.
private static readonly Regex SalaryRx = new(
@"(?:(?:[£$€]|\bNOK\b|\bkr\b|\bUSD\b|\bGBP\b|\bEUR\b)\s?[\d][\d\s.,]{2,}(?:\s?[-]\s?[\d][\d\s.,]{2,})?(?:\s?(?:k|per\s+(?:year|annum|month|hour)|p\.?a\.?))?)|(?:\b(?:salary|lønn|compensation)\b[^.\n]{0,60}?[\d][\d\s.,]{2,}[^.\n]{0,20})",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ResponsibilityRx = new(
@"\b(you will|you'll|responsib|the role|day[-\s]to[-\s]day|arbeidsoppgaver|du vil)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex RequirementRx = new(
@"\b(require|must have|essential|we expect|you have|experience (?:with|in)|proficien|kvalifikasjon|vi ser etter)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private readonly JobTrackerContext _db;
private readonly IJobCvMatchService _match;
public ApplicationIntelligenceService(JobTrackerContext db, IJobCvMatchService match)
{
_db = db;
_match = match;
}
// ---------- Milestone 2: job analysis ----------
public async Task<JobAnalysisDto?> AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
var description = job.Description ?? string.Empty;
var hasDescription = !string.IsNullOrWhiteSpace(description);
var haystack = $"{job.JobTitle}\n{description}";
// Same tagger the job importer and the CV match use, so the vocabulary is consistent everywhere.
var tags = SkillTagger.Detect(haystack);
var technologies = tags.Where(IsTechnology).ToList();
var skills = tags.Where(t => !IsTechnology(t)).ToList();
var bullets = BulletRx.Matches(description)
.Select(m => Tidy(m.Groups["text"].Value))
.Where(b => b.Length > 12)
.ToList();
var responsibilities = Pick(bullets, description, ResponsibilityRx);
var requirements = Pick(bullets, description, RequirementRx);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "job-analysis", ct);
return new JobAnalysisDto(
Role: Blank(job.JobTitle),
Company: Blank(job.Company?.Name),
Location: Blank(job.Location),
EmploymentType: FirstMatch(EmploymentTypes, haystack),
Seniority: FirstMatch(Seniorities, haystack),
Salary: Blank(job.Salary) ?? (hasDescription ? Tidy(SalaryRx.Match(description).Value) is { Length: > 0 } s ? s : null : null),
Technologies: technologies,
Skills: skills,
Responsibilities: responsibilities,
Keywords: tags.ToList(),
Summary: BuildSummary(job, technologies, hasDescription),
ImportantRequirements: requirements,
InterviewTopics: technologies.Concat(skills).Take(8).ToList(),
MissingInformation: MissingInformation(job, hasDescription),
HasJobDescription: hasDescription,
AiSuggestionCount: aiCount);
}
private static string BuildSummary(JobApplication job, IReadOnlyList<string> technologies, bool hasDescription)
{
if (!hasDescription)
{
return "No advert text saved yet, so this analysis is limited to the fields on the application. Paste the advert to get requirements, technologies and interview topics.";
}
var sb = new StringBuilder();
sb.Append(job.JobTitle);
if (!string.IsNullOrWhiteSpace(job.Company?.Name)) sb.Append(" at ").Append(job.Company!.Name);
if (!string.IsNullOrWhiteSpace(job.Location)) sb.Append(" · ").Append(job.Location);
sb.Append('.');
if (technologies.Count > 0)
{
sb.Append(" The advert leans on ")
.Append(string.Join(", ", technologies.Take(5)))
.Append('.');
}
return sb.ToString();
}
private static List<string> MissingInformation(JobApplication job, bool hasDescription)
{
var missing = new List<string>();
if (!hasDescription) missing.Add("The advert text itself");
if (string.IsNullOrWhiteSpace(job.Salary)) missing.Add("Salary or compensation range");
if (string.IsNullOrWhiteSpace(job.Location)) missing.Add("Location or remote policy");
if (string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) missing.Add("A named contact to follow up with");
if (string.IsNullOrWhiteSpace(job.JobUrl)) missing.Add("A link back to the original posting");
return missing;
}
// ---------- Milestone 3: career matching ----------
public async Task<CareerMatchDto?> MatchAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
{
var job = await _db.JobApplications.AsNoTracking().Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (job is null) return null;
// READ ONLY. The master profile is the single source of truth and nothing here writes to it.
var profile = await _db.CareerProfiles.AsNoTracking()
.Include(p => p.Experiences)
.Include(p => p.Projects)
.Include(p => p.Skills)
.FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct);
var aiCount = await _db.AiInteractions.AsNoTracking()
.CountAsync(a => a.OwnerUserId == ownerUserId && a.JobApplicationId == jobApplicationId && a.Module == "career-match", ct);
if (profile is null)
{
return new CareerMatchDto(0, "No profile", false, false,
Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<MatchEvidenceDto>(), Array.Empty<MatchEvidenceDto>(),
new[] { "Build your career profile first — matching compares the advert against it." },
aiCount);
}
// Feed the profile to the SAME deterministic matcher the CV builder uses, so one job scores
// identically whichever surface asks.
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Experience"] = string.Join("\n", profile.Experiences.Select(ExperienceText)),
["Projects"] = string.Join("\n", profile.Projects.Select(ProjectText)),
["Skills"] = string.Join("\n", profile.Skills.Select(s => $"{s.Name} {s.Category} {s.Proficiency}")),
};
var result = _match.Evaluate(job.JobTitle, job.Description ?? string.Empty, sections);
var relevantExperience = profile.Experiences
.Select(e => new { Entry = e, Hits = HitsFor(ExperienceText(e), result.MatchedKeywords) })
.Where(x => x.Hits.Count > 0)
.OrderByDescending(x => x.Hits.Count)
.Take(MaxEvidence)
.Select(x => new MatchEvidenceDto(
x.Entry.Title ?? "Untitled role",
string.Join(" · ", new[] { x.Entry.Company, Period(x.Entry.Start, x.Entry.End, x.Entry.IsCurrent) }.Where(v => !string.IsNullOrWhiteSpace(v))),
x.Hits))
.ToList();
var relevantProjects = profile.Projects
.Select(p => new { Entry = p, Hits = HitsFor(ProjectText(p), result.MatchedKeywords) })
.Where(x => x.Hits.Count > 0)
.OrderByDescending(x => x.Hits.Count)
.Take(MaxEvidence)
.Select(x => new MatchEvidenceDto(
x.Entry.Name ?? "Untitled project",
Blank(x.Entry.Role),
x.Hits))
.ToList();
return new CareerMatchDto(
result.Score,
result.Band,
result.HasEnoughSignal,
HasCareerProfile: true,
MatchedSkills: result.MatchedKeywords,
MissingSkills: result.MissingKeywords,
RelevantExperience: relevantExperience,
RelevantProjects: relevantProjects,
Suggestions: Suggestions(result, relevantExperience.Count),
AiSuggestionCount: aiCount);
}
// Suggestions describe what the USER could change. They never edit anything themselves.
private static List<string> Suggestions(JobCvMatchResult result, int experienceHits)
{
var suggestions = new List<string>();
if (!result.HasEnoughSignal)
{
suggestions.Add("The advert is too short to score reliably — paste the full text for a real match.");
return suggestions;
}
if (result.MissingKeywords.Count > 0)
{
suggestions.Add($"The advert asks for {string.Join(", ", result.MissingKeywords.Take(4))} — add it to your profile if you have it.");
}
if (experienceHits == 0)
{
suggestions.Add("No experience entry matched the advert. Rewrite your bullets in the advert's vocabulary where it is honest to do so.");
}
if (result.Score < 50)
{
suggestions.Add("A CV variant tailored to this advert would lift the match — the builder starts from your master profile.");
}
else if (result.Score < 80)
{
suggestions.Add("Solid match. Lead with the matched skills in your cover letter's opening paragraph.");
}
else
{
suggestions.Add("Strong match. Focus your effort on the cover letter and interview prep rather than the CV.");
}
return suggestions;
}
// ---------- shared ----------
private static string ExperienceText(CareerExperience e) =>
$"{e.Title} {e.Company} {e.Location} {ReadJsonArray(e.BulletsJson)} {ReadJsonArray(e.SkillsJson)}";
private static string ProjectText(CareerProject p) =>
$"{p.Name} {p.Role} {ReadJsonArray(p.BulletsJson)} {ReadJsonArray(p.SkillsJson)}";
private static string ReadJsonArray(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return string.Empty;
try
{
var items = JsonSerializer.Deserialize<List<string>>(json, Json);
return items is null ? string.Empty : string.Join(" ", items);
}
catch (JsonException)
{
// A malformed blob must not break the whole match — treat it as no text.
return string.Empty;
}
}
// Which of the job's matched keywords this specific entry is the evidence for.
private static List<string> HitsFor(string text, IReadOnlyList<string> matchedKeywords) =>
matchedKeywords.Where(k => SkillTagger.MatchesTag(k, text)).ToList();
private static string? Period(string? start, string? end, bool isCurrent)
{
if (string.IsNullOrWhiteSpace(start)) return isCurrent ? "Current" : null;
return isCurrent ? $"{start} present" : string.IsNullOrWhiteSpace(end) ? start : $"{start} {end}";
}
// Pull the bullets nearest the paragraph that introduces requirements/responsibilities. Falls back
// to "all bullets" when the advert has no such heading, which is common enough.
private static List<string> Pick(List<string> bullets, string description, Regex cue)
{
if (bullets.Count == 0) return new List<string>();
if (!cue.IsMatch(description)) return bullets.Take(MaxEvidence).ToList();
var cued = bullets.Where(b => cue.IsMatch(b)).ToList();
return (cued.Count > 0 ? cued : bullets).Take(MaxEvidence).ToList();
}
private static bool IsTechnology(string tag) => tag switch
{
"Communication" or "Collaboration" or "Problem Solving" or "Leadership" or "Ownership"
or "Adaptability" or "Attention to Detail" or "Agile" => false,
_ => true,
};
private static string? FirstMatch((string Label, Regex Pattern)[] table, string text)
{
foreach (var (label, pattern) in table)
{
if (pattern.IsMatch(text)) return label;
}
return null;
}
private static string Tidy(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : Regex.Replace(value.Trim(), @"\s+", " ");
private static string? Blank(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,175 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Phase 5.3 Milestone 1 — timeline intelligence.
//
// A READ-ONLY interpretation layer over JobEvent. JobEvent stays the source of historical truth: this
// writes nothing, stores nothing, and adds no table. It turns rows like
// ("StatusChanged", "Applied", "Interview") into a sentence, tags each event with a category and
// whether it is a milestone, and groups the result by day so the workspace can render a real
// timeline instead of a flat list. docs/architecture/application-workspace.md.
public sealed record TimelineEventDto(
int Id,
string Type,
string Category,
string Summary,
string? Detail,
bool IsMilestone,
DateTime At);
public sealed record TimelineDayDto(DateTime Date, string Label, IReadOnlyList<TimelineEventDto> Events);
public sealed record TimelineDto(
IReadOnlyList<TimelineDayDto> Days,
IReadOnlyList<TimelineEventDto> Milestones,
IReadOnlyList<string> Categories,
int TotalEvents);
public interface IApplicationTimelineService
{
Task<TimelineDto?> GetAsync(string ownerUserId, int jobApplicationId, string? category, bool milestonesOnly, CancellationToken ct);
}
public sealed class ApplicationTimelineService : IApplicationTimelineService
{
// Event categories, so the UI can filter without knowing every raw Type.
public const string CategoryLifecycle = "lifecycle";
public const string CategoryStage = "stage";
public const string CategoryFollowUp = "follow-up";
public const string CategoryCommunication = "communication";
public const string CategoryAi = "ai";
private readonly JobTrackerContext _db;
public ApplicationTimelineService(JobTrackerContext db)
{
_db = db;
}
public async Task<TimelineDto?> GetAsync(string ownerUserId, int jobApplicationId, string? category, bool milestonesOnly, CancellationToken ct)
{
var owns = await _db.JobApplications.AsNoTracking()
.AnyAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
if (!owns) return null;
var events = await _db.JobEvents.AsNoTracking()
.Where(e => e.JobApplicationId == jobApplicationId)
.OrderByDescending(e => e.At)
.ThenByDescending(e => e.Id)
.ToListAsync(ct);
var projected = events.Select(Describe).ToList();
var filtered = projected
.Where(e => string.IsNullOrWhiteSpace(category) || string.Equals(e.Category, category, StringComparison.OrdinalIgnoreCase))
.Where(e => !milestonesOnly || e.IsMilestone)
.ToList();
var days = filtered
.GroupBy(e => e.At.Date)
.OrderByDescending(g => g.Key)
.Select(g => new TimelineDayDto(g.Key, DayLabel(g.Key), g.ToList()))
.ToList();
return new TimelineDto(
days,
// Milestones ignore the active filter: they are the "what actually happened" spine and stay
// visible while the user narrows the detail below.
projected.Where(e => e.IsMilestone).ToList(),
projected.Select(e => e.Category).Distinct().OrderBy(c => c, StringComparer.Ordinal).ToList(),
projected.Count);
}
// One JobEvent row -> a sentence a human can read, plus its category and milestone flag.
private static TimelineEventDto Describe(JobEvent e)
{
var type = (e.Type ?? string.Empty).Trim();
var (category, summary, isMilestone) = type switch
{
"Created" => (CategoryLifecycle, "Application created", true),
"Deleted" => (CategoryLifecycle, "Application moved to trash", false),
"Restored" => (CategoryLifecycle, "Application restored from trash", false),
"Undo" => (CategoryLifecycle, "Change undone", false),
"StatusChanged" => (CategoryStage, StatusSummary(e), IsMilestoneStatus(e.NewValue)),
"FollowUpSet" => (CategoryFollowUp, FollowUpSummary(e), false),
"ResponseUpdated" => (CategoryCommunication, ResponseSummary(e), false),
"ReplyReceived" => (CategoryCommunication, "Reply received", true),
"AiRefreshed" => (CategoryAi, "AI suggestions refreshed", false),
_ => (CategoryLifecycle, string.IsNullOrWhiteSpace(type) ? "Activity recorded" : Humanize(type), false),
};
// The note is the user's own words, so it always wins as the detail line.
var detail = !string.IsNullOrWhiteSpace(e.Note) ? e.Note!.Trim() : null;
return new TimelineEventDto(e.Id, type, category, summary, detail, isMilestone, e.At);
}
private static string StatusSummary(JobEvent e)
{
var from = Clean(e.OldValue);
var to = Clean(e.NewValue);
if (to is null) return "Status changed";
return from is null ? $"Moved to {to}" : $"Moved from {from} to {to}";
}
private static string FollowUpSummary(JobEvent e)
{
var to = Clean(e.NewValue);
if (to is null) return "Follow-up cleared";
return DateTime.TryParse(to, out var parsed)
? $"Follow-up scheduled for {parsed:d MMMM yyyy}"
: $"Follow-up scheduled for {to}";
}
private static string ResponseSummary(JobEvent e)
{
var to = Clean(e.NewValue);
return to is null ? "Response status updated" : $"Response marked {to}";
}
// The stages that actually mean something happened, as opposed to routine housekeeping.
private static bool IsMilestoneStatus(string? status)
{
var s = (status ?? string.Empty).Trim();
if (s.Length == 0) return false;
return s.Contains("applied", StringComparison.OrdinalIgnoreCase)
|| s.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| s.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| s.Contains("rejected", StringComparison.OrdinalIgnoreCase)
|| s.Contains("accepted", StringComparison.OrdinalIgnoreCase)
|| s.Contains("declined", StringComparison.OrdinalIgnoreCase);
}
private static string DayLabel(DateTime date)
{
var today = DateTime.Now.Date;
if (date == today) return "Today";
if (date == today.AddDays(-1)) return "Yesterday";
return date.Year == today.Year ? date.ToString("dddd d MMMM") : date.ToString("d MMMM yyyy");
}
private static string? Clean(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
// "StatusChanged" -> "Status changed", so an unknown future type still reads as a sentence.
private static string Humanize(string type)
{
var chars = new List<char>(type.Length + 4);
for (var i = 0; i < type.Length; i++)
{
if (i > 0 && char.IsUpper(type[i]))
{
chars.Add(' ');
chars.Add(char.ToLowerInvariant(type[i]));
}
else
{
chars.Add(type[i]);
}
}
return new string(chars.ToArray());
}
}