diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs
index 9c5baf2..cc13f0d 100644
--- a/JobTrackerApi.Tests/CareerProfileServiceTests.cs
+++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs
@@ -34,6 +34,43 @@ public sealed class CareerProfileServiceTests
Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id));
}
+ [Fact]
+ public async Task SaveVersionAsync_replaces_duplicate_item_ids_without_dropping_entries()
+ {
+ await using var db = NewContext("user-1");
+ var service = new CareerProfileService(db);
+ var profile = new StructuredCvProfile
+ {
+ Jobs = { new StructuredCvJob { Id = "shared", Title = "Engineer", Company = "Acme" } },
+ Education = { new StructuredCvEducation { Id = "shared", Qualification = "BSc", Institution = "University" } },
+ };
+
+ var saved = await service.SaveVersionAsync("user-1", profile, "manual", default);
+
+ Assert.Equal("shared", saved.Jobs[0].Id);
+ Assert.NotEqual(saved.Jobs[0].Id, saved.Education[0].Id);
+ Assert.Single(await db.CareerExperiences.IgnoreQueryFilters().ToListAsync());
+ Assert.Single(await db.CareerEducations.IgnoreQueryFilters().ToListAsync());
+ }
+
+ [Fact]
+ public async Task LoadStructuredAsync_hides_race_affected_duplicate_child_rows()
+ {
+ await using var db = NewContext("user-1");
+ var profile = new CareerProfile { OwnerUserId = "user-1", ProfileJson = "{}", LongTailJson = "{}", Version = 1 };
+ db.CareerProfiles.Add(profile);
+ await db.SaveChangesAsync();
+ db.CareerExperiences.AddRange(
+ new CareerExperience { CareerProfileId = profile.Id, OwnerUserId = "user-1", ItemKey = "duplicate", SortOrder = 0, Title = "Engineer" },
+ new CareerExperience { CareerProfileId = profile.Id, OwnerUserId = "user-1", ItemKey = "duplicate", SortOrder = 0, Title = "Engineer" });
+ await db.SaveChangesAsync();
+
+ var loaded = await new CareerProfileService(db).LoadStructuredAsync("user-1", default);
+
+ Assert.Single(loaded.Jobs);
+ Assert.Equal("duplicate", loaded.Jobs[0].Id);
+ }
+
[Fact]
public async Task SaveVersionAsync_preserves_existing_ids_across_saves()
{
diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs
index 0359a32..7cb5273 100644
--- a/JobTrackerApi.Tests/CvBuilderTests.cs
+++ b/JobTrackerApi.Tests/CvBuilderTests.cs
@@ -537,4 +537,15 @@ public sealed class CvBuilderTests
Assert.Contains("class=\"paragraphs\">
I confirm these details.
", html);
}
+ [Fact]
+ public void Settings_normalization_removes_duplicate_entry_order_keys()
+ {
+ var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings
+ {
+ Sections = { new CvSectionSetting { Key = "experience", ItemOrder = new() { "first", "first", "second", " " } } },
+ });
+
+ Assert.Equal(new[] { "first", "second" }, settings.Sections[0].ItemOrder);
+ }
+
}
diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs
index b2335ba..194a574 100644
--- a/JobTrackerApi/Models/CvVariantSettings.cs
+++ b/JobTrackerApi/Models/CvVariantSettings.cs
@@ -175,6 +175,12 @@ public static class CvVariantSettingsJson
{
section.Presentation = NormalizeChoice(section.Presentation, "rows", "grid", "compact", "bubble");
section.Columns = section.Columns is 1 or 2 ? section.Columns : null;
+ section.ItemOrder = section.ItemOrder?
+ .Where(key => !string.IsNullOrWhiteSpace(key))
+ .Select(key => key.Trim())
+ .Distinct(StringComparer.Ordinal)
+ .Take(500)
+ .ToList();
}
foreach (var section in s.CustomSections)
{
diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs
index 14726f0..a26fd53 100644
--- a/JobTrackerApi/Services/CareerProfileService.cs
+++ b/JobTrackerApi/Services/CareerProfileService.cs
@@ -53,6 +53,13 @@ public sealed class CareerProfileService : ICareerProfileService
AssignStableIds(profile);
NormalizeDates(profile);
+ // Publish the profile row, relational children, and history revision atomically. Without
+ // this boundary a concurrent outline/read can observe the new profile before its children,
+ // enter the legacy backfill path, and insert a second copy of every child row.
+ await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
+ ? await _db.Database.BeginTransactionAsync(cancellationToken)
+ : null;
+
var json = StructuredCvProfileJson.SerializePersisted(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
@@ -90,6 +97,7 @@ public sealed class CareerProfileService : ICareerProfileService
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
+ if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return profile;
}
@@ -115,12 +123,12 @@ public sealed class CareerProfileService : ICareerProfileService
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
}
- var experiences = await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var education = await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var skills = await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var projects = await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var certifications = await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var languages = await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
+ var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
}
@@ -130,12 +138,12 @@ public sealed class CareerProfileService : ICareerProfileService
var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return new StructuredCvProfile();
- var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
- var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
+ var experiences = UniqueChildren(await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var education = UniqueChildren(await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var skills = UniqueChildren(await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var projects = UniqueChildren(await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var certifications = UniqueChildren(await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
+ var languages = UniqueChildren(await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
// No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to
// the blob so a public CV still renders. Read-only, so we don't backfill here.
@@ -195,26 +203,45 @@ public sealed class CareerProfileService : ICareerProfileService
private static void AssignStableIds(StructuredCvProfile profile)
{
+ var used = new HashSet(StringComparer.Ordinal);
foreach (var job in profile.Jobs)
{
- if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
+ job.Id = UniqueItemId(job.Id, used);
}
foreach (var education in profile.Education)
{
- if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
+ education.Id = UniqueItemId(education.Id, used);
}
foreach (var certification in profile.Certifications)
{
- if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
+ certification.Id = UniqueItemId(certification.Id, used);
}
foreach (var project in profile.Projects)
{
- if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
+ project.Id = UniqueItemId(project.Id, used);
}
}
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
+ private static string UniqueItemId(string? candidate, HashSet used)
+ {
+ var normalized = candidate?.Trim();
+ if (!string.IsNullOrWhiteSpace(normalized) && used.Add(normalized)) return normalized;
+ string generated;
+ do generated = NewItemId(); while (!used.Add(generated));
+ return generated;
+ }
+
+ // Old race-affected databases can contain exact duplicate rows with one stable ItemKey. Keep
+ // reads deterministic and avoid duplicate CV output while preserving the stored rows for an
+ // explicit repair migration/backup workflow.
+ private static List UniqueChildren(List items) where T : CareerChildEntity =>
+ items.GroupBy(item => item.ItemKey, StringComparer.Ordinal)
+ .Select(group => group.OrderBy(item => item.SortOrder).ThenBy(item => item.Id).First())
+ .OrderBy(item => item.SortOrder).ThenBy(item => item.Id)
+ .ToList();
+
private static void NormalizeDates(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md
index 300efe7..33fb7af 100644
--- a/docs/work-programmes/master-progress.md
+++ b/docs/work-programmes/master-progress.md
@@ -63,6 +63,7 @@ Updated: 2026-08-29
- The previous Dashboard rendered its time-in-stage empty state using the unrelated “No tags yet” copy. Empty analytical panels are now suppressed instead of presenting mismatched messages.
- The Application Analysis match previously ignored both `JobApplication.DescriptionLanguage`/`TranslatedDescription` and the linked CV's language setting. This could under-score an English CV against a Norwegian advert even when import had already stored a usable English translation.
- The bundled portable Node runtime starts Next 16 successfully, but Turbopack repeatedly panicked while emitting `/page`; the webpack development path serves the same route correctly and is now the documented/scripted default.
+- Playwright exposed duplicate React keys in the long Code-template CV despite passing its assertions. Read-only inspection of the disposable SQLite database proved every relational Career Profile child had been inserted twice: a newly visible profile could be read and legacy-backfilled before its child replacement completed. Profile snapshot/children/history saves are now atomic, malformed duplicate item/order keys are normalized, and race-affected stored rows resolve once on reads without destructive cleanup.
### Verification
@@ -92,6 +93,7 @@ Updated: 2026-08-29
- Job creation and Gmail workflow localization verification: 4 focused suites, 11/11 passed; TypeScript passed. The add-job stepper, Gmail continuity management, review queue, usage progress labels, errors and status copy now switch between English and Bokmål without translating user/job/email content.
- Shared active-surface localization/accessibility verification: Profile 13/13 and settings/auth/landing 18/18 passed; TypeScript passed. Tag entry, Pro notices, disabled registration, profile load recovery, dashboard totals, language selector semantics, job-selection checkboxes and locale-aware correspondence dates were aligned with the global EN/NB system.
- Final localization regression gate: all 62 frontend suites and 267/267 tests passed after correcting the isolated Pro-notice provider fallback; the optimized Next production build and integrated TypeScript check passed; the full .NET 9 backend suite passed 713/713.
+- Career Profile atomicity/CV locale verification: focused backend 55/55 and frontend 10/10 passed; the targeted long Code-template Playwright/PDF flow passed 1/1 with no duplicate-key or out-of-range locale warning. Its fresh disposable database contained exactly 9 experiences, 1 education, 8 skills, 1 project, 1 certification and 2 languages, with zero duplicate experience ItemKeys.
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
- Full backend: 712/712 tests passed on .NET 9.
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
diff --git a/job-tracker-ui/src/cvBuilder.test.ts b/job-tracker-ui/src/cvBuilder.test.ts
index 0bc91cc..5cb20be 100644
--- a/job-tracker-ui/src/cvBuilder.test.ts
+++ b/job-tracker-ui/src/cvBuilder.test.ts
@@ -1,4 +1,4 @@
-import { getCvPageCount, getCvPageMetrics, moveItem, wrapSelection } from "./cvBuilder";
+import { getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
describe("moveItem", () => {
test("moves an item forward", () => {
@@ -51,3 +51,12 @@ describe("CV page measurement", () => {
expect(getCvPageCount(heightPx * 2 + 20, heightPx)).toBe(3);
});
});
+
+describe("CV language compatibility", () => {
+ test("maps legacy Norwegian values to the supported Bokmål locale", () => {
+ expect(normalizeCvLanguage("no")).toBe("nb-NO");
+ expect(normalizeCvLanguage("nb")).toBe("nb-NO");
+ expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO");
+ expect(normalizeCvLanguage("en")).toBe("en");
+ });
+});
diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts
index 6cc1d94..8930b40 100644
--- a/job-tracker-ui/src/cvBuilder.ts
+++ b/job-tracker-ui/src/cvBuilder.ts
@@ -176,6 +176,11 @@ export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): n
return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx));
}
+export function normalizeCvLanguage(language?: string | null): string {
+ const normalized = language?.trim().toLowerCase();
+ return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en";
+}
+
export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
return {
themeId,
diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx
index 0ca0e62..375e05e 100644
--- a/job-tracker-ui/src/views/CvBuilderEditor.tsx
+++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx
@@ -37,7 +37,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
import {
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
- cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
+ cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage,
} from "../cvBuilder";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
@@ -575,9 +575,11 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null
if (sectionSetting?.hidden) continue;
const title = sectionSetting?.title ?? section.title;
if (section.kind === "entries") {
- const orderedKeys = sectionSetting?.itemOrder?.length
- ? [...sectionSetting.itemOrder, ...section.entries.map((entry, index) => entry.key ?? `${index}`).filter((key) => !sectionSetting.itemOrder?.includes(key))]
- : section.entries.map((entry, index) => entry.key ?? `${index}`);
+ const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`)));
+ const storedOrder = Array.from(new Set(sectionSetting?.itemOrder ?? []));
+ const orderedKeys = storedOrder.length
+ ? [...storedOrder.filter((key) => entryKeys.includes(key)), ...entryKeys.filter((key) => !storedOrder.includes(key))]
+ : entryKeys;
const byKey = new Map(section.entries.map((entry, index) => [entry.key ?? `${index}`, entry]));
for (const key of orderedKeys) {
const entry = byKey.get(key);
@@ -659,11 +661,11 @@ function DocumentAiTab({ settings, outline, update }: {
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
const blocks = useMemo(() => documentAiBlocks(settings, outline), [settings, outline]);
- const [targetLanguage, setTargetLanguage] = useState(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en");
+ const [targetLanguage, setTargetLanguage] = useState(normalizeCvLanguage(settings.language));
const [busy, setBusy] = useState(null);
const [suggestion, setSuggestion] = useState(null);
- useEffect(() => setTargetLanguage(settings.language === "no" || settings.language === "nb" ? "nb-NO" : settings.language ?? "en"), [settings.language]);
+ useEffect(() => setTargetLanguage(normalizeCvLanguage(settings.language)), [settings.language]);
const run = async (action: string) => {
if (!blocks.length) return;
@@ -1047,10 +1049,11 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
const { t } = useI18n();
// Entry order: itemOrder if set, else master order; always covering every entry key.
const orderedKeys: string[] = useMemo(() => {
- const master = section.entries.map((e) => e.key ?? "").filter(Boolean);
+ const master = Array.from(new Set(section.entries.map((e) => e.key ?? "").filter(Boolean)));
if (!row.itemOrder?.length) return master;
- const set = new Set(row.itemOrder);
- return [...row.itemOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))];
+ const storedOrder = Array.from(new Set(row.itemOrder));
+ const set = new Set(storedOrder);
+ return [...storedOrder.filter((k) => master.includes(k)), ...master.filter((k) => !set.has(k))];
}, [row.itemOrder, section.entries]);
const entriesByKey = useMemo(() => {
@@ -1190,7 +1193,7 @@ function CustomizeTab({ mode, settings, update, themes }: {
{t("cvEditorPageSize")}
{t("cvEditorDensity")}
>}
- {t("cvEditorDocumentLanguage")}
+ {t("cvEditorDocumentLanguage")}
{t("cvEditorDateFormat")}
{supports("layout") && {t("cvEditorColumns")}}