feat: complete phase 2 UX improvements
CI and Deploy / test (push) Successful in 2m42s
CI and Deploy / deploy (push) Failing after 9s

This commit is contained in:
cesnimda
2026-07-30 21:35:52 +02:00
parent 173187dcbb
commit 56fed05d70
26 changed files with 1979 additions and 12283 deletions
@@ -1,3 +1,5 @@
using System.Reflection;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using Xunit;
@@ -74,4 +76,71 @@ public sealed class CvExtractionCoverageTests
Assert.NotEmpty(profile.Certifications);
Assert.Equal(2, profile.Languages.Count);
}
[Fact]
public void Normalized_markdown_strips_skill_group_labels()
{
var profile = InvokeProfileBuilder("""
# Skills
Development: C#
DevOps & Infrastructure: Docker
Practices: CI/CD
""");
Assert.Equal(new[] { "C#", "CI/CD", "Docker" }, profile.Skills);
Assert.DoesNotContain(profile.Skills, skill => skill.StartsWith("Development", System.StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Normalized_markdown_separates_glued_dates_from_job_title()
{
var profile = InvokeProfileBuilder("""
# Work Experience
20152023System Developer
Warwickshire County Council, UK
- Built APIs
""");
var job = Assert.Single(profile.Jobs);
Assert.Equal("2015", job.Start);
Assert.Equal("2023", job.End);
Assert.Contains("System Developer", job.Title ?? string.Empty);
}
[Fact]
public void Extraction_repairs_common_utf8_as_latin1_mojibake()
{
var method = typeof(ProfileCvController).GetMethod("RepairKnownMojibake", BindingFlags.NonPublic | BindingFlags.Static)!;
var repaired = Assert.IsType<string>(method.Invoke(null, new object[] { "Tønsberg 2015–2023" }));
Assert.Equal("Tønsberg 20152023", repaired);
}
[Fact]
public void Earlier_part_time_roles_become_separate_experience_entries()
{
var profile = InvokeProfileBuilder("""
# Work Experience
System Developer
Warwickshire County Council
20152023
- Built APIs
Earlier roles (part-time)
- Sales Assistant Royal Vapes | 20132015
- Labourer The Hodcarrier | 20142016
- Lifeguard Nuffield Health | 20122014
""");
Assert.Contains(profile.Jobs, job => job.Title == "Sales Assistant" && job.Company == "Royal Vapes");
Assert.Contains(profile.Jobs, job => job.Title == "Labourer" && job.Company == "The Hodcarrier");
Assert.Contains(profile.Jobs, job => job.Title == "Lifeguard" && job.Company == "Nuffield Health");
Assert.Equal(4, profile.Jobs.Count);
}
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
{
var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", BindingFlags.NonPublic | BindingFlags.Static)!;
return Assert.IsType<StructuredCvProfile>(method.Invoke(null, new object[] { markdown }));
}
}
@@ -0,0 +1,219 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
// Phase 2.1-a — the diff engine. Pure comparison; applies nothing. These pin the conservative policy:
// clear matches only, updates only on non-empty differing values, no deletions, dedup, and the
// High/Medium/Low confidence markers that drive the review screen.
public sealed class CvProfileDiffServiceTests
{
private static readonly CvProfileDiffService Svc = new();
private static CvCategoryDiff Cat(CvImportDiff d, string name) => d.Categories.First(c => c.Category == name);
private static StructuredCvJob Job(string title, string company, string? start = null, string? end = null, params string[] bullets)
=> new() { Title = title, Company = company, Start = start, End = end, Bullets = bullets.ToList() };
[Fact]
public void Empty_current_makes_everything_an_addition()
{
var current = new StructuredCvProfile();
var extracted = new StructuredCvProfile
{
Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built full-stack apps.") },
Projects = { new StructuredCvProject { Name = "JobTrack", Bullets = { "Job tracker." } } },
Skills = { "C#", ".NET", "Docker" },
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" }, new StructuredCvLanguage { Name = "Norwegian", Level = "B1" } },
};
var diff = Svc.Diff(current, extracted);
Assert.True(diff.HasChanges);
Assert.Single(Cat(diff, "Experience").Added);
Assert.Single(Cat(diff, "Projects").Added);
Assert.Equal(3, Cat(diff, "Skills").Added.Count);
Assert.Equal(2, Cat(diff, "Languages").Added.Count);
Assert.Empty(Cat(diff, "Experience").Updated);
}
[Fact]
public void A_matched_job_with_no_new_information_is_unchanged_not_duplicated()
{
var job = Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built apps.");
var current = new StructuredCvProfile { Jobs = { job } };
var extracted = new StructuredCvProfile { Jobs = { Job("system developer", "WARWICKSHIRE COUNTY COUNCIL", "2015", "2023", "Built apps.") } };
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Empty(exp.Added); // same company+title -> not a new entry
Assert.Empty(exp.Updated); // same dates + bullets -> nothing to update
Assert.Equal(1, exp.UnchangedCount);
}
[Fact]
public void A_matched_job_with_new_dates_is_an_update_not_an_add()
{
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
var extracted = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023") } };
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Empty(exp.Added);
Assert.Single(exp.Updated);
Assert.Contains(exp.Updated[0].FieldChanges, f => f.Field == "Dates");
}
[Fact]
public void A_genuinely_new_job_is_an_addition()
{
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
var extracted = new StructuredCvProfile
{
Jobs = { Job("System Developer", "Warwickshire County Council"), Job("Bartender", "The Hodcarrier", "2016", "2018") },
};
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Single(exp.Added);
Assert.Equal("Bartender — The Hodcarrier", exp.Added[0].Label);
}
[Fact]
public void Skills_dedup_case_insensitively_and_only_new_ones_are_added()
{
var current = new StructuredCvProfile { Skills = { "C#", "SQL" } };
var extracted = new StructuredCvProfile { Skills = { "c#", ".NET", "sql", "Docker", "docker" } };
var skills = Cat(Svc.Diff(current, extracted), "Skills");
Assert.Equal(2, skills.Added.Count); // .NET and Docker only, deduped
Assert.Contains(skills.Added, s => s.Label == ".NET");
Assert.Contains(skills.Added, s => s.Label == "Docker");
}
[Fact]
public void A_language_level_change_is_an_update_a_new_language_is_an_add()
{
var current = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } } };
var extracted = new StructuredCvProfile
{
Languages =
{
new StructuredCvLanguage { Name = "English", Level = "Native" },
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
},
};
var langs = Cat(Svc.Diff(current, extracted), "Languages");
Assert.Single(langs.Added);
Assert.Equal("Norwegian (B1)", langs.Added[0].Label);
Assert.Equal(1, langs.UnchangedCount);
}
[Fact]
public void A_language_without_a_level_is_low_confidence()
{
var extracted = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "French" } } };
var langs = Cat(Svc.Diff(new StructuredCvProfile(), extracted), "Languages");
Assert.Equal("Low", langs.Added[0].Confidence);
Assert.Equal(1, diffLow(langs));
}
private static int diffLow(CvCategoryDiff c) => c.LowConfidenceCount;
[Fact]
public void Extraction_never_proposes_blanking_an_existing_value_and_never_deletes()
{
// current has a rich job; extraction found the same job but with an empty location and no bullets.
var current = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022", "Did things.") } };
current.Jobs[0].Location = "Oslo";
var extracted = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022") } }; // no location, no bullets
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Empty(exp.Added);
Assert.Empty(exp.Updated); // empty extracted fields never overwrite
Assert.Equal(1, exp.UnchangedCount);
// And a job the extraction did NOT mention simply doesn't appear in the diff (never deleted).
var extracted2 = new StructuredCvProfile();
Assert.False(Svc.Diff(current, extracted2).HasChanges);
}
[Fact]
public void Merge_preserves_existing_items_and_adds_new_information()
{
var current = new StructuredCvProfile
{
Jobs = { Job("Developer", "Acme", "2020", "2022", "Curated bullet") },
Skills = { "C#" },
};
current.Jobs[0].Id = "keep-me";
current.Jobs[0].Location = "Oslo";
var extracted = new StructuredCvProfile
{
Jobs =
{
Job("developer", "ACME", "2020", "2024", "Curated bullet", "New extracted bullet"),
Job("Engineer", "New Co", "2024", "Present", "Built systems"),
},
Skills = { "c#", "Docker" },
};
var merged = Svc.Merge(current, extracted);
Assert.Equal(2, merged.Jobs.Count);
Assert.Equal("keep-me", merged.Jobs[0].Id);
Assert.Equal("Oslo", merged.Jobs[0].Location);
Assert.Equal("2024", merged.Jobs[0].End);
Assert.Equal(new[] { "Curated bullet", "New extracted bullet" }, merged.Jobs[0].Bullets);
Assert.Equal(new[] { "C#", "Docker" }, merged.Skills);
}
[Fact]
public void Merge_never_blanks_or_deletes_existing_data()
{
var current = new StructuredCvProfile
{
Contact = new StructuredCvContact { FullName = "Demo User", Email = "demo@example.com" },
Jobs = { Job("Developer", "Acme", "2020", "2024", "Keep this") },
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } },
};
var extracted = new StructuredCvProfile
{
Contact = new StructuredCvContact { FullName = "Demo User" },
Languages = { new StructuredCvLanguage { Name = "English" } },
};
var merged = Svc.Merge(current, extracted);
Assert.Equal("demo@example.com", merged.Contact.Email);
Assert.Single(merged.Jobs);
Assert.Equal("Native", merged.Languages[0].Level);
}
[Fact]
public void Merge_requires_explicit_acceptance_for_each_low_confidence_change()
{
var extracted = new StructuredCvProfile
{
Languages =
{
new StructuredCvLanguage { Name = "French" },
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
},
};
var diff = Svc.Diff(new StructuredCvProfile(), extracted);
var french = Cat(diff, "Languages").Added.Single(x => x.Label == "French");
Assert.Equal("Languages|french", french.Id);
var defaultMerge = Svc.Merge(new StructuredCvProfile(), extracted);
var confirmedMerge = Svc.Merge(new StructuredCvProfile(), extracted, new HashSet<string> { french.Id });
Assert.DoesNotContain(defaultMerge.Languages, x => x.Name == "French");
Assert.Contains(defaultMerge.Languages, x => x.Name == "Norwegian");
Assert.Contains(confirmedMerge.Languages, x => x.Name == "French");
}
}
+55 -16
View File
@@ -40,7 +40,7 @@ public sealed class ProfileCvControllerTests
}
[Fact]
public async Task Upload_stores_cv_artifact_and_extraction_run_metadata()
public async Task Upload_waits_for_review_then_accept_merges_and_applies()
{
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
@@ -81,21 +81,55 @@ public sealed class ProfileCvControllerTests
Assert.IsType<OkObjectResult>(result);
var artifact = await db.CvUploadArtifacts.SingleAsync();
var run = await db.CvExtractionRuns.SingleAsync();
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("user-1", artifact.OwnerUserId);
Assert.Equal("resume.md", artifact.OriginalFileName);
Assert.True(System.IO.File.Exists(artifact.StoragePath));
Assert.Equal("applied", run.Status);
Assert.Equal("pending_review", run.Status);
Assert.Equal("upload", run.Trigger);
Assert.Equal(artifact.Id, run.ArtifactId);
Assert.Null(user.ProfileCvStructureJson);
Assert.Null(user.CurrentCvExtractionRunId);
Assert.IsType<OkObjectResult>(await controller.GetRunDiff(run.Id));
Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id));
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("applied", run.Status);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
Assert.Equal(1, user.CurrentCvProfileVersion);
Assert.Equal(run.Id, parsed.Metadata.AppliedExtractionRunId);
Assert.True(parsed.Metadata.ProfileVersion >= 1);
Assert.Contains(parsed.Metadata.Fields.Keys, key => key == "contact.fullName" || key == "summary");
}
[Fact]
public async Task DiscardRun_leaves_profile_unchanged()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvStructureJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "C#" } }) };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
await using var db = CreateDb();
db.CvExtractionRuns.Add(new CvExtractionRun
{
OwnerUserId = user.Id,
Trigger = "upload",
Status = "pending_review",
ParserVersion = "test",
NormalizerVersion = "test",
LlmPromptVersion = "test",
StructuredProfileJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "Docker" } }),
});
await db.SaveChangesAsync();
var run = await db.CvExtractionRuns.SingleAsync();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
Assert.IsType<NoContentResult>(await controller.DiscardRun(run.Id));
Assert.Equal("discarded", run.Status);
Assert.Equal(new[] { "C#" }, StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Skills);
}
[Fact]
public async Task GetRuns_returns_latest_extraction_runs()
{
@@ -186,10 +220,10 @@ public sealed class ProfileCvControllerTests
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("reprocess", run.Trigger);
Assert.Equal("applied", run.Status);
Assert.Equal(2, user.CurrentCvProfileVersion);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText);
Assert.Equal("pending_review", run.Status);
Assert.Equal(1, user.CurrentCvProfileVersion);
Assert.Null(user.CurrentCvExtractionRunId);
Assert.Null(user.ProfileCvText);
}
[Fact]
@@ -268,9 +302,10 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
Assert.Equal(reconstructed, user.ProfileCvText);
var savedRun = await db.CvExtractionRuns.SingleAsync();
Assert.Equal(reconstructed, savedRun.NormalizedText);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Single(structured.Summary);
Assert.Single(structured.Jobs);
@@ -326,10 +361,11 @@ public sealed class ProfileCvControllerTests
Assert.IsType<OkObjectResult>(result);
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var savedRun = await db.CvExtractionRuns.SingleAsync();
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("# Skills", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -366,7 +402,8 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var savedRun = await db.CvExtractionRuns.SingleAsync();
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Equal("connor.babbington@cesnimda.co.uk", structured.Contact.Email);
Assert.Equal("+47 41 33 44 70", structured.Contact.Phone);
@@ -965,8 +1002,10 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
Assert.Contains("Built APIs", user.ProfileCvText);
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Contains("Built APIs", run.NormalizedText);
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(run.StructuredProfileJson).Contact.FullName);
Assert.Equal("pending_review", run.Status);
}
[Fact]
+154 -63
View File
@@ -72,8 +72,9 @@ public sealed class ProfileCvController : ControllerBase
private readonly ICvProcessingQueue _cvProcessingQueue;
private readonly IAppEmailSender _emailSender;
private readonly ICareerProfileService _careerProfileService;
private readonly ICvProfileDiffService _cvProfileDiffService;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null, ICvProfileDiffService? cvProfileDiffService = null)
{
_users = users;
_aiService = aiService;
@@ -87,6 +88,7 @@ public sealed class ProfileCvController : ControllerBase
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
_emailSender = emailSender ?? NoOpEmailSender.Instance;
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
_cvProfileDiffService = cvProfileDiffService ?? new CvProfileDiffService();
}
private sealed class NoOpEmailSender : IAppEmailSender
@@ -120,6 +122,7 @@ public sealed class ProfileCvController : ControllerBase
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
// (Phase 4), not this foundation integration.
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds);
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
@@ -159,45 +162,21 @@ public sealed class ProfileCvController : ControllerBase
try
{
var result = await ExtractStructuredCvFromFileAsync(file, extension, HttpContext.RequestAborted);
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.RawExtractedText = result.RawText;
run.NormalizedText = result.NormalizedText;
run.StructuredProfileJson = structuredJson;
run.Status = "applied";
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
run.AppliedAtUtc = run.CompletedAtUtc;
user.ProfileCvText = result.NormalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvUploadArtifactId = artifact.Id;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = result.StructuredCv.Metadata.ProfileVersion;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return BadRequest(run.ErrorMessage);
}
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new
{
imported = true,
imported = false,
pendingReview = true,
characters = result.NormalizedText.Length,
structuredCv = result.StructuredCv,
sections = result.StructuredCv.Sections,
artifactId = artifact.Id,
extractionRunId = run.Id,
profileVersion = result.StructuredCv.Metadata.ProfileVersion,
status = run.Status,
});
}
catch (Exception ex)
@@ -238,6 +217,71 @@ public sealed class ProfileCvController : ControllerBase
return Ok(runs);
}
[HttpGet("runs/{id:int}/diff")]
public async Task<IActionResult> GetRunDiff([FromRoute] int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) });
}
[HttpPost("runs/{id:int}/accept")]
public async Task<IActionResult> AcceptRun([FromRoute] int id, [FromBody] AcceptCvRunRequest? request = null)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
var diff = _cvProfileDiffService.Diff(current, extracted);
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
merged.Metadata.AppliedExtractionRunId = run.Id;
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(merged);
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
run.Status = "applied";
run.AppliedAtUtc = DateTimeOffset.UtcNow;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged });
}
[HttpPost("runs/{id:int}/discard")]
public async Task<IActionResult> DiscardRun([FromRoute] int id)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
if (run is null) return NotFound();
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
run.Status = "discarded";
await _db.SaveChangesAsync(HttpContext.RequestAborted);
return NoContent();
}
[HttpPost("reprocess")]
public async Task<IActionResult> Reprocess()
{
@@ -807,6 +851,7 @@ public sealed class ProfileCvController : ControllerBase
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
}
text = RepairKnownMojibake(text);
var normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
@@ -916,7 +961,7 @@ public sealed class ProfileCvController : ControllerBase
var normalizedText = rebuilt.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "improve":
@@ -931,7 +976,7 @@ public sealed class ProfileCvController : ControllerBase
var normalizedText = improved.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, normalizedText, normalizedText, structuredCv, run.ArtifactId, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "reprocess":
@@ -951,7 +996,7 @@ public sealed class ProfileCvController : ControllerBase
};
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken);
await ApplyQueuedRunResultAsync(run, user, result.RawText, result.NormalizedText, result.StructuredCv, artifact.Id, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, result.RawText, result.NormalizedText, result.StructuredCv, cancellationToken);
break;
}
default:
@@ -971,39 +1016,13 @@ public sealed class ProfileCvController : ControllerBase
}
}
private async Task ApplyQueuedRunResultAsync(CvExtractionRun run, ApplicationUser user, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
private async Task CompleteQueuedRunForReviewAsync(CvExtractionRun run, string rawText, string normalizedText, StructuredCvProfile structuredCv, CancellationToken cancellationToken)
{
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.RawExtractedText = rawText;
run.NormalizedText = normalizedText;
run.StructuredProfileJson = structuredJson;
run.Status = "applied";
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
run.AppliedAtUtc = run.CompletedAtUtc;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken);
}
@@ -1606,6 +1625,15 @@ public sealed class ProfileCvController : ControllerBase
{
var normalized = content.Replace("\r\n", "\n").Trim();
var structured = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Work Experience", Content = normalized } }).Jobs;
var earlierRoles = ParseEarlierRoles(normalized);
foreach (var role in earlierRoles)
{
if (!structured.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase)))
{
structured.Add(role);
}
}
if (ArePlausibleJobs(structured, null))
{
return structured;
@@ -1711,6 +1739,36 @@ public sealed class ProfileCvController : ControllerBase
return jobs;
}
private static List<StructuredCvJob> ParseEarlierRoles(string content)
{
var heading = Regex.Match(content, @"(?im)^\s*(?:[-*]\s*)?Earlier roles(?:\s*\(part[- ]?time\))?\s*:?\s*$");
if (!heading.Success) return new List<StructuredCvJob>();
var roles = new List<StructuredCvJob>();
foreach (var rawLine in content[(heading.Index + heading.Length)..].Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (rawLine.StartsWith('#')) break;
var line = rawLine.Trim().TrimStart('-', '*', '•', ' ');
var dates = Regex.Match(line, @"(?<start>\d{4})\s*[-–—]\s*(?<end>\d{4}|Present|Current)", RegexOptions.IgnoreCase);
if (!dates.Success) continue;
var identity = Regex.Replace(line, @"\s*[|,(]?\s*\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current)\s*\)?\s*$", string.Empty, RegexOptions.IgnoreCase).Trim();
var parts = Regex.Split(identity, @"\s+(?:—||\||at)\s+", RegexOptions.IgnoreCase);
if (parts.Length != 2 || parts.Any(string.IsNullOrWhiteSpace)) continue;
roles.Add(new StructuredCvJob
{
Title = parts[0].Trim(),
Company = parts[1].Trim(),
Start = dates.Groups["start"].Value,
End = dates.Groups["end"].Value,
IsCurrent = dates.Groups["end"].Value.Equals("Present", StringComparison.OrdinalIgnoreCase)
|| dates.Groups["end"].Value.Equals("Current", StringComparison.OrdinalIgnoreCase),
});
}
return roles;
}
private static string? TitleCasePreservingAcronyms(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
@@ -2129,6 +2187,7 @@ public sealed class ProfileCvController : ControllerBase
private static StructuredCvProfile BuildStructuredCvFromNormalizedMarkdown(string text)
{
text = SeparateGluedDateAndTitle(text);
var sections = ParseSections(text)
.Select(section => new StructuredCvSection
{
@@ -2140,6 +2199,16 @@ public sealed class ProfileCvController : ControllerBase
var profile = StructuredCvProfileJson.FromSections(sections);
profile.Sections = sections;
var workExperience = sections.FirstOrDefault(section => section.Name == "Work Experience")?.Content;
if (!string.IsNullOrWhiteSpace(workExperience))
{
profile.Jobs.RemoveAll(job => (job.Title ?? string.Empty).StartsWith("Earlier roles", StringComparison.OrdinalIgnoreCase));
foreach (var role in ParseEarlierRoles(workExperience))
{
if (!profile.Jobs.Any(job => string.Equals(job.Title, role.Title, StringComparison.OrdinalIgnoreCase)
&& string.Equals(job.Company, role.Company, StringComparison.OrdinalIgnoreCase))) profile.Jobs.Add(role);
}
}
if (string.IsNullOrWhiteSpace(profile.Contact.FullName))
{
@@ -2175,11 +2244,33 @@ public sealed class ProfileCvController : ControllerBase
private static List<string> OrderSkills(List<string> skills)
{
return skills
.Select(CleanSkillGroupPrefix)
.Where(skill => skill.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(skill => skill, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string CleanSkillGroupPrefix(string skill)
=> Regex.Replace(skill.Trim(), @"^(?:Development|DevOps(?:\s*&\s*Infrastructure)?|Infrastructure|Practices|Tools|Technologies|Technical Skills)\s*:\s*", string.Empty, RegexOptions.IgnoreCase).Trim();
private static string SeparateGluedDateAndTitle(string text)
=> Regex.Replace(text, @"(?<date>\b\d{4}\s*[-–—]\s*(?:\d{4}|Present|Current))(?<title>[\p{L}][^\r\n]*)", "${title}\n${date}", RegexOptions.IgnoreCase);
private static string RepairKnownMojibake(string text)
=> text
.Replace("ø", "ø", StringComparison.Ordinal)
.Replace("Ø", "Ø", StringComparison.Ordinal)
.Replace("æ", "æ", StringComparison.Ordinal)
.Replace("Æ", "Æ", StringComparison.Ordinal)
.Replace("Ã¥", "å", StringComparison.Ordinal)
.Replace("Ã…", "Å", StringComparison.Ordinal)
.Replace("–", "", StringComparison.Ordinal)
.Replace("—", "—", StringComparison.Ordinal)
.Replace("’", "", StringComparison.Ordinal)
.Replace("“", "“", StringComparison.Ordinal)
.Replace("”", "”", StringComparison.Ordinal);
private static List<string> CleanInterestItems(List<string> interests)
{
return interests
@@ -2229,7 +2320,7 @@ public sealed class ProfileCvController : ControllerBase
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
{
var raw = Encoding.UTF8.GetString(bytes);
var raw = Encoding.Latin1.GetString(bytes);
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
.Select(match => match.Groups[1].Value)
.Concat(Regex.Matches(raw, @"\[(.*?)\]TJ", RegexOptions.Singleline)
+1
View File
@@ -39,6 +39,7 @@ builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
+427
View File
@@ -0,0 +1,427 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// Phase 2.1-a — the diff half of the review-and-merge workflow. Pure comparison of the user's current
// Career Profile against a freshly-extracted profile; produces structured, user-facing changes. It
// applies NOTHING — the merge engine consumes an accepted diff separately. No DB, no AI, no state.
//
// Conservative by design (the approved policy): items match only when they clearly refer to the same
// thing (company+title, institution+qualification, project/certification name, language/skill text),
// a field is only ever proposed as an *update* when the extracted value is non-empty and differs, and
// nothing the user already has is ever marked for deletion.
public enum CvChangeKind { Add, Update }
public sealed record CvFieldChange(string Field, string? OldValue, string? NewValue);
public sealed class CvItemChange
{
public string Id { get; init; } = "";
public CvChangeKind Kind { get; init; }
public string Category { get; init; } = "";
public string Label { get; init; } = "";
public string Confidence { get; init; } = "Medium"; // High | Medium | Low
public List<CvFieldChange> FieldChanges { get; init; } = new();
}
public sealed class CvCategoryDiff
{
public string Category { get; init; } = "";
public List<CvItemChange> Added { get; init; } = new();
public List<CvItemChange> Updated { get; init; } = new();
public int UnchangedCount { get; init; }
public int LowConfidenceCount => Added.Concat(Updated).Count(c => c.Confidence == "Low");
}
public sealed class CvImportDiff
{
public List<CvCategoryDiff> Categories { get; init; } = new();
public int TotalAdded => Categories.Sum(c => c.Added.Count);
public int TotalUpdated => Categories.Sum(c => c.Updated.Count);
public int TotalLowConfidence => Categories.Sum(c => c.LowConfidenceCount);
public bool HasChanges => TotalAdded > 0 || TotalUpdated > 0;
}
public interface ICvProfileDiffService
{
CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted);
StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null);
}
public sealed class CvProfileDiffService : ICvProfileDiffService
{
public CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted)
{
current ??= new StructuredCvProfile();
extracted ??= new StructuredCvProfile();
return new CvImportDiff
{
Categories = new List<CvCategoryDiff>
{
DiffContact(current.Contact, extracted.Contact),
DiffSummary(current.Summary, extracted.Summary),
DiffList("Experience", current.Jobs, extracted.Jobs, JobKey, JobLabel, JobFields, JobConfidence),
DiffList("Education", current.Education, extracted.Education, EduKey, EduLabel, EduFields, EduConfidence),
DiffList("Projects", current.Projects, extracted.Projects, p => Norm(p.Name), p => p.Name ?? "Project", ProjectFields, p => Confidence(p.Name, p.Bullets.Count > 0)),
DiffList("Certifications", current.Certifications, extracted.Certifications, c => Norm(c.Name), c => c.Name ?? "Certification", CertFields, c => Confidence(c.Name, !string.IsNullOrWhiteSpace(c.Issuer))),
DiffLanguages(current.Languages, extracted.Languages),
DiffScalars("Skills", current.Skills, extracted.Skills),
DiffScalars("Interests", current.Interests, extracted.Interests),
}.Where(c => c is not null).Select(c => c!).ToList(),
};
}
public StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null)
{
var merged = StructuredCvProfileJson.Deserialize(StructuredCvProfileJson.Serialize(current ?? new StructuredCvProfile()));
extracted = FilterLowConfidence(extracted ?? new StructuredCvProfile(), acceptedLowConfidenceIds);
MergeContact(merged.Contact, extracted.Contact);
if (extracted.Summary.Any(x => !string.IsNullOrWhiteSpace(x))) merged.Summary = extracted.Summary.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
MergeList(merged.Jobs, extracted.Jobs, JobKey, MergeJob);
MergeList(merged.Education, extracted.Education, EduKey, MergeEducation);
MergeList(merged.Projects, extracted.Projects, p => Norm(p.Name), MergeProject);
MergeList(merged.Certifications, extracted.Certifications, c => Norm(c.Name), MergeCertification);
MergeLanguages(merged.Languages, extracted.Languages);
AppendUnique(merged.Skills, extracted.Skills);
AppendUnique(merged.Interests, extracted.Interests);
MergeList(merged.OtherSections, extracted.OtherSections, x => Norm(x.Title), (a, b) => AppendUnique(a.Items, b.Items));
if (extracted.Sections.Count > 0) merged.Sections = extracted.Sections;
foreach (var field in extracted.Metadata.Fields) merged.Metadata.Fields[field.Key] = field.Value;
return merged;
}
private static StructuredCvProfile FilterLowConfidence(StructuredCvProfile source, IReadOnlySet<string>? accepted)
{
var filtered = JsonSerializer.Deserialize<StructuredCvProfile>(JsonSerializer.Serialize(source)) ?? new StructuredCvProfile();
bool Allowed(string category, string key, string confidence) => confidence != "Low" || (accepted?.Contains(ChangeId(category, key)) ?? false);
if (!Allowed("Contact", string.Empty, Confidence(filtered.Contact.FullName, !string.IsNullOrWhiteSpace(filtered.Contact.Email)))) filtered.Contact = new StructuredCvContact();
filtered.Jobs = filtered.Jobs.Where(x => Allowed("Experience", JobKey(x), JobConfidence(x))).ToList();
filtered.Education = filtered.Education.Where(x => Allowed("Education", EduKey(x), EduConfidence(x))).ToList();
filtered.Projects = filtered.Projects.Where(x => Allowed("Projects", Norm(x.Name), Confidence(x.Name, x.Bullets.Count > 0))).ToList();
filtered.Certifications = filtered.Certifications.Where(x => Allowed("Certifications", Norm(x.Name), Confidence(x.Name, !string.IsNullOrWhiteSpace(x.Issuer)))).ToList();
filtered.Languages = filtered.Languages.Where(x => Allowed("Languages", Norm(x.Name), string.IsNullOrWhiteSpace(x.Level) ? "Low" : "High")).ToList();
return filtered;
}
private static void MergeList<T>(List<T> current, IEnumerable<T> extracted, Func<T, string> key, Action<T, T> merge)
{
var currentByKey = current.Where(x => key(x).Length > 0).GroupBy(key).ToDictionary(g => g.Key, g => g.First());
foreach (var incoming in extracted)
{
var k = key(incoming);
if (k.Length > 0 && currentByKey.TryGetValue(k, out var existing)) merge(existing, incoming);
else if (k.Length > 0 && !currentByKey.ContainsKey(k))
{
current.Add(incoming);
currentByKey[k] = incoming;
}
}
}
private static void MergeContact(StructuredCvContact current, StructuredCvContact incoming)
{
SetIfPresent(incoming.FullName, v => current.FullName = v);
SetIfPresent(incoming.Headline, v => current.Headline = v);
SetIfPresent(incoming.Email, v => current.Email = v);
SetIfPresent(incoming.Phone, v => current.Phone = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Website, v => current.Website = v);
SetIfPresent(incoming.LinkedIn, v => current.LinkedIn = v);
}
private static void MergeJob(StructuredCvJob current, StructuredCvJob incoming)
{
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
current.IsCurrent = incoming.IsCurrent || current.IsCurrent;
AppendUnique(current.Bullets, incoming.Bullets);
AppendUnique(current.Skills, incoming.Skills);
}
private static void MergeEducation(StructuredCvEducation current, StructuredCvEducation incoming)
{
SetIfPresent(incoming.QualificationLevel, v => current.QualificationLevel = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
AppendUnique(current.Details, incoming.Details);
}
private static void MergeProject(StructuredCvProject current, StructuredCvProject incoming)
{
SetIfPresent(incoming.Role, v => current.Role = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
AppendUnique(current.Bullets, incoming.Bullets);
AppendUnique(current.Skills, incoming.Skills);
}
private static void MergeCertification(StructuredCvCertification current, StructuredCvCertification incoming)
{
SetIfPresent(incoming.Issuer, v => current.Issuer = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Date, v => current.Date = v);
AppendUnique(current.Details, incoming.Details);
}
private static void MergeLanguages(List<StructuredCvLanguage> current, IEnumerable<StructuredCvLanguage> extracted)
{
var currentByName = current.Where(x => !string.IsNullOrWhiteSpace(x.Name)).GroupBy(x => Norm(x.Name)).ToDictionary(g => g.Key, g => g.First());
foreach (var incoming in extracted.Where(x => !string.IsNullOrWhiteSpace(x.Name)))
{
var k = Norm(incoming.Name);
if (currentByName.TryGetValue(k, out var existing))
{
SetIfPresent(incoming.Level, v => existing.Level = v);
SetIfPresent(incoming.Notes, v => existing.Notes = v);
}
else
{
current.Add(incoming);
currentByName[k] = incoming;
}
}
}
private static void AppendUnique(List<string> current, IEnumerable<string> incoming)
{
var seen = new HashSet<string>(current.Select(Norm).Where(x => x.Length > 0));
foreach (var value in incoming.Where(x => !string.IsNullOrWhiteSpace(x)))
if (seen.Add(Norm(value))) current.Add(value.Trim());
}
private static void SetIfPresent(string? value, Action<string> set)
{
if (!string.IsNullOrWhiteSpace(value)) set(value.Trim());
}
// ---- list diff (Experience / Education / Projects / Certifications) --------------------------
private static CvCategoryDiff DiffList<T>(
string category,
List<T> current,
List<T> extracted,
Func<T, string> key,
Func<T, string> label,
Func<T, T, List<CvFieldChange>> fieldChanges,
Func<T, string> confidence)
{
var currentByKey = new Dictionary<string, T>();
foreach (var item in current)
{
var k = key(item);
if (!string.IsNullOrEmpty(k)) currentByKey[k] = item;
}
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
var unchanged = 0;
foreach (var item in extracted)
{
var k = key(item);
if (!string.IsNullOrEmpty(k) && currentByKey.TryGetValue(k, out var existing))
{
var changes = fieldChanges(existing, item);
if (changes.Count > 0)
updated.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Update, Category = category, Label = label(item), Confidence = confidence(item), FieldChanges = changes });
else
unchanged++;
}
else
{
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = label(item), Confidence = confidence(item) });
}
}
return new CvCategoryDiff { Category = category, Added = added, Updated = updated, UnchangedCount = unchanged };
}
// ---- contact (field-level) ------------------------------------------------------------------
private static CvCategoryDiff DiffContact(StructuredCvContact current, StructuredCvContact extracted)
{
var fields = new List<CvFieldChange>();
void Compare(string name, string? oldV, string? newV)
{
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
fields.Add(new CvFieldChange(name, oldV, newV));
}
Compare("Full name", current.FullName, extracted.FullName);
Compare("Headline", current.Headline, extracted.Headline);
Compare("Email", current.Email, extracted.Email);
Compare("Phone", current.Phone, extracted.Phone);
Compare("Location", current.Location, extracted.Location);
Compare("Website", current.Website, extracted.Website);
Compare("LinkedIn", current.LinkedIn, extracted.LinkedIn);
var isNew = string.IsNullOrWhiteSpace(current.FullName) && string.IsNullOrWhiteSpace(current.Email);
var changes = new List<CvItemChange>();
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
if (fields.Count > 0)
{
var change = new CvItemChange
{
Id = "Contact",
Kind = isNew ? CvChangeKind.Add : CvChangeKind.Update,
Category = "Contact",
Label = extracted.FullName ?? "Contact details",
Confidence = Confidence(extracted.FullName, !string.IsNullOrWhiteSpace(extracted.Email)),
FieldChanges = fields,
};
(isNew ? added : updated).Add(change);
}
return new CvCategoryDiff { Category = "Contact", Added = added, Updated = updated };
}
private static CvCategoryDiff DiffSummary(List<string> current, List<string> extracted)
{
var cur = JoinLines(current);
var ext = JoinLines(extracted);
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
if (!string.IsNullOrWhiteSpace(ext) && !ValuesEqual(cur, ext))
{
var change = new CvItemChange
{
Id = "Professional summary",
Kind = string.IsNullOrWhiteSpace(cur) ? CvChangeKind.Add : CvChangeKind.Update,
Category = "Professional summary",
Label = "Professional summary",
Confidence = "Medium",
FieldChanges = new List<CvFieldChange> { new("Summary", Trunc(cur), Trunc(ext)) },
};
(string.IsNullOrWhiteSpace(cur) ? added : updated).Add(change);
}
return new CvCategoryDiff { Category = "Professional summary", Added = added, Updated = updated };
}
// ---- languages & skills (scalar-ish, dedup by normalized name) -------------------------------
private static CvCategoryDiff DiffLanguages(List<StructuredCvLanguage> current, List<StructuredCvLanguage> extracted)
{
var currentByName = current.Where(l => !string.IsNullOrWhiteSpace(l.Name)).ToDictionary(l => Norm(l.Name), l => l);
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
var unchanged = 0;
foreach (var lang in extracted)
{
if (string.IsNullOrWhiteSpace(lang.Name)) continue;
var k = Norm(lang.Name);
if (currentByName.TryGetValue(k, out var existing))
{
if (!string.IsNullOrWhiteSpace(lang.Level) && !ValuesEqual(existing.Level, lang.Level))
updated.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Update, Category = "Languages", Label = lang.Name!, Confidence = "Medium", FieldChanges = new List<CvFieldChange> { new("Level", existing.Level, lang.Level) } });
else
unchanged++;
}
else
{
added.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Add, Category = "Languages", Label = string.IsNullOrWhiteSpace(lang.Level) ? lang.Name! : $"{lang.Name} ({lang.Level})", Confidence = string.IsNullOrWhiteSpace(lang.Level) ? "Low" : "High" });
}
}
return new CvCategoryDiff { Category = "Languages", Added = added, Updated = updated, UnchangedCount = unchanged };
}
private static CvCategoryDiff DiffScalars(string category, List<string> current, List<string> extracted)
{
var currentSet = new HashSet<string>(current.Select(Norm).Where(s => s.Length > 0));
var added = new List<CvItemChange>();
var seen = new HashSet<string>();
foreach (var item in extracted)
{
var k = Norm(item);
if (k.Length == 0 || !seen.Add(k)) continue;
if (!currentSet.Contains(k))
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = item.Trim(), Confidence = "High" });
}
return new CvCategoryDiff { Category = category, Added = added, UnchangedCount = currentSet.Count };
}
// ---- keys / labels / field comparisons -------------------------------------------------------
private static string JobKey(StructuredCvJob j) => $"{Norm(j.Company)}|{Norm(j.Title)}";
private static string JobLabel(StructuredCvJob j) => string.Join(" — ", new[] { j.Title, j.Company }.Where(v => !string.IsNullOrWhiteSpace(v)));
private static List<CvFieldChange> JobFields(StructuredCvJob a, StructuredCvJob b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
AddIf(f, "Location", a.Location, b.Location);
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
f.Add(new CvFieldChange("Bullets", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
return f;
}
private static string JobConfidence(StructuredCvJob j) => Confidence(j.Title, !string.IsNullOrWhiteSpace(j.Company) && (!string.IsNullOrWhiteSpace(j.Start) || j.Bullets.Count > 0));
private static string EduKey(StructuredCvEducation e) => $"{Norm(e.Institution)}|{Norm(e.Qualification)}";
private static string EduLabel(StructuredCvEducation e) => string.Join(" — ", new[] { e.Qualification, e.Institution }.Where(v => !string.IsNullOrWhiteSpace(v)));
private static List<CvFieldChange> EduFields(StructuredCvEducation a, StructuredCvEducation b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
AddIf(f, "Location", a.Location, b.Location);
return f;
}
private static string EduConfidence(StructuredCvEducation e) => Confidence(e.Qualification, !string.IsNullOrWhiteSpace(e.Institution));
private static List<CvFieldChange> ProjectFields(StructuredCvProject a, StructuredCvProject b)
{
var f = new List<CvFieldChange>();
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
f.Add(new CvFieldChange("Details", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
return f;
}
private static List<CvFieldChange> CertFields(StructuredCvCertification a, StructuredCvCertification b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Issuer", a.Issuer, b.Issuer);
AddIf(f, "Date", a.Date, b.Date);
return f;
}
// ---- helpers --------------------------------------------------------------------------------
private static string ChangeId(string category, string key) => key.Length == 0 ? category : $"{category}|{key}";
private static void AddIf(List<CvFieldChange> f, string name, string? oldV, string? newV)
{
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
f.Add(new CvFieldChange(name, oldV, newV));
}
private static string Confidence(string? primary, bool corroborated)
{
if (string.IsNullOrWhiteSpace(primary)) return "Low";
return corroborated ? "High" : "Medium";
}
private static string DateRange(string? start, string? end)
{
var s = (start ?? "").Trim();
var e = (end ?? "").Trim();
if (s.Length == 0 && e.Length == 0) return "";
return $"{s} - {e}".Trim(' ', '-');
}
private static bool ValuesEqual(string? a, string? b) => Norm(a) == Norm(b);
private static string Norm(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
var lowered = value.Trim().ToLowerInvariant();
return Regex.Replace(lowered, @"[^\p{L}\p{Nd}]+", " ").Trim();
}
private static string JoinLines(IEnumerable<string> lines) => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()));
private static string Trunc(string? s, int max = 140) => string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max] + "…");
}
+16 -11
View File
@@ -158,19 +158,24 @@ Until then they remain behind the toggle so no tested functionality is lost.
component and per-section actionable empty-state copy ("No work experience added yet" → the Add button
already present). Neither is blocking; both are cosmetic.
### Phase 2 — Import CV review screen
Add a post-upload "New information found" review (Experience / Skills / Languages / Education) with
`[Accept all] · [Review individually] · [Discard]`, diffing the extracted profile against the current
one **client-side** (no backend change — the extraction API already returns the structured result and
field metadata). Nothing is written until the user accepts. Relocate run history to Settings →
Advanced → Import history.
### Phase 2 — Import CV review screen (DONE 2026-07-30)
Extraction runs now stop at `pending_review`; they no longer overwrite the career profile. A backend diff
and conservative merge preserve curated/unmatched data, stable item IDs and non-empty values. The
Career Profile shows additions/updates by category with Apply/Discard actions. Low-confidence changes
have stable change IDs, are excluded by default in the API, and require individual checkbox confirmation.
Projects, certifications, languages-from-prose, grouped skill prefixes, glued date/title text, common
mojibake, and nested earlier/part-time roles are covered by regression tests. Part-time roles are separate
experience entries. No schema migration was required; pending profiles already fit `CvExtractionRun`.
### Phase 3 — Remove the second CV builder
Delete the rewrite-template + PDF-carousel flow from `CareerProfilePage`. Confirm the CV Builder
(`/career/builder`) covers templates/layout/styling/variants/PDF first (it does). Verify public CV and
PDF generation still work end to end.
### Phase 3 — Remove the second CV builder (DONE 2026-07-30)
The hidden template/rewrite/PDF-carousel block and all of its state, helpers, saved-job fetch, preview
dialog, and obsolete tests were deleted from `CareerProfilePage`. Career Profile now links directly to
`/career/builder`, the single owner of templates, layout, variants, previews and PDF generation. The
raw import section parser remains under Advanced CV tools as a recovery path.
### Phase 4 — WYSIWYG for long-form fields (DONE 2026-07-30)
Reused the existing dependency-free Markdown toolbar for the professional summary, work and education details, custom/project sections, and cover letters. Native textarea undo/redo remains available and stored payloads remain clean strings/string arrays.
### Phase 4 — WYSIWYG for long-form fields
Rich editor (bold, lists, links, undo/redo) for professional summary, work descriptions, achievements,
projects, cover letters. **Store clean HTML or Markdown only** — no editor-specific state — and the
renderer consumes the same format. Choose a small dependency already compatible with the stack, or a
+9 -9
View File
@@ -50,21 +50,21 @@ Goal: finish surfacing the pre-application workflow in the UI, and close the sec
---
## Phase 2 — UX improvements
## Phase 2 — UX improvements ✅ DONE (2026-07-30)
Goal: the guide's "users should always understand where they are, what they can do, what happens next."
| # | Task | Priority | Difficulty | Dependencies | Expected value |
|---|---|---|---|---|---|
| 2.1 | ~~Split `/profile` and `/career`~~**DONE (2026-07-17, commit `66cc6a7`)**`/profile` = identity + security + preferences; `/career` = master career profile. The two saves are now scoped (partial-update-safe `PUT /auth/profile`) so neither wipes the other; the inert CV-Builder tab and dead `careerView` prop are gone. **The master profile is the source of truth; generated docs reference snapshots (not built yet).** | **P1** | **M** | none | Fixed the guide's "everything should have one obvious place". Functional separation via the existing `careerOnly` fork + scoped saves. |
| 2.2 | **Decompose `ProfilePage.tsx` (still ~1370 lines) into two real components** | **P1** | **M** | 2.1 | Phase 2 delivered the *functional* split via the `careerOnly` fork + scoped saves, but it is still one file behind a boolean. Extracting `ProfilePage` (identity/security) and a `CareerWorkspace` content component (master profile) is the remaining cleanup — mechanical, deferred so Phase 2 stayed low-risk. Blocks nothing; do before heavy Phase 3/4 edits. |
| 2.3 | **Real onboarding flow**`Signup → Verify → Profile → Import CV → Connect email → First job` replacing the 2-item checklist | **P1** | **M** | 2.1 | Currently a dismissible checkbox pair. **Surface the Gmail connect step** — the strongest differentiator is buried in `/settings/connected-accounts`. |
| 2.4 | **Dedicated `/register` screen** | **P2** | **S** | none | Endpoint exists but returns 403 by default and has no route; signup is hidden inside `LoginPage`. Required for Phase 7; harmless now. |
| 2.5 | **Introduce a server-cache layer** (React Query or equivalent) | **P2** | **M** | none | Root cause of the 6001400-line components and the hand-rolled `refreshToken` prop-threading. Pays for itself across Phases 36. Adopt incrementally, not as a rewrite. |
| 2.6 | **Decompose `JobDetailsDialog.tsx` (1400 lines)** | **P2** | **M** | 2.5 | A dialog carrying an entire workspace. |
| 2.7 | **Interview prep hub screen** | **P2** | **S** | none | `/jobapplications/{id}/interview-prep` already works and is invisible. Cheap win — a shipped feature nobody can reach. |
| 2.8 | **Retire `react-scripts` as test runner** (move to Jest+SWC or Vitest) | **P2** | **M** | none | Removes one of three frontend toolchains. Do not touch the router in the same change. |
| 2.9 | **Expand analytics** funnel, response rate, time-in-stage | **P3** | **M** | 1.3 | The paid feature at Teal/Huntr. Needs correct prospect-vs-applied accounting from 1.3 first. |
| 2.2 | **DONE (2026-07-30)** — decomposed identity/security and Career Workspace into separate `ProfilePage` and `CareerProfilePage` components, with career sections extracted into focused presentational components. | **P1** | **M** | 2.1 | Phase 2 delivered the *functional* split via the `careerOnly` fork + scoped saves, but it is still one file behind a boolean. Extracting `ProfilePage` (identity/security) and a `CareerWorkspace` content component (master profile) is the remaining cleanup — mechanical, deferred so Phase 2 stayed low-risk. Blocks nothing; do before heavy Phase 3/4 edits. |
| 2.3 | **DONE (2026-07-30)**`Signup → Verify → Profile → Import CV → Connect email → First job`; the dashboard progress flow reuses existing signup/verification screens, reads structured career data, detects Gmail/Outlook/IMAP connections, and disappears when complete. | **P1** | **M** | 2.1 | Currently a dismissible checkbox pair. **Surface the Gmail connect step** — the strongest differentiator is buried in `/settings/connected-accounts`. |
| 2.4 | **DONE (2026-07-30)** — dedicated `/register` route reuses the hardened auth form and clearly disables submission when registration is unavailable. | **P2** | **S** | none | Endpoint exists but returns 403 by default and has no route; signup is hidden inside `LoginPage`. Required for Phase 7; harmless now. |
| 2.5 | **DONE** — incremental cache equivalents ship through `useViewResource` for shared server state and `useWorkspaceTabCache` for expensive workspace tabs; no dependency added. | **P2** | **M** | none | Root cause of the 6001400-line components and the hand-rolled `refreshToken` prop-threading. Pays for itself across Phases 36. Adopt incrementally, not as a rewrite. |
| 2.6 | **DONE (2026-07-30)** — extracted shared presentation cards and four intelligence tab panels into `JobDetailsPanels` and `JobInsightTabs`; the dialog remains the orchestration boundary. | **P2** | **M** | 2.5 | A dialog carrying an entire workspace. |
| 2.7 | **DONE** — user-owned interview preparation is exposed in `ApplicationWorkspacePage`, reachable from workflow signals and reminders, with grouped editable prep items. | **P2** | **S** | none | `/jobapplications/{id}/interview-prep` already works and is invisible. Cheap win — a shipped feature nobody can reach. |
| 2.8 | **DONE (2026-07-30)** — direct Jest + Babel configuration replaces `react-scripts`; packages were promoted from the existing lockfile without a new download. | **P2** | **M** | none | Removes one of three frontend toolchains. Do not touch the router in the same change. |
| 2.9 | **DONE** — dashboard analytics include funnel, response rate by source, top companies, and time-in-stage backed by `AnalyticsService`. | **P3** | **M** | 1.3 | The paid feature at Teal/Huntr. Needs correct prospect-vs-applied accounting from 1.3 first. |
---
+18
View File
@@ -0,0 +1,18 @@
module.exports = {
testEnvironment: "jsdom",
roots: ["<rootDir>/src"],
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
transform: {
"^.+\.[jt]sx?$": ["babel-jest", {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
["@babel/preset-react", { runtime: "automatic" }],
["@babel/preset-typescript", { allExtensions: true, isTSX: true }],
],
}],
},
moduleNameMapper: {
"\.(css|less|scss|sass)$": "identity-obj-proxy",
"\.(gif|ttf|eot|svg|png|jpg|jpeg|webp)$": "<rootDir>/test/fileMock.js",
},
};
+381 -11162
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -26,7 +26,6 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"typescript": "^5.9.3",
"web-vitals": "^2.1.4"
},
@@ -34,7 +33,7 @@
"dev": "next dev",
"start": "next dev",
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
"test": "react-scripts test"
"test": "jest"
},
"eslintConfig": {
"extends": [
@@ -53,5 +52,14 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"babel-jest": "^27.5.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^27.5.1",
"jest-environment-jsdom": "^27.5.1"
}
}
+1
View File
@@ -385,6 +385,7 @@ export default function App() {
const router = useMemo(() => createBrowserRouter([
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/register", element: <LoginPage initialMode="register" />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/verify-email", element: <VerifyEmailPage />, errorElement: <RouteErrorPage /> },
@@ -4,6 +4,7 @@ import {
Alert, Box, Button, Chip, Divider, IconButton, MenuItem, Paper, Skeleton, Stack, TextField,
Tooltip, Typography,
} from "@mui/material";
import RichTextField from "./RichTextField";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RestoreIcon from "@mui/icons-material/Restore";
@@ -290,14 +291,12 @@ export function ApplicationCoverLetterSection({ jobId }: { jobId: number }) {
error={error}
>
<Stack spacing={2}>
<TextField
multiline
<RichTextField
minRows={12}
fullWidth
label="Cover letter"
value={text}
disabled={busy}
onChange={(e) => setDraft(e.target.value)}
onChange={setDraft}
placeholder="Write it yourself, start from the template, or generate a draft with the AI panel below."
/>
@@ -29,6 +29,8 @@ import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines }
import Correspondence from "./Correspondence";
import Attachments from "./Attachments";
import AiWorkspacePanel from "./AiWorkspacePanel";
import JobInsightTabs from "./JobInsightTabs";
import { DraftCard, ListCard, MatchScoreCard, PaperRow, SectionChips, TwoColumnSection, WorkspaceDraftCard } from "./JobDetailsPanels";
import JobFlowBar from "./JobFlowBar";
import GradientButton from "./GradientButton";
import { useI18n } from "../i18n/I18nProvider";
@@ -1189,92 +1191,23 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box>
)}
{tab === 5 && (
<Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
</Box>
</Box>
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
</Box>
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
</Box>
)}
{tab === 6 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
</Box>
)}
{tab === 7 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
</Box>
)}
{tab === 8 && (
<Box>
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
<Chip label={readiness.level} variant="outlined" />
</Box>
</Box>
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
</Box>
)}
<JobInsightTabs
tab={tab}
matchScore={matchScore}
loadingMatchScore={loadingMatchScore}
candidateFit={candidateFit}
loadingCandidateFit={loadingCandidateFit}
regenerateCandidateFit={regenerateCandidateFit}
fitLevel={fitLevel}
focusPlan={focusPlan}
loadingFocusPlan={loadingFocusPlan}
regenerateFocusPlan={regenerateFocusPlan}
interviewPrep={interviewPrep}
loadingInterviewPrep={loadingInterviewPrep}
regenerateInterviewPrep={regenerateInterviewPrep}
readiness={readiness}
loadingReadiness={loadingReadiness}
/>
{tab === 9 && isAdmin && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
@@ -1287,177 +1220,3 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Dialog>
);
}
function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
const { t } = useI18n();
if (loading && !score) {
return (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={18} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
</Box>
);
}
if (!score) return null;
const color: "success" | "warning" | "error" | "inherit" =
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
aria-hidden="true"
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
</Box>
</Box>
) : (
<Typography variant="h4" sx={{ fontWeight: 800 }}></Typography>
)}
<Box sx={{ flex: 1, minWidth: 200 }}>
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
</Box>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
</Box>
</Box>
<Box>
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
</Box>
</Box>
</Box>
{score.sectionCoverage.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
</Box>
</Box>
) : null}
</Box>
);
}
function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
const { t } = useI18n();
return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline">{title}</Typography>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
</Box>
</Box>
);
}
function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<ListCard title={leftTitle} items={leftItems} />
<ListCard title={rightTitle} items={rightItems} />
</Box>
);
}
function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
const { t } = useI18n();
return (
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box>
<Typography variant="overline">{title}</Typography>
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
</Box>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}> {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
</Box>
</Box>
);
}
function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
const { t } = useI18n();
return (
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
<Chip size="small" color={statusColor} label={statusLabel} />
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
</Box>
</Box>
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
</Box>
);
}
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
const { t } = useI18n();
const [value, setValue] = React.useState(content);
React.useEffect(() => {
setValue(content);
}, [content]);
return (
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
</Box>
</Box>
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
</Box>
);
}
function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
return (
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{type}
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{at ? new Date(at).toLocaleString() : ""}
{note ? ` - ${note}` : ""}
</Typography>
</Box>
);
}
@@ -0,0 +1,183 @@
import React from "react";
import { Box, Button, Chip, CircularProgress, TextField, Typography } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
import { MatchScore } from "../types";
function copyLines(items: string[]) {
void navigator.clipboard.writeText(items.join("\n"));
}
export function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading: boolean }) {
const { t } = useI18n();
if (loading && !score) {
return (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={18} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
</Box>
);
}
if (!score) return null;
const color: "success" | "warning" | "error" | "inherit" =
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
aria-hidden="true"
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
</Box>
</Box>
) : (
<Typography variant="h4" sx={{ fontWeight: 800 }}></Typography>
)}
<Box sx={{ flex: 1, minWidth: 200 }}>
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
</Box>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
</Box>
</Box>
<Box>
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
</Box>
</Box>
</Box>
{score.sectionCoverage.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
</Box>
</Box>
) : null}
</Box>
);
}
export function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
const { t } = useI18n();
return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline">{title}</Typography>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
</Box>
</Box>
);
}
export function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<ListCard title={leftTitle} items={leftItems} />
<ListCard title={rightTitle} items={rightItems} />
</Box>
);
}
export function ListCard({ title, items, subtitle }: { title: string; items: string[]; subtitle?: string }) {
const { t } = useI18n();
return (
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Box>
<Typography variant="overline">{title}</Typography>
{subtitle ? <Typography variant="caption" sx={{ display: "block", color: "text.secondary" }}>{subtitle}</Typography> : null}
</Box>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
{items.length ? items.map((item, index) => <Typography key={`${title}-${index}-${item}`} sx={{ color: "text.primary" }}> {item}</Typography>) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
</Box>
</Box>
);
}
export function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
const { t } = useI18n();
return (
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
<Chip size="small" color={statusColor} label={statusLabel} />
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
</Box>
</Box>
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
</Box>
);
}
export function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
const { t } = useI18n();
const [value, setValue] = React.useState(content);
React.useEffect(() => {
setValue(content);
}, [content]);
return (
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(value)}>{saving ? t("jobDetailsSaving") : t("save")}</Button> : null}
</Box>
</Box>
<TextField value={value} onChange={(e) => setValue(e.target.value)} multiline minRows={6} fullWidth />
</Box>
);
}
export function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
return (
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{type}
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{at ? new Date(at).toLocaleString() : ""}
{note ? ` - ${note}` : ""}
</Typography>
</Box>
);
}
@@ -0,0 +1,117 @@
import React from "react";
import { Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse } from "../types";
import { DraftCard, ListCard, MatchScoreCard, SectionChips, TwoColumnSection } from "./JobDetailsPanels";
type Props = {
tab: number;
matchScore: MatchScore | null;
loadingMatchScore: boolean;
candidateFit: CandidateFit | null;
loadingCandidateFit: boolean;
regenerateCandidateFit: () => void;
fitLevel: { label: string; color: "success" | "warning" | "default" } | null;
focusPlan: FocusPlanResponse | null;
loadingFocusPlan: boolean;
regenerateFocusPlan: () => void;
interviewPrep: InterviewPrepResponse | null;
loadingInterviewPrep: boolean;
regenerateInterviewPrep: () => void;
readiness: ReadinessResponse | null;
loadingReadiness: boolean;
};
export default function JobInsightTabs(props: Props) {
const { t } = useI18n();
const { tab, matchScore, loadingMatchScore, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
return <>
{tab === 5 && (
<Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
</Box>
</Box>
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
</Box>
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
</Box>
)}
{tab === 6 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
{loadingFocusPlan ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingFocusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
</Box>
)}
{tab === 7 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
</Box>
)}
{tab === 8 && (
<Box>
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
<Chip label={readiness.level} variant="outlined" />
</Box>
</Box>
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
</Box>
)}
</>;
}
@@ -1,68 +1,92 @@
import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
import { Box, Button, LinearProgress, Paper, Stack, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
import CloseIcon from "@mui/icons-material/Close";
import { alpha, useTheme } from "@mui/material/styles";
import { api } from "../api";
import { getUserKeyFromToken } from "../themePrefs";
import { useI18n } from "../i18n/I18nProvider";
function dismissKey() {
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
}
type MeResponse = { profileCvText?: string | null };
type MeResponse = {
email?: string | null;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
};
type CareerProfileResponse = {
cvText?: string | null;
profile?: {
summary?: string[];
jobs?: unknown[];
education?: unknown[];
projects?: unknown[];
certifications?: unknown[];
languages?: unknown[];
otherSections?: unknown[];
skills?: string[];
interests?: string[];
};
};
type ConnectionStatus = { connected?: boolean };
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
const theme = useTheme();
const navigate = useNavigate();
const { t } = useI18n();
const [hasCv, setHasCv] = useState<boolean | null>(null);
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
const [status, setStatus] = useState<{ profile: boolean; cv: boolean; email: boolean } | null>(null);
useEffect(() => {
let active = true;
api.get<MeResponse>("/auth/me")
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
.catch(() => { if (active) setHasCv(false); });
Promise.all([
api.get<MeResponse>("/auth/me"),
api.get<CareerProfileResponse>("/career/profile"),
Promise.all(["/gmail/status", "/microsoft-graph/status", "/imap/status"].map((url) =>
api.get<ConnectionStatus>(url).then((r) => Boolean(r.data?.connected)).catch(() => false),
)),
]).then(([me, career, connections]) => {
if (!active) return;
const profile = career.data?.profile;
const structuredCount = [
...(profile?.summary ?? []),
...(profile?.jobs ?? []),
...(profile?.education ?? []),
...(profile?.projects ?? []),
...(profile?.certifications ?? []),
...(profile?.languages ?? []),
...(profile?.otherSections ?? []),
...(profile?.skills ?? []),
...(profile?.interests ?? []),
].length;
setStatus({
profile: Boolean(me.data?.displayName?.trim() || me.data?.firstName?.trim() || me.data?.lastName?.trim()),
cv: structuredCount > 0 || Boolean(career.data?.cvText?.trim()),
email: connections.some(Boolean),
});
}).catch(() => { if (active) setStatus({ profile: false, cv: false, email: false }); });
return () => { active = false; };
}, []);
const allDone = hasCv === true && hasJobs;
if (dismissed || allDone || hasCv === null) return null;
const dismiss = () => {
window.localStorage.setItem(dismissKey(), "1");
setDismissed(true);
};
if (!status) return null;
const steps = [
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
{ done: true, label: t("onboardingStepSignup"), actionLabel: "" },
{ done: true, label: t("onboardingStepVerify"), actionLabel: "" },
{ done: status.profile, label: t("onboardingStepProfile"), action: () => navigate("/profile"), actionLabel: t("onboardingStepProfileAction") },
{ done: status.cv, label: t("onboardingStepCv"), action: () => navigate("/career"), actionLabel: t("onboardingStepCvAction") },
{ done: status.email, label: t("onboardingStepEmail"), action: () => navigate("/settings/connected-accounts"), actionLabel: t("onboardingStepEmailAction") },
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
];
const completed = steps.filter((step) => step.done).length;
if (completed === steps.length) return null;
return (
<Paper
sx={{
p: 2.25,
mb: 2,
borderRadius: 4,
border: "1px solid",
borderColor: alpha(theme.palette.primary.main, 0.25),
background: alpha(theme.palette.primary.main, 0.04),
position: "relative",
}}
>
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
<CloseIcon fontSize="small" />
</IconButton>
<Paper sx={{ p: 2.25, mb: 2, borderRadius: 4, border: "1px solid", borderColor: alpha(theme.palette.primary.main, 0.25), background: alpha(theme.palette.primary.main, 0.04) }}>
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("onboardingBody")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{completed} / {steps.length}</Typography>
<LinearProgress variant="determinate" value={(completed / steps.length) * 100} sx={{ my: 1.5, borderRadius: 2 }} />
<Stack spacing={1}>
{steps.map((step) => (
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
@@ -72,7 +96,7 @@ export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
{step.label}
</Typography>
</Box>
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
{!step.done && step.action ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
</Box>
))}
</Stack>
@@ -19,6 +19,7 @@ export default function RichTextField({
placeholder,
minRows = 2,
ariaLabel,
disabled = false,
}: {
value: string;
onChange: (v: string) => void;
@@ -26,6 +27,7 @@ export default function RichTextField({
placeholder?: string;
minRows?: number;
ariaLabel?: string;
disabled?: boolean;
}) {
const ref = useRef<HTMLTextAreaElement | null>(null);
@@ -42,7 +44,7 @@ export default function RichTextField({
const btn = (title: string, icon: React.ReactNode, before: string, after: string, ph: string) => (
<Tooltip title={title}>
<IconButton size="small" aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
<IconButton size="small" disabled={disabled} aria-label={title} onMouseDown={(e) => e.preventDefault()} onClick={() => apply(before, after, ph)}>
{icon}
</IconButton>
</Tooltip>
@@ -65,6 +67,7 @@ export default function RichTextField({
fullWidth
size="small"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
slotProps={{ htmlInput: { "aria-label": ariaLabel ?? label } }}
/>
+14 -8
View File
@@ -382,13 +382,16 @@ export const translations = {
dashboardHeroLabel: "Job search overview",
onboardingTitle: "Get set up",
onboardingBody: "A few steps to get the most out of Jobbjakt.",
onboardingDismiss: "Dismiss",
onboardingStepCv: "Add your CV",
onboardingStepSignup: "Create your account",
onboardingStepVerify: "Verify your email",
onboardingStepProfile: "Complete your profile",
onboardingStepProfileAction: "Open profile",
onboardingStepCv: "Import your CV",
onboardingStepCvAction: "Add CV",
onboardingStepJob: "Import your first job",
onboardingStepJobAction: "Add job",
onboardingStepMatch: "Check your CV match score on a job",
onboardingStepMatchAction: "Open jobs",
onboardingStepEmail: "Connect your email",
onboardingStepEmailAction: "Connect email",
dashboardResponseRate: "{rate}% response rate",
dashboardMonthsShort: "{count} mo",
dashboardAppliedCount: "{count} applied",
@@ -1452,13 +1455,16 @@ export const translations = {
dashboardHeroLabel: "Oversikt over jobbsøket",
onboardingTitle: "Kom i gang",
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
onboardingDismiss: "Lukk",
onboardingStepCv: "Legg til CV-en din",
onboardingStepSignup: "Opprett kontoen din",
onboardingStepVerify: "Bekreft e-postadressen din",
onboardingStepProfile: "Fullfør profilen din",
onboardingStepProfileAction: "Åpne profil",
onboardingStepCv: "Importer CV-en din",
onboardingStepCvAction: "Legg til CV",
onboardingStepJob: "Importer din første jobb",
onboardingStepJobAction: "Legg til jobb",
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
onboardingStepMatchAction: "Åpne jobber",
onboardingStepEmail: "Koble til e-post",
onboardingStepEmailAction: "Koble til e-post",
dashboardResponseRate: "{rate}% svarrate",
dashboardMonthsShort: "{count} md",
dashboardAppliedCount: "{count} søkt",
+15 -2
View File
@@ -20,12 +20,12 @@ const mockedApi = api as jest.Mocked<typeof api>;
let consoleErrorSpy: jest.SpyInstance;
function renderLoginPage() {
function renderLoginPage(initialMode: "login" | "register" = "login") {
return render(
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<I18nProvider>
<ToastProvider>
<LoginPage />
<LoginPage initialMode={initialMode} />
</ToastProvider>
</I18nProvider>
</MemoryRouter>,
@@ -33,6 +33,19 @@ function renderLoginPage() {
}
describe('LoginPage', () => {
it('renders a dedicated registration screen and explains when registration is disabled', async () => {
mockedApi.get.mockResolvedValueOnce({
data: { requireAuth: true, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false, requireEmailVerification: true },
} as any);
renderLoginPage("register");
expect(await screen.findByText('Registration is currently unavailable.')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Create account' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Create account' })).toBeDisabled();
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
beforeEach(() => {
const originalConsoleError = console.error.bind(console);
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
@@ -0,0 +1,35 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import OnboardingChecklist from "./components/OnboardingChecklist";
import { I18nProvider } from "./i18n/I18nProvider";
import { api } from "./api";
jest.mock("./api", () => ({
api: { get: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } } },
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderChecklist() {
return render(<MemoryRouter><I18nProvider><OnboardingChecklist hasJobs={false} /></I18nProvider></MemoryRouter>);
}
test("uses structured career data and connected inbox status for onboarding progress", async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === "/auth/me") return Promise.resolve({ data: { firstName: "Ada" } } as any);
if (url === "/career/profile") return Promise.resolve({ data: { cvText: "", profile: { jobs: [{ title: "Engineer" }] } } } as any);
return Promise.resolve({ data: { connected: url === "/gmail/status" } } as any);
});
renderChecklist();
expect(await screen.findByText("5 / 6")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add CV" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open profile" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Connect email" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add job" })).toBeInTheDocument();
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/career/profile"));
});
+54 -84
View File
@@ -7,17 +7,6 @@ import ProfilePage from './views/ProfilePage';
import CareerProfilePage from './views/CareerProfilePage';
import { api } from './api';
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
const revokeObjectURLMock = jest.fn();
Object.defineProperty(window.URL, 'createObjectURL', {
writable: true,
value: createObjectURLMock,
});
Object.defineProperty(window.URL, 'revokeObjectURL', {
writable: true,
value: revokeObjectURLMock,
});
jest.mock('./api', () => ({
api: {
get: jest.fn(),
@@ -34,7 +23,7 @@ jest.mock('./components/CropImageDialog', () => () => null);
const mockedApi = api as jest.Mocked<typeof api>;
const REWRITE_TEMPLATES_COUNT = 6;
let extractionRunsResponse: any[] = [];
const structuredCv = {
version: '1',
@@ -95,6 +84,18 @@ function renderPage() {
void ProfilePage;
beforeEach(() => {
extractionRunsResponse = [{
id: 12,
trigger: 'upload',
status: 'applied',
artifactFileName: 'resume.pdf',
startedAtUtc: '2026-03-28T12:00:00Z',
completedAtUtc: '2026-03-28T12:00:05Z',
appliedAtUtc: '2026-03-28T12:00:05Z',
parserVersion: 'm005-s01',
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
}];
mockedApi.get.mockImplementation((url: string) => {
if (url === '/career/profile') {
// Phase 3: /career reads the structured profile from the relational source of truth.
@@ -125,22 +126,18 @@ beforeEach(() => {
} as any);
}
if (url === '/profile-cv/runs') {
return Promise.resolve({
data: [
{
id: 12,
trigger: 'upload',
status: 'applied',
artifactFileName: 'resume.pdf',
startedAtUtc: '2026-03-28T12:00:00Z',
completedAtUtc: '2026-03-28T12:00:05Z',
appliedAtUtc: '2026-03-28T12:00:05Z',
parserVersion: 'm005-s01',
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
},
],
} as any);
return Promise.resolve({ data: extractionRunsResponse } as any);
}
if (/^\/profile-cv\/runs\/\d+\/diff$/.test(url)) {
return Promise.resolve({ data: {
runId: 13,
status: 'pending_review',
diff: { totalAdded: 3, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [
{ category: 'Skills', added: [{ id: 'Skills|docker', label: 'Docker', confidence: 'High' }], updated: [], unchangedCount: 2, lowConfidenceCount: 0 },
{ category: 'Languages', added: [{ id: 'Languages|french', label: 'French', confidence: 'Low' }], updated: [], unchangedCount: 1, lowConfidenceCount: 1 },
{ category: 'Experience', added: [{ id: 'Experience|new co engineer', label: 'Engineer - New Co', confidence: 'High' }], updated: [{ id: 'Experience|demo co developer', label: 'Developer - Demo Co', confidence: 'High' }], unchangedCount: 0, lowConfidenceCount: 0 },
] },
} } as any);
}
if (url === '/jobapplications') {
return Promise.resolve({
@@ -180,12 +177,6 @@ beforeEach(() => {
},
} as any);
}
if (url === '/profile-cv/rewrite-preview') {
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
}
if (url === '/profile-cv/export-pdf') {
return Promise.resolve({ data: new Blob([`pdf-${payload?.templateId ?? 'ats-minimal'}`], { type: 'application/pdf' }), config } as any);
}
if (url === '/profile-cv/reprocess') {
return Promise.resolve({ data: { reprocessed: true } } as any);
}
@@ -197,8 +188,6 @@ beforeEach(() => {
afterEach(() => {
jest.clearAllMocks();
createObjectURLMock.mockClear();
revokeObjectURLMock.mockClear();
});
test('profile page loads persisted structured cv and can re-parse it', async () => {
@@ -210,6 +199,8 @@ test('profile page loads persisted structured cv and can re-parse it', async ()
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
expect(screen.getByText(/current run/i)).toBeInTheDocument();
expect(screen.queryByText(/template-driven cv builder/i)).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: /open cv builder/i })).toHaveAttribute('href', '/career/builder');
expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0);
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
@@ -299,61 +290,15 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument();
// Reveal the advanced tools (Phase 1 increment 2) — the template-builder copy button lives there.
fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i }));
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
expect(copyButton).toBeDisabled();
expect(screen.queryByRole('button', { name: /copy cv text/i })).not.toBeInTheDocument();
fireEvent.click(originalExtractionToggle);
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
const copyButtons = screen.getAllByRole('button', { name: /copy cv text/i });
expect(copyButtons.some((button) => !button.hasAttribute('disabled'))).toBe(true);
});
test('profile page rewrite tools use selected template and saved job context', async () => {
renderPage();
// Template-driven CV builder is hidden from the default workflow (Phase 1 increment 2); reveal it.
await screen.findByRole('button', { name: /advanced cv tools/i });
fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i }));
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
fireEvent.click(screen.getByText(/harvard/i));
fireEvent.change(screen.getByLabelText(/prompt-based cv brief/i), { target: { value: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.' } });
fireEvent.change(screen.getByLabelText(/target role/i), { target: { value: 'Senior Platform Engineer' } });
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
fireEvent.click(rewriteButton);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-preview', expect.objectContaining({
sectionName: null,
style: 'harvard',
templateId: 'harvard',
jobApplicationId: null,
promptBackground: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.',
targetRole: 'Senior Platform Engineer',
language: 'English',
tone: 'Concise and direct',
}));
});
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
fireEvent.click(buildCarouselButton);
await waitFor(() => {
const exportCalls = mockedApi.post.mock.calls.filter(([url]) => url === '/profile-cv/export-pdf');
expect(exportCalls.length).toBe(REWRITE_TEMPLATES_COUNT);
});
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
expect(screen.getByRole('button', { name: /copy cv text/i })).toBeEnabled();
});
test('saving the master profile (career) persists the structured profile via /career/profile', async () => {
@@ -388,3 +333,28 @@ test('/career shows the profile completeness overview', async () => {
expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument();
expect(screen.getByText(/70%/)).toBeInTheDocument();
});
test('pending CV extraction can be reviewed and applied', async () => {
extractionRunsResponse = [{
id: 13,
trigger: 'upload',
status: 'pending_review',
artifactFileName: 'new-resume.pdf',
startedAtUtc: '2026-03-29T12:00:00Z',
completedAtUtc: '2026-03-29T12:00:05Z',
parserVersion: 'm005-s01',
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
}];
renderPage();
expect(await screen.findByText(/3 additions/i)).toBeInTheDocument();
const lowConfidence = screen.getByRole('checkbox', { name: /include low-confidence languages: french/i });
expect(lowConfidence).not.toBeChecked();
fireEvent.click(lowConfidence);
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/13/accept', {
acceptedLowConfidenceIds: ['Languages|french'],
}));
});
+92 -602
View File
@@ -1,11 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Chip, Dialog, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, TextField, Typography } from "@mui/material";
import { Accordion, AccordionDetails, AccordionSummary, Alert, Avatar, Box, Button, Checkbox, Chip, Divider, FormControlLabel, LinearProgress, Paper, TextField, Typography } from "@mui/material";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
import { api, getApiErrorMessage } from "../api";
import GoogleAuthCard from "../components/GoogleAuthCard";
@@ -37,15 +36,8 @@ import {
StructuredCvFieldMetadata,
StructuredCvProfile,
} from "../profileCv";
import { JobApplication } from "../types";
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
type CvBuilderTone = "Concise and direct" | "Executive and polished" | "Technical and detailed" | "Warm and people-focused";
type CvBuilderLanguage = "English" | "Norwegian" | "Spanish" | "French" | "German";
type ExtractionRun = {
id: number;
trigger: string;
@@ -60,63 +52,28 @@ type ExtractionRun = {
errorMessage?: string;
};
type CvImportDiff = {
totalAdded: number;
totalUpdated: number;
totalLowConfidence: number;
hasChanges: boolean;
categories: Array<{
category: string;
added: Array<{ id: string; label: string; confidence: string }>;
updated: Array<{ id: string; label: string; confidence: string }>;
unchangedCount: number;
lowConfidenceCount: number;
}>;
};
type CvRunDiffResponse = { runId: number; status: string; diff: CvImportDiff };
type QueuedCvRunResponse = {
queued: boolean;
extractionRunId: number;
status: string;
};
type JobListResponse = {
items: JobApplication[];
total: number;
page: number;
pageSize: number;
};
type RewriteTemplateOption = {
id: CvSectionStyle;
title: string;
eyebrow: string;
accent: string;
blurb: string;
sampleHeading: string;
sampleMeta: string;
sampleBullets: string[];
};
type CvBuilderPreview = {
templateId: CvSectionStyle;
html: string;
suggestedFileName: string;
fullText: string;
rewrittenText: string;
structuredCv: StructuredCvProfile;
sectionName?: string | null;
targetRole?: string | null;
jobApplicationId?: number | null;
};
type PdfCarouselItem = {
templateId: CvSectionStyle;
title: string;
fileName: string;
pdfUrl?: string;
status: "loading" | "ready" | "error";
error?: string;
};
type RewriteRequestPayload = {
sectionName: string | null;
style: CvSectionStyle;
templateId: CvSectionStyle;
targetRole: string | null;
jobApplicationId: number | null;
sourceText: string | null;
promptBackground: string | null;
tone: string | null;
language: string | null;
};
type MeResponse = {
provider?: "local" | "google" | "external";
id?: string;
@@ -138,69 +95,6 @@ type MeResponse = {
const CV_UPLOAD_ACCEPT = ".pdf,.docx,.txt,.md,image/png,image/jpeg,image/webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown";
const AVATAR_UPLOAD_ACCEPT = "image/png,image/jpeg,image/webp";
const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
{
id: "ats-minimal",
title: "ATS Minimal",
eyebrow: "Scanner-friendly",
accent: "#0f172a",
blurb: "Compact, direct, and easy for screening systems to parse.",
sampleHeading: "Senior Backend Engineer",
sampleMeta: "Acme Systems · Oslo · 2021 - Present",
sampleBullets: ["Built API workflows with measurable delivery outcomes.", "Kept skills and achievements easy to scan."]
},
{
id: "harvard",
title: "Harvard",
eyebrow: "Traditional",
accent: "#7f1d1d",
blurb: "Formal hierarchy and restrained tone for conservative hiring flows.",
sampleHeading: "Professional Summary",
sampleMeta: "Clear structure · precise dates · credible language",
sampleBullets: ["Emphasizes polished summaries.", "Works well for broad professional roles."]
},
{
id: "auckland",
title: "Auckland",
eyebrow: "Modern sidebar",
accent: "#0f766e",
blurb: "Sharper highlights with a more contemporary, design-forward rhythm.",
sampleHeading: "Selected Impact",
sampleMeta: "Focused strengths · compact highlights",
sampleBullets: ["Pulls skills into stronger highlight clusters.", "Good when you want a fresher feel."]
},
{
id: "edinburgh",
title: "Edinburgh",
eyebrow: "Editorial",
accent: "#5b21b6",
blurb: "More personality and stronger section contrast without losing clarity.",
sampleHeading: "Experience Highlights",
sampleMeta: "Premium spacing · stronger visual voice",
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
},
{
id: "monarch",
title: "Monarch",
eyebrow: "Executive",
accent: "#7c2d12",
blurb: "High-contrast premium presentation for leadership-heavy applications.",
sampleHeading: "Executive Profile",
sampleMeta: "Leadership clarity · premium hierarchy",
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
},
{
id: "fjord",
title: "Fjord",
eyebrow: "Technical",
accent: "#0f4c5c",
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
sampleHeading: "Projects & Systems",
sampleMeta: "Technical depth · practical readability",
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
},
];
function initialsFrom(values: Array<string | undefined>) {
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
if (joined.length === 0) return "?";
@@ -242,31 +136,12 @@ export default function CareerProfilePage() {
const [headline, setHeadline] = useState("");
const [profileCvText, setProfileCvText] = useState("");
const [rewritingSection, setRewritingSection] = useState(false);
const [cvSection, setCvSection] = useState<CvSectionOption>("");
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
const [cvPromptBackground, setCvPromptBackground] = useState("");
const [cvTone, setCvTone] = useState<CvBuilderTone>("Concise and direct");
const [cvLanguage, setCvLanguage] = useState<CvBuilderLanguage>("English");
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
const [pdfCarousel, setPdfCarousel] = useState<PdfCarouselItem[]>([]);
const [activePdfIndex, setActivePdfIndex] = useState(0);
const [buildingPdfDeck, setBuildingPdfDeck] = useState(false);
const [downloadingPdf, setDownloadingPdf] = useState(false);
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
const [parsingCvSections, setParsingCvSections] = useState(false);
const [reprocessingCv, setReprocessingCv] = useState(false);
const [structuredCv, setStructuredCv] = useState<StructuredCvProfile>(emptyStructuredCv());
const [completeness, setCompleteness] = useState<CareerCompleteness | null>(null);
const [versions, setVersions] = useState<CareerVersion[]>([]);
// Phase 1 increment 2: "Profile sections" (CV structure overview) and the "Template-driven CV
// builder" are duplicate/implementation concepts — the real CV Builder lives at /career/builder.
// Hidden from the default Career Profile workflow behind this toggle; the underlying functionality
// (and its tests) stay intact and reachable. Removal is planned for a later phase — see
// docs/career-workspace-ux-refactor.md.
// The raw import/section parser remains available as an advanced recovery tool.
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
const loadVersions = useCallback(async () => {
@@ -293,53 +168,31 @@ export default function CareerProfilePage() {
}
}, [loadVersions, t, toast]);
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
const [runDiffs, setRunDiffs] = useState<Record<number, CvImportDiff>>({});
const [reviewingRunId, setReviewingRunId] = useState<number | null>(null);
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
const runStatusRef = useRef<Record<number, string>>({});
// Keep a ref to the latest carousel so the unmount cleanup can revoke the
// outstanding preview object URLs without re-running on every change.
const pdfCarouselRef = useRef<PdfCarouselItem[]>([]);
useEffect(() => {
pdfCarouselRef.current = pdfCarousel;
}, [pdfCarousel]);
useEffect(() => {
// Revoke any remaining preview object URLs only on unmount. Per-change
// revocation is already handled explicitly in savePdfToCarousel (replace) and
// resetPdfCarousel (clear); doing it here on every pdfCarousel change revoked
// URLs that were still referenced by other items in the deck, breaking their
// previews.
return () => {
pdfCarouselRef.current.forEach((item) => {
if (item.pdfUrl) {
window.URL.revokeObjectURL(item.pdfUrl);
}
});
};
}, []);
const loadProfile = useCallback(async () => {
setLoading(true);
try {
// /career reads the structured profile from the relational source of truth (/career/profile);
// /auth/me still provides the account row (avatar, provider chips) shown in the header.
const [careerResponse, meResponse, runsResponse, jobsResponse] = await Promise.all([
const [careerResponse, meResponse, runsResponse] = await Promise.all([
api.get<CareerProfileResponse>("/career/profile"),
api.get<MeResponse>("/auth/me"),
api.get<ExtractionRun[]>("/profile-cv/runs").catch(() => ({ data: [] as ExtractionRun[] } as any)),
api.get<JobListResponse>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }).catch(() => ({ data: { items: [], total: 0, page: 1, pageSize: 100 } } as any)),
]);
setMe(meResponse.data);
setProfileCvText(careerResponse.data?.cvText ?? "");
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
setCompleteness(careerResponse.data?.completeness ?? null);
setExtractionRuns(runsResponse.data ?? []);
setSavedJobs(jobsResponse.data?.items ?? []);
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
setLoadError(null);
} catch (error: any) {
setMe(null);
setExtractionRuns([]);
setSavedJobs([]);
setLoadError(String(error?.response?.data || error?.message || "Unable to load profile right now."));
} finally {
setLoading(false);
@@ -362,12 +215,23 @@ export default function CareerProfilePage() {
return () => window.clearInterval(timer);
}, [extractionRuns, loadProfile]);
useEffect(() => {
const pending = extractionRuns.filter((run) => run.status === "pending_review" && !runDiffs[run.id]);
if (pending.length === 0) return;
void Promise.all(pending.map((run) => api.get<CvRunDiffResponse>(`/profile-cv/runs/${run.id}/diff`)))
.then((responses) => setRunDiffs((current) => Object.fromEntries([
...Object.entries(current),
...responses.map((response) => [response.data.runId, response.data.diff]),
])))
.catch(() => undefined);
}, [extractionRuns, runDiffs]);
useEffect(() => {
const previous = runStatusRef.current;
for (const run of extractionRuns) {
const prior = previous[run.id];
if ((prior === "queued" || prior === "running") && run.status === "applied") {
toast(`CV ${run.trigger} completed.`, "success");
if ((prior === "queued" || prior === "running") && run.status === "pending_review") {
toast(`CV ${run.trigger} is ready to review.`, "info");
}
if ((prior === "queued" || prior === "running") && run.status === "failed") {
toast(run.errorMessage || `CV ${run.trigger} failed.`, "error");
@@ -393,107 +257,6 @@ export default function CareerProfilePage() {
: t("profileGoogleNotLinked");
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
const latestRun = extractionRuns[0];
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
const activePdfItem = pdfCarousel[activePdfIndex] ?? null;
const releasePdfCarousel = useCallback((items: PdfCarouselItem[]) => {
items.forEach((item) => {
if (item.pdfUrl) {
window.URL.revokeObjectURL(item.pdfUrl);
}
});
}, []);
const buildRewritePayload = useCallback((templateId: CvSectionStyle): RewriteRequestPayload => ({
sectionName: cvSection || null,
style: templateId,
templateId,
targetRole: cvSectionTargetRole.trim() || null,
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
sourceText: profileCvText.trim() || null,
promptBackground: cvPromptBackground.trim() || null,
tone: cvTone,
language: cvLanguage,
}), [cvLanguage, cvPromptBackground, cvSection, cvSectionTargetRole, cvTone, profileCvText, selectedRewriteJob]);
const resetPdfCarousel = useCallback(() => {
setPdfCarousel((current) => {
releasePdfCarousel(current);
return [];
});
setActivePdfIndex(0);
}, [releasePdfCarousel]);
const savePdfToCarousel = useCallback(async (templateId: CvSectionStyle, download = false) => {
const template = REWRITE_TEMPLATES.find((option) => option.id === templateId) ?? REWRITE_TEMPLATES[0];
const payload = buildRewritePayload(templateId);
const response = await api.post("/profile-cv/export-pdf", payload, { responseType: "blob" });
const blob = new Blob([response.data], { type: "application/pdf" });
const url = window.URL.createObjectURL(blob);
const item: PdfCarouselItem = {
templateId,
title: template.title,
fileName: rewritePreview?.suggestedFileName || `${templateId}-cv.pdf`,
pdfUrl: url,
status: "ready",
};
setPdfCarousel((current) => {
const existing = current.find((entry) => entry.templateId === templateId);
if (existing?.pdfUrl) {
window.URL.revokeObjectURL(existing.pdfUrl);
}
const next = existing
? current.map((entry) => (entry.templateId === templateId ? item : entry))
: [...current, item];
setActivePdfIndex(next.findIndex((entry) => entry.templateId === templateId));
return next;
});
if (download) {
const link = document.createElement("a");
link.href = url;
link.download = item.fileName;
document.body.appendChild(link);
link.click();
link.remove();
}
return item;
}, [buildRewritePayload, rewritePreview?.suggestedFileName]);
const buildPdfCarousel = useCallback(async () => {
setBuildingPdfDeck(true);
resetPdfCarousel();
const orderedTemplates = [selectedRewriteTemplate.id, ...REWRITE_TEMPLATES.map((option) => option.id).filter((id) => id !== selectedRewriteTemplate.id)];
const seedItems = orderedTemplates.map((templateId) => ({
templateId,
title: REWRITE_TEMPLATES.find((option) => option.id === templateId)?.title ?? templateId,
fileName: `${templateId}-cv.pdf`,
status: "loading" as const,
}));
setPdfCarousel(seedItems);
setActivePdfIndex(0);
for (const templateId of orderedTemplates) {
try {
const item = await savePdfToCarousel(templateId, false);
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? item : entry));
} catch (error: any) {
const message = getApiErrorMessage(error, `Failed to generate the ${templateId} PDF preview.`);
setPdfCarousel((current) => current.map((entry) => entry.templateId === templateId ? { ...entry, status: "error", error: message } : entry));
}
}
setBuildingPdfDeck(false);
}, [resetPdfCarousel, savePdfToCarousel, selectedRewriteTemplate.id]);
useEffect(() => {
resetPdfCarousel();
}, [rewritePreview?.fullText, rewritePreview?.templateId, rewritePreview?.targetRole, resetPdfCarousel]);
return (
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<ProfileCompleteness
@@ -595,6 +358,7 @@ export default function CareerProfilePage() {
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
<Button size="small" variant="contained" href="/career/builder">Open CV Builder</Button>
</Box>
</Box>
@@ -626,7 +390,7 @@ export default function CareerProfilePage() {
try {
await api.post<QueuedCvRunResponse>("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } });
await loadProfile();
toast(t("profileCvUploaded"), "success");
toast("CV extracted. Review the changes before applying them.", "info");
} catch (e: any) {
toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error");
} finally {
@@ -753,6 +517,61 @@ export default function CareerProfilePage() {
{run.errorMessage}
</Typography>
) : null}
{run.status === "pending_review" ? (
<Box sx={{ mt: 1.25 }}>
{runDiffs[run.id] ? (
<>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{runDiffs[run.id].totalAdded} additions | {runDiffs[run.id].totalUpdated} updates
{runDiffs[run.id].totalLowConfidence ? ` | ${runDiffs[run.id].totalLowConfidence} need attention` : ""}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
{runDiffs[run.id].categories
.filter((category) => category.added.length || category.updated.length)
.map((category) => `${category.category}: +${category.added.length} / ~${category.updated.length}`)
.join(" | ") || "No profile changes found"}
</Typography>
</>
) : <LinearProgress sx={{ my: 1 }} />}
{runDiffs[run.id]?.categories.flatMap((category) => [...category.added, ...category.updated].map((change) => ({ ...change, category: category.category }))).filter((change) => change.confidence === "Low").map((change) => (
<FormControlLabel
key={change.id}
sx={{ display: "flex", mt: 0.5 }}
control={<Checkbox size="small" checked={(acceptedLowConfidenceIds[run.id] ?? []).includes(change.id)} onChange={(event) => setAcceptedLowConfidenceIds((current) => ({
...current,
[run.id]: event.target.checked
? [...(current[run.id] ?? []), change.id]
: (current[run.id] ?? []).filter((id) => id !== change.id),
}))} />}
label={`Include low-confidence ${change.category}: ${change.label}`}
/>
))}
<Box sx={{ display: "flex", gap: 1, mt: 1, flexWrap: "wrap" }}>
<Button size="small" variant="contained" disabled={!runDiffs[run.id] || reviewingRunId !== null} onClick={async () => {
setReviewingRunId(run.id);
try {
await api.post(`/profile-cv/runs/${run.id}/accept`, { acceptedLowConfidenceIds: acceptedLowConfidenceIds[run.id] ?? [] });
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
await Promise.all([loadProfile(), loadVersions()]);
toast("CV changes merged into your career profile.", "success");
} catch (error) {
toast(getApiErrorMessage(error, "Could not apply CV changes."), "error");
} finally { setReviewingRunId(null); }
}}>Apply changes</Button>
<Button size="small" color="inherit" disabled={reviewingRunId !== null} onClick={async () => {
setReviewingRunId(run.id);
try {
await api.post(`/profile-cv/runs/${run.id}/discard`);
setRunDiffs((current) => { const next = { ...current }; delete next[run.id]; return next; });
await loadProfile();
toast("CV extraction discarded.", "info");
} catch (error) {
toast(getApiErrorMessage(error, "Could not discard CV extraction."), "error");
} finally { setReviewingRunId(null); }
}}>Discard</Button>
</Box>
</Box>
) : null}
</Box>
))}
</Box>
@@ -836,335 +655,6 @@ export default function CareerProfilePage() {
<OtherSectionsSection value={structuredCv.otherSections} onChange={(next) => setStructuredCv((prev) => ({ ...prev, otherSections: next }))} />
</Box>
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)", display: showAdvancedCvTools ? "block" : "none" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", maxWidth: 720 }}>
Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" variant="outlined" label={cvSection || "Whole CV rewrite"} />
{selectedRewriteJob ? <Chip size="small" color="primary" variant="outlined" label={`Saved job · ${selectedRewriteJob.jobTitle}`} /> : null}
{rewriteReady ? <Chip size="small" color="success" label="Preview ready" /> : null}
</Box>
</Box>
<Box sx={{ mb: 2 }}>
<Paper sx={{ p: { xs: 1.5, md: 2 }, borderRadius: 4, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 100%)`, boxShadow: "0 18px 40px rgba(15,23,42,0.08)" }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", lg: "1.15fr 0.85fr" }, gap: 2, alignItems: "stretch" }}>
<Box sx={{ p: { xs: 1.25, md: 2 }, borderRadius: 3.5, background: "rgba(255,255,255,0.82)", border: "1px solid", borderColor: "rgba(15,23,42,0.08)" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1.5, mb: 1.5 }}>
<Box>
<Typography variant="overline" sx={{ color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.16em' }}>{selectedRewriteTemplate.eyebrow}</Typography>
<Typography variant="h6" sx={{ fontWeight: 900 }}>{selectedRewriteTemplate.title}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, maxWidth: 560 }}>{selectedRewriteTemplate.blurb}</Typography>
</Box>
<IconButton size="small" onClick={() => setRewritePreviewTemplate(selectedRewriteTemplate)}>
<ZoomInOutlinedIcon fontSize="small" />
</IconButton>
</Box>
<Box sx={{ borderRadius: 3.5, overflow: "hidden", border: "1px solid", borderColor: "rgba(15,23,42,0.1)", background: "white", minHeight: { xs: 280, md: 340 }, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.7)" }}>
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 }, borderBottom: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(135deg, ${selectedRewriteTemplate.accent}14 0%, rgba(255,255,255,0.96) 72%)` }}>
<Typography variant="caption" sx={{ display: "block", color: selectedRewriteTemplate.accent, fontWeight: 900, letterSpacing: '0.14em', mb: 0.5 }}>{selectedRewriteTemplate.eyebrow}</Typography>
<Typography sx={{ fontSize: { xs: '1.1rem', md: '1.35rem' }, fontWeight: 900, lineHeight: 1.1 }}>{selectedRewriteTemplate.sampleHeading}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>{selectedRewriteTemplate.sampleMeta}</Typography>
</Box>
<Box sx={{ px: { xs: 2, md: 3 }, py: { xs: 2, md: 2.5 } }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Preview of the generated PDF style</Typography>
{selectedRewriteTemplate.sampleBullets.map((bullet) => (
<Typography key={bullet} variant="body2" sx={{ display: "block", color: "text.primary", mb: 0.85, lineHeight: 1.55 }}> {bullet}</Typography>
))}
<Box sx={{ mt: 2, pt: 1.5, borderTop: "1px dashed", borderColor: "divider", display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
<Chip size="small" variant="outlined" label="Readable hierarchy" />
<Chip size="small" variant="outlined" label="PDF-first spacing" />
<Chip size="small" variant="outlined" label="ATS-safe structure" />
</Box>
</Box>
</Box>
</Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Choose a visual direction before generating</Typography>
<Box sx={{ display: "grid", gap: 1.1 }}>
{REWRITE_TEMPLATES.map((option) => {
const selected = option.id === cvSectionStyle;
return (
<Paper
key={option.id}
role="button"
tabIndex={0}
aria-label={`${option.title} template preview`}
onClick={() => setCvSectionStyle(option.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setCvSectionStyle(option.id);
}
}}
sx={{
p: 1.15,
borderRadius: 3,
cursor: "pointer",
border: "1px solid",
borderColor: selected ? "primary.main" : "divider",
background: selected ? `linear-gradient(180deg, ${option.accent}10 0%, rgba(255,255,255,0.98) 100%)` : "rgba(255,255,255,0.84)",
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.16), 0 10px 24px rgba(15,23,42,0.08)" : "0 6px 16px rgba(15,23,42,0.04)",
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
'&:hover': { transform: 'translateY(-1px)' },
}}
>
<Box sx={{ display: "grid", gridTemplateColumns: "92px minmax(0, 1fr)", gap: 1.1, alignItems: "stretch" }}>
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "rgba(15,23,42,0.08)", background: `linear-gradient(180deg, ${option.accent}1e 0%, rgba(255,255,255,0.98) 100%)`, p: 1, minHeight: 102, display: "flex", flexDirection: "column", justifyContent: "space-between" }}>
<Typography variant="caption" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.08em' }}>{option.eyebrow}</Typography>
<Box>
<Typography variant="caption" sx={{ display: "block", fontWeight: 800, lineHeight: 1.25 }}>{option.sampleHeading}</Typography>
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.5, lineHeight: 1.25 }}>{option.sampleMeta}</Typography>
</Box>
</Box>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, lineHeight: 1.4 }}>{option.blurb}</Typography>
</Box>
{selected ? <Chip size="small" color="primary" label="Selected" /> : null}
</Box>
</Box>
</Box>
</Paper>
);
})}
</Box>
</Box>
</Box>
</Paper>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
<TextField
label="Prompt-based CV brief"
value={cvPromptBackground}
onChange={(e) => setCvPromptBackground(e.target.value)}
fullWidth
multiline
minRows={4}
helperText="Describe your strengths, preferred emphasis, industry background, or the angle you want the AI to lean into."
sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}
/>
<FormControl fullWidth size="small">
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
<MenuItem value="">Whole CV</MenuItem>
<MenuItem value="Professional Summary">{t("profileCvSectionSummary")}</MenuItem>
<MenuItem value="Core Skills">{t("profileCvSectionSkills")}</MenuItem>
<MenuItem value="Experience Highlights">{t("profileCvSectionExperience")}</MenuItem>
<MenuItem value="Selected Achievements">{t("profileCvSectionAchievements")}</MenuItem>
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
</Select>
</FormControl>
<TextField
label={t("profileCvSectionTargetRole")}
value={cvSectionTargetRole}
onChange={(e) => setCvSectionTargetRole(e.target.value)}
fullWidth
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
/>
<FormControl fullWidth size="small">
<InputLabel>Language</InputLabel>
<Select value={cvLanguage} label="Language" onChange={(e) => setCvLanguage(e.target.value as CvBuilderLanguage)}>
<MenuItem value="English">English</MenuItem>
<MenuItem value="Norwegian">Norwegian</MenuItem>
<MenuItem value="Spanish">Spanish</MenuItem>
<MenuItem value="French">French</MenuItem>
<MenuItem value="German">German</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth size="small">
<InputLabel>Tone</InputLabel>
<Select value={cvTone} label="Tone" onChange={(e) => setCvTone(e.target.value as CvBuilderTone)}>
<MenuItem value="Concise and direct">Concise and direct</MenuItem>
<MenuItem value="Executive and polished">Executive and polished</MenuItem>
<MenuItem value="Technical and detailed">Technical and detailed</MenuItem>
<MenuItem value="Warm and people-focused">Warm and people-focused</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
<InputLabel>Saved job context</InputLabel>
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
<MenuItem value="">None</MenuItem>
{savedJobs.map((job) => (
<MenuItem key={job.id} value={String(job.id)}>{job.jobTitle} · {job.company?.name ?? "Unknown company"}</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Builder output</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button
variant="contained"
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
onClick={async () => {
setRewritingSection(true);
resetPdfCarousel();
try {
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", buildRewritePayload(cvSectionStyle));
setRewritePreview(res.data);
toast(t("profileCvSectionRewritten"), "success");
} catch (e: any) {
toast(getApiErrorMessage(e, t("profileCvSectionRewriteFailed")), "error");
} finally {
setRewritingSection(false);
}
}}
>
{rewritingSection ? t("profileCvSectionRewriting") : rewriteReady ? "Refresh preview" : "Build preview"}
</Button>
<Button
variant="outlined"
disabled={!rewriteReady || downloadingPdf}
onClick={async () => {
setDownloadingPdf(true);
try {
await savePdfToCarousel(cvSectionStyle, true);
toast("CV PDF downloaded and added to the carousel.", "success");
} catch (e: any) {
toast(getApiErrorMessage(e, "Failed to export the CV PDF."), "error");
} finally {
setDownloadingPdf(false);
}
}}
>
{downloadingPdf ? "Generating PDF…" : "Download PDF"}
</Button>
<Button
variant="text"
disabled={!rewriteReady || buildingPdfDeck}
onClick={buildPdfCarousel}
>
{buildingPdfDeck ? "Building PDF carousel…" : "Build PDF carousel"}
</Button>
</Box>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
</Box>
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
{rewriteReady ? (
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
) : (
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
)}
</Box>
<Box sx={{ mt: 1.25, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
<Button variant="text" disabled={!rewriteReady} onClick={() => navigator.clipboard.writeText(rewritePreview?.fullText ?? "")}>{t("profileCopyCvText")}</Button>
<Button
variant="contained"
disabled={!rewriteReady}
onClick={() => {
setProfileCvText(rewritePreview?.fullText ?? "");
if (rewritePreview?.structuredCv) setStructuredCv(normalizeStructuredCv(rewritePreview.structuredCv));
}}
>
Replace master CV
</Button>
</Box>
</Paper>
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>PDF carousel</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{activePdfItem?.title ? `${activePdfItem.title} · generated PDF` : `${selectedRewriteTemplate.title} · print-ready layout`}
</Typography>
</Box>
{activePdfItem?.fileName ? <Chip size="small" variant="outlined" label={activePdfItem.fileName} /> : rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
</Box>
{pdfCarousel.length > 0 ? (
<>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 1.25 }}>
{pdfCarousel.map((item, index) => (
<Button
key={item.templateId}
size="small"
variant={index === activePdfIndex ? "contained" : "outlined"}
color={item.status === "error" ? "error" : item.status === "ready" ? "primary" : "inherit"}
onClick={() => setActivePdfIndex(index)}
>
{item.title}
</Button>
))}
</Box>
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
{activePdfItem?.status === "ready" && activePdfItem.pdfUrl ? (
<iframe title={`${activePdfItem.title} PDF preview`} src={activePdfItem.pdfUrl} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
) : activePdfItem?.status === "error" ? (
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem.title} PDF unavailable</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{activePdfItem.error || "This template could not be rendered as a PDF right now."}</Typography>
</Box>
</Box>
) : (
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
<Box sx={{ maxWidth: 420, textAlign: "center" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 900, mb: 1 }}>{activePdfItem?.title || "Preparing PDF preview"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{buildingPdfDeck ? "The carousel is generating PDFs across the current template set." : "Generate the PDF carousel to inspect rendered export files without leaving the page."}
</Typography>
</Box>
</Box>
)}
</Box>
</>
) : (
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
{rewriteReady ? (
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
) : (
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, then generate the PDF carousel to compare rendered files template by template.
</Typography>
</Box>
)}
</Box>
)}
</Paper>
</Box>
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
<DialogContent>
{rewritePreviewTemplate ? (
<Box sx={{ p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: `linear-gradient(180deg, ${rewritePreviewTemplate.accent}12 0%, rgba(255,255,255,0) 100%)` }}>
<Typography variant="overline" sx={{ color: rewritePreviewTemplate.accent, fontWeight: 800 }}>{rewritePreviewTemplate.eyebrow}</Typography>
<Typography variant="h6" sx={{ fontWeight: 900, mb: 0.5 }}>{rewritePreviewTemplate.sampleHeading}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{rewritePreviewTemplate.sampleMeta}</Typography>
{rewritePreviewTemplate.sampleBullets.map((bullet) => (
<Typography key={bullet} variant="body2" sx={{ mb: 0.75 }}> {bullet}</Typography>
))}
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1.5 }}>{rewritePreviewTemplate.blurb}</Typography>
</Box>
) : null}
</DialogContent>
</Dialog>
</Box>
<Box sx={{ mt: 1, display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{cvWordCount} words
+9 -8
View File
@@ -21,7 +21,7 @@ type AuthConfig = {
requireEmailVerification: boolean;
};
export default function LoginPage() {
export default function LoginPage({ initialMode = "login" }: { initialMode?: "login" | "register" }) {
const { toast } = useToast();
const { t } = useI18n();
const navigate = useNavigate();
@@ -40,7 +40,7 @@ export default function LoginPage() {
const [resendingVerification, setResendingVerification] = useState(false);
const [verificationResent, setVerificationResent] = useState(false);
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
const [registerMode, setRegisterMode] = useState(false);
const [registerMode, setRegisterMode] = useState(initialMode === "register");
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -132,7 +132,7 @@ export default function LoginPage() {
>
<Paper sx={{ width: "min(520px, 100%)", p: 4, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
<Typography variant="h5" sx={{ fontWeight: 900, mb: 0.5 }}>
{t("signInTitle")}
{registerMode ? t("createAccount") : t("signInTitle")}
</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
{cfg?.requireAuth ? t("authRequired") : t("authOptional")}
@@ -146,11 +146,11 @@ export default function LoginPage() {
/>
) : (
<>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
{initialMode === "login" ? <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label={t("emailAndPassword")} />
<Tab label={t("google")} />
<Tab label={t("microsoft")} />
</Tabs>
</Tabs> : null}
{tab === 0 && (
<Box
@@ -158,6 +158,7 @@ export default function LoginPage() {
onSubmit={(e) => { e.preventDefault(); void submit(registerMode ? "register" : "login"); }}
sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}
>
{registerMode && cfg && !allowReg ? <Alert severity="info">Registration is currently unavailable.</Alert> : null}
{cfg?.requireEmailVerification && emailNotVerified && (
<Alert
severity="warning"
@@ -230,20 +231,20 @@ export default function LoginPage() {
)}
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1, mt: 1, flexWrap: "wrap" }}>
{allowReg && (
{(allowReg || initialMode === "register") && (
<Button
type="button"
variant="text"
size="small"
disableRipple
disabled={loading}
onClick={() => { setRegisterMode((v) => !v); setFieldErrors({}); }}
onClick={() => { if (initialMode === "register") navigate("/login"); else setRegisterMode((v) => !v); setFieldErrors({}); }}
sx={{ px: 0, minWidth: 0, fontWeight: 700 }}
>
{registerMode ? t("backToLogin") : t("createAccount")}
</Button>
)}
<Button type="submit" variant="contained" disableRipple disabled={loading} sx={{ ml: "auto" }}>
<Button type="submit" variant="contained" disableRipple disabled={loading || (registerMode && cfg !== null && !allowReg)} sx={{ ml: "auto" }}>
{registerMode ? t("createAccount") : t("signInTitle")}
</Button>
</Box>
@@ -1,6 +1,7 @@
import React from "react";
import { Box, Button, Chip, TextField, Typography } from "@mui/material";
import RichTextField from "../../components/RichTextField";
import { useI18n } from "../../i18n/I18nProvider";
import {
joinLines,
@@ -95,15 +96,7 @@ function LinesField({ label, value, onChange, metadata, minRows }: { label: stri
const { t } = useI18n();
return (
<Box>
<TextField
label={label}
value={joinLines(value)}
onChange={(e) => onChange(splitLines(e.target.value))}
helperText={t("profileCvStructuredListHelp")}
multiline
minRows={minRows}
fullWidth
/>
<TextField label={label} value={joinLines(value)} onChange={(e) => onChange(splitLines(e.target.value))} helperText={t("profileCvStructuredListHelp")} multiline minRows={minRows} fullWidth />
<FieldReviewNote metadata={metadata} />
</Box>
);
@@ -111,7 +104,12 @@ function LinesField({ label, value, onChange, metadata, minRows }: { label: stri
export function ProfessionalSummarySection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
const { t } = useI18n();
return <LinesField label={t("profileCvStructuredSummary")} value={value} onChange={onChange} metadata={getMetadata("summary")} minRows={5} />;
return (
<Box>
<RichTextField label={t("profileCvStructuredSummary")} value={joinLines(value)} onChange={(text) => onChange(splitLines(text))} minRows={5} />
<FieldReviewNote metadata={getMetadata("summary")} />
</Box>
);
}
export function SkillsSection({ value, onChange, getMetadata }: { value: string[]; onChange: (next: string[]) => void; getMetadata: MetadataLookup }) {
@@ -169,7 +167,7 @@ export function WorkExperienceSection({ value, onChange }: { value: StructuredCv
<TextField label={t("profileCvJobStart")} value={job.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvJobEnd")} value={job.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined, isCurrent: /present|current/i.test(e.target.value) || job.isCurrent })} fullWidth />
</Box>
<TextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(e) => update(index, { bullets: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={5} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvJobBullets")} value={joinLines(job.bullets)} onChange={(text) => update(index, { bullets: splitLines(text) })} minRows={5} /></Box>
<TextField label={t("profileCvJobSkills")} value={joinLines(job.skills)} onChange={(e) => update(index, { skills: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={3} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
@@ -201,7 +199,7 @@ export function EducationSection({ value, onChange }: { value: StructuredCvEduca
<TextField label={t("profileCvEducationStart")} value={education.start ?? ""} onChange={(e) => update(index, { start: e.target.value || undefined })} fullWidth />
<TextField label={t("profileCvEducationEnd")} value={education.end ?? ""} onChange={(e) => update(index, { end: e.target.value || undefined })} fullWidth />
</Box>
<TextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(e) => update(index, { details: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvEducationDetails")} value={joinLines(education.details)} onChange={(text) => update(index, { details: splitLines(text) })} minRows={4} /></Box>
<Box sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" }, display: "flex", justifyContent: "flex-end" }}>
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
</Box>
@@ -227,7 +225,7 @@ export function OtherSectionsSection({ value, onChange }: { value: StructuredCvO
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto" }, gap: 1 }}>
<TextField label={t("profileCvOtherSectionTitle")} value={section.title ?? ""} onChange={(e) => update(index, { title: e.target.value || undefined })} fullWidth />
<Button color="inherit" onClick={() => onChange(value.filter((_, i) => i !== index))}>{t("profileCvStructuredRemove")}</Button>
<TextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(e) => update(index, { items: splitLines(e.target.value) })} helperText={t("profileCvStructuredListHelp")} multiline minRows={4} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
<Box sx={{ gridColumn: "1 / -1" }}><RichTextField label={t("profileCvOtherSectionItems")} value={joinLines(section.items)} onChange={(text) => update(index, { items: splitLines(text) })} minRows={4} /></Box>
</Box>
</Box>
))}
+1
View File
@@ -0,0 +1 @@
module.exports = "test-file-stub";