diff --git a/e2e/visits.spec.ts b/e2e/visits.spec.ts
new file mode 100644
index 000000000..76daf12c0
--- /dev/null
+++ b/e2e/visits.spec.ts
@@ -0,0 +1,102 @@
+/**
+ * Visits — the incidental-linking promise, proven end to end once.
+ *
+ * The flow this milestone is checked against, in one script:
+ *
+ * 1. create a visit with ONLY a date — nothing else touched, Save enabled;
+ * 2. it appears in the list;
+ * 3. open a document dated inside the ±7-day window;
+ * 4. the suggestion offers that visit;
+ * 5. take the offer;
+ * 6. the document's detail names the visit it now belongs to.
+ *
+ * Every assertion addresses a stable `data-slot`, never viewport text: the
+ * copy is i18n-driven and the mobile project renders the same slots at a
+ * different size, so a text assertion would break for the wrong reason.
+ *
+ * The visit is created through the API rather than by driving the sheet twice:
+ * step 1 is about the SAVE being possible with only a date, which the sheet
+ * assertion below covers, and the linking half needs a visit whose instant is
+ * pinned relative to the seeded document's date.
+ */
+import { expect, test } from "./setup/test";
+
+import { ensureVaultFixture, MRT_DOC_ID } from "./setup/vault-fixture";
+
+test.beforeEach(async () => {
+ await ensureVaultFixture();
+});
+
+test.describe("visits", () => {
+ test("a visit saves with a date and nothing else", async ({ page }) => {
+ await page.goto("/checkups");
+ await page.getByTestId("checkups-tab-visits").click();
+
+ const add = page.locator('[data-slot="visits-add"]').first();
+ await expect(add).toBeVisible();
+ await add.click();
+
+ // The date is pre-filled with now, and NOTHING else has been touched.
+ const save = page.locator('[data-slot="encounter-save"]').first();
+ await expect(save).toBeVisible();
+ // The whole product constraint: not disabled, with no practitioner, no
+ // reason, no outcome and no link chosen.
+ await expect(save).toBeEnabled();
+
+ await save.click();
+
+ // It appears in the list. A visit dated now is in the past arm.
+ await expect(page.locator('[data-slot="visit-card"]').first()).toBeVisible({
+ timeout: 15_000,
+ });
+ });
+
+ test("a document offers the visit its date falls near, and files itself against it", async ({
+ page,
+ request,
+ }) => {
+ // The seeded MRI report carries a fixed filing date; the visit is placed
+ // two days before it so the ±7-day window resolves to exactly one
+ // candidate — the branch that pre-selects.
+ const document = await request
+ .get(`/api/documents/inbound/${MRT_DOC_ID}`)
+ .then((res) => res.json());
+ const anchorDay: string =
+ document.data.reportDate ?? document.data.documentDate;
+ expect(
+ anchorDay,
+ "the fixture document carries a filing date",
+ ).toBeTruthy();
+ const occurredAt = new Date(`${anchorDay}T09:00:00.000Z`);
+ occurredAt.setUTCDate(occurredAt.getUTCDate() - 2);
+
+ const created = await request.post("/api/encounters", {
+ data: {
+ occurredAt: occurredAt.toISOString(),
+ status: "DONE",
+ kind: "SPECIALIST",
+ },
+ });
+ expect(created.status()).toBe(201);
+
+ await page.goto(`/documents?doc=${MRT_DOC_ID}`);
+
+ const suggestion = page
+ .locator('[data-slot="document-encounter-suggestion"]')
+ .first();
+ await expect(suggestion).toBeVisible({ timeout: 15_000 });
+ // One candidate, so the single-candidate branch: a pre-selectable offer
+ // rather than a picker.
+ await expect(suggestion).toHaveAttribute("data-verdict", "one");
+
+ await page
+ .locator('[data-slot="document-encounter-suggestion-option"]')
+ .first()
+ .click();
+
+ // The filing landed: the read-only "belongs to visit" row appears.
+ await expect(
+ page.locator('[data-slot="document-visit-links"]').first(),
+ ).toBeVisible({ timeout: 15_000 });
+ });
+});
diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx
index 4b2fbf1c5..a9d754672 100644
--- a/src/app/checkups/page.tsx
+++ b/src/app/checkups/page.tsx
@@ -59,10 +59,10 @@ export default function CheckupsPage() {
-
+
{t("checkups.tabVorsorge")}
-
+
{t("checkups.tabVisits")}
diff --git a/tests/integration/encounter-phase2-surfaces.test.ts b/tests/integration/encounter-phase2-surfaces.test.ts
new file mode 100644
index 000000000..a71fa7998
--- /dev/null
+++ b/tests/integration/encounter-phase2-surfaces.test.ts
@@ -0,0 +1,468 @@
+/**
+ * The Phase 2 surfaces against real Postgres, through the real routes.
+ *
+ * Everything here is about ROWS — how many link rows exist, whether a second
+ * call wrote a second satisfaction, which visits a filter actually returns —
+ * and none of it can be answered by a mocked client. Three properties in
+ * particular would pass a mock-shaped assertion while being wrong:
+ *
+ * - filing a visit against a due checkup twice must satisfy it ONCE. A test
+ * phrased as "the second call returned 200" proves nothing; the assertion
+ * is that `lastSatisfiedAt` did not move.
+ * - a lab commit writes one link PER RESULT ROW. A test that counted "there
+ * is a link" would pass on a single link for a three-marker panel.
+ * - the visit list's episode filter runs in SQL over the link table. A test
+ * with one visit in the account cannot tell a filter from no filter, so
+ * there are always two.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { GET as listEncounters } from "@/app/api/encounters/route";
+import { POST as createEncounter } from "@/app/api/encounters/route";
+import { GET as suggestEncounter } from "@/app/api/encounters/suggest/route";
+import { POST as commitOcr } from "@/app/api/labs/ocr/commit/route";
+import { POST as bulkDocuments } from "@/app/api/documents/inbound/bulk/route";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => cookieJar.set(name, value),
+ delete: (name: string) => cookieJar.delete(name),
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+const OWNER_ID = "phase2-owner";
+
+function daysFromNow(days: number): Date {
+ return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
+}
+
+async function seedSession() {
+ const prisma = getPrismaClient();
+ await prisma.user.create({
+ data: {
+ id: OWNER_ID,
+ username: "phase2-owner",
+ email: "phase2-owner@example.test",
+ timezone: "Europe/Berlin",
+ // The document and lab surfaces are module-gated; the visit is not.
+ modulePreferencesJson: {
+ inboundDocuments: true,
+ labs: true,
+ illness: true,
+ },
+ },
+ });
+ const session = await prisma.session.create({
+ data: {
+ userId: OWNER_ID,
+ expiresAt: daysFromNow(7),
+ mfaVerifiedAt: new Date(),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+}
+
+function post(body: unknown): Promise {
+ return createEncounter(
+ new Request("http://localhost/api/encounters", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }) as never,
+ );
+}
+
+async function json(res: Response): Promise {
+ return ((await res.json()) as { data: T }).data;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ await seedSession();
+});
+
+describe("filing a visit against a due checkup", () => {
+ async function dueCheckup() {
+ return getPrismaClient().measurementReminder.create({
+ data: {
+ userId: OWNER_ID,
+ label: "Annual panel",
+ measurementType: null,
+ intervalDays: 365,
+ anchorDate: daysFromNow(-400),
+ notifyHour: 9,
+ nextDueAt: daysFromNow(-35),
+ },
+ });
+ }
+
+ it("advances lastSatisfiedAt, and doing it twice does not advance it again", async () => {
+ const prisma = getPrismaClient();
+ const checkup = await dueCheckup();
+ expect(checkup.lastSatisfiedAt).toBeNull();
+
+ // ONE instant, captured once and reused. Two calls to `daysFromNow(-30)`
+ // are milliseconds apart, and the second would be strictly later — which
+ // the forward-only guard correctly advances on. The property under test is
+ // the one that matters in the product: the SAME visit filed twice.
+ const occurredAt = daysFromNow(-30).toISOString();
+
+ const first = await post({
+ occurredAt,
+ status: "DONE",
+ reminderId: checkup.id,
+ });
+ expect(first.status).toBe(201);
+
+ const afterFirst = await prisma.measurementReminder.findUniqueOrThrow({
+ where: { id: checkup.id },
+ });
+ expect(afterFirst.lastSatisfiedAt).not.toBeNull();
+
+ // The SAME instant again. `satisfyReminder` is forward-only, so this is a
+ // no-op that still succeeds — the property the plan calls idempotent, and
+ // the one a status-code assertion alone would not see.
+ const second = await post({
+ occurredAt,
+ status: "DONE",
+ reminderId: checkup.id,
+ });
+ expect(second.status).toBe(201);
+
+ const afterSecond = await prisma.measurementReminder.findUniqueOrThrow({
+ where: { id: checkup.id },
+ });
+ expect(afterSecond.lastSatisfiedAt?.toISOString()).toBe(
+ afterFirst.lastSatisfiedAt?.toISOString(),
+ );
+ expect(afterSecond.nextDueAt?.toISOString()).toBe(
+ afterFirst.nextDueAt?.toISOString(),
+ );
+ });
+
+ it("satisfies at the VISIT's instant rather than at now", async () => {
+ const prisma = getPrismaClient();
+ const checkup = await dueCheckup();
+ const occurredAt = daysFromNow(-30);
+
+ await post({
+ occurredAt: occurredAt.toISOString(),
+ status: "DONE",
+ reminderId: checkup.id,
+ });
+
+ const after = await prisma.measurementReminder.findUniqueOrThrow({
+ where: { id: checkup.id },
+ });
+ // Satisfying at `now` would push next-due 365 days from today rather than
+ // from the draw; the honest date is the visit's own.
+ expect(after.lastSatisfiedAt?.toISOString()).toBe(occurredAt.toISOString());
+ });
+
+ it("leaves a checkup untouched when the visit is not done", async () => {
+ const prisma = getPrismaClient();
+ const checkup = await dueCheckup();
+
+ const res = await post({
+ occurredAt: daysFromNow(-30).toISOString(),
+ status: "NO_SHOW",
+ reminderId: checkup.id,
+ });
+ expect(res.status).toBe(201);
+
+ const after = await prisma.measurementReminder.findUniqueOrThrow({
+ where: { id: checkup.id },
+ });
+ expect(after.lastSatisfiedAt).toBeNull();
+ });
+});
+
+describe("a lab panel filed against a visit", () => {
+ const rows = [
+ { analyte: "LDL", value: 3.1, unit: "mmol/L" },
+ { analyte: "HDL", value: 1.4, unit: "mmol/L" },
+ { analyte: "Ferritin", value: 88, unit: "µg/L" },
+ ];
+
+ function commit(body: unknown): Promise {
+ return commitOcr(
+ new Request("http://localhost/api/labs/ocr/commit", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }) as never,
+ );
+ }
+
+ it("writes ONE link per result row", async () => {
+ const prisma = getPrismaClient();
+ const visit = await json<{ id: string }>(
+ await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }),
+ );
+ const takenAt = daysFromNow(-1).toISOString();
+
+ const res = await commit({
+ rows: rows.map((row) => ({ ...row, takenAt })),
+ encounterId: visit.id,
+ });
+ expect(res.status).toBe(200);
+
+ const results = await prisma.labResult.findMany({
+ where: { userId: OWNER_ID },
+ });
+ expect(results).toHaveLength(3);
+
+ const links = await prisma.encounterLabLink.findMany({
+ where: { userId: OWNER_ID, encounterId: visit.id },
+ });
+ // Three, not one: a marker re-run on a different day belongs to its own
+ // visit, which is why the join is m:n rather than a column on the result.
+ expect(links).toHaveLength(3);
+ expect(new Set(links.map((link) => link.labResultId))).toEqual(
+ new Set(results.map((result) => result.id)),
+ );
+ });
+
+ it("succeeds and writes no link when no visit is named", async () => {
+ const prisma = getPrismaClient();
+ const res = await commit({
+ rows: [{ ...rows[0], takenAt: daysFromNow(-2).toISOString() }],
+ });
+ expect(res.status).toBe(200);
+
+ expect(await prisma.labResult.count({ where: { userId: OWNER_ID } })).toBe(
+ 1,
+ );
+ expect(
+ await prisma.encounterLabLink.count({ where: { userId: OWNER_ID } }),
+ ).toBe(0);
+ });
+});
+
+describe("filing documents against a visit in bulk", () => {
+ async function seedDocuments(count: number): Promise {
+ const prisma = getPrismaClient();
+ const ids: string[] = [];
+ for (let index = 0; index < count; index += 1) {
+ const row = await prisma.inboundDocument.create({
+ data: {
+ userId: OWNER_ID,
+ kind: "OTHER",
+ filename: `doc-${index}.pdf`,
+ mimeType: "application/pdf",
+ byteSize: 4,
+ status: "STORED",
+ contentEncrypted: Buffer.from("test"),
+ contentCodec: "v1",
+ },
+ });
+ ids.push(row.id);
+ }
+ return ids;
+ }
+
+ function bulk(body: unknown): Promise {
+ return bulkDocuments(
+ new Request("http://localhost/api/documents/inbound/bulk", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }) as never,
+ );
+ }
+
+ it("links a whole batch, per entry, and is idempotent", async () => {
+ const prisma = getPrismaClient();
+ const visit = await json<{ id: string }>(
+ await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }),
+ );
+ const ids = await seedDocuments(100);
+
+ const res = await bulk({
+ ids,
+ action: "linkEncounter",
+ encounterId: visit.id,
+ });
+ expect(res.status).toBe(200);
+ const first = await json<{ results: Array<{ ok: boolean }> }>(res);
+ // Per-entry tolerance: a hundred outcomes, one per id, not one verdict.
+ expect(first.results).toHaveLength(100);
+ expect(first.results.every((row) => row.ok)).toBe(true);
+ expect(
+ await prisma.encounterDocumentLink.count({
+ where: { userId: OWNER_ID, encounterId: visit.id },
+ }),
+ ).toBe(100);
+
+ // Re-posting the same batch changes nothing and still succeeds.
+ const again = await bulk({
+ ids,
+ action: "linkEncounter",
+ encounterId: visit.id,
+ });
+ expect(again.status).toBe(200);
+ expect(
+ await prisma.encounterDocumentLink.count({
+ where: { userId: OWNER_ID, encounterId: visit.id },
+ }),
+ ).toBe(100);
+ });
+
+ it("reports one unknown id as one failed row and files the rest", async () => {
+ const prisma = getPrismaClient();
+ const visit = await json<{ id: string }>(
+ await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }),
+ );
+ const ids = await seedDocuments(2);
+
+ const res = await bulk({
+ ids: [...ids, "not-a-document"],
+ action: "linkEncounter",
+ encounterId: visit.id,
+ });
+ const payload = await json<{
+ results: Array<{ id: string; ok: boolean; error: string | null }>;
+ }>(res);
+ expect(payload.results).toHaveLength(3);
+ expect(payload.results.filter((row) => row.ok)).toHaveLength(2);
+ expect(
+ payload.results.find((row) => row.id === "not-a-document")?.error,
+ ).toBe("notFound");
+ expect(
+ await prisma.encounterDocumentLink.count({ where: { userId: OWNER_ID } }),
+ ).toBe(2);
+ });
+
+ it("refuses the whole batch when the visit is not the caller's", async () => {
+ const prisma = getPrismaClient();
+ const ids = await seedDocuments(2);
+ const res = await bulk({
+ ids,
+ action: "linkEncounter",
+ encounterId: "someone-elses-visit",
+ });
+ expect(res.status).toBe(404);
+ expect(
+ await prisma.encounterDocumentLink.count({ where: { userId: OWNER_ID } }),
+ ).toBe(0);
+ });
+});
+
+describe("the visit list's condition filter", () => {
+ it("returns only the visits filed against that episode", async () => {
+ const prisma = getPrismaClient();
+ const episode = await prisma.illnessEpisode.create({
+ data: {
+ userId: OWNER_ID,
+ label: "Knee",
+ type: "INJURY",
+ onsetAt: daysFromNow(-40),
+ },
+ });
+
+ const linked = await json<{ id: string }>(
+ await post({
+ occurredAt: daysFromNow(-5).toISOString(),
+ status: "DONE",
+ episodeIds: [episode.id],
+ }),
+ );
+ // A SECOND visit with no link. Without it a filter and no filter return
+ // the same list and the assertion below would prove nothing.
+ await post({ occurredAt: daysFromNow(-6).toISOString(), status: "DONE" });
+
+ const res = await listEncounters(
+ new Request(
+ `http://localhost/api/encounters?episodeId=${episode.id}`,
+ ) as never,
+ );
+ expect(res.status).toBe(200);
+ const list = await json<{
+ upcoming: Array<{ id: string }>;
+ past: Array<{ id: string }>;
+ }>(res);
+ expect(list.past.map((row) => row.id)).toEqual([linked.id]);
+
+ const unfiltered = await json<{ past: Array<{ id: string }> }>(
+ await listEncounters(
+ new Request("http://localhost/api/encounters") as never,
+ ),
+ );
+ expect(unfiltered.past).toHaveLength(2);
+ });
+});
+
+describe("the suggestion, over real rows", () => {
+ function suggest(anchor: string): Promise {
+ return suggestEncounter(
+ new Request(
+ `http://localhost/api/encounters/suggest?anchor=${encodeURIComponent(anchor)}`,
+ ) as never,
+ );
+ }
+
+ it("pre-selects a single candidate and offers a picker for two", async () => {
+ const anchor = daysFromNow(-3);
+
+ const only = await json<{ id: string }>(
+ await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" }),
+ );
+ const one = await json<{ kind: string; encounter: { id: string } }>(
+ await suggest(anchor.toISOString()),
+ );
+ expect(one.kind).toBe("one");
+ expect(one.encounter.id).toBe(only.id);
+
+ await post({ occurredAt: daysFromNow(-4).toISOString(), status: "DONE" });
+ const many = await json<{ kind: string; encounters: unknown[] }>(
+ await suggest(anchor.toISOString()),
+ );
+ expect(many.kind).toBe("many");
+ expect(many.encounters).toHaveLength(2);
+ // No pre-selection on the two-candidate arm — the shape itself has none.
+ expect(many).not.toHaveProperty("encounter");
+ });
+
+ it("offers nothing outside the window, and nothing for a soft-deleted visit", async () => {
+ const prisma = getPrismaClient();
+ await post({ occurredAt: daysFromNow(-30).toISOString(), status: "DONE" });
+ const far = await json<{ kind: string }>(
+ await suggest(new Date().toISOString()),
+ );
+ expect(far.kind).toBe("none");
+
+ const near = await json<{ id: string }>(
+ await post({ occurredAt: daysFromNow(-1).toISOString(), status: "DONE" }),
+ );
+ await prisma.encounter.update({
+ where: { id: near.id },
+ data: { deletedAt: new Date() },
+ });
+ const tombstoned = await json<{ kind: string }>(
+ await suggest(new Date().toISOString()),
+ );
+ expect(tombstoned.kind).toBe("none");
+ });
+});