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
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 17:53:56 +01:00
1 parent 910f28dcfe
commit 65f1a3db91
17 files changed
+232 -54

No files matched your search

+8
View File
@@ -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
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -10569,6 +10569,7 @@
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
"adoptsPanel": "남긴 바이오마커가 {panel} 패널을 이어받아요.",
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -10569,6 +10569,7 @@
"convertedOther": "{count} измерений, пересчитаны из {unit} в {targetUnit}",
"refused": "{unit} нельзя пересчитать в {targetUnit}.",
"aliasesAdded": "Эти имена будут вести к оставленному показателю:",
"adoptsPanel": "Оставленный показатель получает панель {panel}.",
"blocked": "Объединить нельзя, пока единицу не удаётся пересчитать. Исключите этот показатель или оставьте другой.",
"noUndo": "Объединение нельзя отменить.",
"doneToastOne": "Объединено в {name}, перенесено измерений: {count}",
@@ -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,
+27 -12
View File
@@ -204,18 +204,7 @@ export function BiomarkerMergeSheet({
</li>
))}
</ul>
{preview.aliasesAdded.length > 0 ? (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("labs.merge.aliasesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{preview.aliasesAdded.map((alias) => (
<TagChip key={alias}>{alias}</TagChip>
))}
</div>
</div>
) : null}
<MergePreviewNotes preview={preview} />
{(refused?.length ?? 0) > 0 ? (
<p role="alert" className="text-destructive text-sm">
{t("labs.merge.blocked")}
@@ -283,3 +272,29 @@ export function BiomarkerMergeSheet({
</ResponsiveSheet>
);
}
/** 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 ? (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("labs.merge.aliasesAdded")}
</p>
<div className="flex flex-wrap gap-1.5">
{preview.aliasesAdded.map((alias) => (
<TagChip key={alias}>{alias}</TagChip>
))}
</div>
</div>
) : null}
{preview.adoptedPanel ? (
<p className="text-sm" data-slot="biomarker-merge-adopts-panel">
{t("labs.merge.adoptsPanel", { panel: preview.adoptedPanel })}
</p>
) : null}
</>
);
}
+2
View File
@@ -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;
}
+81 -39
View File
@@ -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<string, string>();
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<string, string>;
}> {
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<string, string>();
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
+6
View File
@@ -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({
+95
View File
@@ -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");