From 65f1a3db919e5206555d04e256e544c808738dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 6 Nov 2026 17:53:56 +0100 Subject: [PATCH] feat(labs): keep a marker's panel when it is merged into one without A merge into a target without a panel now takes over the first panel a source carries, in the order the sources were given, so the merged marker keeps its category. The result reports it as adoptedPanel, on a dry run as well, and the merge preview says so before the person confirms. A target with its own panel keeps it. The ledger does not record panels; a merge has no undo. The integration suite also proves the clear path end to end: an explicit null on PUT clears panel, context and both bounds. Refs #1349 --- docs/api/openapi.yaml | 8 ++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + messages/ru.json | 1 + .../baselines/source-function-lines.json | 6 +- src/components/labs/biomarker-merge-sheet.tsx | 39 ++++-- src/components/labs/types.ts | 2 + src/lib/labs/biomarker-merge.ts | 120 ++++++++++++------ src/lib/openapi/routes/biomarkers.ts | 6 + tests/integration/biomarker-merge.test.ts | 95 ++++++++++++++ 17 files changed, 232 insertions(+), 54 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 615a13b0b..ec63a5ca0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -78354,6 +78354,13 @@ components: items: type: string description: Names that become aliases of the target. + adoptedPanel: + description: "The panel the target takes over: when the target has none, the first panel a source carries, in the order + the sources were given. `null` when the target keeps its own panel or no source has one. Reported on a dry + run too, while `target.panel` there is still the stored one." + type: + - string + - "null" mergeId: description: "`null` on a dry run." type: @@ -78366,6 +78373,7 @@ components: - movedReadings - convertedReadings - aliasesAdded + - adoptedPanel - mergeId additionalProperties: false description: What a merge did, or would do on a dry run. Readings in another unit are converted where the analyte's diff --git a/messages/de.json b/messages/de.json index ce094622f..c958b77cb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}", "refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.", "aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:", + "adoptsPanel": "Der behaltene Biomarker übernimmt das Panel {panel}.", "blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.", "noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.", "doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben", diff --git a/messages/en.json b/messages/en.json index 4691baf80..9e061c059 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} readings, converted from {unit} to {targetUnit}", "refused": "{unit} cannot be converted to {targetUnit}.", "aliasesAdded": "These names will lead to the kept biomarker:", + "adoptsPanel": "The kept biomarker takes over the panel {panel}.", "blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.", "noUndo": "A merge cannot be undone.", "doneToastOne": "Merged into {name}, {count} reading moved", diff --git a/messages/es.json b/messages/es.json index 3df9d94d7..b8abc50ff 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}", "refused": "{unit} no se puede convertir a {targetUnit}.", "aliasesAdded": "Estos nombres llevarán al biomarcador conservado:", + "adoptsPanel": "El biomarcador conservado adopta el panel {panel}.", "blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.", "noUndo": "Una combinación no se puede deshacer.", "doneToastOne": "Combinado en {name}, {count} medición movida", diff --git a/messages/fr.json b/messages/fr.json index a61b5944f..8a5e789cc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} mesures, converties de {unit} en {targetUnit}", "refused": "{unit} ne peut pas être converti en {targetUnit}.", "aliasesAdded": "Ces noms mèneront au biomarqueur conservé :", + "adoptsPanel": "Le biomarqueur conservé reprend le panel {panel}.", "blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laissez ce biomarqueur de côté ou conservez-en un autre.", "noUndo": "Une fusion ne peut pas être annulée.", "doneToastOne": "Fusionné dans {name}, {count} mesure déplacée", diff --git a/messages/it.json b/messages/it.json index b1c7e66af..545294812 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}", "refused": "{unit} non può essere convertito in {targetUnit}.", "aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:", + "adoptsPanel": "Il biomarcatore mantenuto assume il panel {panel}.", "blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.", "noUndo": "Un'unione non può essere annullata.", "doneToastOne": "Unito in {name}, {count} misurazione spostata", diff --git a/messages/ko.json b/messages/ko.json index 3834a66ac..4ae094853 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10569,6 +10569,7 @@ "convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산", "refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.", "aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:", + "adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.", "blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.", "noUndo": "합친 뒤에는 되돌릴 수 없어요.", "doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", diff --git a/messages/nl.json b/messages/nl.json index c0c3eeda5..9df3a49be 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} metingen, omgerekend van {unit} naar {targetUnit}", "refused": "{unit} kan niet worden omgerekend naar {targetUnit}.", "aliasesAdded": "Deze namen verwijzen voortaan naar de behouden biomarker:", + "adoptsPanel": "De behouden biomarker neemt de groep {panel} over.", "blocked": "Er kan niets worden samengevoegd zolang een eenheid niet kan worden omgerekend. Laat die biomarker weg of behoud een andere.", "noUndo": "Samenvoegen kan niet ongedaan worden gemaakt.", "doneToastOne": "Samengevoegd in {name}, {count} meting verplaatst", diff --git a/messages/pl.json b/messages/pl.json index 5d10dd0f0..7caba6896 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}", "refused": "Nie można przeliczyć {unit} na {targetUnit}.", "aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:", + "adoptsPanel": "Zachowany biomarker przejmuje panel {panel}.", "blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.", "noUndo": "Scalenia nie można cofnąć.", "doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index ddd915acd..903c659d5 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} leituras, convertidas de {unit} para {targetUnit}", "refused": "{unit} não pode ser convertida para {targetUnit}.", "aliasesAdded": "Estes nomes levarão ao biomarcador mantido:", + "adoptsPanel": "O biomarcador mantido assume o painel {panel}.", "blocked": "Nada pode ser mesclado enquanto uma unidade não puder ser convertida. Deixe esse biomarcador fora ou mantenha outro.", "noUndo": "Uma mesclagem não pode ser desfeita.", "doneToastOne": "Mesclado em {name}, {count} leitura movida", diff --git a/messages/ru.json b/messages/ru.json index 7be6be626..cd0a0418c 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10569,6 +10569,7 @@ "convertedOther": "{count} измерений, пересчитаны из {unit} в {targetUnit}", "refused": "{unit} нельзя пересчитать в {targetUnit}.", "aliasesAdded": "Эти имена будут вести к оставленному показателю:", + "adoptsPanel": "Оставленный показатель получает панель {panel}.", "blocked": "Объединить нельзя, пока единицу не удаётся пересчитать. Исключите этот показатель или оставьте другой.", "noUndo": "Объединение нельзя отменить.", "doneToastOne": "Объединено в {name}, перенесено измерений: {count}", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 4fa80a682..1e4ab45ca 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 344, - "total": 111553, + "total": 111520, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -132,7 +132,7 @@ "components/insights/trends-row.tsx :: TrendsRow": 215, "components/labs/biomarker-form.tsx :: BiomarkerForm": 229, "components/labs/biomarker-manager.tsx :: BiomarkerManager": 242, - "components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 250, + "components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239, "components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370, "components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481, "components/labs/lab-form.tsx :: LabForm": 473, @@ -314,7 +314,7 @@ "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 592, "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 587, "lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324, - "lib/labs/biomarker-merge.ts :: runMerge": 254, + "lib/labs/biomarker-merge.ts :: runMerge": 232, "lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278, "lib/measurements/consolidate-legacy-steps.ts :: consolidateLegacySteps": 263, "lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention": 539, diff --git a/src/components/labs/biomarker-merge-sheet.tsx b/src/components/labs/biomarker-merge-sheet.tsx index 721af3157..6073c4d91 100644 --- a/src/components/labs/biomarker-merge-sheet.tsx +++ b/src/components/labs/biomarker-merge-sheet.tsx @@ -204,18 +204,7 @@ export function BiomarkerMergeSheet({ ))} - {preview.aliasesAdded.length > 0 ? ( -
-

