From 46dc31d10e6be29d9a69f781240524876a6108de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:37:21 +0200 Subject: [PATCH 01/10] fix(notifications): offer no completion action on an appointment nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An appointment reminder rides the same MEASUREMENT_REMINDER event type as a checkup — one engine, deliberately — so every channel attached its Done / Later affordance to it: the Telegram inline keyboard and the APNs category whose actions the iOS app registers. Both resolve the reminder by id, and every by-id reminder surface refuses an ENCOUNTER-origin row, so each button could only answer "not found". The producer knows the origin, so it says the dispatch carries no action and each channel drops its own affordance rather than re-deriving the rule. The reminder id still rides the metadata: it identifies the nudge for the client-managed suppression tag, and what is withheld is the offer to act. - suppressActions on the notification payload, documented where it is declared - Telegram builds no measurement keyboard for a suppressed dispatch - APNs withholds the category and strips the reminder id from the iOS payload - a negative control keeps a checkup nudge actionable --- .../__tests__/measurement-reminder.test.ts | 42 +++++++++++++++ src/lib/jobs/measurement-reminder.ts | 8 +++ .../senders/__tests__/apns.test.ts | 26 ++++++++++ .../senders/__tests__/telegram.test.ts | 17 +++++++ src/lib/notifications/senders/apns.ts | 51 +++++++++++++++++-- src/lib/notifications/senders/telegram.ts | 10 +++- src/lib/notifications/types.ts | 17 +++++++ 7 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/lib/jobs/__tests__/measurement-reminder.test.ts b/src/lib/jobs/__tests__/measurement-reminder.test.ts index e2936555f..bf813ce74 100644 --- a/src/lib/jobs/__tests__/measurement-reminder.test.ts +++ b/src/lib/jobs/__tests__/measurement-reminder.test.ts @@ -106,6 +106,8 @@ describe("evaluateMeasurementReminderDue — window boundary", () => { interface FakeReminder { id: string; + /** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */ + origin?: string; measurementType: string | null; intervalDays: number | null; rrule: string | null; @@ -248,6 +250,46 @@ describe("runMeasurementReminderTick", () => { expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime()); }); + it("dispatches an appointment nudge with its completion affordance suppressed", async () => { + // `measure_done` / `measure_later` and the iOS "Erledigt" action all + // resolve the reminder by id, and every by-id surface refuses an + // ENCOUNTER-origin row. The producer knows the origin, so it is the + // producer that says the dispatch carries no action; the channels drop + // their own affordance from the flag. + const { prisma } = makePrisma({ + reminders: [ + reminder({ origin: "ENCOUNTER", measurementType: null, id: "appt-1" }), + ], + measurementMatch: null, + labMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + const payload = dispatch.mock.calls[0][0]; + expect(payload.suppressActions).toBe(true); + // The id still rides the dispatch — it identifies the nudge for the + // client-managed suppression tag. What is withheld is the offer to act. + expect(payload.metadata?.reminderId).toBe("appt-1"); + }); + + it("leaves a checkup nudge actionable", async () => { + // The negative control. Without it the assertion above would still pass + // against a producer that suppressed every reminder's affordance. + const { prisma } = makePrisma({ + reminders: [reminder({})], + measurementMatch: null, + }); + const dispatch = vi.fn(async () => OK); + + await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined(); + }); + it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => { const { prisma } = makePrisma({ reminders: [reminder({ measurementType: null })], diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 880f0b6cf..c6a467ffa 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -338,12 +338,20 @@ export async function runMeasurementReminderTick( return { title: rendered.title, message: rendered.body }; }; + // An appointment nudge is dispatched without its Done / Later + // affordance. It rides the same event type as a checkup — one engine, + // one cron, deliberately — but every by-id reminder surface refuses an + // ENCOUNTER-origin row: satisfying one would mark an appointment + // attended from a notification, and postponing one would move a date the + // visit record still believes it owns. The channels each drop their own + // affordance from this flag rather than re-deriving the origin. const outcome = await dispatchImpl({ eventType: "MEASUREMENT_REMINDER", userId: reminder.user.id, title, message: body, renderForRecipient, + ...(reminder.origin === "ENCOUNTER" ? { suppressActions: true } : {}), metadata: { scheduledAt: now.toISOString(), reminderId: reminder.id, diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 2294c3790..7d567c230 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -674,6 +674,32 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload.replyMarkup).toBeUndefined(); }); + it("withholds the category and the reminder id when actions are suppressed", async () => { + // The category IS the action row on iOS, and the id is what the action + // would POST. An appointment nudge has neither: the by-id reminder family + // refuses an ENCOUNTER-origin row, so both are dropped and the push + // arrives as a plain alert. The event type still routes it. + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEASUREMENT_REMINDER", + suppressActions: true, + metadata: { + reminderId: "rem-1", + scheduledAt: "2026-08-08T09:00:00.000Z", + }, + }); + const note = sendMock.mock.calls[0][0]; + expect(note.category).toBeUndefined(); + expect(note.payload.reminderId).toBeUndefined(); + expect(note.payload.eventType).toBe("MEASUREMENT_REMINDER"); + expect(note.payload.scheduledAt).toBe("2026-08-08T09:00:00.000Z"); + }); + it("forwards MOOD_REMINDER eventType as aps.category too", async () => { // SR-2 contract: the mood-reminder daily job emits this event-type; // the iOS app registers a `MOOD_REMINDER` category whose only diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index fdbc30d10..14455e171 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -318,6 +318,23 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => { ); }); + it("offers no keyboard on a MEASUREMENT_REMINDER whose actions are suppressed", async () => { + // An appointment nudge. The reminder id is present and the event type + // matches, so only the flag stands between the person and two buttons + // that can answer nothing but "not found". + await sendViaTelegram(config, { + eventType: "MEASUREMENT_REMINDER", + userId: "user-1", + title: "t", + message: "m", + suppressActions: true, + metadata: { reminderId: "rem-1" }, + }); + const opts = sendTelegramMessageMock.mock.calls[0][3]; + expect(opts.replyMarkup).toBeUndefined(); + expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled(); + }); + it("sends a plain MEASUREMENT_REMINDER (no keyboard, no self-clean) without a reminderId", async () => { await sendViaTelegram(config, { eventType: "MEASUREMENT_REMINDER", diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 83bbccc71..6ea4afdc7 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -592,6 +592,8 @@ export async function sendViaApns( metadata?: Record; discreet?: boolean; urgent?: boolean; + /** The dispatch offers no completion action — see `NotificationPayload`. */ + suppressActions?: boolean; recordUserId?: string; recipientUserId?: string; }, @@ -732,12 +734,21 @@ export async function sendViaApns( // an iOS action identifier or deep link that can mutate it. // Keep the ordinary event envelope so opening the notification // follows the normal authorised record-selection flow. + // `suppressActions` removes the act-on-it keys for the same + // reason it clears the category below: the affordance the app + // would build from them resolves to a refusal. ...pickIosMetadata( managedDelivery ? undefined - : payload.discreet - ? stripCycleMetadata(payload.metadata) - : payload.metadata, + : payload.suppressActions + ? stripActionMetadata( + payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, + ) + : payload.discreet + ? stripCycleMetadata(payload.metadata) + : payload.metadata, ), }, threadId: routingEvent, @@ -746,7 +757,13 @@ export async function sendViaApns( // ignores categories it doesn't know about, so adding the key // here is safe for event-types that don't have an actionable // category registered yet — they fall back to a plain alert. - category: managedDelivery ? undefined : routingEvent, + // A suppressed-action dispatch drops the category for the same + // reason a managed delivery does: the category IS the action row, + // and an appointment nudge has no action the server would accept. + category: + managedDelivery || payload.suppressActions + ? undefined + : routingEvent, // Future-proof for an iOS Notification Service Extension. iOS // ignores the flag when no extension is registered, so it's a // no-op on today's binary but unblocks NSE work without a @@ -1028,6 +1045,32 @@ function pickIosMetadata( return out; } +/** + * Metadata keys that exist so the iOS app can ACT on the notification rather + * than merely read it. Dropped when the producer says the dispatch carries no + * completion affordance. + */ +const ACTION_METADATA_KEYS = new Set(["reminderId"]); + +/** + * Strip the act-on-it keys from a payload whose actions the server will refuse. + * + * An appointment nudge rides the same `MEASUREMENT_REMINDER` event type as a + * checkup, so the iOS app would offer the registered "Erledigt" action and + * POST a completion the by-id reminder family answers 404 for. Removing the + * id is what removes the action: the handler has nothing to complete. + */ +function stripActionMetadata( + metadata: Record | undefined, +): Record | undefined { + if (!metadata) return metadata; + const out: Record = {}; + for (const key of Object.keys(metadata)) { + if (!ACTION_METADATA_KEYS.has(key)) out[key] = metadata[key]; + } + return out; +} + /** Cycle-semantic metadata keys that must never ride a discreet payload. */ const CYCLE_METADATA_KEYS = new Set(["phase"]); diff --git a/src/lib/notifications/senders/telegram.ts b/src/lib/notifications/senders/telegram.ts index 8a2bf2479..205bde142 100644 --- a/src/lib/notifications/senders/telegram.ts +++ b/src/lib/notifications/senders/telegram.ts @@ -150,7 +150,15 @@ export async function sendViaTelegram( interactiveKeyboard = buildMoodKeyboard( await resolveSenderLocale(userId), ); - } else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) { + } else if ( + payload.eventType === "MEASUREMENT_REMINDER" && + reminderId && + // An appointment nudge is dispatched with the affordance suppressed: + // `measure_done` / `measure_later` both refuse an ENCOUNTER-origin row, + // so offering the pair would put two buttons in the chat that can only + // answer "not found". + !payload.suppressActions + ) { interactiveKeyboard = buildMeasurementKeyboard( reminderId, await resolveSenderLocale(userId), diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index a8db92882..ce78a0e31 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -319,6 +319,23 @@ export interface NotificationPayload { * Normal events leave `urgent` unset and behave exactly as before. */ urgent?: boolean; + /** + * The nudge carries no completion affordance. + * + * A reminder push normally ships a Done / Later pair — the Telegram inline + * keyboard and the APNs category whose actions the iOS app registers. Both + * resolve the reminder by id and both are refused for an appointment: an + * appointment reminder belongs to a visit, is managed through the visit + * routes, and every by-id reminder surface answers "not found" for one. An + * offered button the server will refuse is worse than no button, so the + * producer that knows the origin says so here and each channel drops its own + * affordance rather than each channel re-deriving the rule. + * + * The reminder's id still rides `metadata` — it identifies the dispatch for + * the client-managed suppression tag and the wide event; what this flag + * removes is the OFFER to act on it. + */ + suppressActions?: boolean; } /** From 467e83d63913f41279041baafc8e3e2e0112a140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 12:43:51 +0200 Subject: [PATCH 02/10] feat(encounters): one suggestion rule for every filing moment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document arriving in the vault and a lab panel being committed both ask the same question — does this belong to a visit? — and the answer has to be the same question asked once. The rule lives in one module with one exported window constant: the caller's live DONE or PLANNED visits within seven days of an anchor date, resolved into a verdict rather than a list. One candidate pre-selects, visibly, with one undo. Two or more offer a picker with nothing pre-selected: pre-selecting the first of two is what teaches a person to stop reading suggestions, and the return type has no field on that arm a caller could mistake for a choice. Zero offers nothing. Cancelled visits and no-shows are not candidates. Neither happened, so neither produced the letter or the panel being filed, and admitting them would widen the set with rows that are wrong most of the time — which pushes the honest single match into the picker branch. The verdict is served over HTTP because all three moments live in the browser. Handing the client a windowed list and letting it decide would put the rule in two places, and the copy in the browser is the one nobody re-reads. - suggest-window.ts with the window as an exported constant, not a literal - GET /api/encounters/suggest, profile domain, reviewed into the frozen literal - OpenAPI regenerated - fixtures are relative to the run instant; a fixed date slides out of a relative window and rots the test into a false green --- docs/api/openapi.yaml | 123 +++++++++ src/__tests__/sharing-surface-guard.test.ts | 8 +- src/app/api/encounters/suggest/route.ts | 59 +++++ .../__tests__/suggest-window.test.ts | 244 ++++++++++++++++++ src/lib/encounters/suggest-window.ts | 150 +++++++++++ src/lib/openapi/routes/encounters.ts | 60 +++++ src/lib/query-keys/encounters.ts | 8 + src/lib/validations/encounters.ts | 16 ++ 8 files changed, 667 insertions(+), 1 deletion(-) create mode 100644 src/app/api/encounters/suggest/route.ts create mode 100644 src/lib/encounters/__tests__/suggest-window.test.ts create mode 100644 src/lib/encounters/suggest-window.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 33fb729bd..6a3dbc7d4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9825,6 +9825,36 @@ paths: "403": *a6 "422": *a3 "429": *a4 + /api/encounters/suggest: + get: + tags: + - Records + summary: Which visit does a record dated around this day belong to? + description: "Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three + capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab + panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means + offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop + reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: + neither happened, so neither produced the record being filed. No AI provider is involved and none is required." + parameters: + - in: query + name: anchor + schema: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: true + responses: + "200": + description: The verdict. + content: + application/json: + schema: + $ref: "#/components/schemas/SuggestEncounterEnvelope" + "401": *a1 + "403": *a6 + "422": *a3 + "429": *a4 /api/encounters/{id}: get: tags: @@ -34923,6 +34953,99 @@ components: - data - error additionalProperties: false + SuggestEncounterEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EncounterSuggestion" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EncounterSuggestion: + oneOf: + - type: object + properties: + kind: + type: string + const: one + encounter: + $ref: "#/components/schemas/EncounterSuggestionCandidate" + required: + - kind + - encounter + additionalProperties: false + - type: object + properties: + kind: + type: string + const: many + encounters: + type: array + items: + $ref: "#/components/schemas/EncounterSuggestionCandidate" + required: + - kind + - encounters + additionalProperties: false + - type: object + properties: + kind: + type: string + const: none + required: + - kind + additionalProperties: false + description: "The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a + client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to + make impossible." + type: object + EncounterSuggestionCandidate: + type: object + properties: + id: + type: string + occurredAt: + type: string + status: + type: string + enum: + - PLANNED + - DONE + - CANCELLED + - NO_SHOW + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + practitionerName: + anyOf: + - type: string + - type: "null" + required: + - id + - occurredAt + - status + - kind + - practitionerName + additionalProperties: false + description: One visit a record might belong to, resolved far enough to render a row without a second round trip. + `practitionerName` is null when the visit names no practice, which is an absence rather than a gap. GetEncounterEnvelope: type: object properties: diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4af6c2557..7a7b2570d 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -935,6 +935,10 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.", }, + "app/api/encounters/suggest/route.ts": { + domain: "profile", + why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.", + }, "app/api/practitioners/route.ts": { domain: "profile", why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.", @@ -1861,8 +1865,10 @@ const ACTOR_ROUTES: Record = { * read-only profile summary makes the next explicit admission: 197 → 198. * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. * The mood day's linked figures, read-only, add one: 200 → 201. + * The visit-suggestion read, which serves the three incidental-linking + * moments from the same rule, adds one: 213 → 214. */ -const FROZEN_ENTRY_COUNT = 213; +const FROZEN_ENTRY_COUNT = 214; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/encounters/suggest/route.ts b/src/app/api/encounters/suggest/route.ts new file mode 100644 index 000000000..84914950c --- /dev/null +++ b/src/app/api/encounters/suggest/route.ts @@ -0,0 +1,59 @@ +/** + * `GET /api/encounters/suggest?anchor=` — which visit does a record dated + * `anchor` most plausibly belong to? + * + * The three capture moments that ask this question live in the browser: the + * document upload review, the lab extraction review and the manual lab form. + * The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and + * the one-pre-selects / two-offer-a-picker verdict — lives in + * `src/lib/encounters/suggest-window.ts` and is answered here so all three read + * the same answer. Handing the client a windowed list and letting it decide + * would put the rule in two places, and the copy in the browser is the one + * nobody re-reads when the rule changes. + * + * A read of the record's own visits, so it declares the same domain the visit + * list does. `userId` is narrowed from auth; the anchor is the only input and + * it is a date, not an id. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { encounterSuggestQuerySchema } from "@/lib/validations/encounters"; +import { suggestEncounterForDate } from "@/lib/encounters/suggest-window"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = encounterSuggestQuerySchema.safeParse({ + anchor: params.get("anchor") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "encounter.invalid", + }); + } + + const result = await suggestEncounterForDate(prisma, { + userId: user.id, + anchor: new Date(parsed.data.anchor), + }); + + annotate({ + action: { name: "encounter.visit.suggest", entity_type: "encounter" }, + meta: { + verdict: result.kind, + candidates: + result.kind === "many" + ? result.encounters.length + : result.kind === "one" + ? 1 + : 0, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/lib/encounters/__tests__/suggest-window.test.ts b/src/lib/encounters/__tests__/suggest-window.test.ts new file mode 100644 index 000000000..ec2ea3c61 --- /dev/null +++ b/src/lib/encounters/__tests__/suggest-window.test.ts @@ -0,0 +1,244 @@ +/** + * The suggestion rule, pinned. + * + * Two properties carry the whole feature and both are easy to break without + * noticing: + * + * - the window is ±7 days and not "recently"; + * - two candidates offer a picker with nothing pre-selected, and never the + * first one. + * + * Every fixture is RELATIVE to the moment the test runs (`now − 3 days`, never + * `2026-03-14`). A fixed-date fixture slides out of a relative window as the + * calendar moves and the test rots into a false green; this repository has + * shipped that bug before. + * + * The fake below honours the `where` it is given. A fake that ignores it would + * return every seeded row for every query, so the window assertions would pass + * against a module with no window at all — a check that cannot fail is worse + * than none. + * + * Mutation checks (both run, both seen red): + * - widen `ENCOUNTER_SUGGEST_WINDOW_DAYS` to 90 → the out-of-window cases go + * red naming the 30-day visit that started matching; + * - make the two-candidate branch return `{ kind: "one" }` with the first row + * → "offers a picker with nothing pre-selected" goes red. + */ +import { describe, it, expect } from "vitest"; + +import { + ENCOUNTER_SUGGEST_WINDOW_DAYS, + suggestEncounterForDate, +} from "../suggest-window"; +import type { Prisma } from "@/generated/prisma/client"; + +const DAY_MS = 24 * 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; + +interface Row { + id: string; + userId: string; + occurredAt: Date; + status: string; + kind: string; + deletedAt: Date | null; + practitioner: { name: string } | null; +} + +function row(over: Partial & { id: string; occurredAt: Date }): Row { + return { + userId: "u1", + status: "DONE", + kind: "ROUTINE", + deletedAt: null, + practitioner: null, + ...over, + }; +} + +/** + * An in-memory `encounter.findMany` that actually applies the filter. + * + * It evaluates every clause the module writes — the record id, the tombstone, + * the status set and both window edges — so a module that dropped one of them + * returns rows this fake would have excluded, and the assertion below fails. + */ +function fakeTx(rows: Row[]): Prisma.TransactionClient { + return { + encounter: { + findMany: async (args: { + where: { + userId: string; + deletedAt: null; + status: { in: string[] }; + occurredAt: { gte: Date; lte: Date }; + }; + take?: number; + }) => { + const { where } = args; + const matched = rows + .filter((r) => r.userId === where.userId) + .filter((r) => + where.deletedAt === null ? r.deletedAt === null : true, + ) + .filter((r) => where.status.in.includes(r.status)) + .filter( + (r) => + r.occurredAt.getTime() >= where.occurredAt.gte.getTime() && + r.occurredAt.getTime() <= where.occurredAt.lte.getTime(), + ) + .sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime()); + return matched.slice(0, args.take ?? matched.length); + }, + }, + } as unknown as Prisma.TransactionClient; +} + +const anchor = new Date(); +const ago = (days: number, extraMs = 0) => + new Date(anchor.getTime() - days * DAY_MS - extraMs); +const ahead = (days: number, extraMs = 0) => + new Date(anchor.getTime() + days * DAY_MS + extraMs); + +describe("the window", () => { + it("is seven days wide on each side", () => { + expect(ENCOUNTER_SUGGEST_WINDOW_DAYS).toBe(7); + }); + + it("admits a visit exactly seven days before the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(7) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ + kind: "one", + encounter: expect.objectContaining({ id: "e1" }), + }); + }); + + it("admits a visit exactly seven days after the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ahead(7) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ + kind: "one", + encounter: expect.objectContaining({ id: "e1" }), + }); + }); + + it("refuses a visit seven days and one minute before the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(7, MINUTE_MS) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("refuses a visit seven days and one minute after the anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ahead(7, MINUTE_MS) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("refuses a visit thirty days out", async () => { + // The widened-window mutation lands here first: at ±90 days this row + // starts matching and the verdict stops being "none". + const result = await suggestEncounterForDate( + fakeTx([row({ id: "far", occurredAt: ahead(30) })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); +}); + +describe("the verdict", () => { + it("offers nothing when there is no candidate", async () => { + const result = await suggestEncounterForDate(fakeTx([]), { + userId: "u1", + anchor, + }); + expect(result).toEqual({ kind: "none" }); + }); + + it("pre-selects the single candidate, resolved", async () => { + const result = await suggestEncounterForDate( + fakeTx([ + row({ + id: "e1", + occurredAt: ago(3), + kind: "SPECIALIST", + practitioner: { name: "Praxis am Park" }, + }), + ]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ + kind: "one", + encounter: { + id: "e1", + occurredAt: ago(3).toISOString(), + status: "DONE", + kind: "SPECIALIST", + practitionerName: "Praxis am Park", + }, + }); + }); + + it("offers a picker with nothing pre-selected for two candidates", async () => { + const result = await suggestEncounterForDate( + fakeTx([ + row({ id: "e1", occurredAt: ago(3) }), + row({ id: "e2", occurredAt: ago(1) }), + ]), + { userId: "u1", anchor }, + ); + // The shape is the assertion: there is no field on a "many" verdict a + // caller could read as a pre-selection. + expect(result.kind).toBe("many"); + expect(result).not.toHaveProperty("encounter"); + if (result.kind !== "many") throw new Error("unreachable"); + expect(result.encounters.map((e) => e.id)).toEqual(["e1", "e2"]); + }); +}); + +describe("what is never a candidate", () => { + it("a soft-deleted visit", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(1), deletedAt: new Date() })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("a cancelled visit, and a no-show", async () => { + // Neither happened, so neither produced the record being filed. Admitting + // them would widen the set with rows that are wrong most of the time. + const result = await suggestEncounterForDate( + fakeTx([ + row({ id: "e1", occurredAt: ago(1), status: "CANCELLED" }), + row({ id: "e2", occurredAt: ago(2), status: "NO_SHOW" }), + ]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("another account's visit", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(1), userId: "u2" })]), + { userId: "u1", anchor }, + ); + expect(result).toEqual({ kind: "none" }); + }); + + it("an unusable anchor", async () => { + const result = await suggestEncounterForDate( + fakeTx([row({ id: "e1", occurredAt: ago(1) })]), + { userId: "u1", anchor: new Date("not a date") }, + ); + expect(result).toEqual({ kind: "none" }); + }); +}); diff --git a/src/lib/encounters/suggest-window.ts b/src/lib/encounters/suggest-window.ts new file mode 100644 index 000000000..cc8a974b3 --- /dev/null +++ b/src/lib/encounters/suggest-window.ts @@ -0,0 +1,150 @@ +/** + * "Gehört das zu einem Termin?" — one rule, one constant, one module. + * + * Three moments ask it: a document arriving in the vault, a lab panel being + * committed from an extraction, and a lab panel typed by hand. They must ask + * the same question, so none of them owns the answer. + * + * ── The rule ──────────────────────────────────────────────────────────────── + * + * candidates = the caller's own live visits, DONE or PLANNED, whose instant + * is within ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days of the + * anchor + * + * exactly one → pre-select it, visibly, with one undo + * two or more → offer a picker with NOTHING pre-selected + * zero → offer nothing at all + * + * The middle branch is the whole point. Pre-selecting the first of two is the + * failure that teaches a person to stop reading suggestions, which costs more + * than the suggestion ever saved. This module therefore returns the SHAPE of + * the answer rather than a best guess, and a caller cannot collapse "many" into + * "one" without writing the collapse itself. + * + * ── What it is not ────────────────────────────────────────────────────────── + * + * It is not an AI call. The match is a date-window query over the person's own + * rows: it costs one index scan, cannot hallucinate, and resolves on an account + * with no provider configured — which matters, because the document assist + * itself refuses without one. + * + * It never blocks. Every caller treats the verdict as an offer; a save works + * with it ignored, dismissed, or never rendered. + * + * ── Why CANCELLED and NO_SHOW are not candidates ──────────────────────────── + * + * Both name an appointment that did not happen, so neither produced the letter + * or the panel being filed. Admitting them would widen the candidate set with + * rows that are wrong most of the time, which pushes the honest single-match + * case into the picker branch and trains exactly the distrust the two-candidate + * rule exists to prevent. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client"; + +/** + * The half-width of the match window, in days, exported so the three call + * sites and the tests read the same number. A literal at three call sites is + * three numbers that agree until one of them is edited. + */ +export const ENCOUNTER_SUGGEST_WINDOW_DAYS = 7; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * A bound on how many candidates are read. Anything above one already means + * "offer a picker", so the exact count past this cap changes no decision — and + * an unbounded read on a busy account would load a page of rows to answer a + * yes/no question. + */ +const MAX_CANDIDATES = 20; + +/** + * One candidate, resolved. Enough to render a row without a second round trip + * and without the client re-deriving a label the server already holds. + */ +export interface EncounterSuggestion { + id: string; + occurredAt: string; + status: EncounterStatus; + kind: EncounterKind; + /** The practice, when the visit names one. `null` is an absence, not a gap. */ + practitionerName: string | null; +} + +/** + * The verdict. A discriminated union rather than an array plus a boolean, + * because "one" and "many" are different offers and a caller that receives an + * array will eventually take `[0]`. + */ +export type EncounterSuggestionResult = + | { kind: "one"; encounter: EncounterSuggestion } + | { kind: "many"; encounters: EncounterSuggestion[] } + | { kind: "none" }; + +/** The statuses a visit can carry and still be the source of a filed record. */ +const CANDIDATE_STATUSES: EncounterStatus[] = ["DONE", "PLANNED"]; + +/** + * The window around an anchor instant, inclusive on both edges. + * + * Exported so a caller that needs the bounds for a different query — the + * report offer reads the same idea forward-only — states them from here rather + * than re-deriving the arithmetic. + */ +export function encounterSuggestWindow(anchor: Date): { from: Date; to: Date } { + const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS; + return { + from: new Date(anchor.getTime() - half), + to: new Date(anchor.getTime() + half), + }; +} + +/** + * Resolve the visit a record dated `anchor` most plausibly belongs to. + * + * `userId` is the caller's resolved record id and is fed straight to the + * `where`; this module never accepts one from a request body. + */ +export async function suggestEncounterForDate( + tx: Prisma.TransactionClient, + input: { userId: string; anchor: Date }, +): Promise { + if (Number.isNaN(input.anchor.getTime())) return { kind: "none" }; + + const { from, to } = encounterSuggestWindow(input.anchor); + + const rows = await tx.encounter.findMany({ + where: { + userId: input.userId, + deletedAt: null, + status: { in: CANDIDATE_STATUSES }, + occurredAt: { gte: from, lte: to }, + }, + // Nearest to the anchor would be the kinder order, but Prisma cannot sort + // by a computed distance; the caller renders a picker in chronological + // order, which is how a person reads their own week anyway. + orderBy: { occurredAt: "asc" }, + take: MAX_CANDIDATES, + select: { + id: true, + occurredAt: true, + status: true, + kind: true, + practitioner: { select: { name: true } }, + }, + }); + + const encounters: EncounterSuggestion[] = rows.map((row) => ({ + id: row.id, + occurredAt: row.occurredAt.toISOString(), + status: row.status, + kind: row.kind, + practitionerName: row.practitioner?.name ?? null, + })); + + if (encounters.length === 0) return { kind: "none" }; + if (encounters.length === 1) return { kind: "one", encounter: encounters[0] }; + return { kind: "many", encounters }; +} diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index 15a860b41..d46141083 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -21,6 +21,7 @@ import { encounterCreateSchema, encounterUpdateSchema, encounterListQuerySchema, + encounterSuggestQuerySchema, encounterLinkSchema, encounterStatusEnum, encounterKindEnum, @@ -56,6 +57,12 @@ encounterListQuerySchema.meta({ "Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).", }); +encounterSuggestQuerySchema.meta({ + id: "SuggestEncounterQuery", + description: + "The date to resolve around, as an ISO-8601 instant with an offset. A document uses `reportDate ?? documentDate`; a lab panel uses `takenAt`.", +}); + encounterLinkSchema.meta({ id: "EncounterLinkRequest", description: @@ -172,6 +179,35 @@ const encounterList = z "Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.", }); +const suggestionCandidate = z + .object({ + id: z.string(), + occurredAt: z.string(), + status: encounterStatusEnum, + kind: encounterKindEnum, + practitionerName: z.string().nullable(), + }) + .meta({ + id: "EncounterSuggestionCandidate", + description: + "One visit a record might belong to, resolved far enough to render a row without a second round trip. `practitionerName` is null when the visit names no practice, which is an absence rather than a gap.", + }); + +const encounterSuggestion = z + .discriminatedUnion("kind", [ + z.object({ kind: z.literal("one"), encounter: suggestionCandidate }), + z.object({ + kind: z.literal("many"), + encounters: z.array(suggestionCandidate), + }), + z.object({ kind: z.literal("none") }), + ]) + .meta({ + id: "EncounterSuggestion", + description: + "The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to make impossible.", + }); + const encounterNotFound = { "404": { description: "Visit not found (or owned by another user).", @@ -232,6 +268,30 @@ export const encounterPaths: NonNullable = { }, }, }, + "/api/encounters/suggest": { + get: { + tags: ["Records"], + summary: "Which visit does a record dated around this day belong to?", + description: + "Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: neither happened, so neither produced the record being filed. No AI provider is involved and none is required.", + requestParams: { query: encounterSuggestQuerySchema }, + responses: { + ...recordRefusal(), + "200": { + description: "The verdict.", + content: { + "application/json": { + schema: dataEnvelope( + encounterSuggestion, + "SuggestEncounterEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/encounters/{id}": { get: { tags: ["Records"], diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index d59d9d591..a5ea82533 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -34,6 +34,14 @@ export const encounterKeys = { encounterList: (from: string | null, to: string | null, status?: string) => ["encounters", "list", from, to, status ?? null] as const, encounter: (id: string) => ["encounters", "detail", id] as const, + /** + * The "which visit does this belong to" verdict for one anchor date. The + * anchor is part of the key because it IS the question: a document dated the + * 3rd and one dated the 20th get different answers, and one overwriting the + * other would pre-select the wrong visit on the second review step. + */ + encounterSuggestion: (anchor: string) => + ["encounters", "suggest", anchor] as const, /** * The address book. Under its own root rather than nested beneath * `["encounters"]`: a practitioner outlives the visits that name it, and a diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 2217b53be..ce6a1d54c 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -148,6 +148,22 @@ export const encounterListQuerySchema = z export type EncounterListQuery = z.infer; +/** + * The suggestion query. + * + * One input, and it is a date rather than an id: the answer is derived from the + * caller's own visits and the anchor only says where to look. Bounded by the + * same instant rule the visit itself carries, so an anchor a client mis-parsed + * into the year 12000 is a 422 rather than a full-table scan. + */ +export const encounterSuggestQuerySchema = z + .object({ + anchor: visitInstant, + }) + .strict(); + +export type EncounterSuggestQuery = z.infer; + /* ── links ────────────────────────────────────────────────────────── */ export const encounterLinkTargetEnum = z.enum([ From ed1438230c8ee161b8b6c0935a155219c4650b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 13:21:43 +0200 Subject: [PATCH 03/10] feat(encounters): visits and the address book become reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /checkups gains a second section rather than a nav entry. The page already owns the right sentence — when do I have to do what, where — and is deliberately not module gated, which is the same reason a visit carries no module key of its own: what a visit may point AT follows the modules that own those things, so the gate sits on the three link pickers and blanks the block rather than rendering an empty one. A visit saves with a date and nothing else. No required practitioner, no required kind, no required link, and the only thing that can disable Save is a date that will not parse. The status is derived from the date on a new visit and only becomes a field on an existing one, because somebody booking an appointment should not also have to tell the app that it is in the future. The address book is record content, not configuration, so it sits under /checkups rather than in Settings — and it inherits that page's route-family classification with it. Deleting an entry says in one sentence that the old visits stay, because a person deleting a doctor has every reason to fear otherwise. Two defects closed on the way: - /checkups carried sharedRecord: true and appeared in neither route family, so a scoped grant could not present it at all. It was invisible while a whole-record grant admitted every path; the visits section makes it load bearing, and the page is now listed under both domains whose content it shows. Deduplicated, or a grant holding both would offer the page twice. - the address-book search read the name only, so looking a practice up by the name on the door returned nothing and read as "not in the book". The practitioner search description in the OpenAPI module moved with it. --- messages/de.json | 82 ++++- messages/en.json | 82 ++++- messages/es.json | 82 ++++- messages/fr.json | 82 ++++- messages/it.json | 82 ++++- messages/pl.json | 82 ++++- .../destructive-control-guard.test.ts | 20 ++ .../shared-record-navigation.test.tsx | 36 ++- src/app/api/practitioners/route.ts | 21 +- src/app/checkups/page.tsx | 45 ++- src/app/checkups/practitioners/page.tsx | 28 ++ .../__tests__/encounter-form.test.tsx | 184 ++++++++++++ .../__tests__/visits-section.test.tsx | 127 ++++++++ src/components/encounters/encounter-form.tsx | 283 ++++++++++++++++++ src/components/encounters/encounter-labels.ts | 74 +++++ .../encounters/encounter-link-pickers.tsx | 248 +++++++++++++++ src/components/encounters/encounter-sheet.tsx | 207 +++++++++++++ .../encounters/practitioner-combobox.tsx | 177 +++++++++++ src/components/encounters/visit-card.tsx | 127 ++++++++ src/components/encounters/visits-section.tsx | 147 +++++++++ .../practitioners/practitioner-list.tsx | 228 ++++++++++++++ .../practitioners/practitioner-sheet.tsx | 205 +++++++++++++ src/hooks/use-encounters.ts | 117 ++++++++ src/hooks/use-practitioners.ts | 72 +++++ src/lib/navigation/shared-record.ts | 21 +- src/lib/openapi/routes/encounters.ts | 2 +- src/lib/query-keys/encounters.ts | 9 + 27 files changed, 2851 insertions(+), 19 deletions(-) create mode 100644 src/app/checkups/practitioners/page.tsx create mode 100644 src/components/encounters/__tests__/encounter-form.test.tsx create mode 100644 src/components/encounters/__tests__/visits-section.test.tsx create mode 100644 src/components/encounters/encounter-form.tsx create mode 100644 src/components/encounters/encounter-labels.ts create mode 100644 src/components/encounters/encounter-link-pickers.tsx create mode 100644 src/components/encounters/encounter-sheet.tsx create mode 100644 src/components/encounters/practitioner-combobox.tsx create mode 100644 src/components/encounters/visit-card.tsx create mode 100644 src/components/encounters/visits-section.tsx create mode 100644 src/components/practitioners/practitioner-list.tsx create mode 100644 src/components/practitioners/practitioner-sheet.tsx create mode 100644 src/hooks/use-encounters.ts create mode 100644 src/hooks/use-practitioners.ts diff --git a/messages/de.json b/messages/de.json index 22e0b9015..8d03ea71b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10073,6 +10073,86 @@ "hospital": "Krankenhaus", "therapy": "Therapietermin", "other": "Arzttermin" - } + }, + "sectionTitle": "Termine", + "sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.", + "add": "Termin", + "loadError": "Termine konnten nicht geladen werden.", + "emptyTitle": "Noch kein Termin erfasst", + "emptyDescription": "Ein Datum genügt. Alles andere ist freiwillig.", + "upcomingHeading": "Anstehend", + "pastHeading": "Vergangen", + "createTitle": "Termin erfassen", + "editTitle": "Termin bearbeiten", + "formDescription": "Nur das Datum ist Pflicht.", + "created": "Termin gespeichert.", + "updated": "Termin aktualisiert.", + "saveFailed": "Termin konnte nicht gespeichert werden.", + "checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen — dafür reicht deine Freigabe nicht.", + "status": { + "planned": "Geplant", + "done": "Stattgefunden", + "cancelled": "Abgesagt", + "noShow": "Nicht wahrgenommen" + }, + "card": { + "documentCount": "verknüpfte Dokumente", + "labCount": "verknüpfte Laborwerte", + "conditionCount": "verknüpfte Erkrankungen" + }, + "form": { + "date": "Datum und Uhrzeit", + "dateDerivesStatusHint": "Ein Datum in der Zukunft ist ein gebuchter Termin.", + "status": "Status", + "practitioner": "Praxis oder Ärztin", + "practitionerHint": "Optional — du kannst den Namen später nachtragen.", + "practitionerNone": "Keine Angabe", + "practitionerSearch": "Name oder Praxis suchen", + "practitionerNoMatch": "Kein Eintrag gefunden.", + "practitionerAdd": "Neu anlegen", + "practitionerClear": "Auswahl entfernen", + "kind": "Art", + "reason": "Anlass", + "outcome": "Ergebnis", + "outcomeReveal": "Ergebnis eintragen", + "linkDocuments": "Dokumente", + "linkLabResults": "Laborwerte", + "linkConditions": "Erkrankungen", + "linkNothingToOffer": "Nichts zum Verknüpfen vorhanden." + }, + "deleteTitle": "Termin löschen?", + "deleteDescription": "Der Eintrag wird entfernt. Ist der Termin gebucht, entfällt auch die Erinnerung dazu.", + "deleteFailed": "Termin konnte nicht gelöscht werden." + }, + "checkups": { + "viewSwitchLabel": "Vorsorge oder Termine anzeigen", + "tabVorsorge": "Vorsorge", + "tabVisits": "Termine" + }, + "practitioners": { + "title": "Praxen und Ärzte", + "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.", + "linkLabel": "Adressbuch", + "add": "Eintrag", + "searchPlaceholder": "Name oder Praxis suchen", + "loadError": "Adressbuch konnte nicht geladen werden.", + "emptyTitle": "Noch kein Eintrag", + "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.", + "noMatchTitle": "Kein Treffer", + "noMatchDescription": "Zu dieser Suche gibt es keinen Eintrag.", + "createTitle": "Eintrag anlegen", + "editTitle": "Eintrag bearbeiten", + "formDescription": "Nur der Name ist Pflicht.", + "name": "Name", + "specialty": "Fachrichtung", + "practice": "Praxis", + "location": "Adresse", + "phone": "Telefon", + "note": "Notiz", + "noteHint": "Verschlüsselt gespeichert.", + "saveFailed": "Eintrag konnte nicht gespeichert werden.", + "deleteTitle": "Eintrag löschen?", + "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", + "deleteFailed": "Eintrag konnte nicht gelöscht werden." } } diff --git a/messages/en.json b/messages/en.json index afacc42d2..c62cff149 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10073,6 +10073,86 @@ "hospital": "Hospital", "therapy": "Therapy session", "other": "Appointment" - } + }, + "sectionTitle": "Visits", + "sectionDescription": "Doctor's visits and booked appointments in one place.", + "add": "Visit", + "loadError": "Visits could not be loaded.", + "emptyTitle": "No visit recorded yet", + "emptyDescription": "A date is enough. Everything else is optional.", + "upcomingHeading": "Upcoming", + "pastHeading": "Past", + "createTitle": "Record a visit", + "editTitle": "Edit visit", + "formDescription": "Only the date is required.", + "created": "Visit saved.", + "updated": "Visit updated.", + "saveFailed": "The visit could not be saved.", + "checkupNotClosed": "Visit saved. The checkup stays open — your access does not cover that.", + "status": { + "planned": "Planned", + "done": "Attended", + "cancelled": "Cancelled", + "noShow": "Missed" + }, + "card": { + "documentCount": "linked documents", + "labCount": "linked lab results", + "conditionCount": "linked conditions" + }, + "form": { + "date": "Date and time", + "dateDerivesStatusHint": "A date in the future is a booked appointment.", + "status": "Status", + "practitioner": "Practice or doctor", + "practitionerHint": "Optional — you can add the name later.", + "practitionerNone": "Not specified", + "practitionerSearch": "Search name or practice", + "practitionerNoMatch": "No entry found.", + "practitionerAdd": "Add new", + "practitionerClear": "Clear selection", + "kind": "Kind", + "reason": "Reason", + "outcome": "Outcome", + "outcomeReveal": "Add an outcome", + "linkDocuments": "Documents", + "linkLabResults": "Lab results", + "linkConditions": "Conditions", + "linkNothingToOffer": "Nothing here to link yet." + }, + "deleteTitle": "Delete this visit?", + "deleteDescription": "The entry is removed. If the visit is booked, its reminder stops with it.", + "deleteFailed": "The visit could not be deleted." + }, + "checkups": { + "viewSwitchLabel": "Show checkups or visits", + "tabVorsorge": "Checkups", + "tabVisits": "Visits" + }, + "practitioners": { + "title": "Practices and doctors", + "description": "Your own address book — type a practice once, pick it everywhere.", + "linkLabel": "Address book", + "add": "Entry", + "searchPlaceholder": "Search name or practice", + "loadError": "The address book could not be loaded.", + "emptyTitle": "No entry yet", + "emptyDescription": "Add a practice so you can pick it when you record a visit.", + "noMatchTitle": "No match", + "noMatchDescription": "No entry matches this search.", + "createTitle": "Add an entry", + "editTitle": "Edit entry", + "formDescription": "Only the name is required.", + "name": "Name", + "specialty": "Specialty", + "practice": "Practice", + "location": "Address", + "phone": "Phone", + "note": "Note", + "noteHint": "Stored encrypted.", + "saveFailed": "The entry could not be saved.", + "deleteTitle": "Delete this entry?", + "deleteDescription": "Your existing visits stay, and they keep this name.", + "deleteFailed": "The entry could not be deleted." } } diff --git a/messages/es.json b/messages/es.json index 7cec1ba45..a7c38c795 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10073,6 +10073,86 @@ "hospital": "Hospital", "therapy": "Sesión de terapia", "other": "Cita médica" - } + }, + "sectionTitle": "Citas", + "sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.", + "add": "Cita", + "loadError": "No se pudieron cargar las citas.", + "emptyTitle": "Todavía no hay ninguna cita", + "emptyDescription": "Basta con una fecha. Todo lo demás es opcional.", + "upcomingHeading": "Próximas", + "pastHeading": "Pasadas", + "createTitle": "Registrar una cita", + "editTitle": "Editar la cita", + "formDescription": "Solo la fecha es obligatoria.", + "created": "Cita guardada.", + "updated": "Cita actualizada.", + "saveFailed": "No se pudo guardar la cita.", + "checkupNotClosed": "Cita guardada. La revisión sigue pendiente: tu acceso no lo permite.", + "status": { + "planned": "Prevista", + "done": "Realizada", + "cancelled": "Cancelada", + "noShow": "No acudida" + }, + "card": { + "documentCount": "documentos vinculados", + "labCount": "resultados de laboratorio vinculados", + "conditionCount": "enfermedades vinculadas" + }, + "form": { + "date": "Fecha y hora", + "dateDerivesStatusHint": "Una fecha futura es una cita reservada.", + "status": "Estado", + "practitioner": "Consulta o médico", + "practitionerHint": "Opcional: puedes añadir el nombre más tarde.", + "practitionerNone": "Sin indicar", + "practitionerSearch": "Buscar nombre o consulta", + "practitionerNoMatch": "No se encontró ninguna entrada.", + "practitionerAdd": "Añadir nueva", + "practitionerClear": "Quitar la selección", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Resultado", + "outcomeReveal": "Añadir un resultado", + "linkDocuments": "Documentos", + "linkLabResults": "Resultados de laboratorio", + "linkConditions": "Enfermedades", + "linkNothingToOffer": "Todavía no hay nada que vincular." + }, + "deleteTitle": "¿Eliminar esta cita?", + "deleteDescription": "Se elimina la entrada. Si la cita está reservada, su recordatorio también desaparece.", + "deleteFailed": "No se pudo eliminar la cita." + }, + "checkups": { + "viewSwitchLabel": "Mostrar revisiones o citas", + "tabVorsorge": "Revisiones", + "tabVisits": "Citas" + }, + "practitioners": { + "title": "Consultas y médicos", + "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.", + "linkLabel": "Agenda", + "add": "Entrada", + "searchPlaceholder": "Buscar nombre o consulta", + "loadError": "No se pudo cargar la agenda.", + "emptyTitle": "Todavía no hay entradas", + "emptyDescription": "Añade una consulta para poder elegirla al registrar una cita.", + "noMatchTitle": "Sin coincidencias", + "noMatchDescription": "Ninguna entrada coincide con esta búsqueda.", + "createTitle": "Añadir una entrada", + "editTitle": "Editar la entrada", + "formDescription": "Solo el nombre es obligatorio.", + "name": "Nombre", + "specialty": "Especialidad", + "practice": "Consulta", + "location": "Dirección", + "phone": "Teléfono", + "note": "Nota", + "noteHint": "Se guarda cifrada.", + "saveFailed": "No se pudo guardar la entrada.", + "deleteTitle": "¿Eliminar esta entrada?", + "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", + "deleteFailed": "No se pudo eliminar la entrada." } } diff --git a/messages/fr.json b/messages/fr.json index d91f2cf3d..3b69dd66d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10073,6 +10073,86 @@ "hospital": "Hôpital", "therapy": "Séance de thérapie", "other": "Rendez-vous médical" - } + }, + "sectionTitle": "Rendez-vous", + "sectionDescription": "Consultations et rendez-vous pris, au même endroit.", + "add": "Rendez-vous", + "loadError": "Impossible de charger les rendez-vous.", + "emptyTitle": "Aucun rendez-vous enregistré", + "emptyDescription": "Une date suffit. Le reste est facultatif.", + "upcomingHeading": "À venir", + "pastHeading": "Passés", + "createTitle": "Enregistrer un rendez-vous", + "editTitle": "Modifier le rendez-vous", + "formDescription": "Seule la date est obligatoire.", + "created": "Rendez-vous enregistré.", + "updated": "Rendez-vous mis à jour.", + "saveFailed": "Le rendez-vous n'a pas pu être enregistré.", + "checkupNotClosed": "Rendez-vous enregistré. Le dépistage reste ouvert : votre accès ne le couvre pas.", + "status": { + "planned": "Prévu", + "done": "Effectué", + "cancelled": "Annulé", + "noShow": "Non honoré" + }, + "card": { + "documentCount": "documents liés", + "labCount": "résultats de laboratoire liés", + "conditionCount": "maladies liées" + }, + "form": { + "date": "Date et heure", + "dateDerivesStatusHint": "Une date à venir correspond à un rendez-vous pris.", + "status": "Statut", + "practitioner": "Cabinet ou médecin", + "practitionerHint": "Facultatif : vous pourrez ajouter le nom plus tard.", + "practitionerNone": "Non précisé", + "practitionerSearch": "Rechercher un nom ou un cabinet", + "practitionerNoMatch": "Aucune entrée trouvée.", + "practitionerAdd": "Créer une entrée", + "practitionerClear": "Retirer la sélection", + "kind": "Type", + "reason": "Motif", + "outcome": "Résultat", + "outcomeReveal": "Ajouter un résultat", + "linkDocuments": "Documents", + "linkLabResults": "Résultats de laboratoire", + "linkConditions": "Maladies", + "linkNothingToOffer": "Rien à lier pour le moment." + }, + "deleteTitle": "Supprimer ce rendez-vous ?", + "deleteDescription": "L'entrée est supprimée. Si le rendez-vous est pris, son rappel disparaît avec elle.", + "deleteFailed": "Le rendez-vous n'a pas pu être supprimé." + }, + "checkups": { + "viewSwitchLabel": "Afficher les dépistages ou les rendez-vous", + "tabVorsorge": "Dépistages", + "tabVisits": "Rendez-vous" + }, + "practitioners": { + "title": "Cabinets et médecins", + "description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.", + "linkLabel": "Carnet d'adresses", + "add": "Entrée", + "searchPlaceholder": "Rechercher un nom ou un cabinet", + "loadError": "Impossible de charger le carnet d'adresses.", + "emptyTitle": "Aucune entrée", + "emptyDescription": "Ajoutez un cabinet pour pouvoir le choisir lors d'un rendez-vous.", + "noMatchTitle": "Aucun résultat", + "noMatchDescription": "Aucune entrée ne correspond à cette recherche.", + "createTitle": "Ajouter une entrée", + "editTitle": "Modifier l'entrée", + "formDescription": "Seul le nom est obligatoire.", + "name": "Nom", + "specialty": "Spécialité", + "practice": "Cabinet", + "location": "Adresse", + "phone": "Téléphone", + "note": "Note", + "noteHint": "Enregistrée chiffrée.", + "saveFailed": "L'entrée n'a pas pu être enregistrée.", + "deleteTitle": "Supprimer cette entrée ?", + "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", + "deleteFailed": "L'entrée n'a pas pu être supprimée." } } diff --git a/messages/it.json b/messages/it.json index 60bfd0bf4..c79d33ad0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10073,6 +10073,86 @@ "hospital": "Ospedale", "therapy": "Seduta di terapia", "other": "Appuntamento medico" - } + }, + "sectionTitle": "Appuntamenti", + "sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.", + "add": "Appuntamento", + "loadError": "Impossibile caricare gli appuntamenti.", + "emptyTitle": "Nessun appuntamento registrato", + "emptyDescription": "Basta una data. Tutto il resto è facoltativo.", + "upcomingHeading": "In arrivo", + "pastHeading": "Passati", + "createTitle": "Registra un appuntamento", + "editTitle": "Modifica appuntamento", + "formDescription": "Solo la data è obbligatoria.", + "created": "Appuntamento salvato.", + "updated": "Appuntamento aggiornato.", + "saveFailed": "Non è stato possibile salvare l'appuntamento.", + "checkupNotClosed": "Appuntamento salvato. Il controllo resta aperto: il tuo accesso non lo comprende.", + "status": { + "planned": "Previsto", + "done": "Effettuato", + "cancelled": "Annullato", + "noShow": "Non presentato" + }, + "card": { + "documentCount": "documenti collegati", + "labCount": "esami di laboratorio collegati", + "conditionCount": "malattie collegate" + }, + "form": { + "date": "Data e ora", + "dateDerivesStatusHint": "Una data futura è un appuntamento fissato.", + "status": "Stato", + "practitioner": "Studio o medico", + "practitionerHint": "Facoltativo: puoi aggiungere il nome più tardi.", + "practitionerNone": "Non indicato", + "practitionerSearch": "Cerca nome o studio", + "practitionerNoMatch": "Nessuna voce trovata.", + "practitionerAdd": "Aggiungi nuova", + "practitionerClear": "Rimuovi la selezione", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Esito", + "outcomeReveal": "Aggiungi un esito", + "linkDocuments": "Documenti", + "linkLabResults": "Esami di laboratorio", + "linkConditions": "Malattie", + "linkNothingToOffer": "Per ora non c'è nulla da collegare." + }, + "deleteTitle": "Eliminare questo appuntamento?", + "deleteDescription": "La voce viene rimossa. Se l'appuntamento è fissato, anche il promemoria sparisce.", + "deleteFailed": "Non è stato possibile eliminare l'appuntamento." + }, + "checkups": { + "viewSwitchLabel": "Mostra controlli o appuntamenti", + "tabVorsorge": "Controlli", + "tabVisits": "Appuntamenti" + }, + "practitioners": { + "title": "Studi e medici", + "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.", + "linkLabel": "Rubrica", + "add": "Voce", + "searchPlaceholder": "Cerca nome o studio", + "loadError": "Impossibile caricare la rubrica.", + "emptyTitle": "Nessuna voce", + "emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.", + "noMatchTitle": "Nessuna corrispondenza", + "noMatchDescription": "Nessuna voce corrisponde a questa ricerca.", + "createTitle": "Aggiungi una voce", + "editTitle": "Modifica la voce", + "formDescription": "Solo il nome è obbligatorio.", + "name": "Nome", + "specialty": "Specializzazione", + "practice": "Studio", + "location": "Indirizzo", + "phone": "Telefono", + "note": "Nota", + "noteHint": "Salvata cifrata.", + "saveFailed": "Non è stato possibile salvare la voce.", + "deleteTitle": "Eliminare questa voce?", + "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", + "deleteFailed": "Non è stato possibile eliminare la voce." } } diff --git a/messages/pl.json b/messages/pl.json index cd1f4c71e..6ac56d55c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10073,6 +10073,86 @@ "hospital": "Szpital", "therapy": "Sesja terapeutyczna", "other": "Wizyta lekarska" - } + }, + "sectionTitle": "Wizyty", + "sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.", + "add": "Wizyta", + "loadError": "Nie udało się wczytać wizyt.", + "emptyTitle": "Brak zapisanych wizyt", + "emptyDescription": "Wystarczy data. Reszta jest opcjonalna.", + "upcomingHeading": "Nadchodzące", + "pastHeading": "Minione", + "createTitle": "Zapisz wizytę", + "editTitle": "Edytuj wizytę", + "formDescription": "Wymagana jest tylko data.", + "created": "Wizyta zapisana.", + "updated": "Wizyta zaktualizowana.", + "saveFailed": "Nie udało się zapisać wizyty.", + "checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte — twój dostęp tego nie obejmuje.", + "status": { + "planned": "Zaplanowana", + "done": "Odbyta", + "cancelled": "Odwołana", + "noShow": "Niewykorzystana" + }, + "card": { + "documentCount": "powiązane dokumenty", + "labCount": "powiązane wyniki badań", + "conditionCount": "powiązane choroby" + }, + "form": { + "date": "Data i godzina", + "dateDerivesStatusHint": "Data w przyszłości oznacza umówiony termin.", + "status": "Status", + "practitioner": "Gabinet lub lekarz", + "practitionerHint": "Opcjonalne — nazwę możesz dodać później.", + "practitionerNone": "Nie podano", + "practitionerSearch": "Szukaj nazwiska lub gabinetu", + "practitionerNoMatch": "Nie znaleziono wpisu.", + "practitionerAdd": "Dodaj nowy", + "practitionerClear": "Usuń wybór", + "kind": "Rodzaj", + "reason": "Powód", + "outcome": "Wynik", + "outcomeReveal": "Dodaj wynik", + "linkDocuments": "Dokumenty", + "linkLabResults": "Wyniki badań", + "linkConditions": "Choroby", + "linkNothingToOffer": "Na razie nie ma nic do powiązania." + }, + "deleteTitle": "Usunąć tę wizytę?", + "deleteDescription": "Wpis zostanie usunięty. Jeśli wizyta jest umówiona, przypomnienie o niej też zniknie.", + "deleteFailed": "Nie udało się usunąć wizyty." + }, + "checkups": { + "viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty", + "tabVorsorge": "Profilaktyka", + "tabVisits": "Wizyty" + }, + "practitioners": { + "title": "Gabinety i lekarze", + "description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.", + "linkLabel": "Książka adresowa", + "add": "Wpis", + "searchPlaceholder": "Szukaj nazwiska lub gabinetu", + "loadError": "Nie udało się wczytać książki adresowej.", + "emptyTitle": "Brak wpisów", + "emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.", + "noMatchTitle": "Brak wyników", + "noMatchDescription": "Żaden wpis nie pasuje do tego wyszukiwania.", + "createTitle": "Dodaj wpis", + "editTitle": "Edytuj wpis", + "formDescription": "Wymagana jest tylko nazwa.", + "name": "Nazwa", + "specialty": "Specjalizacja", + "practice": "Gabinet", + "location": "Adres", + "phone": "Telefon", + "note": "Notatka", + "noteHint": "Zapisywana w postaci zaszyfrowanej.", + "saveFailed": "Nie udało się zapisać wpisu.", + "deleteTitle": "Usunąć ten wpis?", + "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", + "deleteFailed": "Nie udało się usunąć wpisu." } } diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 8d798ce6a..413863fac 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -388,6 +388,26 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["AlertDialog"], }, + // ── Visits and the address book ───────────────────────────────────────── + { + file: "hooks/use-encounters.ts", + destroys: + "a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it", + triggers: ["components/encounters/encounter-sheet.tsx"], + // The route soft-deletes and a restore route exists, but nothing in the + // product reaches it yet, so from a person's side the visit is gone. This + // becomes "restorable" the day a surface offers the undo, not before. + recovery: "tombstoned-no-restore", + confirm: ["AlertDialog"], + }, + { + file: "hooks/use-practitioners.ts", + destroys: "one address-book entry", + triggers: ["components/practitioners/practitioner-list.tsx"], + recovery: "tombstoned-no-restore", + confirm: ["AlertDialog"], + }, + // ── Cycle ─────────────────────────────────────────────────────────────── { file: "components/cycle/use-cycle.ts", diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index 3413dc1a3..65cf82ca5 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -44,12 +44,44 @@ describe("shared-record navigation", () => { it("keeps profile facts outside Settings and makes profile the scoped doorway", () => { const navigation = resolveSharedRecordNavigation(["profile"]); - expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]); - expect(navigation.destinationHrefs).toEqual(["/profile"]); + expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([ + "/profile", + "/checkups", + ]); + expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]); expect(navigation.allowsPath("/profile")).toBe(true); expect(navigation.allowsPath("/settings/anamnesis")).toBe(false); }); + it("presents the checkups page under both domains whose content it shows", () => { + // The page holds two things: the preventive-care list, which is a + // `measurements` read, and the visits section, which is `profile`. A grant + // naming either one must be able to reach it; a grant naming neither must + // not. Before the visits section the `measurements` omission was invisible, + // because a whole-record grant admits every path regardless. + expect( + resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"), + ).toBe(true); + expect( + resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"), + ).toBe(true); + expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe( + true, + ); + expect( + resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"), + ).toBe(false); + + // Listed twice, offered once: a grant holding both domains must not put + // two identical entries in the navigation. + expect( + resolveSharedRecordNavigation([ + "measurements", + "profile", + ]).destinationHrefs.filter((href) => href === "/checkups"), + ).toEqual(["/checkups"]); + }); + it("uses the active record scope instead of the actor's module flags", () => { const actorModules = { medications: false, diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index 04a495b5a..bac66da4b 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null, + // Both plaintext columns, because a person looks a practice up by + // whichever of the two they remember: the doctor's name on the referral + // or the practice name on the door. Searching only the name made the + // second attempt return nothing and read as "not in the address book". ...(parsed.data.q - ? { name: { contains: parsed.data.q, mode: "insensitive" as const } } + ? { + OR: [ + { + name: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + { + practice: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + ], + } : {}), }, orderBy: { name: "asc" }, diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 2105fb54b..4b2fbf1c5 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; @@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; -import { queryKeys } from "@/lib/query-keys"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; /** * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated @@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys"; * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood, * measurements): a pull invalidates the measurement-reminders read so the * list + server-computed next-due repaint. + * + * The page now holds two things, split by a segmented control rather than by a + * second nav entry: the preventive-care list, and the visits. They answer the + * same question from two directions — what do I still have to arrange, and what + * did I actually attend — and the page's own sentence already covers both. A + * visit carries no module gate for the same reason this page never had one. + * + * The pull invalidates both reads. Refreshing one and leaving the other stale + * is how a page ends up showing a visit that closed a checkup still listed as + * due. */ export default function CheckupsPage() { const { isAuthenticated, isLoading } = useAuth(); const mounted = useMounted(); const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [view, setView] = useState("vorsorge"); const refresh = useCallback( () => - queryClient.invalidateQueries({ - queryKey: queryKeys.measurementReminders(), - }), + invalidateKeys(queryClient, [ + queryKeys.measurementReminders(), + queryKeys.encounters(), + ]), [queryClient], ); const pull = usePullToRefresh({ onRefresh: refresh }); @@ -41,7 +57,24 @@ export default function CheckupsPage() { return (
- + + + + {t("checkups.tabVorsorge")} + + + {t("checkups.tabVisits")} + + + + + + + {/* Mounted only on the active tab, so the visits read does not fire + for a person who never opens it. */} + + +
); } diff --git a/src/app/checkups/practitioners/page.tsx b/src/app/checkups/practitioners/page.tsx new file mode 100644 index 000000000..1a4d27e7f --- /dev/null +++ b/src/app/checkups/practitioners/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { PractitionerList } from "@/components/practitioners/practitioner-list"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; + +/** + * The address book, one level under the checkups page the visits live on. + * + * Deliberately not in Settings. The Settings IA rule is that a settings surface + * configures behaviour; a list of one's own doctors configures nothing and is + * record content like every other clinical list. Sitting under `/checkups` + * also means it inherits that page's route-family classification, so a + * `profile`-scoped delegate reaches it exactly as they reach the visits. + */ +export default function PractitionersPage() { + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + + if (!mounted || isLoading) return ; + + return ( +
+ +
+ ); +} diff --git a/src/components/encounters/__tests__/encounter-form.test.tsx b/src/components/encounters/__tests__/encounter-form.test.tsx new file mode 100644 index 000000000..ee7b9a04b --- /dev/null +++ b/src/components/encounters/__tests__/encounter-form.test.tsx @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The one product constraint this whole feature exists to hold: **a visit + * saves with a date and nothing else.** + * + * Three properties, each of which has an obvious way to get broken by a later + * "improvement": + * + * - no field but the date can block Save; + * - the status is DERIVED from the date on a new visit, never asked; + * - a link picker whose module is off is BLANK, not empty — an empty picker + * for a switched-off module advertises a feature that is not there. + */ + +let modules: Record = {}; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canWrite: true }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + EncounterForm, + derivedStatus, + draftInstant, + draftToBody, + emptyDraft, +} from "../encounter-form"; + +/** + * The form body, rendered directly rather than through its sheet: the sheet is + * a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a + * test that went through it would assert against an empty string and pass for + * the wrong reason. + */ +function renderForm(over?: { + modules?: Record; + isEdit?: boolean; +}) { + modules = over?.modules ?? {}; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + + {}} + isEdit={over?.isEdit ?? false} + /> + + , + ); +} + +describe("nothing but the date is required", () => { + it("admits a draft carrying only a date", () => { + // `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft + // with no practitioner, no reason, no outcome and no link is saveable. + const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" }); + expect(draft.practitioner).toBeNull(); + expect(draft.reason).toBe(""); + expect(draft.documentIds).toEqual([]); + expect(draftInstant(draft.occurredAt)).not.toBeNull(); + }); + + it("refuses only when the date itself cannot be read", () => { + expect(draftInstant("not a date")).toBeNull(); + }); + + it("marks the date field required and nothing else", () => { + const html = renderForm(); + const required = html.match(/aria-required="true"/g) ?? []; + expect(required).toHaveLength(1); + expect(html).toContain('data-testid="encounter-occurred-at"'); + }); + + it("turns a date-only draft into a complete write body", () => { + const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), { + isEdit: false, + }); + expect(body).not.toBeNull(); + expect(body?.practitionerId).toBeNull(); + expect(body?.reason).toBeNull(); + expect(body?.outcome).toBeNull(); + expect(body?.documentIds).toEqual([]); + }); + + it("refuses to build a body from a date that does not parse", () => { + expect( + draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }), + ).toBeNull(); + }); +}); + +describe("the status follows the date on a new visit", () => { + const now = Date.parse("2026-08-08T12:00:00.000Z"); + + it("reads a future instant as a booked appointment", () => { + expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED"); + }); + + it("reads a past instant as a visit that happened", () => { + expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE"); + }); + + it("never asks for the status on a new visit", () => { + // The picker is what "asked" would look like; its absence is the property. + expect(renderForm()).not.toContain('id="encounter-status"'); + }); + + it("carries the derived status into the body rather than the draft default", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }), + { isEdit: false }, + ); + expect(body?.status).toBe("PLANNED"); + }); + + it("uses the draft's own status on an edit, where it is editable", () => { + const body = draftToBody( + emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }), + { isEdit: true }, + ); + expect(body?.status).toBe("CANCELLED"); + }); +}); + +describe("the link pickers follow the modules that own their targets", () => { + it("renders no link block at all when all three modules are off", () => { + const html = renderForm({ modules: {} }); + expect(html).not.toContain('data-slot="encounter-link-pickers"'); + expect(html).not.toContain('data-slot="encounter-link-documents"'); + expect(html).not.toContain('data-slot="encounter-link-labs"'); + expect(html).not.toContain('data-slot="encounter-link-conditions"'); + }); + + it("blanks the block of a disabled module while keeping its neighbours", () => { + const html = renderForm({ + modules: { inboundDocuments: true, labs: false, illness: false }, + }); + expect(html).toContain('data-slot="encounter-link-documents"'); + // Not "renders an empty list" — absent. + expect(html).not.toContain('data-slot="encounter-link-labs"'); + expect(html).not.toContain('data-slot="encounter-link-conditions"'); + }); + + it("offers all three when all three modules are on", () => { + const html = renderForm({ + modules: { inboundDocuments: true, labs: true, illness: true }, + }); + expect(html).toContain('data-slot="encounter-link-documents"'); + expect(html).toContain('data-slot="encounter-link-labs"'); + expect(html).toContain('data-slot="encounter-link-conditions"'); + // The positive control: without it the three assertions above would pass + // against a component that never renders any block. + expect(html).toContain('data-slot="encounter-link-pickers"'); + }); +}); diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx new file mode 100644 index 000000000..a7da7ed91 --- /dev/null +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * The visits surface's designed states, pinned by single-pass static renders. + * + * - an account with no visits paints the empty state and one action, not an + * error and not a teaching wall; + * - upcoming sorts above past, in the two lists the SERVER split — the + * client renders the order it was given rather than re-deriving one; + * - a soft-deleted visit never arrives, so it cannot be rendered. That is a + * server property and the API test owns it; what this file pins is that + * the section renders exactly what the response carried, with nothing + * filtered or re-sorted on this side that could disagree with it. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", modules: {} }, + isLoading: false, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canWrite: true }), +})); + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + // Never resolves inside a synchronous render, so an unseeded query stays + // pending and cannot accidentally satisfy an assertion. + apiGet: () => new Promise(() => {}), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { EncounterDTO } from "@/lib/encounters/dto"; +import { VisitsSection } from "../visits-section"; + +function visit(over: Partial & { id: string }): EncounterDTO { + return { + occurredAt: "2026-08-01T09:00:00.000Z", + status: "DONE", + kind: "ROUTINE", + practitioner: null, + reason: null, + outcome: null, + reminderNextDueAt: null, + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-01T09:00:00.000Z", + ...over, + }; +} + +function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData( + queryKeys.encounterList(null, null, undefined), + list, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + it("paints the empty state, with one action, when there is no visit", () => { + const html = render({ upcoming: [], past: [] }); + expect(html).toContain("No visit recorded yet"); + expect(html).toContain("A date is enough"); + expect(html).toContain('data-slot="visits-add"'); + // An empty account is not an error, and the two states never share copy. + expect(html).not.toContain("Visits could not be loaded."); + expect(html).not.toContain('data-slot="visits-upcoming"'); + }); + + it("puts the upcoming list above the past one", () => { + const html = render({ + upcoming: [visit({ id: "later", status: "PLANNED" })], + past: [visit({ id: "earlier" })], + }); + const upcomingAt = html.indexOf('data-slot="visits-upcoming"'); + const pastAt = html.indexOf('data-slot="visits-past"'); + expect(upcomingAt).toBeGreaterThan(-1); + expect(pastAt).toBeGreaterThan(-1); + expect(upcomingAt).toBeLessThan(pastAt); + expect(html.indexOf('data-encounter-id="later"')).toBeLessThan( + html.indexOf('data-encounter-id="earlier"'), + ); + }); + + it("renders the server's order inside a list rather than re-sorting it", () => { + // Two rows deliberately handed over in an order the client has no reason + // to like. Re-sorting here would be the second place the ordering lives. + const html = render({ + upcoming: [], + past: [ + visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }), + visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }), + ], + }); + expect(html.indexOf('data-encounter-id="second"')).toBeLessThan( + html.indexOf('data-encounter-id="first"'), + ); + }); + + it("offers the address book beside the add action", () => { + const html = render({ upcoming: [], past: [] }); + expect(html).toContain('href="/checkups/practitioners"'); + }); +}); diff --git a/src/components/encounters/encounter-form.tsx b/src/components/encounters/encounter-form.tsx new file mode 100644 index 000000000..5c611a276 --- /dev/null +++ b/src/components/encounters/encounter-form.tsx @@ -0,0 +1,283 @@ +"use client"; + +/** + * The visit form. + * + * **A visit saves with a date and nothing else.** No required practitioner, no + * required kind, no required link, and no Save button disabled until something + * is picked. That is the whole product constraint this feature exists to hold: + * a required field on a form somebody fills after a doctor's appointment is the + * bureaucracy it was built to avoid. The only thing that can block Save is a + * missing or unparseable date, because a visit with no instant is not a record + * of anything. + * + * Status is DERIVED from the date on a new visit, never asked: a future instant + * means PLANNED and a past one means DONE. A person booking an appointment + * should not have to also tell the app that it is in the future. On an existing + * visit the status becomes editable, because moving a planned visit to done, + * cancelled or a no-show is the transition that closes a checkup. + * + * The three link pickers are gated by the modules that own what they point at, + * and the gate BLANKS the block rather than post-filtering it — an empty + * picker for a module the account turned off advertises a feature that is not + * there. + */ +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { DateTimeField } from "@/components/ui/date-time-field"; +import { FieldGroup } from "@/components/ui/field-group"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Textarea } from "@/components/ui/textarea"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters"; +import type { Practitioner } from "@/hooks/use-practitioners"; +import { + ENCOUNTER_KINDS, + ENCOUNTER_STATUSES, + encounterKindText, + encounterStatusText, +} from "./encounter-labels"; +import { PractitionerCombobox } from "./practitioner-combobox"; +import { EncounterLinkPickers } from "./encounter-link-pickers"; + +export interface EncounterDraft { + /** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */ + occurredAt: string; + status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW"; + kind: string; + practitioner: Practitioner | null; + reason: string; + outcome: string; + documentIds: string[]; + labResultIds: string[]; + episodeIds: string[]; +} + +function localValue(instant: Date): string { + const offset = instant.getTimezoneOffset(); + return new Date(instant.getTime() - offset * 60_000) + .toISOString() + .slice(0, 16); +} + +export function nowLocalValue(): string { + return localValue(new Date()); +} + +/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */ +export function draftInstant(value: string): string | null { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +export function emptyDraft( + overrides?: Partial, +): EncounterDraft { + return { + occurredAt: nowLocalValue(), + status: "DONE", + kind: "OTHER", + practitioner: null, + reason: "", + outcome: "", + documentIds: [], + labResultIds: [], + episodeIds: [], + ...overrides, + }; +} + +export function draftFromEncounter(row: Encounter): EncounterDraft { + return { + occurredAt: localValue(new Date(row.occurredAt)), + status: row.status, + kind: row.kind, + practitioner: row.practitioner, + reason: row.reason ?? "", + outcome: row.outcome ?? "", + documentIds: row.links?.documents.map((link) => link.id) ?? [], + labResultIds: row.links?.labResults.map((link) => link.id) ?? [], + episodeIds: row.links?.conditions.map((link) => link.id) ?? [], + }; +} + +/** + * The status a NEW visit carries, read off its own date. + * + * Exported so the sheet and its tests agree on the derivation rather than each + * spelling out the comparison. + */ +export function derivedStatus( + occurredAt: string, + now = Date.now(), +): "PLANNED" | "DONE" { + const parsed = new Date(occurredAt).getTime(); + return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE"; +} + +/** The body a draft becomes, with the empty strings turned into absences. */ +export function draftToBody( + draft: EncounterDraft, + options: { isEdit: boolean; reminderId?: string | null }, +): EncounterWriteBody | null { + const occurredAt = draftInstant(draft.occurredAt); + if (!occurredAt) return null; + return { + occurredAt, + status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt), + kind: draft.kind, + practitionerId: draft.practitioner?.id ?? null, + reason: draft.reason.trim() || null, + outcome: draft.outcome.trim() || null, + ...(options.reminderId ? { reminderId: options.reminderId } : {}), + documentIds: draft.documentIds, + labResultIds: draft.labResultIds, + episodeIds: draft.episodeIds, + }; +} + +export function EncounterForm({ + draft, + onChange, + isEdit, +}: { + draft: EncounterDraft; + onChange: (next: EncounterDraft) => void; + isEdit: boolean; +}) { + const { t } = useTranslations(); + const { user } = useAuth(); + const patch = (part: Partial) => + onChange({ ...draft, ...part }); + + const planned = useMemo( + () => + isEdit + ? draft.status === "PLANNED" + : derivedStatus(draft.occurredAt) === "PLANNED", + [isEdit, draft.status, draft.occurredAt], + ); + + // The outcome field starts collapsed on a planned visit: there is nothing to + // write about a thing that has not happened. Derived rather than + // synchronised — a visit that already carries an outcome is never collapsed + // over it, and expressing that as an effect would be a second source of + // truth for something the draft already knows. + const [revealed, setRevealed] = useState(false); + const outcomeOpen = revealed || !planned || draft.outcome.length > 0; + + return ( +
+ + patch({ occurredAt: value })} + required + aria-required="true" + data-testid="encounter-occurred-at" + /> + + + {isEdit ? ( + + + patch({ + status: event.target.value as EncounterDraft["status"], + }) + } + > + {ENCOUNTER_STATUSES.map((status) => ( + + ))} + + + ) : null} + + + patch({ practitioner: next })} + /> + + + + patch({ kind: event.target.value })} + > + {ENCOUNTER_KINDS.map((kind) => ( + + ))} + + + + +