From 367b70681ac3b078177a8f4a901b39e8f6f42a8d Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 24 Aug 2026 20:21:23 +0200 Subject: [PATCH] feat(cv): rebuild professional resume studio --- JobTrackerApi.Tests/ApplicationAssetsTests.cs | 2 +- JobTrackerApi.Tests/CvBuilderTests.cs | 63 ++++ JobTrackerApi.Tests/MigrationChainTests.cs | 4 + .../Controllers/CvVariantController.cs | 49 ++- JobTrackerApi/Models/CvVariantSettings.cs | 58 ++++ JobTrackerApi/Models/StructuredCvProfile.cs | 8 + .../Models/StructuredCvProfileJson.cs | 30 ++ .../Services/AccountDataExportService.cs | 1 + JobTrackerApi/Services/CvRenderModel.cs | 57 +++- JobTrackerApi/Services/CvVariantService.cs | 43 ++- JobTrackerApi/Services/ThemedCvRenderer.cs | 102 ++++--- docs/architecture/cv-builder.md | 24 +- docs/architecture/cv-theme-engine.md | 12 +- docs/verification/career-002-cv-builder.md | 22 +- job-tracker-ui/e2e/smoke.spec.ts | 7 + .../src/career-workspace-page.test.tsx | 2 +- .../src/components/CvTemplateThumbnail.tsx | 65 ++++ .../src/cv-builder-deep-link.test.tsx | 32 +- job-tracker-ui/src/cv-builder-page.test.tsx | 8 +- job-tracker-ui/src/cvBuilder.ts | 28 +- job-tracker-ui/src/i18n/translations.ts | 2 + job-tracker-ui/src/profileCv.ts | 6 +- job-tracker-ui/src/public-cv-page.test.tsx | 2 +- .../src/views/CareerProfilePage.tsx | 5 +- job-tracker-ui/src/views/CvBuilderEditor.tsx | 289 ++++++++++++------ job-tracker-ui/src/views/CvBuilderPage.tsx | 84 ++++- .../views/career/CareerProfileSections.tsx | 9 +- 27 files changed, 827 insertions(+), 187 deletions(-) create mode 100644 job-tracker-ui/src/components/CvTemplateThumbnail.tsx diff --git a/JobTrackerApi.Tests/ApplicationAssetsTests.cs b/JobTrackerApi.Tests/ApplicationAssetsTests.cs index 241bf23..5675d07 100644 --- a/JobTrackerApi.Tests/ApplicationAssetsTests.cs +++ b/JobTrackerApi.Tests/ApplicationAssetsTests.cs @@ -27,7 +27,7 @@ public sealed class ApplicationAssetsTests .Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((string owner, CancellationToken _) => db.CvVariants .Where(v => v.OwnerUserId == owner) - .Select(v => new CvVariantSummary(v.Id, v.Name, "nordic", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc)) + .Select(v => new CvVariantSummary(v.Id, v.Name, "nordic", "en", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc, null, null)) .ToList()); var intelligence = new ApplicationIntelligenceService(db, new JobCvMatchService()); diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs index 4ada67b..fba1f49 100644 --- a/JobTrackerApi.Tests/CvBuilderTests.cs +++ b/JobTrackerApi.Tests/CvBuilderTests.cs @@ -249,6 +249,54 @@ public sealed class CvBuilderTests Assert.Contains("#123456", result.Html); } + [Fact] + public void Design_and_layout_overrides_are_normalized_and_reach_the_shared_renderer() + { + var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings + { + ThemeId = "modern", + Layout = "sidebar-right", + TextColor = "#123456", + MutedColor = "#456789", + BackgroundColor = "#fafafa", + BaseFontSizePt = 99, + HeadingSizePt = 15, + LineHeight = 1.55, + PageMarginMm = 20, + SidebarWidthMm = 70, + SidebarSections = new() { "skills", "languages" }, + SkillsStyle = "text", + }); + var model = CvVariantResolver.Build(Rich(), settings, "F", null); + var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html; + + Assert.Equal(13, settings.BaseFontSizePt); + Assert.Contains("grid-template-columns:minmax(0,1fr) 70mm", html); + Assert.Contains("color:#123456", html); + Assert.Contains("background:#fafafa", html); + Assert.Contains("line-height:1.55", html); + Assert.Contains("font-size:13pt", html); + Assert.Contains("class=\"skills-text\">C# · SQL

", html); + } + + [Fact] + public void Norwegian_variant_localizes_default_labels_dates_and_keeps_custom_links_clickable() + { + var profile = Rich(); + profile.Jobs[0].StartDate = "2020-01"; + profile.Contact.GitHub = "github.com/ada"; + profile.Contact.Links.Add(new StructuredCvLink { Label = "Portfolio", Url = "https://ada.example/work" }); + var settings = new CvVariantSettings { Language = "no", DateFormat = "year" }; + + var model = CvVariantResolver.Build(profile, settings, "F", null); + var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html; + + Assert.Equal("Arbeidserfaring", model.Sections.First(section => section.Key == "experience").Title); + Assert.Equal("2020 – nå", model.Sections.First(section => section.Key == "experience").Entries[0].Meta); + Assert.Contains("href=\"https://github.com/ada\"", html); + Assert.Contains("href=\"https://ada.example/work\">Portfolio", html); + } + // ---- Variant service (CRUD, autosave history, public, render) ---- private static (JobTrackerContext db, CvVariantService svc) NewService(string userId, string? dbName = null) @@ -303,6 +351,21 @@ public sealed class CvBuilderTests Assert.Equal("elegant", CvVariantSettingsJson.Deserialize(copy.SettingsJson).ThemeId); } + [Fact] + public async Task Create_rejects_a_job_application_owned_by_another_user() + { + var (db, svc) = NewService("user-1"); + await using var _ = db; + var company = new Company { OwnerUserId = "user-2", Name = "Other tenant" }; + var foreignJob = new JobApplication { OwnerUserId = "user-2", JobTitle = "Private role", Company = company }; + db.Add(foreignJob); + await db.SaveChangesAsync(); + + await Assert.ThrowsAsync(() => + svc.CreateAsync("user-1", "CV", foreignJob.Id, new CvVariantSettings(), default)); + Assert.Empty(await db.CvVariants.IgnoreQueryFilters().ToListAsync()); + } + [Fact] public async Task Delete_removes_the_variant_and_its_versions() { diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs index 2eb1e51..1d0c120 100644 --- a/JobTrackerApi.Tests/MigrationChainTests.cs +++ b/JobTrackerApi.Tests/MigrationChainTests.cs @@ -36,6 +36,10 @@ public sealed class MigrationChainTests 'LastReminderEmailSentAt', 'RecruiterMessageDraft', 'SalaryMin', 'SalaryMax', 'SalaryCurrency', 'SalaryPeriod'); """)); + Assert.Equal(1, await ScalarAsync(connection, """ + SELECT COUNT(*) FROM pragma_table_info('AspNetUsers') + WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true'); + """)); } [Fact] diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs index d16b312..5ed9116 100644 --- a/JobTrackerApi/Controllers/CvVariantController.cs +++ b/JobTrackerApi/Controllers/CvVariantController.cs @@ -18,13 +18,15 @@ public sealed class CvVariantController : ControllerBase private readonly ICvVariantService _variants; private readonly ICvPdfExporter _pdf; private readonly ISummarizerService _ai; + private readonly IThemedCvRenderer _renderer; - public CvVariantController(UserManager users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai) + public CvVariantController(UserManager users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null) { _users = users; _variants = variants; _pdf = pdf; _ai = ai; + _renderer = renderer ?? new ThemedCvRenderer(); } public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc); @@ -61,6 +63,20 @@ public sealed class CvVariantController : ControllerBase return Ok(themes); } + // Render-only sample for visual template thumbnails. This in-memory content is never written to + // the authenticated user's master profile or to a CV variant. + [HttpGet("themes/{themeId}/preview")] + public async Task> GetThemePreview(string themeId) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + if (!CvThemeCatalog.Exists(themeId)) return NotFound(); + var theme = CvThemeCatalog.Resolve(themeId); + var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true }); + var render = _renderer.Render(TemplatePreviewModel(), theme, settings); + return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName)); + } + // The master profile as sections+entries (with ItemKeys) — the Content tab reads this to render // editable rows without duplicating the profile shape on the client. [HttpGet("outline")] @@ -88,6 +104,8 @@ public sealed class CvVariantController : ControllerBase return BadRequest("Unknown theme."); if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId)) return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); + if (request?.JobApplicationId is int jobApplicationId && !await _variants.CanAssociateJobAsync(user.Id, jobApplicationId, ct)) + return BadRequest("The job application is unavailable."); var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct); return Ok(ToDto(variant)); } @@ -228,6 +246,7 @@ public sealed class CvVariantController : ControllerBase "shorten" => "Make the text more concise without losing meaning.", "expand" => "Expand the text with more concrete, relevant detail — but never invent facts.", "grammar" => "Fix grammar, spelling and punctuation only. Keep wording and meaning.", + "impact" => "Strengthen the text by identifying where the user could add measurable impact. Use clearly marked placeholders for missing numbers and never invent a metric or result.", "ats" => "Rewrite for ATS keyword optimisation: strong action verbs, quantified impact, clear phrasing. Do not invent facts.", "bullets" => "Rewrite as 3–5 tight achievement bullet points, each starting with a strong action verb. Do not invent facts.", "summary" => "Write a concise professional summary (2–3 sentences) from this text. Do not invent facts.", @@ -241,6 +260,34 @@ public sealed class CvVariantController : ControllerBase private async Task CanUseThemeAsync(ApplicationUser user, string? themeId) => CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes); + private static CvRenderModel TemplatePreviewModel() => new() + { + FullName = "Alex Morgan", + Headline = "Product & Platform Engineer", + Contact = + [ + new() { Icon = "email", Value = "alex@example.com", Href = "mailto:alex@example.com" }, + new() { Icon = "location", Value = "Oslo, Norway" }, + new() { Icon = "web", Value = "alexmorgan.dev", Href = "https://alexmorgan.dev" }, + ], + Sections = + [ + new() { Key = "summary", Title = "Profile", Kind = "bullets", Bullets = ["Engineer focused on clear products, resilient systems and measurable delivery."] }, + new() + { + Key = "experience", Title = "Experience", Kind = "entries", + Entries = + [ + new() { Title = "Senior Platform Engineer", Subtitle = "Northstar Labs", Meta = "2022 – Present", Bullets = ["Led platform improvements across product teams.", "Reduced release lead time through safer automation."] }, + new() { Title = "Software Engineer", Subtitle = "Studio Works", Meta = "2019 – 2022", Bullets = ["Built accessible customer workflows and APIs."] }, + ], + }, + new() { Key = "skills", Title = "Skills", Kind = "tags", Tags = ["C#", "React", "Cloud", "Design systems", "Delivery"] }, + new() { Key = "education", Title = "Education", Kind = "entries", Entries = [new() { Title = "BSc Computer Science", Subtitle = "University of Oslo", Meta = "2016 – 2019" }] }, + new() { Key = "languages", Title = "Languages", Kind = "tags", Tags = ["English", "Norwegian"] }, + ], + }; + private static CvRenderPerson Person(ApplicationUser user) { var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs index 005adda..98a1e93 100644 --- a/JobTrackerApi/Models/CvVariantSettings.cs +++ b/JobTrackerApi/Models/CvVariantSettings.cs @@ -20,6 +20,25 @@ public sealed class CvVariantSettings public string? Language { get; set; } public string? Headline { get; set; } // override the contact headline for this variant + // Normalized design overrides. Null keeps the selected theme's value, which means old variants + // retain their exact appearance while new editor controls can be added without a schema change. + public string? TextColor { get; set; } + public string? MutedColor { get; set; } + public string? HeadingColor { get; set; } + public string? BackgroundColor { get; set; } + public double? BaseFontSizePt { get; set; } + public double? HeadingSizePt { get; set; } + public double? LineHeight { get; set; } + public double? PageMarginMm { get; set; } + public double? SectionGapMm { get; set; } + public double? EntryGapMm { get; set; } + public string? HeadingStyle { get; set; } // caps-rule | underline | plain | bar + public string? HeaderStyle { get; set; } // plain | band | centered | kicker + public string? SkillsStyle { get; set; } // tags | text + public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band + public double? SidebarWidthMm { get; set; } + public List? SidebarSections { get; set; } + public bool ShowPhoto { get; set; } public bool ShowPageNumbers { get; set; } public bool ShowIcons { get; set; } = true; @@ -94,8 +113,36 @@ public static class CvVariantSettingsJson s ??= new CvVariantSettings(); s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant(); s.AccentColor = NormalizeColor(s.AccentColor); + s.TextColor = NormalizeColor(s.TextColor); + s.MutedColor = NormalizeColor(s.MutedColor); + s.HeadingColor = NormalizeColor(s.HeadingColor); + s.BackgroundColor = NormalizeColor(s.BackgroundColor); s.HeadingFont = NormalizeFont(s.HeadingFont); s.BodyFont = NormalizeFont(s.BodyFont); + s.BaseFontSizePt = Clamp(s.BaseFontSizePt, 7, 13); + s.HeadingSizePt = Clamp(s.HeadingSizePt, 9, 20); + s.LineHeight = Clamp(s.LineHeight, 1.1, 1.8); + s.PageMarginMm = Clamp(s.PageMarginMm, 8, 28); + s.SectionGapMm = Clamp(s.SectionGapMm, 2, 14); + s.EntryGapMm = Clamp(s.EntryGapMm, 1, 10); + s.SidebarWidthMm = Clamp(s.SidebarWidthMm, 45, 85); + s.HeadingStyle = NormalizeChoice(s.HeadingStyle, "caps-rule", "underline", "plain", "bar"); + s.HeaderStyle = NormalizeChoice(s.HeaderStyle, "plain", "band", "centered", "kicker"); + s.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text"); + s.Layout = NormalizeChoice(s.Layout, "single", "sidebar-left", "sidebar-right", "header-band"); + s.DateFormat = NormalizeChoice(s.DateFormat, "long", "short", "numeric", "year"); + if (!string.IsNullOrWhiteSpace(s.Language)) + { + var language = s.Language.Trim().ToLowerInvariant(); + s.Language = language[..Math.Min(12, language.Length)]; + } + else s.Language = null; + s.SidebarSections = s.SidebarSections? + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Select(key => key.Trim().ToLowerInvariant()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(20) + .ToList(); s.Sections ??= new(); s.Overrides ??= new(); s.CustomSections ??= new(); @@ -117,4 +164,15 @@ public static class CvVariantSettingsJson var candidate = value?.Trim(); return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null; } + + private static double? Clamp(double? value, double min, double max) => + value is null || double.IsNaN(value.Value) || double.IsInfinity(value.Value) + ? null + : Math.Clamp(value.Value, min, max); + + private static string? NormalizeChoice(string? value, params string[] choices) + { + var candidate = value?.Trim().ToLowerInvariant(); + return candidate is not null && choices.Contains(candidate, StringComparer.Ordinal) ? candidate : null; + } } diff --git a/JobTrackerApi/Models/StructuredCvProfile.cs b/JobTrackerApi/Models/StructuredCvProfile.cs index 6ed20f3..2c67c2a 100644 --- a/JobTrackerApi/Models/StructuredCvProfile.cs +++ b/JobTrackerApi/Models/StructuredCvProfile.cs @@ -49,6 +49,14 @@ public sealed class StructuredCvContact public string? Location { get; set; } public string? Website { get; set; } public string? LinkedIn { get; set; } + public string? GitHub { get; set; } + public List Links { get; set; } = new(); +} + +public sealed class StructuredCvLink +{ + public string? Label { get; set; } + public string? Url { get; set; } } public sealed class StructuredCvJob diff --git a/JobTrackerApi/Models/StructuredCvProfileJson.cs b/JobTrackerApi/Models/StructuredCvProfileJson.cs index ef7a425..9fa77db 100644 --- a/JobTrackerApi/Models/StructuredCvProfileJson.cs +++ b/JobTrackerApi/Models/StructuredCvProfileJson.cs @@ -94,6 +94,8 @@ public static class StructuredCvProfileJson profile.Contact.Location = TrimOrNull(profile.Contact.Location); profile.Contact.Website = TrimOrNull(profile.Contact.Website); profile.Contact.LinkedIn = TrimOrNull(profile.Contact.LinkedIn); + profile.Contact.GitHub = NormalizeExternalLink(profile.Contact.GitHub); + profile.Contact.Links = NormalizeLinks(profile.Contact.Links); profile.Summary = CleanList(profile.Summary); profile.Jobs = (profile.Jobs ?? new List()) @@ -213,6 +215,11 @@ public static class StructuredCvProfileJson primary.Contact.Location ??= secondary.Contact.Location; primary.Contact.Website ??= secondary.Contact.Website; primary.Contact.LinkedIn ??= secondary.Contact.LinkedIn; + primary.Contact.GitHub ??= secondary.Contact.GitHub; + primary.Contact.Links ??= new(); + foreach (var link in secondary.Contact.Links ?? new()) + if (!primary.Contact.Links.Any(existing => string.Equals(existing.Url, link.Url, StringComparison.OrdinalIgnoreCase))) + primary.Contact.Links.Add(link); primary.Summary = primary.Summary.Count == 0 ? secondary.Summary @@ -393,9 +400,20 @@ public static class StructuredCvProfileJson contact.Location = NormalizeLocationValue(contact.Location); contact.Website = NormalizeWebsite(contact.Website); contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn); + contact.GitHub = NormalizeExternalLink(contact.GitHub); + contact.Links = NormalizeLinks(contact.Links); return contact; } + private static List NormalizeLinks(List? links) => + (links ?? new List()) + .Select(link => new StructuredCvLink { Label = TrimOrNull(link?.Label), Url = NormalizeExternalLink(link?.Url) }) + .Where(link => link.Url is not null) + .GroupBy(link => link.Url, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .Take(12) + .ToList(); + private static StructuredCvJob NormalizeJob(StructuredCvJob? job) { job ??= new StructuredCvJob(); @@ -537,6 +555,16 @@ public static class StructuredCvProfileJson return $"https://www.linkedin.com{path}"; } + private static string? NormalizeExternalLink(string? value) + { + var trimmed = TrimOrNull(value); + if (trimmed is null) return null; + var candidate = trimmed.Contains("://", StringComparison.Ordinal) ? trimmed : $"https://{trimmed}"; + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null; + if (uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host) || !string.IsNullOrEmpty(uri.UserInfo)) return null; + return uri.AbsoluteUri.TrimEnd('/'); + } + private static string? NormalizeDateValue(string? value) { var trimmed = TrimOrNull(value); @@ -726,6 +754,8 @@ public static class StructuredCvProfileJson AddIf(contactLines, profile.Contact.Location); AddIf(contactLines, profile.Contact.Website); AddIf(contactLines, profile.Contact.LinkedIn); + AddIf(contactLines, profile.Contact.GitHub); + foreach (var link in profile.Contact.Links) AddIf(contactLines, link.Url); AddSectionIfAny(sections, "Contact", contactLines); AddSectionIfAny(sections, "Professional Summary", profile.Summary); diff --git a/JobTrackerApi/Services/AccountDataExportService.cs b/JobTrackerApi/Services/AccountDataExportService.cs index 6d2601a..fb86731 100644 --- a/JobTrackerApi/Services/AccountDataExportService.cs +++ b/JobTrackerApi/Services/AccountDataExportService.cs @@ -98,6 +98,7 @@ public sealed class AccountDataExportService( user.StripeLastEventCreatedUtc, user.AiEnabled, user.ExternalAiProcessingAllowed, + user.EmailFollowUpRemindersEnabled, Roles = roles, Claims = claims, ExternalLogins = logins, diff --git a/JobTrackerApi/Services/CvRenderModel.cs b/JobTrackerApi/Services/CvRenderModel.cs index a7dc6a1..d479ed5 100644 --- a/JobTrackerApi/Services/CvRenderModel.cs +++ b/JobTrackerApi/Services/CvRenderModel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using JobTrackerApi.Models; namespace JobTrackerApi.Services; @@ -64,10 +65,17 @@ public static class CvVariantResolver }; AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}"); - AddContact(model.Contact, "phone", profile.Contact.Phone, null); + AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}"); AddContact(model.Contact, "location", profile.Contact.Location, null); AddContact(model.Contact, "web", profile.Contact.Website, AsUrl); AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl); + AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl); + foreach (var link in profile.Contact.Links) + { + var url = Trim(link.Url); + if (url is null) continue; + model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) }); + } var built = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -84,6 +92,7 @@ public static class CvVariantResolver ["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations), ["references"] = BulletSection("references", "References", profile.References), }; + ApplyLanguageLabels(built, settings.Language); // OtherSections from the master profile become body sections keyed other:. for (var i = 0; i < profile.OtherSections.Count; i++) @@ -160,7 +169,7 @@ public static class CvVariantResolver Key = job.Id, Title = Trim(ov?.Title) ?? Trim(job.Title), Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location), - Meta = DateRange(job.Start, job.End, job.IsCurrent), + Meta = DateRange(job.StartDate ?? job.Start, job.EndDate ?? job.End, job.IsCurrent, settings), Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets), Tags = Clean(job.Skills), }); @@ -181,7 +190,7 @@ public static class CvVariantResolver Key = ed.Id, Title = Trim(ov?.Title) ?? title, Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location), - Meta = DateRange(ed.Start, ed.End, false), + Meta = DateRange(ed.StartDate ?? ed.Start, ed.EndDate ?? ed.End, false, settings), Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details), }); } @@ -200,7 +209,7 @@ public static class CvVariantResolver Key = pr.Id, Title = Trim(ov?.Title) ?? Trim(pr.Name), Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location), - Meta = DateRange(pr.Start, pr.End, false), + Meta = DateRange(pr.StartDate ?? pr.Start, pr.EndDate ?? pr.End, false, settings), Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets), Tags = Clean(pr.Skills), }); @@ -279,13 +288,45 @@ public static class CvVariantResolver return string.IsNullOrWhiteSpace(joined) ? null : joined; } - private static string? DateRange(string? start, string? end, bool isCurrent) + private static string? DateRange(string? start, string? end, bool isCurrent, CvVariantSettings settings) { - var s = Trim(start); - var e = Trim(end); + var s = FormatDate(Trim(start), settings); + var e = FormatDate(Trim(end), settings); if (s is null && e is null) return null; if (s is null) return e; - return $"{s} – {(isCurrent ? "Present" : e ?? "Present")}"; + var present = IsNorwegian(settings.Language) ? "nå" : "Present"; + return $"{s} – {(isCurrent ? present : e ?? present)}"; + } + + private static string? FormatDate(string? value, CvVariantSettings settings) + { + if (value is null || settings.DateFormat is null) return value; + if (!DateTime.TryParseExact(value, new[] { "yyyy-MM", "yyyy-M", "yyyy-MM-dd", "yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return value; + var culture = IsNorwegian(settings.Language) ? CultureInfo.GetCultureInfo("nb-NO") : CultureInfo.GetCultureInfo("en-GB"); + return settings.DateFormat switch + { + "year" => date.ToString("yyyy", culture), + "numeric" => date.ToString("MM/yyyy", culture), + "long" => date.ToString("MMMM yyyy", culture), + _ => date.ToString("MMM yyyy", culture), + }; + } + + private static bool IsNorwegian(string? language) => language?.StartsWith("no", StringComparison.OrdinalIgnoreCase) == true + || language?.StartsWith("nb", StringComparison.OrdinalIgnoreCase) == true + || language?.StartsWith("nn", StringComparison.OrdinalIgnoreCase) == true; + + private static void ApplyLanguageLabels(Dictionary sections, string? language) + { + if (!IsNorwegian(language)) return; + var labels = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["summary"] = "Profil", ["experience"] = "Arbeidserfaring", ["education"] = "Utdanning", + ["projects"] = "Prosjekter", ["skills"] = "Ferdigheter", ["certifications"] = "Sertifiseringer", + ["languages"] = "Språk", ["interests"] = "Interesser", ["awards"] = "Priser og utmerkelser", + ["publications"] = "Publikasjoner", ["organisations"] = "Organisasjoner", ["references"] = "Referanser", + }; + foreach (var (key, label) in labels) if (sections.TryGetValue(key, out var section)) section.Title = label; } private static List Clean(IEnumerable? items) => diff --git a/JobTrackerApi/Services/CvVariantService.cs b/JobTrackerApi/Services/CvVariantService.cs index 4f92267..4873c6b 100644 --- a/JobTrackerApi/Services/CvVariantService.cs +++ b/JobTrackerApi/Services/CvVariantService.cs @@ -4,7 +4,18 @@ using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; -public sealed record CvVariantSummary(int Id, string Name, string ThemeId, string PublicSlug, bool IsPublic, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc); +public sealed record CvVariantSummary( + int Id, + string Name, + string ThemeId, + string Language, + string PublicSlug, + bool IsPublic, + int Version, + int? JobApplicationId, + DateTimeOffset UpdatedAtUtc, + string? JobTitle = null, + string? CompanyName = null); public sealed record CvVariantVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent); // Person + photo needed to render, resolved from ApplicationUser by the controller so this service @@ -14,6 +25,7 @@ public sealed record CvRenderPerson(string FallbackName, string? PhotoDataUrl); public interface ICvVariantService { Task> ListAsync(string ownerUserId, CancellationToken ct); + Task CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task GetAsync(string ownerUserId, int id, CancellationToken ct); Task CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct); Task SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct); @@ -49,18 +61,26 @@ public sealed class CvVariantService : ICvVariantService public async Task> ListAsync(string ownerUserId, CancellationToken ct) { - var query = _db.CvVariants.Where(x => x.OwnerUserId == ownerUserId); + var query = _db.CvVariants + .Include(x => x.JobApplication) + .ThenInclude(x => x!.Company) + .Where(x => x.OwnerUserId == ownerUserId); var variants = _db.Database.IsSqlite() ? (await query.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList() : await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct); return variants.Select(Summarize).ToList(); } + public Task CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) => + _db.JobApplications.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct); + public Task GetAsync(string ownerUserId, int id, CancellationToken ct) => _db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct); public async Task CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct) { + if (jobApplicationId is not null && !await CanAssociateJobAsync(ownerUserId, jobApplicationId.Value, ct)) + throw new ArgumentException("The job application is unavailable.", nameof(jobApplicationId)); var now = DateTimeOffset.UtcNow; var normalized = CvVariantSettingsJson.Normalize(settings); var variant = new CvVariant @@ -110,7 +130,11 @@ public sealed class CvVariantService : ICvVariantService var source = await GetAsync(ownerUserId, id, ct); if (source is null) return null; var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson); - return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, source.JobApplicationId, settings, ct); + var associatedJobId = source.JobApplicationId is not null + && await CanAssociateJobAsync(ownerUserId, source.JobApplicationId.Value, ct) + ? source.JobApplicationId + : null; + return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, associatedJobId, settings, ct); } public async Task DeleteAsync(string ownerUserId, int id, CancellationToken ct) @@ -202,7 +226,18 @@ public sealed class CvVariantService : ICvVariantService private static CvVariantSummary Summarize(CvVariant v) { var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson); - return new CvVariantSummary(v.Id, v.Name, settings.ThemeId, v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc); + return new CvVariantSummary( + v.Id, + v.Name, + settings.ThemeId, + settings.Language ?? "en", + v.PublicSlug, + v.IsPublic, + v.Version, + v.JobApplicationId, + v.UpdatedAtUtc, + v.JobApplication?.JobTitle, + v.JobApplication?.Company?.Name); } private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim(); diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs index 08c087f..e5e29a1 100644 --- a/JobTrackerApi/Services/ThemedCvRenderer.cs +++ b/JobTrackerApi/Services/ThemedCvRenderer.cs @@ -23,30 +23,34 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer settings = CvVariantSettingsJson.Normalize(settings); var accent = Override(settings.AccentColor, theme.Accent); var headerInk = ContrastInk(accent); - var headingColor = theme.HeadingColor ?? accent; + var ink = Override(settings.TextColor, theme.Ink); + var muted = Override(settings.MutedColor, theme.Muted); + var paper = Override(settings.BackgroundColor, theme.Paper); + var headingColor = Override(settings.HeadingColor, theme.HeadingColor ?? accent); var headingFont = Override(settings.HeadingFont, theme.HeadingFont); var bodyFont = Override(settings.BodyFont, theme.BodyFont); var density = DensityScale(settings.Density); + var layout = Override(settings.Layout, theme.Layout); + var headingStyle = Override(settings.HeadingStyle, theme.HeadingStyle); + var headerStyle = Override(settings.HeaderStyle, theme.HeaderStyle); + var sidebarSections = settings.SidebarSections ?? theme.SidebarSections; var pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4"; var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm"); var showIcons = settings.ShowIcons && theme.DefaultIcons; - var twoColumn = theme.Layout is "sidebar-left" or "sidebar-right"; + var twoColumn = layout is "sidebar-left" or "sidebar-right"; var (sidebarHtml, mainHtml) = twoColumn - ? SplitColumns(model, theme, showIcons) - : (string.Empty, RenderSections(model.Sections, theme)); + ? SplitColumns(model, theme, sidebarSections, settings) + : (string.Empty, RenderSections(model.Sections, theme, settings)); - var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn); - var header = RenderHeader(model, theme, showIcons, twoColumn); - var body = theme.Layout switch + var css = BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, headingFont, bodyFont, headingStyle, density, pageDims, layout, twoColumn); + var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle); + var body = layout switch { - "sidebar-left" => $@"
{Sidebar(model, sidebarHtml, theme, showIcons)}
{mainHtml}
", - "sidebar-right" => $@"
{mainHtml}
{Sidebar(model, sidebarHtml, theme, showIcons)}
", + "sidebar-left" => $@"
{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
{mainHtml}
", + "sidebar-right" => $@"
{mainHtml}
{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
", _ => $@"{header}
{mainHtml}
", }; - // For two-column themes the header renders inside the sidebar; single/header-band render it on top. - var page = twoColumn ? body : body; - var html = $@" @@ -55,7 +59,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer -
{page}
+
{body}
"; @@ -63,39 +67,37 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer return new ThemedCvRenderResult(theme.Id, fileName, html); } - private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons) + private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection sidebarKeys, CvVariantSettings settings) { - var sidebarKeys = theme.SidebarSections; var sidebar = new StringBuilder(); var mainSections = new List(); foreach (var section in model.Sections) { - var baseKey = section.Key.Contains(':') ? section.Key : section.Key; - if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase)) - sidebar.Append(RenderSection(section, theme)); + if (sidebarKeys.Contains(section.Key, StringComparer.OrdinalIgnoreCase)) + sidebar.Append(RenderSection(section, theme, settings)); else mainSections.Add(section); } - return (sidebar.ToString(), RenderSections(mainSections, theme)); + return (sidebar.ToString(), RenderSections(mainSections, theme, settings)); } - private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons) + private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection sidebarSections, string headerStyle) { - var header = RenderHeader(model, theme, showIcons, twoColumn: true); - var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase) + var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle); + var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase) ? RenderContactBlock(model.Contact, showIcons, sidebar: true) : string.Empty; return $@""; } - private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn) + private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle) { var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape); - var kicker = theme.HeaderStyle == "kicker" ? @"
Curriculum Vitae
" : string.Empty; + var kicker = headerStyle == "kicker" ? @"
Curriculum Vitae
" : string.Empty; var name = $@"

{Enc(model.FullName)}

"; var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"
{Enc(model.Headline)}
"; var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false); - var headerClass = twoColumn ? "hero" : $"header header-{theme.HeaderStyle}"; + var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}"; return $@"
{photo}
{kicker}{name}{headline}{contact}
"; } @@ -118,19 +120,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer return $@"
{items}
"; } - private static string RenderSections(IEnumerable sections, CvTheme theme) + private static string RenderSections(IEnumerable sections, CvTheme theme, CvVariantSettings settings) { var sb = new StringBuilder(); - foreach (var section in sections) sb.Append(RenderSection(section, theme)); + foreach (var section in sections) sb.Append(RenderSection(section, theme, settings)); return sb.ToString(); } - private static string RenderSection(CvRenderSection section, CvTheme theme) + private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null) { if (section.IsEmpty) return string.Empty; var inner = section.Kind switch { "bullets" => $@"
    {Items(section.Bullets)}
", + "tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"

{string.Join(" · ", section.Tags.Select(Enc))}

", "tags" => $@"
    {string.Join("", section.Tags.Select(t => $@"
  • {Enc(t)}
  • "))}
", _ => string.Join("", section.Entries.Select(RenderEntry)), }; @@ -190,21 +193,25 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer return s; } - private static string BuildCss(CvTheme t, string accent, string headerInk, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn) + private static string BuildCss(CvTheme t, CvVariantSettings settings, string accent, string headerInk, string ink, string muted, string paper, string headingColor, string headingFont, string bodyFont, string headingStyle, double density, (string w, string h) page, string layout, bool twoColumn) { - var margin = F(t.PageMarginMm * density); - var sectionGap = F(t.SectionGapMm * density); - var entryGap = F(t.EntryGapMm * density); - var headingCss = t.HeadingStyle switch + var margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density); + var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density); + var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density); + var bodySize = settings.BaseFontSizePt ?? t.BodySizePt; + var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt; + var lineHeight = settings.LineHeight ?? t.LineHeight; + var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm; + var headingCss = headingStyle switch { "underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}", "plain" => ".section-title{letter-spacing:.01em;}", "bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}", - _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}", + _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(headingSize * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}", }; - var columnTemplate = t.Layout == "sidebar-right" - ? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm" - : $"{F(t.SidebarWidthMm)}mm minmax(0,1fr)"; + var columnTemplate = layout == "sidebar-right" + ? $"minmax(0,1fr) {F(sidebarWidth)}mm" + : $"{F(sidebarWidth)}mm minmax(0,1fr)"; var layoutCss = twoColumn ? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}} .sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}} @@ -225,12 +232,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer return $@" *{{box-sizing:border-box;}} html,body{{min-width:0;}} -body{{margin:0;background:#e9edf2;color:{t.Ink};font-family:{bodyFont};font-size:{F(t.BodySizePt)}pt;line-height:{F(t.LineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}} -.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}} +body{{margin:0;background:#e9edf2;color:{ink};font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}} +.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}} h1,h2{{font-family:{headingFont};}} -.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;overflow-wrap:anywhere;}} +.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{ink};line-height:1.1;overflow-wrap:anywhere;}} .kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}} -.headline{{margin-top:1.5mm;color:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}} +.headline{{margin-top:1.5mm;color:{muted};font-size:{F(bodySize + 0.5)}pt;}} .head-text,.main,.sidebar,.cols>*{{min-width:0;}} .head-text{{flex:1;}} .photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}} @@ -238,7 +245,7 @@ h1,h2{{font-family:{headingFont};}} .photo-rounded{{border-radius:5mm;}} .photo-circle{{border-radius:50%;}} .photo img{{width:100%;height:100%;object-fit:cover;display:block;}} -.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}} +.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{muted};font-size:{F(bodySize - 0.5)}pt;margin-top:2.5mm;}} .contact-stacked{{flex-direction:column;gap:1.8mm;}} .contact-item{{display:inline-flex;align-items:center;gap:1.2mm;min-width:0;max-width:100%;overflow-wrap:anywhere;}} .contact a{{color:inherit;text-decoration:none;min-width:0;overflow-wrap:anywhere;word-break:break-word;}} @@ -247,18 +254,19 @@ h1,h2{{font-family:{headingFont};}} .hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}} .section{{margin-top:{sectionGap}mm;}} .section:first-child{{margin-top:0;}} -.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(t.HeadingSizePt)}pt;font-weight:700;color:{headingColor};}} +.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(headingSize)}pt;font-weight:700;color:{headingColor};}} {headingCss} .bullets{{margin:0;padding-left:4.5mm;}} .bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}} .tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}} -.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(t.BodySizePt - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}} +.skills-text{{margin:0;overflow-wrap:anywhere;}} +.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}} .entry{{margin-bottom:{entryGap}mm;}} .entry:last-child{{margin-bottom:0;}} .entry-head{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;break-after:avoid-page;page-break-after:avoid;}} -.entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}} -.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}} -.entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}} +.entry-title{{font-weight:700;font-size:{F(bodySize + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}} +.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}} +.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}} .entry-tags{{margin-top:1.4mm;}} {layoutCss} /* Print quality: keep normal entries whole, but allow intentionally classified long entries and diff --git a/docs/architecture/cv-builder.md b/docs/architecture/cv-builder.md index 94f7716..501ac1a 100644 --- a/docs/architecture/cv-builder.md +++ b/docs/architecture/cv-builder.md @@ -29,7 +29,9 @@ The master profile is never written by the builder. A variant references career `SettingsJson` blob (`CvVariantSettings`, `JobTrackerApi/Models/CvVariantSettings.cs`) because it is edited and saved as a unit — never queried field-by-field. A variant stores: -- `ThemeId` + overrides: accent, heading/body font, density, page size, photo/icons/page-numbers. +- `ThemeId` + normalized overrides: printable colours, curated heading/body fonts, typography, + density, A4/Letter, margins/gaps, one/two-column layout, sidebar assignment, heading/header and + skills treatments, photo/icons and the retained page-number extension point. - `Sections`: ordered list with per-section `Hidden` + optional renamed `Title`. - `Overrides`: keyed by `ItemKey` → `{ Hidden, Title, Subtitle, Bullets }` (per-item, job-specific). - `CustomSections`: variant-only sections not in the master profile. @@ -46,7 +48,8 @@ as a unit — never queried field-by-field. A variant stores: ## API -Authenticated (`/api/cv`, `CvVariantController`): `GET themes`; `GET/POST variants`; +Authenticated (`/api/cv`, `CvVariantController`): `GET themes`; `GET themes/{id}/preview`; +`GET/POST variants`; `GET/PUT/DELETE variants/{id}`; `POST variants/{id}/duplicate`; `PUT variants/{id}/public`; `GET variants/{id}/versions`, `POST …/versions/{v}/restore`; `GET variants/{id}/preview` and `POST preview` (live preview of unsaved settings); `POST variants/{id}/export-pdf`; `POST ai/assist`. @@ -61,10 +64,16 @@ The endpoint applies the identical public/private slug check; unknown, revoked, 404 without invoking the exporter. Anonymous PDF generation is limited to three requests per minute per public link because it launches Chromium. `PublicCvPage` exposes it as a native **Download PDF** link. +CV/job associations are authorized server-side during creation. A caller cannot attach a CV to +another user's job merely by supplying its numeric ID. List cards resolve an associated job's title +and company only through the owner-filtered relationship. + ## Builder workflow (frontend) -`/career/builder` lists variants (`CvBuilderPage`); the editor (`CvBuilderEditor`) is three tabs — -**Content**, **Customize**, **AI Tools** (plus **History**) — beside an always-on live preview that +`/career/builder` lists variants (`CvBuilderPage`) with real rendered template thumbnails, language, +modified/version state and associated-job context. Creation asks only for a name and template. The +editor (`CvBuilderEditor`) separates **Content**, **Template**, **Design**, **Layout**, **AI** and +**History** beside an always-on desktop preview that re-renders through `POST /api/cv/preview` on a 300 ms debounce. Edits autosave on an 800 ms debounce (`source: autosave`), appending a version each save; the header shows Unsaved / Saving / Saved. `/cv/:slug` (`PublicCvPage`) renders a public CV in a sandboxed iframe. @@ -75,9 +84,12 @@ buttons (the keyboard-accessible path); entry order is stored per section as `It variant, never on the profile. Each entry exposes hide, title/subtitle override, and rich-text bullet editing (`RichTextField` — a markdown toolbar over a textarea; storage stays plain text, the server renderer converts the `**bold** *italic* __underline__ [text](url)` whitelist to safe HTML). +Contact details and career history remain master data; the Content tab explains this boundary and +links to the master editor while variant-specific wording, order, headings and visibility stay local. -**Preview** has zoom presets (±, slider, measured Fit), physical A4/Letter dimensions, a ceiling-based -page count with prev/next navigation and page-break indicators, and an "updating…" chip. Three-page +**Preview** displays separate labelled A4/Letter page sheets rather than one infinite document. It has +zoom controls, measured Fit, a ceiling-based page count with prev/next navigation, and an "updating…" +chip. Three-page and longer documents receive content-focus guidance rather than automatic font shrinking. Preview requests and autosaves are ordered so stale responses cannot replace newer edits; PDF/public actions save the current variant before consuming the stored render. **Customize** badges ATS-friendly themes. diff --git a/docs/architecture/cv-theme-engine.md b/docs/architecture/cv-theme-engine.md index 038c89b..d81c47e 100644 --- a/docs/architecture/cv-theme-engine.md +++ b/docs/architecture/cv-theme-engine.md @@ -24,15 +24,19 @@ which is retained only for the legacy tailored-draft flow and is not used by the | Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` | | Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons` | -The renderer computes CSS variables from these plus the variant's runtime overrides (accent, fonts, -density, page size, photo, icons) and picks one of the layout wrappers. `SidebarSections` decides which -section keys move to the sidebar for the two-column layouts. +The renderer computes CSS from these plus normalized variant overrides: printable palette, curated +fonts, body/heading size, line height, spacing, page size, layout, sidebar width/content, header and +heading treatments, skills presentation, photo and icons. `SidebarSections` decides which section +keys move to the sidebar for the two-column layouts. Changing a template or override never transforms +the structured career content. ## Adding a theme 1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`JobTrackerApi/Models/CvTheme.cs`). Only override the fields that differ from the defaults. -2. Nothing else. It appears in `GET /api/cv/themes`, the Customize tab picker, and renders. +2. Nothing else. It appears in `GET /api/cv/themes`, the Template tab and dashboard create flow. + `GET /api/cv/themes/{id}/preview` renders isolated in-memory sample data for a real visual thumbnail; + preview data is never persisted. Add a `CvBuilderTests.Every_catalog_theme_renders_valid_html` already loops the whole catalog, so a new theme is smoke-tested automatically. diff --git a/docs/verification/career-002-cv-builder.md b/docs/verification/career-002-cv-builder.md index 953bb26..0c22f9d 100644 --- a/docs/verification/career-002-cv-builder.md +++ b/docs/verification/career-002-cv-builder.md @@ -1,9 +1,29 @@ # CAREER-002 CV Builder redesign -Updated: 2026-08-15 +Updated: 2026-08-24 Status: `IMPLEMENTED — NOT VERIFIED`. Repository, automated and pathological Chromium/PDF gates are complete; authenticated application-browser and production gates remain. +## 2026-08-24 professional-builder increment + +- Split the editor into Content, Template, Design, Layout, AI and History workspaces under a compact + document toolbar with editable name, autosave/retry state, undo/redo, duplicate, public link and PDF. +- Added normalized print-safe colour, typography, spacing, A4/Letter, language/date, skills and + one/two-column controls. The same settings and renderer drive live preview, public output and PDF. +- Replaced the infinite preview with labelled measured page sheets, mobile Edit/Preview switching, + fit/zoom/page navigation and horizontal-overflow reporting. +- Added real rendered demo thumbnails for all eight templates. Demo content is constructed in memory + by a read-only endpoint and is never stored in a user's master profile or CV. +- Expanded structured contacts with GitHub and validated custom links; added Norwegian default labels, + `nå` and configurable date formats. +- Hardened CV/job association so a foreign tenant's numeric job ID is rejected by the service and API. + Dashboard cards now show the authorized associated role/company instead of only an internal ID. +- Added explicit Original/Suggested AI review with Accept/Reject/Edit and a measurable-impact action + that asks for placeholders rather than inventing metrics. +- Frontend gates after this increment: TypeScript pass, optimized production build pass, 61/61 Jest + suites and 246/246 tests. Backend/migration/PDF reruns are blocked locally by the missing .NET 9 SDK; + the new backend paths have focused tests ready for the normal SDK/CI gate. + ## Revalidated capability matrix | Requirement | Existing implementation | Current status | diff --git a/job-tracker-ui/e2e/smoke.spec.ts b/job-tracker-ui/e2e/smoke.spec.ts index a9168d8..cc37a2d 100644 --- a/job-tracker-ui/e2e/smoke.spec.ts +++ b/job-tracker-ui/e2e/smoke.spec.ts @@ -310,6 +310,8 @@ test("Career Workspace loads from the authenticated application shell", async ({ await page.getByRole("link", { name: "Open CV Builder" }).click(); await expect(page).toHaveURL(/\/career\/builder$/); await page.getByRole("button", { name: "New CV" }).first().click(); + await expect(page.getByRole("dialog", { name: "Create a CV" })).toBeVisible(); + await page.getByRole("button", { name: "Create CV" }).click(); await expect(page).toHaveURL(/\/career\/builder\/\d+$/); await expect(page.getByLabel("CV name")).toBeVisible(); @@ -319,6 +321,11 @@ test("Career Workspace loads from the authenticated application shell", async ({ for (const width of [375, 768, 1440]) { await page.setViewportSize({ width, height: 900 }); await expect(page.getByLabel("CV name")).toBeVisible(); + if (width === 375) { + await page.getByRole("button", { name: "Preview" }).click(); + await expect(page.getByTitle("CV preview page 1")).toBeVisible(); + await page.getByRole("button", { name: "Edit" }).click(); + } const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(overflow).toBeLessThanOrEqual(1); } diff --git a/job-tracker-ui/src/career-workspace-page.test.tsx b/job-tracker-ui/src/career-workspace-page.test.tsx index ca5128f..a3dac27 100644 --- a/job-tracker-ui/src/career-workspace-page.test.tsx +++ b/job-tracker-ui/src/career-workspace-page.test.tsx @@ -68,7 +68,7 @@ test("returning workspace shows missing profile information and recent general a expect(await screen.findByText("Backend CV")).toBeInTheDocument(); expect(screen.getAllByText("General CV").length).toBeGreaterThan(1); expect(screen.getByText("Job-specific")).toBeInTheDocument(); - expect(screen.getAllByRole("link", { name: "Open", exact: true })[0]).toHaveAttribute("href", "/career/builder/8"); + expect(screen.getAllByRole("link", { name: /^Open$/ })[0]).toHaveAttribute("href", "/career/builder/8"); }); test.each([ diff --git a/job-tracker-ui/src/components/CvTemplateThumbnail.tsx b/job-tracker-ui/src/components/CvTemplateThumbnail.tsx new file mode 100644 index 0000000..c0498f8 --- /dev/null +++ b/job-tracker-ui/src/components/CvTemplateThumbnail.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from "react"; +import { Box } from "@mui/material"; + +import { CvTheme, cvBuilderApi } from "../cvBuilder"; + +const previewCache = new Map(); + +export default function CvTemplateThumbnail({ theme, height = 150 }: { theme?: CvTheme; height?: number }) { + const [previewHtml, setPreviewHtml] = useState(() => theme ? previewCache.get(theme.id) ?? "" : ""); + const accent = theme?.swatches?.[0] || "#3157d5"; + const sidebar = theme?.swatches?.[1] || "#eef1f4"; + const paper = theme?.swatches?.[2] || "#ffffff"; + const layout = theme?.layout || "header-band"; + const hasSidebar = layout === "sidebar-left" || layout === "sidebar-right"; + const sidebarRight = layout === "sidebar-right"; + const lines = [92, 72, 84, 61, 88, 76, 95, 67]; + + useEffect(() => { + if (!theme) return; + const cached = previewCache.get(theme.id); + if (cached) { + setPreviewHtml(cached); + return; + } + setPreviewHtml(""); + let active = true; + void cvBuilderApi.themePreview(theme.id).then((render) => { + previewCache.set(theme.id, render.html); + if (active) setPreviewHtml(render.html); + }).catch(() => undefined); + return () => { active = false; }; + }, [theme]); + + if (previewHtml) { + const scale = height / (297 * 96 / 25.4); + return ( + + +