diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index 73be0a3..efbe8a5 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -520,6 +520,20 @@ public sealed class JobApplicationsApplicationPackageTests Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Tailored_template_catalog_preserves_legacy_ids_and_defaults() + { + Assert.Equal( + new[] { "ats-minimal", "harvard", "auckland", "edinburgh", "monarch", "fjord" }, + TailoredCvTemplateCatalog.Templates.Select(template => template.Id)); + Assert.Equal("ats-minimal", TailoredCvTemplateCatalog.NormalizeId("legacy-text")); + Assert.Equal("ats-minimal", TailoredCvTemplateCatalog.NormalizeId("unknown")); + Assert.Equal("brick", TailoredCvTemplateCatalog.Resolve("harvard").AccentColor); + Assert.Contains("Harvard template", TailoredCvTemplateCatalog.Resolve("harvard").RewriteGuidance); + Assert.Equal("#047857", TailoredCvTemplateCatalog.ResolveAccent("emerald")); + Assert.Equal("#123456", TailoredCvTemplateCatalog.ResolveAccent("#123456")); + } + [Fact] public void Template_renderer_wraps_long_content_and_escapes_sidebar_values() { diff --git a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs index fa5499b..ab0b485 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs @@ -73,15 +73,7 @@ public sealed partial class ProfileCvController : ControllerBase RenderOptions = new TailoredCvRenderOptions { ShowPhoto = true, - AccentColor = templateId switch - { - "harvard" => "brick", - "auckland" => "emerald", - "edinburgh" => "plum", - "monarch" => "#7c2d12", - "fjord" => "#0f4c5c", - _ => "slate", - }, + AccentColor = TailoredCvTemplateCatalog.Resolve(templateId).AccentColor, SectionOrder = new List { "summary", "skills", "experience", "education", "custom" }, } }); diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index 9379fb6..8d8abd4 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -571,32 +571,10 @@ public sealed partial class ProfileCvController : ControllerBase private static string DescribeRewriteTemplate(string templateId) { - return templateId.ToLowerInvariant() switch - { - "harvard" => "Harvard template: refined, traditional, strong hierarchy, restrained and credible.", - "auckland" => "Auckland template: modern sidebar layout, crisp highlights, confident but readable.", - "edinburgh" => "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing.", - "monarch" => "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals.", - "fjord" => "Fjord template: calm technical layout with clear information density and practical scanability.", - _ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor." - }; + return TailoredCvTemplateCatalog.Resolve(templateId).RewriteGuidance; } - private static string NormalizeTemplateId(string? value) - { - var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); - return normalized switch - { - "base" => "ats-minimal", - "legacy-text" => "ats-minimal", - "harvard" => "harvard", - "auckland" => "auckland", - "edinburgh" => "edinburgh", - "monarch" => "monarch", - "fjord" => "fjord", - _ => "ats-minimal" - }; - } + private static string NormalizeTemplateId(string? value) => TailoredCvTemplateCatalog.NormalizeId(value); private static string? NormalizeRewriteSectionName(string? value) { @@ -642,17 +620,16 @@ public sealed partial class ProfileCvController : ControllerBase private static IReadOnlyList GetCvTemplateDescriptors() { - // 7-arg shape matches CvTemplateDescriptor in ProfileCvDtos.cs. The LayoutFamily/AtsRating - // fields the branch added here are deferred with the rest of the ATS-badge work (Phase 4). - return new[] - { - new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }), - new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List { "Classic serif rhythm", "Strong chronology", "Credible tone" }), - new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List { "Sidebar details", "Compact highlights", "Modern contrast" }), - new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List { "Premium spacing", "Stronger personality", "Readable density" }), - new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }), - new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List { "Technical depth", "Dense but readable", "Practical hierarchy" }), - }; + return TailoredCvTemplateCatalog.Templates + .Select(template => new CvTemplateDescriptor( + template.Id, + template.Title, + template.Tone, + template.AccentColor, + template.PreviewTagline, + template.PreviewSummary, + template.PreviewBullets.ToList())) + .ToList(); } private TailoredCvRenderResult RenderProfileCv(TailoredCvDocument document, ApplicationUser user, string targetRole, string? companyName) diff --git a/JobTrackerApi/Models/TailoredCvTemplate.cs b/JobTrackerApi/Models/TailoredCvTemplate.cs new file mode 100644 index 0000000..896a03c --- /dev/null +++ b/JobTrackerApi/Models/TailoredCvTemplate.cs @@ -0,0 +1,59 @@ +namespace JobTrackerApi.Models; + +/// +/// Compatibility catalog for the job-specific CV draft flow. These IDs predate the +/// document builder theme catalog, so they remain stable while their metadata and +/// defaults have one owner. +/// +public sealed record TailoredCvTemplate( + string Id, + string Title, + string Tone, + string AccentColor, + string PreviewTagline, + string PreviewSummary, + IReadOnlyList PreviewBullets, + string RewriteGuidance); + +public static class TailoredCvTemplateCatalog +{ + public static readonly IReadOnlyList Templates = new[] + { + new TailoredCvTemplate("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new[] { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }, "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor."), + new TailoredCvTemplate("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new[] { "Classic serif rhythm", "Strong chronology", "Credible tone" }, "Harvard template: refined, traditional, strong hierarchy, restrained and credible."), + new TailoredCvTemplate("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new[] { "Sidebar details", "Compact highlights", "Modern contrast" }, "Auckland template: modern sidebar layout, crisp highlights, confident but readable."), + new TailoredCvTemplate("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new[] { "Premium spacing", "Stronger personality", "Readable density" }, "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing."), + new TailoredCvTemplate("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new[] { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }, "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals."), + new TailoredCvTemplate("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new[] { "Technical depth", "Dense but readable", "Practical hierarchy" }, "Fjord template: calm technical layout with clear information density and practical scanability."), + }; + + private static readonly IReadOnlyDictionary NamedAccents = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["slate"] = "#334155", + ["blue"] = "#1d4ed8", + ["emerald"] = "#047857", + ["plum"] = "#7c3aed", + ["brick"] = "#b45309", + }; + + public static string NormalizeId(string? value) + { + var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); + if (normalized is "base" or "legacy-text") return Templates[0].Id; + return Templates.Any(template => template.Id == normalized) ? normalized : Templates[0].Id; + } + + public static TailoredCvTemplate Resolve(string? value) + { + var id = NormalizeId(value); + return Templates.First(template => template.Id == id); + } + + public static string ResolveAccent(string? value) + { + var normalized = (value ?? string.Empty).Trim(); + if (NamedAccents.TryGetValue(normalized, out var accent)) return accent; + return normalized.StartsWith('#') ? normalized : NamedAccents["slate"]; + } +} diff --git a/JobTrackerApi/Services/CvTemplateRenderer.cs b/JobTrackerApi/Services/CvTemplateRenderer.cs index 682b957..56318f2 100644 --- a/JobTrackerApi/Services/CvTemplateRenderer.cs +++ b/JobTrackerApi/Services/CvTemplateRenderer.cs @@ -16,7 +16,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null) { var normalized = TailoredCvDraftJson.Normalize(document); - var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId); + var effectiveTemplateId = TailoredCvTemplateCatalog.NormalizeId(templateId ?? normalized.TemplateId); normalized.TemplateId = effectiveTemplateId; var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf"; var html = effectiveTemplateId switch @@ -31,25 +31,9 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html); } - private static string NormalizeTemplateId(string? value) - { - var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); - return normalized switch - { - "base" => "ats-minimal", - "legacy-text" => "ats-minimal", - "harvard" => "harvard", - "auckland" => "auckland", - "edinburgh" => "edinburgh", - "monarch" => "monarch", - "fjord" => "fjord", - _ => "ats-minimal" - }; - } - private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) { - var accent = ResolveAccent(document.RenderOptions.AccentColor); + var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var body = RenderMainSections(document, accent, headingStyle: "caps-rule"); var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"Company focus: {Encode(companyName)}"; @@ -93,7 +77,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName) { - var accent = ResolveAccent(document.RenderOptions.AccentColor); + var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor); var body = RenderMainSections(document, accent, headingStyle: "harvard"); var contactLine = string.Join("  •  ", new[] { @@ -135,7 +119,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader) { - var accent = ResolveAccent(document.RenderOptions.AccentColor); + var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var sidebarSections = new StringBuilder(); sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[] @@ -207,7 +191,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) { - var accent = ResolveAccent(document.RenderOptions.AccentColor); + var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; var body = RenderMainSections(document, accent, headingStyle: "sidebar"); @@ -259,7 +243,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl) { - var accent = ResolveAccent(document.RenderOptions.AccentColor); + var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor); var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl); var body = RenderMainSections(document, accent, headingStyle: "sidebar"); var photoMarkup = showPhoto ? $"
\"Profile
" : string.Empty; @@ -445,21 +429,6 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}"; } - private static string ResolveAccent(string? accentColor) - { - var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant(); - return normalized switch - { - "slate" => "#334155", - "blue" => "#1d4ed8", - "emerald" => "#047857", - "plum" => "#7c3aed", - "brick" => "#b45309", - _ when normalized.StartsWith("#") => normalized, - _ => "#334155" - }; - } - private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty); private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "'", StringComparison.Ordinal); diff --git a/docs/todo/work.md b/docs/todo/work.md index 2933d89..2d2ca67 100644 --- a/docs/todo/work.md +++ b/docs/todo/work.md @@ -2,11 +2,12 @@ ## Current -- [ ] Run the complete backend, frontend, browser, migration, PDF, Compose and diff verification matrix; fix any regressions. +- [ ] Commit and deploy the centralized job-specific CV compatibility catalog; verify live release health. ## Next -- [ ] Audit remaining TODOs/dead CV code after the full regression run and document any genuine environment-only blockers. +- [ ] Complete authenticated production CV Builder editing/PDF smoke when a safe signed-in test session is available. +- [ ] Remove the two temporary Gitea credential-transfer files after explicit deletion confirmation. ## Completed @@ -19,6 +20,12 @@ - [x] Added shared A4/Letter page grouping for browser preview and Chromium PDF, including regression coverage for long multi-page Code CVs and searchable linked PDF output. - [x] Improved DOCX structural extraction, Norwegian headings/languages/dates, contact and website parsing, summary/skills/education fallbacks, and confidence-preserving import output. - [x] Benchmarked both supplied reference DOCX variants without committing personal data: 100% structural coverage and consistency, with zero suspicious locations. +- [x] Ran the complete backend, frontend, browser, PDF, Compose and diff verification matrix without product regressions. +- [x] Installed .NET 9.0.317, corrected user PATH precedence, configured Git author/authentication and pushed the CV rebuild. +- [x] Hardened the self-hosted CI runner against corrupt SDK installs and intermittent Roslyn exit-139 crashes without weakening build/test gates. +- [x] Deployed release 266 and verified public health plus the Career and CV Builder application routes. +- [x] Audited CV TODOs and obsolete-code candidates; confirmed the remaining old renderer is an active job-specific compatibility path rather than dead Builder code. +- [x] Centralized the compatibility path's template IDs, aliases, accents, chooser metadata and AI rewrite guidance while preserving saved IDs and HTML rendering. ---