a7cecce13d
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>
176 lines
7.0 KiB
C#
176 lines
7.0 KiB
C#
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());
|
|
}
|
|
}
|