From a7cecce13d056d2234393971c42145b428d3bc8a Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 19 Jul 2026 13:52:37 +0200 Subject: [PATCH] feat(workspace): add application intelligence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ApplicationIntelligenceTests.cs | 352 +++++++++++++++++ .../ApplicationIntelligenceController.cs | 63 +++ JobTrackerApi/Program.cs | 2 + .../ApplicationIntelligenceService.cs | 365 ++++++++++++++++++ .../Services/ApplicationTimelineService.cs | 175 +++++++++ docs/architecture/application-workspace.md | 68 +++- .../src/application-intelligence.test.tsx | 180 +++++++++ job-tracker-ui/src/applicationWorkspace.ts | 77 +++- .../components/ApplicationIntelligence.tsx | 344 +++++++++++++++++ .../src/views/ApplicationWorkspacePage.tsx | 9 +- 10 files changed, 1632 insertions(+), 3 deletions(-) create mode 100644 JobTrackerApi.Tests/ApplicationIntelligenceTests.cs create mode 100644 JobTrackerApi/Controllers/ApplicationIntelligenceController.cs create mode 100644 JobTrackerApi/Services/ApplicationIntelligenceService.cs create mode 100644 JobTrackerApi/Services/ApplicationTimelineService.cs create mode 100644 job-tracker-ui/src/application-intelligence.test.tsx create mode 100644 job-tracker-ui/src/components/ApplicationIntelligence.tsx diff --git a/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs new file mode 100644 index 0000000..353153a --- /dev/null +++ b/JobTrackerApi.Tests/ApplicationIntelligenceTests.cs @@ -0,0 +1,352 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Phase 5.3 — Application Intelligence. These tests pin the two properties that matter most: the +// results are OWNERSHIP-SCOPED, and nothing here writes to the user's data. Career matching in +// particular reads the master CareerProfile and must leave it byte-identical. +public sealed class ApplicationIntelligenceTests +{ + private const string Advert = """ + We are hiring a Senior Backend Developer (full-time) to join our platform team in Oslo. + + You will: + - Build and operate REST APIs used by every product surface + - Own services end to end, from design through production support + - Work closely with product and design on new features + + We expect: + - Strong experience with C# and .NET + - Solid SQL and Docker knowledge + - Experience with unit tests and CI/CD + """; + + private static (JobTrackerContext db, ApplicationIntelligenceService intelligence, ApplicationTimelineService timeline) New(string userId) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var currentUser = new Mock(); + currentUser.SetupGet(s => s.UserId).Returns(userId); + var db = new JobTrackerContext(options, currentUser.Object); + return (db, new ApplicationIntelligenceService(db, new JobCvMatchService()), new ApplicationTimelineService(db)); + } + + private static async Task SeedJobAsync(JobTrackerContext db, string owner, Action? tweak = null) + { + var company = new Company { OwnerUserId = owner, Name = "Acme" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var job = new JobApplication + { + OwnerUserId = owner, + CompanyId = company.Id, + JobTitle = "Senior Backend Developer", + Status = "Applied", + Location = "Oslo", + Description = Advert, + }; + tweak?.Invoke(job); + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static async Task SeedProfileAsync(JobTrackerContext db, string owner) + { + var profile = new CareerProfile + { + OwnerUserId = owner, + Experiences = + { + new CareerExperience + { + OwnerUserId = owner, Title = "Backend Developer", Company = "Initech", Start = "2021", IsCurrent = true, + BulletsJson = """["Built REST APIs in C# and .NET","Ran the SQL migration programme"]""", + }, + new CareerExperience + { + OwnerUserId = owner, Title = "Barista", Company = "Coffee Co", Start = "2018", End = "2020", + BulletsJson = """["Served customers"]""", + }, + }, + Projects = + { + new CareerProject + { + OwnerUserId = owner, Name = "Deploy pipeline", Role = "Author", + BulletsJson = """["Docker based CI/CD for six services"]""", + }, + }, + Skills = { new CareerSkill { OwnerUserId = owner, Name = "C#", Category = "Languages" } }, + }; + db.CareerProfiles.Add(profile); + await db.SaveChangesAsync(); + return profile; + } + + // ---------- Milestone 1: timeline ---------- + + [Fact] + public async Task Timeline_turns_raw_events_into_readable_summaries() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-5) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", OldValue = "Applied", NewValue = "Interview", At = DateTime.Now.AddDays(-1) }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, null, false, default); + + Assert.NotNull(result); + var summaries = result!.Days.SelectMany(d => d.Events).Select(e => e.Summary).ToList(); + Assert.Contains("Moved from Applied to Interview", summaries); + Assert.Contains("Application created", summaries); + } + + [Fact] + public async Task Timeline_groups_by_day_newest_first() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Created", At = DateTime.Now.AddDays(-3) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-3).AddHours(2) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", At = DateTime.Now }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, null, false, default); + + Assert.Equal(2, result!.Days.Count); + Assert.True(result.Days[0].Date > result.Days[1].Date); + Assert.Equal(2, result.Days[1].Events.Count); + Assert.Equal("Today", result.Days[0].Label); + } + + [Fact] + public async Task Timeline_detects_milestones_and_ignores_routine_events() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Interview", At = DateTime.Now }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Waiting", At = DateTime.Now.AddDays(-1) }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now.AddDays(-2) }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, null, false, default); + + Assert.Single(result!.Milestones); + Assert.Equal("Moved to Interview", result.Milestones[0].Summary); + } + + [Fact] + public async Task Timeline_filters_by_category_but_keeps_the_milestone_spine() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "StatusChanged", NewValue = "Offer", At = DateTime.Now }); + db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "AiRefreshed", At = DateTime.Now }); + await db.SaveChangesAsync(); + + var result = await timeline.GetAsync("user-1", job.Id, "ai", false, default); + + Assert.Single(result!.Days.SelectMany(d => d.Events)); + Assert.Equal("ai", result.Days[0].Events[0].Category); + // Filtering the detail must not hide what actually happened. + Assert.Single(result.Milestones); + Assert.Equal(2, result.TotalEvents); + } + + [Fact] + public async Task Timeline_is_not_readable_for_another_users_application() + { + var (db, _, timeline) = New("user-1"); + await using var _d = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await timeline.GetAsync("user-1", other.Id, null, false, default)); + } + + // ---------- Milestone 2: job analysis ---------- + + [Fact] + public async Task Analysis_extracts_structure_from_the_advert() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.NotNull(a); + Assert.Equal("Senior Backend Developer", a!.Role); + Assert.Equal("Acme", a.Company); + Assert.Equal("Oslo", a.Location); + Assert.Equal("Full-time", a.EmploymentType); + Assert.Equal("Senior", a.Seniority); + Assert.Contains("C#", a.Technologies); + Assert.Contains(".NET", a.Technologies); + Assert.Contains("Docker", a.Technologies); + Assert.NotEmpty(a.ImportantRequirements); + Assert.NotEmpty(a.Responsibilities); + Assert.True(a.HasJobDescription); + } + + [Fact] + public async Task Analysis_reports_what_the_advert_does_not_say() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1", j => { j.Salary = null; j.JobUrl = null; }); + + var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.Contains(a!.MissingInformation, m => m.Contains("Salary", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(a.MissingInformation, m => m.Contains("link", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Analysis_degrades_gracefully_without_an_advert() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1", j => j.Description = null); + + var a = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.False(a!.HasJobDescription); + Assert.Empty(a.Responsibilities); + Assert.Contains("advert", a.Summary, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Analysis_is_deterministic() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + var first = await intelligence.AnalyzeAsync("user-1", job.Id, default); + var second = await intelligence.AnalyzeAsync("user-1", job.Id, default); + + Assert.Equal(first!.Keywords, second!.Keywords); + Assert.Equal(first.Summary, second.Summary); + } + + [Fact] + public async Task Analysis_is_not_readable_for_another_users_application() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await intelligence.AnalyzeAsync("user-1", other.Id, default)); + } + + // ---------- Milestone 3: career matching ---------- + + [Fact] + public async Task Match_scores_the_profile_against_the_advert_with_evidence() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.NotNull(m); + Assert.True(m!.HasCareerProfile); + Assert.True(m.Score > 0); + Assert.Contains("C#", m.MatchedSkills); + // The relevant-experience list is evidence drawn FROM the profile, not a copy of it. + Assert.Contains(m.RelevantExperience, e => e.Title == "Backend Developer"); + Assert.DoesNotContain(m.RelevantExperience, e => e.Title == "Barista"); + Assert.NotEmpty(m.Suggestions); + } + + [Fact] + public async Task Match_never_writes_to_the_career_profile() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + var profile = await SeedProfileAsync(db, "user-1"); + var bulletsBefore = profile.Experiences[0].BulletsJson; + var experienceCountBefore = profile.Experiences.Count; + var versionBefore = profile.Version; + + await intelligence.MatchAsync("user-1", job.Id, default); + + var after = await db.CareerProfiles.Include(p => p.Experiences).FirstAsync(p => p.OwnerUserId == "user-1"); + Assert.Equal(experienceCountBefore, after.Experiences.Count); + Assert.Equal(bulletsBefore, after.Experiences.First(e => e.Title == "Backend Developer").BulletsJson); + Assert.Equal(versionBefore, after.Version); + } + + [Fact] + public async Task Match_asks_for_a_profile_before_scoring_anything() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.False(m!.HasCareerProfile); + Assert.Equal(0, m.Score); + Assert.Contains(m.Suggestions, s => s.Contains("career profile", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task Match_only_reads_the_requesting_users_profile() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-2"); // someone else's profile must not be scored + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.False(m!.HasCareerProfile); + } + + [Fact] + public async Task Match_is_not_readable_for_another_users_application() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var other = await SeedJobAsync(db, "user-2"); + + Assert.Null(await intelligence.MatchAsync("user-1", other.Id, default)); + } + + [Fact] + public async Task Match_counts_ai_suggestions_without_generating_any() + { + var (db, intelligence, _) = New("user-1"); + await using var _d = db; + var job = await SeedJobAsync(db, "user-1"); + await SeedProfileAsync(db, "user-1"); + db.AiInteractions.Add(new AiInteraction + { + OwnerUserId = "user-1", JobApplicationId = job.Id, Module = "career-match", + Title = "Career match", Provider = "p", ResultJson = "{}", CreatedAtUtc = DateTimeOffset.UtcNow, + }); + await db.SaveChangesAsync(); + var interactionsBefore = await db.AiInteractions.CountAsync(); + + var m = await intelligence.MatchAsync("user-1", job.Id, default); + + Assert.Equal(1, m!.AiSuggestionCount); + // Reading the match must not itself call the AI or append history. + Assert.Equal(interactionsBefore, await db.AiInteractions.CountAsync()); + } +} diff --git a/JobTrackerApi/Controllers/ApplicationIntelligenceController.cs b/JobTrackerApi/Controllers/ApplicationIntelligenceController.cs new file mode 100644 index 0000000..4d1b7ae --- /dev/null +++ b/JobTrackerApi/Controllers/ApplicationIntelligenceController.cs @@ -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 _users; + private readonly IApplicationTimelineService _timeline; + private readonly IApplicationIntelligenceService _intelligence; + + public ApplicationIntelligenceController( + UserManager users, + IApplicationTimelineService timeline, + IApplicationIntelligenceService intelligence) + { + _users = users; + _timeline = timeline; + _intelligence = intelligence; + } + + [HttpGet("timeline")] + public async Task> 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> 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> 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 CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id; +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 3ff81e8..b94aa4c 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -43,6 +43,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/ApplicationIntelligenceService.cs b/JobTrackerApi/Services/ApplicationIntelligenceService.cs new file mode 100644 index 0000000..bcf4620 --- /dev/null +++ b/JobTrackerApi/Services/ApplicationIntelligenceService.cs @@ -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 Technologies, + IReadOnlyList Skills, + IReadOnlyList Responsibilities, + IReadOnlyList Keywords, + string Summary, + IReadOnlyList ImportantRequirements, + IReadOnlyList InterviewTopics, + IReadOnlyList MissingInformation, + bool HasJobDescription, + int AiSuggestionCount); + +public sealed record MatchEvidenceDto(string Title, string? Subtitle, IReadOnlyList Matched); + +public sealed record CareerMatchDto( + int Score, + string Band, + bool HasEnoughSignal, + bool HasCareerProfile, + IReadOnlyList MatchedSkills, + IReadOnlyList MissingSkills, + IReadOnlyList RelevantExperience, + IReadOnlyList RelevantProjects, + IReadOnlyList Suggestions, + int AiSuggestionCount); + +public interface IApplicationIntelligenceService +{ + Task AnalyzeAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task 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+(?.+)$", + 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 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 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 MissingInformation(JobApplication job, bool hasDescription) + { + var missing = new List(); + 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 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(), Array.Empty(), + Array.Empty(), Array.Empty(), + 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(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 Suggestions(JobCvMatchResult result, int experienceHits) + { + var suggestions = new List(); + + 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>(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 HitsFor(string text, IReadOnlyList 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 Pick(List bullets, string description, Regex cue) + { + if (bullets.Count == 0) return new List(); + 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(); +} diff --git a/JobTrackerApi/Services/ApplicationTimelineService.cs b/JobTrackerApi/Services/ApplicationTimelineService.cs new file mode 100644 index 0000000..cb3a01b --- /dev/null +++ b/JobTrackerApi/Services/ApplicationTimelineService.cs @@ -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 Events); + +public sealed record TimelineDto( + IReadOnlyList Days, + IReadOnlyList Milestones, + IReadOnlyList Categories, + int TotalEvents); + +public interface IApplicationTimelineService +{ + Task 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 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(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()); + } +} diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md index 2846908..a648a0f 100644 --- a/docs/architecture/application-workspace.md +++ b/docs/architecture/application-workspace.md @@ -18,6 +18,8 @@ The workspace **owns no data and duplicates none**. It is an aggregate read plus | Section | Backed by (existing system) | |---|---| +| Timeline | `JobEvent` — interpreted, never replaced | +| Analysis / Match | the advert and the master `CareerProfile`, read deterministically | | Checklist | `ApplicationChecklistItem` — completion state only, seeded from the readiness signals | | CV | Phase 4 `CvVariant` — a lens over the master `CareerProfile` | | Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history | @@ -158,6 +160,68 @@ its own parallel checklist. It **projects** the persisted checklist: So the division is: **the checklist is the workflow the user drives, readiness is the calculation and health indicator derived from it.** One system, two projections. +## Application intelligence (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". All deterministic, all owned by nothing: + +| Endpoint | Reads | Owns | +|---|---|---| +| `GET /{id}/timeline` | `JobEvent` | nothing | +| `GET /{id}/analysis` | `JobApplication.Description` | nothing | +| `GET /{id}/match` | `CareerProfile` + the advert | nothing | + +No new table, no new column. `ApplicationTimelineService` and `ApplicationIntelligenceService` write +nothing at all. + +### The AI boundary + +**The deterministic answer and the AI narrative are separate on purpose.** + +- The three endpoints above never call the AI. Opening the Analysis or Match section costs nothing + and cannot change anything — the page renders a computed answer. +- The narrative lives where it already did: `AiWorkspaceService`'s `job-analysis` and `career-match` + modules, reached from `AiWorkspacePanel`, generated only when the user asks. +- Every generation is appended as an `AiInteraction` — that append-only history *is* the versioning, + and the user restores, compares or deletes from it. +- AI output is a suggestion. Nothing in this phase writes to the `CareerProfile`, a `CvVariant`, a + cover letter, or the `JobApplication`. `Match_never_writes_to_the_career_profile` pins that. + +So a user gets a trustworthy number for free, and pays for prose only when they want it. + +### Timeline + +`JobEvent` stays the source of historical truth; the service is an interpretation layer over it. Each +row gains a readable summary (`("StatusChanged", "Applied", "Interview")` → "Moved from Applied to +Interview"), a category (`lifecycle`, `stage`, `follow-up`, `communication`, `ai`) and a milestone +flag. Events group by day with relative labels. + +Milestones are the stages that mean something happened — applied, interview, offer, rejected, +accepted, declined — plus creation and replies received. They are returned **unfiltered**: narrowing +the detail below must not hide what actually happened. + +An unrecognised future `Type` degrades to a humanised sentence rather than disappearing. + +### Job analysis + +Structured extraction from the advert, reusing the existing `SkillTagger` so the vocabulary matches +the job importer and the CV match. Facts (role, company, location, employment type, seniority, +salary), lists (technologies, skills, responsibilities, requirements, keywords, interview topics), +and — deliberately — **what the advert does not say**, which is usually the more useful half. + +With no advert saved it degrades to the fields on the application and says so. + +### Career matching + +Feeds the master `CareerProfile` into the same `JobCvMatchService` the CV builder uses, so one +application scores identically whichever surface asks. Returns the score and band, matched and +missing skills, and — the part that makes it actionable — **which experience and project entries are +the evidence** for each matched keyword, ranked by hit count. + +Suggestions describe what the user could change. They never change it. + +With no profile it returns score 0 and asks the user to build one, rather than implying a bad match. + ## Extension points - **New section**: add to `WORKSPACE_SECTIONS` and render it; nav is data-driven. @@ -170,5 +234,7 @@ health indicator derived from it.** One system, two projections. 1. ✅ Workspace foundation — route, nav shell, aggregate overview, next recommended action. 2. ✅ Checklist and progress tracking — persisted items, auto-completion from the readiness signals, custom items, reordering, dismissal; readiness refactored into a projection of it. -3. Timeline and activity history. 4. Job analysis. 5. Career matching. 6. CV integration. +3. ✅ Application intelligence — timeline interpretation, structured job analysis, career matching + (Phase 5.3, all three deterministic and read-only). +4. CV integration. 7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements. diff --git a/job-tracker-ui/src/application-intelligence.test.tsx b/job-tracker-ui/src/application-intelligence.test.tsx new file mode 100644 index 0000000..eda9deb --- /dev/null +++ b/job-tracker-ui/src/application-intelligence.test.tsx @@ -0,0 +1,180 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { + ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, +} from "./components/ApplicationIntelligence"; +import { api } from "./api"; + +jest.mock("./api", () => ({ + api: { + get: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (_e: any, fallback?: string) => fallback || "Request failed.", +})); + +const mockedApi = api as jest.Mocked; + +const timeline = { + days: [ + { + date: "2026-07-19", + label: "Today", + events: [ + { id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" }, + { id: 3, type: "AiRefreshed", category: "ai", summary: "AI suggestions refreshed", detail: null, isMilestone: false, at: "2026-07-19T08:00:00Z" }, + ], + }, + ], + milestones: [ + { id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" }, + ], + categories: ["ai", "stage"], + totalEvents: 2, +}; + +const analysis = { + role: "Senior Backend Developer", + company: "Acme", + location: "Oslo", + employmentType: "Full-time", + seniority: "Senior", + salary: null, + technologies: ["C#", ".NET"], + skills: ["Collaboration"], + responsibilities: ["Build and operate REST APIs"], + keywords: ["C#", ".NET", "Collaboration"], + summary: "Senior Backend Developer at Acme · Oslo.", + importantRequirements: ["Strong experience with C# and .NET"], + interviewTopics: ["C#", ".NET"], + missingInformation: ["Salary or compensation range"], + hasJobDescription: true, + aiSuggestionCount: 0, +}; + +const match = { + score: 72, + band: "Good", + hasEnoughSignal: true, + hasCareerProfile: true, + matchedSkills: ["C#", "SQL"], + missingSkills: ["Kubernetes"], + relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }], + relevantProjects: [], + suggestions: ["Solid match. Lead with the matched skills."], + aiSuggestionCount: 0, +}; + +beforeEach(() => jest.clearAllMocks()); + +// ---------- Timeline ---------- + +test("timeline renders grouped days, milestones and readable summaries", async () => { + mockedApi.get.mockResolvedValue({ data: timeline } as any); + + render(); + + expect(await screen.findByText("Milestones")).toBeInTheDocument(); + expect(screen.getByText("Today")).toBeInTheDocument(); + expect(screen.getAllByText("Moved from Applied to Interview").length).toBeGreaterThan(0); + expect(screen.getByText("AI suggestions refreshed")).toBeInTheDocument(); +}); + +test("timeline filters by category", async () => { + mockedApi.get.mockResolvedValue({ data: timeline } as any); + + render(); + fireEvent.click(await screen.findByRole("button", { name: "AI" })); + + await waitFor(() => + expect(mockedApi.get).toHaveBeenLastCalledWith( + "/jobapplications/7/timeline", + { params: { category: "ai", milestonesOnly: undefined } }, + )); +}); + +test("timeline shows an empty state when nothing has happened", async () => { + mockedApi.get.mockResolvedValue({ data: { days: [], milestones: [], categories: [], totalEvents: 0 } } as any); + + render(); + + expect(await screen.findByText(/Nothing has happened yet/i)).toBeInTheDocument(); +}); + +test("timeline surfaces an error instead of rendering nothing", async () => { + mockedApi.get.mockRejectedValue(new Error("boom")); + + render(); + + expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument(); +}); + +// ---------- Analysis ---------- + +test("analysis renders the extracted structure", async () => { + mockedApi.get.mockResolvedValue({ data: analysis } as any); + + render(); + + expect(await screen.findByText("Senior Backend Developer at Acme · Oslo.")).toBeInTheDocument(); + expect(screen.getByText("Full-time")).toBeInTheDocument(); + expect(screen.getByText("Strong experience with C# and .NET")).toBeInTheDocument(); + expect(screen.getByText("Salary or compensation range")).toBeInTheDocument(); +}); + +test("analysis prompts for the advert when there is none", async () => { + mockedApi.get.mockResolvedValue({ + data: { ...analysis, hasJobDescription: false, technologies: [], responsibilities: [], importantRequirements: [] }, + } as any); + + render(); + + expect(await screen.findByText(/No advert text saved yet/i)).toBeInTheDocument(); +}); + +test("analysis shows a loading state before the data arrives", () => { + mockedApi.get.mockReturnValue(new Promise(() => {}) as any); + + const { container } = render(); + + expect(container.querySelectorAll(".MuiSkeleton-root").length).toBeGreaterThan(0); +}); + +// ---------- Match ---------- + +test("match renders the score, evidence and suggestions", async () => { + mockedApi.get.mockResolvedValue({ data: match } as any); + + render(); + + expect(await screen.findByText("72%")).toBeInTheDocument(); + expect(screen.getByText("Good")).toBeInTheDocument(); + expect(screen.getByText("Backend Developer")).toBeInTheDocument(); + expect(screen.getByText("Kubernetes")).toBeInTheDocument(); + expect(screen.getByText(/Solid match/i)).toBeInTheDocument(); +}); + +test("match asks for a career profile before showing a score", async () => { + mockedApi.get.mockResolvedValue({ + data: { + ...match, score: 0, band: "No profile", hasCareerProfile: false, + matchedSkills: [], missingSkills: [], relevantExperience: [], + suggestions: ["Build your career profile first."], + }, + } as any); + + render(); + + expect(await screen.findByText(/No career profile yet/i)).toBeInTheDocument(); + expect(screen.queryByText("0%")).not.toBeInTheDocument(); +}); + +test("match warns when the advert is too short to score", async () => { + mockedApi.get.mockResolvedValue({ data: { ...match, hasEnoughSignal: false } } as any); + + render(); + + expect(await screen.findByText(/too short to score reliably/i)).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 747b288..dbb2631 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -84,7 +84,7 @@ export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; mile { key: "portfolio", label: "Portfolio", milestone: 8 }, { key: "documents", label: "Documents" }, { key: "interview", label: "Interview Prep" }, - { key: "timeline", label: "Timeline", milestone: 3 }, + { key: "timeline", label: "Timeline" }, { key: "notes", label: "Notes", milestone: 3 }, { key: "communication", label: "Communication" }, ]; @@ -94,6 +94,81 @@ export const applicationWorkspaceApi = { api.get(`/jobapplications/${jobId}/workspace`).then((r) => r.data), }; +// Phase 5.3 — Application Intelligence. All three reads are deterministic and read-only; the AI +// narrative stays on the existing /ai routes, which require the user to ask for it. +export type TimelineEvent = { + id: number; + type: string; + category: string; + summary: string; + detail: string | null; + isMilestone: boolean; + at: string; +}; + +export type TimelineDay = { date: string; label: string; events: TimelineEvent[] }; + +export type Timeline = { + days: TimelineDay[]; + milestones: TimelineEvent[]; + categories: string[]; + totalEvents: number; +}; + +export type JobAnalysis = { + role: string | null; + company: string | null; + location: string | null; + employmentType: string | null; + seniority: string | null; + salary: string | null; + technologies: string[]; + skills: string[]; + responsibilities: string[]; + keywords: string[]; + summary: string; + importantRequirements: string[]; + interviewTopics: string[]; + missingInformation: string[]; + hasJobDescription: boolean; + aiSuggestionCount: number; +}; + +export type MatchEvidence = { title: string; subtitle: string | null; matched: string[] }; + +export type CareerMatch = { + score: number; + band: string; + hasEnoughSignal: boolean; + hasCareerProfile: boolean; + matchedSkills: string[]; + missingSkills: string[]; + relevantExperience: MatchEvidence[]; + relevantProjects: MatchEvidence[]; + suggestions: string[]; + aiSuggestionCount: number; +}; + +export const TIMELINE_CATEGORY_LABELS: Record = { + lifecycle: "Lifecycle", + stage: "Stage", + "follow-up": "Follow-up", + communication: "Communication", + ai: "AI", +}; + +export const applicationIntelligenceApi = { + timeline: (jobId: number, category?: string, milestonesOnly?: boolean) => + api + .get(`/jobapplications/${jobId}/timeline`, { + params: { category: category || undefined, milestonesOnly: milestonesOnly || undefined }, + }) + .then((r) => r.data), + analysis: (jobId: number) => + api.get(`/jobapplications/${jobId}/analysis`).then((r) => r.data), + match: (jobId: number) => api.get(`/jobapplications/${jobId}/match`).then((r) => r.data), +}; + export const applicationChecklistApi = { get: (jobId: number) => api.get(`/jobapplications/${jobId}/checklist`).then((r) => r.data), diff --git a/job-tracker-ui/src/components/ApplicationIntelligence.tsx b/job-tracker-ui/src/components/ApplicationIntelligence.tsx new file mode 100644 index 0000000..5d4cd84 --- /dev/null +++ b/job-tracker-ui/src/components/ApplicationIntelligence.tsx @@ -0,0 +1,344 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { + Alert, Box, Chip, Divider, LinearProgress, Paper, Skeleton, Stack, ToggleButton, + ToggleButtonGroup, Typography, +} from "@mui/material"; + +import { getApiErrorMessage } from "../api"; +import { + CareerMatch, JobAnalysis, TIMELINE_CATEGORY_LABELS, Timeline, applicationIntelligenceApi, +} from "../applicationWorkspace"; + +// Phase 5.3 — Application Intelligence sections for the workspace. +// +// Everything here renders a deterministic, read-only backend answer. No component triggers an AI +// generation: that stays an explicit user action in AiWorkspacePanel, so nothing on this page can +// silently spend a token or change the user's data. +// docs/architecture/application-workspace.md. + +// One loader for all three sections: same fetch/loading/empty/error shape, so the sections stay +// consistent and each one is just its own rendering. +function useIntelligence(load: () => Promise, deps: React.DependencyList) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const run = useCallback(load, deps); + + useEffect(() => { + let cancelled = false; + setLoading(true); + run() + .then((result) => { + if (cancelled) return; + setData(result); + setError(null); + }) + .catch((err) => { + if (!cancelled) setError(getApiErrorMessage(err, "Could not load this section.")); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [run]); + + return { data, error, loading }; +} + +function SectionShell({ title, subtitle, loading, error, empty, emptyText, children }: { + title: string; + subtitle?: string; + loading: boolean; + error: string | null; + empty?: boolean; + emptyText?: string; + children: React.ReactNode; +}) { + return ( + + {title} + {subtitle && {subtitle}} + + {loading ? ( + {[0, 1, 2].map((i) => )} + ) : error ? ( + {error} + ) : empty ? ( + {emptyText} + ) : ( + children + )} + + ); +} + +function Chips({ label, values, color }: { label: string; values: string[]; color?: "success" | "warning" | "default" }) { + if (values.length === 0) return null; + return ( + + {label} + + {values.map((v) => ( + + ))} + + + ); +} + +function Bullets({ label, values }: { label: string; values: string[] }) { + if (values.length === 0) return null; + return ( + + {label} + + {values.map((v, i) => ( + {v} + ))} + + + ); +} + +// ---------- Timeline ---------- + +export function ApplicationTimeline({ jobId }: { jobId: number }) { + const [category, setCategory] = useState(""); + const { data, error, loading } = useIntelligence( + () => applicationIntelligenceApi.timeline(jobId, category || undefined), + [jobId, category], + ); + + const hasEvents = (data?.totalEvents ?? 0) > 0; + + return ( + + {hasEvents && (data?.milestones.length ?? 0) > 0 && ( + + Milestones + + {(data?.milestones ?? []).map((m) => ( + + {m.summary} + + {new Date(m.at).toLocaleDateString()} + + + ))} + + + )} + + + {/* SectionShell takes children as a prop, so this JSX is built before it decides whether to + render it — every access has to tolerate a null `data`. */} + + {(data?.categories.length ?? 0) > 1 && ( + setCategory(next ?? "")} + aria-label="Filter timeline by category" + sx={{ flexWrap: "wrap" }} + > + All + {(data?.categories ?? []).map((c) => ( + + {TIMELINE_CATEGORY_LABELS[c] ?? c} + + ))} + + )} + + {(data?.days.length ?? 0) === 0 ? ( + No events in this category. + ) : ( + (data?.days ?? []).map((day) => ( + + + {day.label} + + + {day.events.map((e) => ( + + + + {e.summary} + + {e.isMilestone && } + + {e.detail && ( + {e.detail} + )} + + ))} + + + )) + )} + + + + ); +} + +// ---------- Analysis ---------- + +export function ApplicationAnalysis({ jobId }: { jobId: number }) { + const { data, error, loading } = useIntelligence( + () => applicationIntelligenceApi.analysis(jobId), + [jobId], + ); + + const facts: [string, string | null][] = data + ? [ + ["Role", data.role], + ["Company", data.company], + ["Location", data.location], + ["Employment type", data.employmentType], + ["Seniority", data.seniority], + ["Salary", data.salary], + ] + : []; + + return ( + + + {data && !data.hasJobDescription && ( + + No advert text saved yet. Paste it into the application to get requirements, technologies + and interview topics. + + )} + + {data?.summary} + + + {facts.map(([k, v]) => ( + + {k} + {v ?? "—"} + + ))} + + + + + + + + + + + ); +} + +// ---------- Match ---------- + +export function ApplicationMatch({ jobId }: { jobId: number }) { + const { data, error, loading } = useIntelligence( + () => applicationIntelligenceApi.match(jobId), + [jobId], + ); + + return ( + + + {data && !data.hasCareerProfile ? ( + + No career profile yet. Matching compares the advert against your master profile — build it + once and every application scores against it. + + ) : ( + <> + + + {data?.score}% + + + + + + {data && !data.hasEnoughSignal && ( + + The advert is too short to score reliably. Paste the full text for a real match. + + )} + + + + + {(data?.relevantExperience.length ?? 0) > 0 && ( + + + Relevant experience + + + {(data?.relevantExperience ?? []).map((e, i) => ( + + {e.title} + {e.subtitle && {e.subtitle}} + + {e.matched.map((k) => )} + + + ))} + + + )} + + {(data?.relevantProjects.length ?? 0) > 0 && ( + + + Relevant projects + + + {(data?.relevantProjects ?? []).map((p, i) => ( + + {p.title} + {p.subtitle && {p.subtitle}} + + {p.matched.map((k) => )} + + + ))} + + + )} + + )} + + + + + ); +} diff --git a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx index 3d98f93..4912623 100644 --- a/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx +++ b/job-tracker-ui/src/views/ApplicationWorkspacePage.tsx @@ -19,6 +19,9 @@ import Attachments from "../components/Attachments"; import Correspondence from "../components/Correspondence"; import AiWorkspacePanel from "../components/AiWorkspacePanel"; import ApplicationChecklist from "../components/ApplicationChecklist"; +import { + ApplicationAnalysis, ApplicationMatch, ApplicationTimeline, +} from "../components/ApplicationIntelligence"; import { WORKSPACE_SECTIONS, WorkspaceOverview, WorkspaceSectionKey, applicationWorkspaceApi, } from "../applicationWorkspace"; @@ -92,6 +95,10 @@ export default function ApplicationWorkspacePage() { {section === "overview" && } {section === "job-details" && } + {/* Deterministic answer first, then the AI panel below it — the page never generates on load. */} + {section === "analysis" && jobId > 0 && } + {section === "match" && jobId > 0 && } + {section === "timeline" && jobId > 0 && } {(section === "analysis" || section === "match" || section === "interview") && jobId > 0 && ( @@ -106,7 +113,7 @@ export default function ApplicationWorkspacePage() { {section === "checklist" && jobId > 0 && ( )} - {["cv", "cover-letter", "portfolio", "timeline", "notes"].includes(section) && ( + {["cv", "cover-letter", "portfolio", "notes"].includes(section) && ( )}