diff --git a/messages/de.json b/messages/de.json
index 319ae5a90..220f3240b 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Zeitraum: {period}"
}
+ },
+ "related": {
+ "title": "Verwandt",
+ "kind": {
+ "labResult": "Laborwert",
+ "encounter": "Besuch",
+ "document": "Dokument",
+ "conditionEpisode": "Erkrankung",
+ "medication": "Medikament",
+ "vaccination": "Impfung"
+ },
+ "via": {
+ "encounter": "über einen Besuch",
+ "efficacyTarget": "über das Wirkziel"
+ },
+ "pick": {
+ "conditionEpisode": "Erkrankungen",
+ "document": "Dokumente",
+ "encounter": "Besuche",
+ "medication": "Medikamente",
+ "vaccination": "Impfungen"
+ },
+ "nothingToOffer": "Noch nichts zum Verknüpfen.",
+ "saveError": "Die Verknüpfung konnte nicht gespeichert werden."
}
},
"bodySites": {
diff --git a/messages/en.json b/messages/en.json
index a53ab20cd..af02d7890 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Showing {period}"
}
+ },
+ "related": {
+ "title": "Related",
+ "kind": {
+ "labResult": "Lab result",
+ "encounter": "Visit",
+ "document": "Document",
+ "conditionEpisode": "Condition",
+ "medication": "Medication",
+ "vaccination": "Vaccination"
+ },
+ "via": {
+ "encounter": "through a visit",
+ "efficacyTarget": "through the efficacy target"
+ },
+ "pick": {
+ "conditionEpisode": "Conditions",
+ "document": "Documents",
+ "encounter": "Visits",
+ "medication": "Medications",
+ "vaccination": "Vaccinations"
+ },
+ "nothingToOffer": "Nothing to link yet.",
+ "saveError": "The link could not be saved."
}
},
"bodySites": {
diff --git a/messages/es.json b/messages/es.json
index bc30b1b5c..1e5ea3cd1 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Mostrando {period}"
}
+ },
+ "related": {
+ "title": "Relacionado",
+ "kind": {
+ "labResult": "Resultado de laboratorio",
+ "encounter": "Consulta",
+ "document": "Documento",
+ "conditionEpisode": "Enfermedad",
+ "medication": "Medicamento",
+ "vaccination": "Vacuna"
+ },
+ "via": {
+ "encounter": "a través de una consulta",
+ "efficacyTarget": "a través del objetivo de eficacia"
+ },
+ "pick": {
+ "conditionEpisode": "Enfermedades",
+ "document": "Documentos",
+ "encounter": "Consultas",
+ "medication": "Medicamentos",
+ "vaccination": "Vacunas"
+ },
+ "nothingToOffer": "Todavía no hay nada que vincular.",
+ "saveError": "No se pudo guardar el vínculo."
}
},
"bodySites": {
diff --git a/messages/fr.json b/messages/fr.json
index e624f5a3b..89798f425 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Période : {period}"
}
+ },
+ "related": {
+ "title": "Liés",
+ "kind": {
+ "labResult": "Résultat d’analyse",
+ "encounter": "Consultation",
+ "document": "Document",
+ "conditionEpisode": "Maladie",
+ "medication": "Médicament",
+ "vaccination": "Vaccination"
+ },
+ "via": {
+ "encounter": "via une consultation",
+ "efficacyTarget": "via la cible d’efficacité"
+ },
+ "pick": {
+ "conditionEpisode": "Maladies",
+ "document": "Documents",
+ "encounter": "Consultations",
+ "medication": "Médicaments",
+ "vaccination": "Vaccinations"
+ },
+ "nothingToOffer": "Rien à lier pour l’instant.",
+ "saveError": "Le lien n’a pas pu être enregistré."
}
},
"bodySites": {
diff --git a/messages/it.json b/messages/it.json
index eee5718db..64ae2ac4c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Periodo: {period}"
}
+ },
+ "related": {
+ "title": "Correlati",
+ "kind": {
+ "labResult": "Valore di laboratorio",
+ "encounter": "Visita",
+ "document": "Documento",
+ "conditionEpisode": "Malattia",
+ "medication": "Farmaco",
+ "vaccination": "Vaccinazione"
+ },
+ "via": {
+ "encounter": "tramite una visita",
+ "efficacyTarget": "tramite l’obiettivo di efficacia"
+ },
+ "pick": {
+ "conditionEpisode": "Malattie",
+ "document": "Documenti",
+ "encounter": "Visite",
+ "medication": "Farmaci",
+ "vaccination": "Vaccinazioni"
+ },
+ "nothingToOffer": "Ancora niente da collegare.",
+ "saveError": "Non è stato possibile salvare il collegamento."
}
},
"bodySites": {
diff --git a/messages/ko.json b/messages/ko.json
index e78bac6cf..a1dc11c14 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -12342,6 +12342,30 @@
"period": {
"showing": "{period} 표시 중"
}
+ },
+ "related": {
+ "title": "관련 항목",
+ "kind": {
+ "labResult": "검사 결과",
+ "encounter": "진료",
+ "document": "문서",
+ "conditionEpisode": "질환",
+ "medication": "약",
+ "vaccination": "예방접종"
+ },
+ "via": {
+ "encounter": "진료를 통해",
+ "efficacyTarget": "효과 목표를 통해"
+ },
+ "pick": {
+ "conditionEpisode": "질환",
+ "document": "문서",
+ "encounter": "진료",
+ "medication": "약",
+ "vaccination": "예방접종"
+ },
+ "nothingToOffer": "아직 연결할 항목이 없습니다.",
+ "saveError": "연결을 저장하지 못했습니다."
}
},
"bodySites": {
diff --git a/messages/pl.json b/messages/pl.json
index 1a9c56897..93796098e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -12342,6 +12342,30 @@
"period": {
"showing": "Okres: {period}"
}
+ },
+ "related": {
+ "title": "Powiązane",
+ "kind": {
+ "labResult": "Wynik badania",
+ "encounter": "Wizyta",
+ "document": "Dokument",
+ "conditionEpisode": "Choroba",
+ "medication": "Lek",
+ "vaccination": "Szczepienie"
+ },
+ "via": {
+ "encounter": "przez wizytę",
+ "efficacyTarget": "przez cel skuteczności"
+ },
+ "pick": {
+ "conditionEpisode": "Choroby",
+ "document": "Dokumenty",
+ "encounter": "Wizyty",
+ "medication": "Leki",
+ "vaccination": "Szczepienia"
+ },
+ "nothingToOffer": "Na razie nie ma czego powiązać.",
+ "saveError": "Nie udało się zapisać powiązania."
}
},
"bodySites": {
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index a6284a586..d1f96bf77 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -256,6 +256,20 @@ const REGISTRY: DestructiveEntry[] = [
},
],
},
+ {
+ file: "components/links/related-records.tsx",
+ destroys:
+ "a relation link between two of the person's own records (a medication and its condition, document or visit; a visit and a medication or dose)",
+ recovery: "permanent",
+ confirm: [],
+ unconfirmed: [
+ {
+ control: "remove a chip in a relation link picker",
+ reason:
+ "Removing a chip deletes one join row and nothing the person wrote: both records stay untouched, the unlink toast names what was unlinked, and the same picker re-files it with one tap. This is the shape every link picker in the app already has (the visit form, the document sheet, the dose form), and a dialog on one of them alone would make the same act cost two different amounts.",
+ },
+ ],
+ },
{
file: "components/insights/coach-panel/coach-conversation.tsx",
destroys:
diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx
index 56052eb88..b11eab8cb 100644
--- a/src/components/documents/document-detail-sheet.tsx
+++ b/src/components/documents/document-detail-sheet.tsx
@@ -86,6 +86,8 @@ import {
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentRecordLinks } from "./document-record-links";
+import { RelatedRecordsBlock } from "@/components/links/related-records";
+import type { RelatedLink } from "@/lib/links/related";
import { DocumentFactsSection } from "./document-facts-review";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
@@ -1203,6 +1205,14 @@ export function DocumentDetailSheet({
onChange={(part) => patch.mutate(part)}
/>
+ {/* v1.43 — the lab results read from this page and the
+ medications it belongs to. Conditions, visits and doses
+ have their own blocks above. */}
+
+
{/* Staged-facts review + the stored-text extract recovery. The
existing extract/confirm chain finally gets its control on
the document itself: pending facts open the review, a read
diff --git a/src/components/encounters/encounter-related-links.tsx b/src/components/encounters/encounter-related-links.tsx
new file mode 100644
index 000000000..4114b5a1a
--- /dev/null
+++ b/src/components/encounters/encounter-related-links.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+/**
+ * The visit sheet's v1.43 relation links: the medications prescribed,
+ * changed or reviewed at the visit and the doses given there, plus whatever
+ * else the visit relates to that the form above does not already pick.
+ *
+ * Unlike the form's documents, lab results and conditions (a replace-set sent
+ * with Save), these two write straight through the visit's links route when
+ * picked, so they need a saved visit and render in edit mode only.
+ */
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+
+import {
+ RelatedRecordsBlock,
+ RelationLinkPickers,
+} from "@/components/links/related-records";
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+import type { RelatedLink } from "@/lib/links/related";
+
+export function EncounterRelatedLinks({
+ encounterId,
+ occurredAt,
+}: {
+ encounterId: string;
+ occurredAt: string | null;
+}) {
+ const qc = useQueryClient();
+ const detail = useQuery({
+ queryKey: queryKeys.encounter(encounterId),
+ queryFn: () =>
+ apiGet<{ related?: RelatedLink[] }>(
+ `/api/encounters/${encodeURIComponent(encounterId)}`,
+ ),
+ });
+ // The pickers seed from this read; before it lands they would offer an
+ // empty selection that the first tap could not compute a change from.
+ if (!detail.data) return null;
+ const related = detail.data.related;
+ return (
+
+
+ {
+ // The other end lists the visit too.
+ for (const queryKey of [
+ queryKeys.encounter(encounterId),
+ queryKeys.medications(),
+ queryKeys.vaccinations(),
+ ]) {
+ void qc.invalidateQueries({ queryKey });
+ }
+ }}
+ />
+
+ );
+}
diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx
index 654c6ac8f..4c0eefcd0 100644
--- a/src/components/encounters/encounter-sheet.tsx
+++ b/src/components/encounters/encounter-sheet.tsx
@@ -60,6 +60,7 @@ import {
nowLocalValue,
type EncounterDraft,
} from "./encounter-form";
+import { EncounterRelatedLinks } from "./encounter-related-links";
export function EncounterSheet({
open,
@@ -163,6 +164,12 @@ export function EncounterSheet({
>
+ {isEdit ? (
+
+ ) : null}
{error ? (
{error}
diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx
index 91e36a332..bad38802b 100644
--- a/src/components/illness/illness-episode-detail.tsx
+++ b/src/components/illness/illness-episode-detail.tsx
@@ -14,6 +14,8 @@ import Link from "next/link";
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
+import { RelatedRecordsCard } from "@/components/links/related-records";
+import type { RelatedLink } from "@/lib/links/related";
import { EpisodeSymptomsCard } from "@/components/symptoms/episode-symptoms-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -204,6 +206,16 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
so the reverse match would be wrong often enough to train the person
to ignore every suggestion in the product. */}
{episode ? : null}
+
+ {/* v1.43 — what else the condition relates to: the medications that
+ treat it and the lab results it shares a visit with. Documents and
+ visits have their own cards above. */}
+ {episode ? (
+
+ ) : null}
{episode ? (
diff --git a/src/components/links/__tests__/related-records.test.tsx b/src/components/links/__tests__/related-records.test.tsx
new file mode 100644
index 000000000..5639e5ba9
--- /dev/null
+++ b/src/components/links/__tests__/related-records.test.tsx
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { RelatedLink } from "@/lib/links/related";
+
+import { RelatedRecordsBlock, RelatedRecordsList } from "../related-records";
+
+const RELATED: RelatedLink[] = [
+ {
+ kind: "conditionEpisode",
+ id: "c1",
+ label: "Hypothyroidism",
+ date: "2026-01-20T00:00:00.000Z",
+ href: "/illness/c1",
+ via: "direct",
+ },
+ {
+ kind: "encounter",
+ id: "e1",
+ label: "SPECIALIST",
+ date: "2026-07-01T09:00:00.000Z",
+ href: "/checkups?visit=e1",
+ via: "direct",
+ },
+ {
+ kind: "labResult",
+ id: "l1",
+ label: "TSH",
+ date: "2026-06-30T09:00:00.000Z",
+ href: "/labs/b1",
+ via: "efficacyTarget",
+ },
+];
+
+function render(node: React.ReactNode): string {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("", () => {
+ it("links every row to the path the server resolved", () => {
+ const html = render();
+ expect(html).toContain('href="/illness/c1"');
+ expect(html).toContain('href="/checkups?visit=e1"');
+ expect(html).toContain('href="/labs/b1"');
+ expect(html).toContain("Hypothyroidism");
+ });
+
+ it("names a visit labelled by its kind from the reader's bundle", () => {
+ const html = render();
+ expect(html).not.toContain(">SPECIALIST<");
+ });
+
+ it("says how a derived relation runs", () => {
+ const html = render();
+ expect(html).toContain("through the efficacy target");
+ });
+
+ it("leaves out the kinds the surface lists elsewhere, and renders nothing when empty", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain("/checkups?visit=e1");
+ expect(render()).toBe("");
+ expect(
+ render(
+ ,
+ ),
+ ).toBe("");
+ });
+});
diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx
new file mode 100644
index 000000000..68ce45373
--- /dev/null
+++ b/src/components/links/related-records.tsx
@@ -0,0 +1,527 @@
+"use client";
+
+/**
+ * "Verwandt": the records related to the one on screen, and the pickers that
+ * file the v1.43 relation links.
+ *
+ * Every row comes from the detail read's `related[]`, resolved on the server:
+ * the label, the date, the web path and how the relation runs (a stored link,
+ * a visit both are filed against, or a medication's efficacy target). The
+ * client renders it and never re-derives it, so the web, iOS and a push link
+ * all open the same place.
+ *
+ * The pickers write through the record's own links route
+ * (`POST/DELETE /api//{id}/links`) one change at a time and refresh
+ * the detail read afterwards. Those routes are own-account only, so the
+ * pickers render only in the caller's own record. A module that is off leaves
+ * no picker behind, the same rule the visit form follows.
+ */
+import { useMemo, useState } from "react";
+import Link from "next/link";
+import { useQuery } from "@tanstack/react-query";
+import {
+ Activity,
+ CalendarDays,
+ ChevronRight,
+ FlaskConical,
+ FolderOpen,
+ Link2,
+ Pill,
+ Syringe,
+ type LucideIcon,
+} from "lucide-react";
+
+import {
+ EntityLinkPicker,
+ serverPagingFrom,
+ type EntityLinkOption,
+} from "@/components/links/entity-link-picker";
+import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
+import {
+ pickerPivot,
+ useLinkPickerPages,
+} from "@/components/links/use-link-picker-pages";
+import { fetchEpisodePage } from "@/components/encounters/encounter-link-pickers";
+import {
+ ENCOUNTER_KINDS,
+ encounterKindText,
+} from "@/components/encounters/encounter-labels";
+import {
+ fetchEncounterPage,
+ recordOptions,
+} from "@/components/documents/document-record-links";
+import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
+import { TileHeader } from "@/components/insights/tile-header";
+import { Card, CardContent } from "@/components/ui/card";
+import { useAuth } from "@/hooks/use-auth";
+import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
+import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
+import { showErrorToast } from "@/lib/errors/error-toast";
+import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+import type { EncounterDTO } from "@/lib/encounters/dto";
+import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
+import type { RelatedLink } from "@/lib/links/related";
+import type { EncounterKind } from "@/generated/prisma/client";
+
+type Translate = ReturnType["t"];
+type RelatedKind = RelatedLink["kind"];
+
+const KIND_ICONS: Record = {
+ labResult: FlaskConical,
+ encounter: CalendarDays,
+ document: FolderOpen,
+ conditionEpisode: Activity,
+ medication: Pill,
+ vaccination: Syringe,
+};
+
+/** Literal calls, so the i18n coverage guard sees every key. */
+function kindText(t: Translate, kind: RelatedKind): string {
+ switch (kind) {
+ case "labResult":
+ return t("links.related.kind.labResult");
+ case "encounter":
+ return t("links.related.kind.encounter");
+ case "document":
+ return t("links.related.kind.document");
+ case "conditionEpisode":
+ return t("links.related.kind.conditionEpisode");
+ case "medication":
+ return t("links.related.kind.medication");
+ case "vaccination":
+ return t("links.related.kind.vaccination");
+ }
+}
+
+function viaText(t: Translate, via: RelatedLink["via"]): string | null {
+ if (via === "encounter") return t("links.related.via.encounter");
+ if (via === "efficacyTarget") return t("links.related.via.efficacyTarget");
+ return null;
+}
+
+/**
+ * A visit without a reason or a practitioner is labelled by its kind, sent as
+ * the enum constant so each reader names it from their own bundle.
+ */
+function rowLabel(t: Translate, row: RelatedLink): string {
+ if (
+ row.kind === "encounter" &&
+ (ENCOUNTER_KINDS as string[]).includes(row.label)
+ ) {
+ return encounterKindText(t, row.label as EncounterKind);
+ }
+ return row.label;
+}
+
+/** The related rows, grouped in the server's order. Nothing when empty. */
+export function RelatedRecordsList({
+ related,
+ omitKinds = [],
+}: {
+ related: readonly RelatedLink[] | undefined;
+ /** Kinds this surface already lists in a block of its own. */
+ omitKinds?: readonly RelatedKind[];
+}) {
+ const { t } = useTranslations();
+ const format = useFormatters();
+ const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind));
+ if (rows.length === 0) return null;
+ return (
+