- {t("labs.merge.aliasesAdded")} -

-
- {preview.aliasesAdded.map((alias) => ( - {alias} - ))} -
-
- ) : null} + {(refused?.length ?? 0) > 0 ? (

{t("labs.merge.blocked")} @@ -283,3 +272,29 @@ export function BiomarkerMergeSheet({ ); } + +/** What the kept marker gains besides the readings: spellings and a panel. */ +function MergePreviewNotes({ preview }: { preview: BiomarkerMergeResultDto }) { + const { t } = useTranslations(); + return ( + <> + {preview.aliasesAdded.length > 0 ? ( +

+

+ {t("labs.merge.aliasesAdded")} +

+
+ {preview.aliasesAdded.map((alias) => ( + {alias} + ))} +
+
+ ) : null} + {preview.adoptedPanel ? ( +

+ {t("labs.merge.adoptsPanel", { panel: preview.adoptedPanel })} +

+ ) : null} + + ); +} diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 9ff962598..b8971843f 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -183,5 +183,7 @@ export interface BiomarkerMergeResultDto { movedReadings: number; convertedReadings: number; aliasesAdded: string[]; + /** The panel the target takes over from a source, or null. */ + adoptedPanel: string | null; mergeId: string | null; } diff --git a/src/lib/labs/biomarker-merge.ts b/src/lib/labs/biomarker-merge.ts index ac8c545f8..b1a93165a 100644 --- a/src/lib/labs/biomarker-merge.ts +++ b/src/lib/labs/biomarker-merge.ts @@ -22,6 +22,8 @@ * - one `biomarker_merges` row per source, and the rows that already pointed * at a source now point at the target, so an id merged twice still finds * where it went; + * - a target without a panel takes the first panel a source carries, in + * the order the sources were given (the dry run reports it too); * - the sources are deleted. * * There is no undo. The ledger keeps what a later split would need to know @@ -62,6 +64,8 @@ export interface BiomarkerMergeResult { movedReadings: number; convertedReadings: number; aliasesAdded: string[]; + /** The panel the target takes over from a source, or null (dry run too). */ + adoptedPanel: string | null; mergeId: string | null; } @@ -230,47 +234,18 @@ async function runMerge( } const convertedReadings = convertedLive; - // The spellings the target gains: each source's name and its aliases, once - // each, except what the target's name already is or another marker (not a - // source) already answers to. - const sourceAliases = await tx.biomarkerAlias.findMany({ - where: { userId, biomarkerId: { in: [...sourceIds] } }, - select: { id: true, name: true, nameFolded: true }, + const { sourceAliases, added } = await planMergeAliases(tx, { + userId, + target, + sources, }); - const otherAliases = await tx.biomarkerAlias.findMany({ - where: { userId, biomarkerId: { notIn: [...sourceIds] } }, - select: { biomarkerId: true, nameFolded: true }, - }); - const blocked = new Set( - otherAliases - .filter((a) => a.biomarkerId !== targetId) - .map((a) => a.nameFolded), - ); - const targetAliasFolds = new Set( - otherAliases - .filter((a) => a.biomarkerId === targetId) - .map((a) => a.nameFolded), - ); - const otherMarkerFolds = new Set( - ( - await tx.biomarker.findMany({ - where: { userId, id: { notIn: [targetId, ...sourceIds] } }, - select: { name: true }, - }) - ).map((m) => foldForMatch(m.name)), - ); - const targetFold = foldForMatch(target.name); - const added = new Map(); - const consider = (name: string) => { - const folded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH); - if (!folded || folded === targetFold) return; - if (targetAliasFolds.has(folded) || added.has(folded)) return; - if (blocked.has(folded) || otherMarkerFolds.has(folded)) return; - added.set(folded, name.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH)); - }; - for (const source of sources) consider(source.name); - for (const alias of sourceAliases) consider(alias.name); const aliasesAdded = [...added.values()]; + // A target without a panel takes the first one a source carries, in the + // order the sources were given, so the merged marker keeps its category. + const adoptedPanel = + target.panel === null + ? (sources.find((source) => source.panel !== null)?.panel ?? null) + : null; const refused = summaries.filter((s) => s.conversion === "refused"); if (!dryRun && refused.length > 0) { @@ -346,6 +321,12 @@ async function runMerge( }); mergeId ??= row.id; } + if (adoptedPanel !== null) { + await tx.biomarker.update({ + where: { id: targetId }, + data: { panel: adoptedPanel }, + }); + } await tx.biomarker.deleteMany({ where: { userId, id: { in: [...sourceIds] } }, }); @@ -365,11 +346,72 @@ async function runMerge( movedReadings, convertedReadings, aliasesAdded, + adoptedPanel, mergeId, }, }; } +/** + * The spellings the target gains: each source's name and its aliases, once + * each, except what the target's name already is or another marker (not a + * source) already answers to. + */ +async function planMergeAliases( + tx: Prisma.TransactionClient, + input: { + userId: string; + target: { id: string; name: string }; + sources: ReadonlyArray<{ id: string; name: string }>; + }, +): Promise<{ + sourceAliases: Array<{ id: string; name: string; nameFolded: string }>; + added: Map; +}> { + const { userId, target, sources } = input; + const targetId = target.id; + const sourceIds = sources.map((source) => source.id); + const sourceAliases = await tx.biomarkerAlias.findMany({ + where: { userId, biomarkerId: { in: [...sourceIds] } }, + select: { id: true, name: true, nameFolded: true }, + }); + const otherAliases = await tx.biomarkerAlias.findMany({ + where: { userId, biomarkerId: { notIn: [...sourceIds] } }, + select: { biomarkerId: true, nameFolded: true }, + }); + const blocked = new Set( + otherAliases + .filter((a) => a.biomarkerId !== targetId) + .map((a) => a.nameFolded), + ); + const targetAliasFolds = new Set( + otherAliases + .filter((a) => a.biomarkerId === targetId) + .map((a) => a.nameFolded), + ); + const otherMarkerFolds = new Set( + ( + await tx.biomarker.findMany({ + where: { userId, id: { notIn: [targetId, ...sourceIds] } }, + select: { name: true }, + }) + ).map((m) => foldForMatch(m.name)), + ); + const targetFold = foldForMatch(target.name); + const added = new Map(); + const consider = (name: string) => { + const folded = foldForMatch(name).slice(0, BIOMARKER_ALIAS_MAX_LENGTH); + if (!folded || folded === targetFold) return; + if (targetAliasFolds.has(folded) || added.has(folded)) return; + if (blocked.has(folded) || otherMarkerFolds.has(folded)) return; + added.set(folded, name.trim().slice(0, BIOMARKER_ALIAS_MAX_LENGTH)); + }; + for (const source of sources) consider(source.name); + for (const alias of sourceAliases) consider(alias.name); + + return { sourceAliases, added }; +} + /** * Where a merged marker went: the target of the newest merge that removed * `id`, or null. Chains resolve because a merge re-points the rows that named diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index e825a3395..5c01d4acf 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -229,6 +229,12 @@ const biomarkerMergeResult = z aliasesAdded: z .array(z.string()) .describe("Names that become aliases of the target."), + adoptedPanel: z + .string() + .nullable() + .describe( + "The panel the target takes over: when the target has none, the first panel a source carries, in the order the sources were given. `null` when the target keeps its own panel or no source has one. Reported on a dry run too, while `target.panel` there is still the stored one.", + ), mergeId: z.string().nullable().describe("`null` on a dry run."), }) .meta({ diff --git a/tests/integration/biomarker-merge.test.ts b/tests/integration/biomarker-merge.test.ts index b3fdad383..2756090a8 100644 --- a/tests/integration/biomarker-merge.test.ts +++ b/tests/integration/biomarker-merge.test.ts @@ -448,6 +448,101 @@ describe("POST /api/biomarkers/{id}/merge", () => { }); }); +describe("the merged marker's panel", () => { + it("takes the first source panel when the target has none, and says so on the dry run", async () => { + const prisma = getPrismaClient(); + const target = await seedMarker("ALP", "U/L", 2); + const first = await seedMarker("Alk.P", "U/L", 2); + const second = await seedMarker("Alkaline Phosphatase", "U/L", 2); + await prisma.biomarker.update({ + where: { id: first.id }, + data: { panel: "Leber" }, + }); + await prisma.biomarker.update({ + where: { id: second.id }, + data: { panel: "Liver" }, + }); + + const preview = await merge(target.id, { + sourceIds: [first.id, second.id], + dryRun: true, + }); + expect(preview.status).toBe(200); + const dry = (await preview.json()).data; + expect(dry.adoptedPanel).toBe("Leber"); + expect(dry.target.panel).toBeNull(); + expect( + (await prisma.biomarker.findUnique({ where: { id: target.id } }))?.panel, + ).toBeNull(); + + const done = ( + await ( + await merge(target.id, { sourceIds: [first.id, second.id] }) + ).json() + ).data; + expect(done.adoptedPanel).toBe("Leber"); + expect(done.target.panel).toBe("Leber"); + expect( + (await prisma.biomarker.findUnique({ where: { id: target.id } }))?.panel, + ).toBe("Leber"); + }); + + it("keeps the target's own panel", async () => { + const prisma = getPrismaClient(); + const target = await seedMarker("ALP", "U/L", 1); + const source = await seedMarker("Alk.P", "U/L", 1); + await prisma.biomarker.update({ + where: { id: target.id }, + data: { panel: "Mine" }, + }); + await prisma.biomarker.update({ + where: { id: source.id }, + data: { panel: "Theirs" }, + }); + const done = ( + await (await merge(target.id, { sourceIds: [source.id] })).json() + ).data; + expect(done.adoptedPanel).toBeNull(); + expect(done.target.panel).toBe("Mine"); + }); +}); + +describe("clearing a marker's fields", () => { + it("clears panel, context and both bounds on an explicit null", async () => { + const prisma = getPrismaClient(); + const marker = await seedMarker("Ferritin", "ng/mL", 0); + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + const put = (body: unknown) => + PUT(json(body, `http://localhost/api/biomarkers/${marker.id}`, "PUT"), { + params: Promise.resolve({ id: marker.id }), + }); + expect( + ( + await put({ + panel: "Iron", + context: "fasting", + lowerBound: 30, + upperBound: 400, + }) + ).status, + ).toBe(200); + const res = await put({ + panel: null, + context: null, + lowerBound: null, + upperBound: null, + }); + expect(res.status).toBe(200); + const row = await prisma.biomarker.findUniqueOrThrow({ + where: { id: marker.id }, + }); + expect(row.panel).toBeNull(); + expect(row.contextEncrypted).toBeNull(); + expect(row.lowerBound).toBeNull(); + expect(row.upperBound).toBeNull(); + }); +}); + describe("aliases on the marker", () => { async function put(id: string, body: unknown) { const { PUT } = await import("@/app/api/biomarkers/[id]/route");