diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b3f680fd2..7c536f986 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -932,7 +932,7 @@ paths: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -1037,7 +1037,7 @@ paths: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -9199,6 +9199,10 @@ paths: enum: - MANUAL - OCR + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - takenAt responses: @@ -9500,6 +9504,10 @@ paths: type: string minLength: 1 maxLength: 64 + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - rows responses: @@ -9736,6 +9744,12 @@ paths: type: string minLength: 1 maxLength: 40 + - in: query + name: episodeId + schema: + type: string + minLength: 1 + maxLength: 40 - in: query name: limit schema: @@ -9848,6 +9862,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: @@ -11420,6 +11464,12 @@ paths: schema: type: string description: Only documents linked to this illness/condition episode. + - name: encounterId + in: query + required: false + schema: + type: string + description: Only documents linked to this visit. - name: year in: query required: false @@ -11538,6 +11588,12 @@ paths: type: array items: type: string + encounterIds: + description: Optional visit pre-links (repeat the field per id). Every id must be a live visit of the caller; an unknown + one refuses the upload rather than dropping the link the person asked for. + type: array + items: + type: string required: - file responses: @@ -11641,6 +11697,8 @@ paths: - setKind - linkEpisode - unlinkEpisode + - linkEncounter + - unlinkEncounter - delete - restore kind: @@ -11659,6 +11717,10 @@ paths: type: string minLength: 1 maxLength: 40 + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - ids - action @@ -11743,6 +11805,13 @@ paths: type: string minLength: 1 maxLength: 40 + encounterIds: + maxItems: 20 + type: array + items: + type: string + minLength: 1 + maxLength: 40 responses: "200": description: The updated document. @@ -15670,7 +15739,7 @@ components: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -16942,7 +17011,7 @@ components: enabledHeroItemKinds: description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds. - maxItems: 8 + maxItems: 9 type: array items: type: string @@ -16955,6 +17024,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -35046,8 +35116,10 @@ components: - labResults - conditions additionalProperties: false - description: The three link families of one visit, each ordered by when it was filed. Present on the detail response, - absent from the list. + description: "The three link families of one visit, each ordered by when it was filed. Present on BOTH the list and the + detail: a card shows counts of what the visit produced, and an edit opened from a list row seeds its pickers + from that row — a list without them would make the first read zero and the second unfile everything on save. The + list resolves them in three grouped queries for the whole page, not three per row." EncounterLinkedTarget: type: object properties: @@ -35124,6 +35196,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: @@ -36559,6 +36724,10 @@ components: type: array items: $ref: "#/components/schemas/DocumentConditionLink" + encounterLinks: + type: array + items: + $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: @@ -36596,6 +36765,7 @@ components: - factCount - pendingCount - conditionLinks + - encounterLinks - servingClass - hasContentIndex - contentIndexSource @@ -36625,6 +36795,26 @@ components: - name additionalProperties: false description: A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label. + DocumentEncounterLink: + type: object + properties: + encounterId: + type: string + kind: + type: string + occurredAt: + anyOf: + - type: string + - type: "null" + required: + - encounterId + - kind + - occurredAt + additionalProperties: false + description: "A link to one of the caller's visits — \"this letter belongs to that appointment\". `kind` is the + visit-kind ENUM CONSTANT rather than a rendered name: unlike a condition label, which is the person's own words, + a visit kind is one of eight closed values whose human name the reader's own bundle owns, so publishing the + constant is what lets a German reader and an English one each see it named correctly." InboundDocumentEnvelope: type: object properties: @@ -36882,6 +37072,10 @@ components: type: array items: $ref: "#/components/schemas/DocumentConditionLink" + encounterLinks: + type: array + items: + $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: @@ -36939,6 +37133,7 @@ components: - factCount - pendingCount - conditionLinks + - encounterLinks - servingClass - hasContentIndex - contentIndexSource @@ -38635,6 +38830,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit description: Closed rail-item kind. itemKey: description: Stable dismiss identity, namespaced `:...`. Present only on the observational kinds (milestone / @@ -38917,7 +39113,7 @@ components: enabledHeroItemKinds: description: Today-highlight item kinds in catalogue order. Missing means every current kind is enabled; an empty array disables all kinds. - maxItems: 8 + maxItems: 9 type: array items: type: string @@ -38930,6 +39126,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." diff --git a/e2e/visits.spec.ts b/e2e/visits.spec.ts new file mode 100644 index 000000000..76daf12c0 --- /dev/null +++ b/e2e/visits.spec.ts @@ -0,0 +1,102 @@ +/** + * Visits — the incidental-linking promise, proven end to end once. + * + * The flow this milestone is checked against, in one script: + * + * 1. create a visit with ONLY a date — nothing else touched, Save enabled; + * 2. it appears in the list; + * 3. open a document dated inside the ±7-day window; + * 4. the suggestion offers that visit; + * 5. take the offer; + * 6. the document's detail names the visit it now belongs to. + * + * Every assertion addresses a stable `data-slot`, never viewport text: the + * copy is i18n-driven and the mobile project renders the same slots at a + * different size, so a text assertion would break for the wrong reason. + * + * The visit is created through the API rather than by driving the sheet twice: + * step 1 is about the SAVE being possible with only a date, which the sheet + * assertion below covers, and the linking half needs a visit whose instant is + * pinned relative to the seeded document's date. + */ +import { expect, test } from "./setup/test"; + +import { ensureVaultFixture, MRT_DOC_ID } from "./setup/vault-fixture"; + +test.beforeEach(async () => { + await ensureVaultFixture(); +}); + +test.describe("visits", () => { + test("a visit saves with a date and nothing else", async ({ page }) => { + await page.goto("/checkups"); + await page.getByTestId("checkups-tab-visits").click(); + + const add = page.locator('[data-slot="visits-add"]').first(); + await expect(add).toBeVisible(); + await add.click(); + + // The date is pre-filled with now, and NOTHING else has been touched. + const save = page.locator('[data-slot="encounter-save"]').first(); + await expect(save).toBeVisible(); + // The whole product constraint: not disabled, with no practitioner, no + // reason, no outcome and no link chosen. + await expect(save).toBeEnabled(); + + await save.click(); + + // It appears in the list. A visit dated now is in the past arm. + await expect(page.locator('[data-slot="visit-card"]').first()).toBeVisible({ + timeout: 15_000, + }); + }); + + test("a document offers the visit its date falls near, and files itself against it", async ({ + page, + request, + }) => { + // The seeded MRI report carries a fixed filing date; the visit is placed + // two days before it so the ±7-day window resolves to exactly one + // candidate — the branch that pre-selects. + const document = await request + .get(`/api/documents/inbound/${MRT_DOC_ID}`) + .then((res) => res.json()); + const anchorDay: string = + document.data.reportDate ?? document.data.documentDate; + expect( + anchorDay, + "the fixture document carries a filing date", + ).toBeTruthy(); + const occurredAt = new Date(`${anchorDay}T09:00:00.000Z`); + occurredAt.setUTCDate(occurredAt.getUTCDate() - 2); + + const created = await request.post("/api/encounters", { + data: { + occurredAt: occurredAt.toISOString(), + status: "DONE", + kind: "SPECIALIST", + }, + }); + expect(created.status()).toBe(201); + + await page.goto(`/documents?doc=${MRT_DOC_ID}`); + + const suggestion = page + .locator('[data-slot="document-encounter-suggestion"]') + .first(); + await expect(suggestion).toBeVisible({ timeout: 15_000 }); + // One candidate, so the single-candidate branch: a pre-selectable offer + // rather than a picker. + await expect(suggestion).toHaveAttribute("data-verdict", "one"); + + await page + .locator('[data-slot="document-encounter-suggestion-option"]') + .first() + .click(); + + // The filing landed: the read-only "belongs to visit" row appears. + await expect( + page.locator('[data-slot="document-visit-links"]').first(), + ).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/messages/de.json b/messages/de.json index fe298d368..ee8f7a28c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Erkrankung", "familyHistoryColAgeAtOnset": "Alter bei Beginn", "labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:", - "labsCatalogRangeLabel": "gespeicherter Bereich" + "labsCatalogRangeLabel": "gespeicherter Bereich", + "visitsTitle": "Arztbesuche", + "visitsColDate": "Datum", + "visitsColPractice": "Praxis", + "visitsColKind": "Art", + "visitsColReason": "Anlass", + "visitsColOutcome": "Ergebnis", + "visitsColConditions": "Erkrankungen" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -478,7 +485,8 @@ "milestone": "Meilensteine", "ecg_new_recording": "Neue EKG-Aufzeichnungen", "tension_window": "Anspannungsphasen", - "same_time_baseline": "Aktivität zur gleichen Tageszeit" + "same_time_baseline": "Aktivität zur gleichen Tageszeit", + "upcoming_visit": "Termine" }, "layoutReset": "Auf Defaults zurücksetzen", "layoutResetTitle": "Dashboard-Layout zurücksetzen?", @@ -5996,7 +6004,9 @@ "rangeCustomEnd": "Bis", "errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.", "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.", - "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet." + "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.", + "visitOffer": "Am {when} steht ein Termin an: {what}.", + "visitOfferApply": "Bericht bis dahin" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.", @@ -8171,7 +8181,8 @@ "openLink": "Vorsorge öffnen" }, "showMeasurements": "Messergebnisse anzeigen", - "loadError": "Erinnerungen konnten nicht geladen werden." + "loadError": "Erinnerungen konnten nicht geladen werden.", + "fileVisit": "Termin eintragen" }, "moduleList": { "viewToggleLabel": "Ansicht", @@ -9324,7 +9335,8 @@ "previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp", "uploadedMeta": "Hinzugefügt am {date} · {size}", "share": "Teilen", - "labValuesLink": "{count} Laborwerte ansehen" + "labValuesLink": "{count} Laborwerte ansehen", + "visitsLabel": "Gehört zu Termin" }, "dropOverlay": { "title": "Zum Hochladen ablegen", @@ -9341,7 +9353,8 @@ "partialFailure": "{failed} von {total} konnten nicht aktualisiert werden", "failed": "Die Aktion konnte nicht ausgeführt werden", "share": "Teilen", - "shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut." + "shareTooMany": "Ein Freigabelink fasst höchstens {max} Dokumente. Wähle einige ab und versuche es erneut.", + "linkVisit": "Termin zuordnen" }, "episodeCard": { "title": "Dokumente", @@ -9634,6 +9647,11 @@ "below": "Bis {time} stehst du bei {today} Schritten. An einem üblichen Tag sind es um diese Zeit etwa {typical}.", "above": "Bis {time} stehst du bei {today} Schritten — mehr als die {typical}, die um diese Zeit für dich üblich sind." } + }, + "upcomingVisit": { + "title": "Termin steht an", + "bodyToday": "{what} — heute", + "bodyTomorrow": "{what} — morgen" } }, "action": { @@ -9729,7 +9747,8 @@ "sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.", "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", "scopeCount": "{count} Einträge aus {groups} Bereichen", - "scopeSensitive": "inkl. {names}" + "scopeSensitive": "inkl. {names}", + "leafVisits": "Arztbesuche" }, "recordSharing": { "banner": { @@ -10092,6 +10111,98 @@ "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.", + "suggestion": { + "single": "Gehört zu diesem Termin?", + "choose": "Zu welchem Termin gehört das?" + }, + "episodeCard": { + "title": "Termine dazu", + "emptyLine": "Noch kein Termin mit dieser Erkrankung verknüpft.", + "add": "Termin hinzufügen" + }, + "dashboard": { + "nextVisit": "Nächster Termin: {what} am {when}" } + }, + "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 60afd4fa5..7cba48460 100644 --- a/messages/en.json +++ b/messages/en.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Condition", "familyHistoryColAgeAtOnset": "Age at onset", "labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:", - "labsCatalogRangeLabel": "saved range" + "labsCatalogRangeLabel": "saved range", + "visitsTitle": "Visits", + "visitsColDate": "Date", + "visitsColPractice": "Practice", + "visitsColKind": "Kind", + "visitsColReason": "Reason", + "visitsColOutcome": "Outcome", + "visitsColConditions": "Conditions" }, "errorBoundary": { "title": "Something went wrong", @@ -478,7 +485,8 @@ "milestone": "Milestones", "ecg_new_recording": "New ECG recordings", "tension_window": "Tension windows", - "same_time_baseline": "Same-time activity" + "same_time_baseline": "Same-time activity", + "upcoming_visit": "Appointments" }, "layoutReset": "Reset to defaults", "layoutResetTitle": "Reset the dashboard layout?", @@ -5996,7 +6004,9 @@ "rangeCustomEnd": "To", "errorGeneric": "The export could not be generated. Please try again.", "errorRateLimit": "You have reached the export limit for this hour.", - "errorModuleDisabled": "The health record export is switched off for this account." + "errorModuleDisabled": "The health record export is switched off for this account.", + "visitOffer": "An appointment is coming up on {when}: {what}.", + "visitOfferApply": "Report up to then" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.", @@ -8171,7 +8181,8 @@ "openLink": "Open checkups" }, "showMeasurements": "Show measurements", - "loadError": "Could not load your reminders." + "loadError": "Could not load your reminders.", + "fileVisit": "Record a visit" }, "moduleList": { "viewToggleLabel": "View", @@ -9324,7 +9335,8 @@ "previewUnavailable": "No inline preview for this file type", "uploadedMeta": "Added {date} · {size}", "share": "Share", - "labValuesLink": "View {count} lab values" + "labValuesLink": "View {count} lab values", + "visitsLabel": "Belongs to visit" }, "dropOverlay": { "title": "Drop files to upload", @@ -9341,7 +9353,8 @@ "partialFailure": "{failed} of {total} could not be updated", "failed": "The action could not be completed", "share": "Share", - "shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again." + "shareTooMany": "A share link can hold at most {max} documents. Deselect some and try again.", + "linkVisit": "File against visit" }, "episodeCard": { "title": "Documents", @@ -9634,6 +9647,11 @@ "below": "By {time} you are at {today} steps. On a usual day you are around {typical} by now.", "above": "By {time} you are at {today} steps, ahead of the {typical} that is usual for you by now." } + }, + "upcomingVisit": { + "title": "Appointment coming up", + "bodyToday": "{what} — today", + "bodyTomorrow": "{what} — tomorrow" } }, "action": { @@ -9729,7 +9747,8 @@ "sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.", "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.", "scopeCount": "{count} entries from {groups} areas", - "scopeSensitive": "incl. {names}" + "scopeSensitive": "incl. {names}", + "leafVisits": "Visits" }, "recordSharing": { "banner": { @@ -10092,6 +10111,98 @@ "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.", + "suggestion": { + "single": "Does this belong to this visit?", + "choose": "Which visit does this belong to?" + }, + "episodeCard": { + "title": "Visits about this", + "emptyLine": "No visit linked to this condition yet.", + "add": "Add a visit" + }, + "dashboard": { + "nextVisit": "Next appointment: {what} on {when}" } + }, + "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 ac386797c..753ff7a88 100644 --- a/messages/es.json +++ b/messages/es.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Afección", "familyHistoryColAgeAtOnset": "Edad de inicio", "labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:", - "labsCatalogRangeLabel": "rango guardado" + "labsCatalogRangeLabel": "rango guardado", + "visitsTitle": "Visitas médicas", + "visitsColDate": "Fecha", + "visitsColPractice": "Consulta", + "visitsColKind": "Tipo", + "visitsColReason": "Motivo", + "visitsColOutcome": "Resultado", + "visitsColConditions": "Enfermedades" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -478,7 +485,8 @@ "milestone": "Hitos", "ecg_new_recording": "Nuevos registros de ECG", "tension_window": "Periodos de tensión", - "same_time_baseline": "Actividad a la misma hora" + "same_time_baseline": "Actividad a la misma hora", + "upcoming_visit": "Citas" }, "layoutReset": "Restablecer valores por defecto", "layoutResetTitle": "¿Restablecer el diseño del panel?", @@ -5996,7 +6004,9 @@ "rangeCustomEnd": "Hasta", "errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.", "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.", - "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta." + "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.", + "visitOffer": "El {when} hay una cita: {what}.", + "visitOfferApply": "Informe hasta esa fecha" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -8171,7 +8181,8 @@ "openLink": "Abrir revisiones" }, "showMeasurements": "Ver mediciones", - "loadError": "No se pudieron cargar tus recordatorios." + "loadError": "No se pudieron cargar tus recordatorios.", + "fileVisit": "Registrar una cita" }, "moduleList": { "viewToggleLabel": "Vista", @@ -9324,7 +9335,8 @@ "previewUnavailable": "Sin vista previa integrada para este tipo de archivo", "uploadedMeta": "Añadido el {date} · {size}", "share": "Compartir", - "labValuesLink": "Ver {count} valores de laboratorio" + "labValuesLink": "Ver {count} valores de laboratorio", + "visitsLabel": "Pertenece a la cita" }, "dropOverlay": { "title": "Suelta los archivos para subirlos", @@ -9341,7 +9353,8 @@ "partialFailure": "No se pudieron actualizar {failed} de {total}", "failed": "No se pudo completar la acción", "share": "Compartir", - "shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo." + "shareTooMany": "Un enlace para compartir admite como máximo {max} documentos. Deselecciona algunos e inténtalo de nuevo.", + "linkVisit": "Asignar a una cita" }, "episodeCard": { "title": "Documentos", @@ -9634,6 +9647,11 @@ "below": "A las {time} llevas {today} pasos. En un día habitual rondas los {typical} a esta hora.", "above": "A las {time} llevas {today} pasos, por encima de los {typical} habituales a esta hora." } + }, + "upcomingVisit": { + "title": "Cita próxima", + "bodyToday": "{what}: hoy", + "bodyTomorrow": "{what}: mañana" } }, "action": { @@ -9729,7 +9747,8 @@ "sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.", "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", "scopeCount": "{count} entradas de {groups} áreas", - "scopeSensitive": "incl. {names}" + "scopeSensitive": "incl. {names}", + "leafVisits": "Visitas médicas" }, "recordSharing": { "banner": { @@ -10092,6 +10111,98 @@ "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.", + "suggestion": { + "single": "¿Pertenece a esta cita?", + "choose": "¿A qué cita pertenece?" + }, + "episodeCard": { + "title": "Citas relacionadas", + "emptyLine": "Todavía no hay ninguna cita vinculada a esta enfermedad.", + "add": "Añadir una cita" + }, + "dashboard": { + "nextVisit": "Próxima cita: {what} el {when}" } + }, + "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 9a51ce76f..60078376b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Affection", "familyHistoryColAgeAtOnset": "Âge au début", "labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :", - "labsCatalogRangeLabel": "plage enregistrée" + "labsCatalogRangeLabel": "plage enregistrée", + "visitsTitle": "Consultations", + "visitsColDate": "Date", + "visitsColPractice": "Cabinet", + "visitsColKind": "Type", + "visitsColReason": "Motif", + "visitsColOutcome": "Résultat", + "visitsColConditions": "Maladies" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -478,7 +485,8 @@ "milestone": "Étapes franchies", "ecg_new_recording": "Nouveaux enregistrements ECG", "tension_window": "Périodes de tension", - "same_time_baseline": "Activité à la même heure" + "same_time_baseline": "Activité à la même heure", + "upcoming_visit": "Rendez-vous" }, "layoutReset": "Rétablir les valeurs par défaut", "layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?", @@ -5996,7 +6004,9 @@ "rangeCustomEnd": "Au", "errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.", "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.", - "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte." + "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.", + "visitOffer": "Un rendez-vous est prévu le {when} : {what}.", + "visitOfferApply": "Rapport jusque-là" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.", @@ -8171,7 +8181,8 @@ "openLink": "Ouvrir les examens" }, "showMeasurements": "Afficher les mesures", - "loadError": "Impossible de charger vos rappels." + "loadError": "Impossible de charger vos rappels.", + "fileVisit": "Enregistrer un rendez-vous" }, "moduleList": { "viewToggleLabel": "Affichage", @@ -9324,7 +9335,8 @@ "previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier", "uploadedMeta": "Ajouté le {date} · {size}", "share": "Partager", - "labValuesLink": "Voir {count} valeurs de laboratoire" + "labValuesLink": "Voir {count} valeurs de laboratoire", + "visitsLabel": "Rattaché au rendez-vous" }, "dropOverlay": { "title": "Déposez les fichiers pour les téléverser", @@ -9341,7 +9353,8 @@ "partialFailure": "Impossible de mettre à jour {failed} sur {total}", "failed": "L'action n'a pas pu être effectuée", "share": "Partager", - "shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez." + "shareTooMany": "Un lien de partage contient au maximum {max} documents. Désélectionnez-en quelques-uns et réessayez.", + "linkVisit": "Classer sous un rendez-vous" }, "episodeCard": { "title": "Documents", @@ -9634,6 +9647,11 @@ "below": "À {time} vous en êtes à {today} pas. Un jour habituel, vous tournez autour de {typical} à cette heure.", "above": "À {time} vous en êtes à {today} pas, au-dessus des {typical} habituels à cette heure." } + }, + "upcomingVisit": { + "title": "Rendez-vous à venir", + "bodyToday": "{what} — aujourd'hui", + "bodyTomorrow": "{what} — demain" } }, "action": { @@ -9729,7 +9747,8 @@ "sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.", "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.", "scopeCount": "{count} éléments issus de {groups} domaines", - "scopeSensitive": "dont {names}" + "scopeSensitive": "dont {names}", + "leafVisits": "Consultations" }, "recordSharing": { "banner": { @@ -10092,6 +10111,98 @@ "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é.", + "suggestion": { + "single": "Cela concerne-t-il ce rendez-vous ?", + "choose": "À quel rendez-vous cela se rattache-t-il ?" + }, + "episodeCard": { + "title": "Rendez-vous liés", + "emptyLine": "Aucun rendez-vous n'est encore rattaché à cette maladie.", + "add": "Ajouter un rendez-vous" + }, + "dashboard": { + "nextVisit": "Prochain rendez-vous : {what} le {when}" } + }, + "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 9fc05689f..6265593f1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Condizione", "familyHistoryColAgeAtOnset": "Età di esordio", "labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:", - "labsCatalogRangeLabel": "intervallo salvato" + "labsCatalogRangeLabel": "intervallo salvato", + "visitsTitle": "Visite mediche", + "visitsColDate": "Data", + "visitsColPractice": "Studio", + "visitsColKind": "Tipo", + "visitsColReason": "Motivo", + "visitsColOutcome": "Esito", + "visitsColConditions": "Malattie" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -478,7 +485,8 @@ "milestone": "Traguardi", "ecg_new_recording": "Nuove registrazioni ECG", "tension_window": "Periodi di tensione", - "same_time_baseline": "Attività alla stessa ora" + "same_time_baseline": "Attività alla stessa ora", + "upcoming_visit": "Appuntamenti" }, "layoutReset": "Ripristina i valori predefiniti", "layoutResetTitle": "Reimpostare il layout della dashboard?", @@ -5996,7 +6004,9 @@ "rangeCustomEnd": "Al", "errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.", "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.", - "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account." + "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.", + "visitOffer": "Il {when} c'è un appuntamento: {what}.", + "visitOfferApply": "Referto fino a quella data" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.", @@ -8171,7 +8181,8 @@ "openLink": "Apri controlli" }, "showMeasurements": "Mostra le misurazioni", - "loadError": "Impossibile caricare i promemoria." + "loadError": "Impossibile caricare i promemoria.", + "fileVisit": "Registra un appuntamento" }, "moduleList": { "viewToggleLabel": "Vista", @@ -9324,7 +9335,8 @@ "previewUnavailable": "Nessuna anteprima integrata per questo tipo di file", "uploadedMeta": "Aggiunto il {date} · {size}", "share": "Condividi", - "labValuesLink": "Vedi {count} valori di laboratorio" + "labValuesLink": "Vedi {count} valori di laboratorio", + "visitsLabel": "Appartiene all'appuntamento" }, "dropOverlay": { "title": "Rilascia i file per caricarli", @@ -9341,7 +9353,8 @@ "partialFailure": "Impossibile aggiornare {failed} su {total}", "failed": "Impossibile completare l'azione", "share": "Condividi", - "shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova." + "shareTooMany": "Un link di condivisione contiene al massimo {max} documenti. Deseleziona alcuni documenti e riprova.", + "linkVisit": "Assegna a un appuntamento" }, "episodeCard": { "title": "Documenti", @@ -9634,6 +9647,11 @@ "below": "Alle {time} sei a {today} passi. In una giornata tipo a quest'ora sei intorno a {typical}.", "above": "Alle {time} sei a {today} passi, sopra i {typical} che per te sono abituali a quest'ora." } + }, + "upcomingVisit": { + "title": "Appuntamento in arrivo", + "bodyToday": "{what} — oggi", + "bodyTomorrow": "{what} — domani" } }, "action": { @@ -9729,7 +9747,8 @@ "sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.", "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.", "scopeCount": "{count} voci da {groups} aree", - "scopeSensitive": "incl. {names}" + "scopeSensitive": "incl. {names}", + "leafVisits": "Visite mediche" }, "recordSharing": { "banner": { @@ -10092,6 +10111,98 @@ "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.", + "suggestion": { + "single": "Appartiene a questo appuntamento?", + "choose": "A quale appuntamento appartiene?" + }, + "episodeCard": { + "title": "Appuntamenti collegati", + "emptyLine": "Nessun appuntamento ancora collegato a questa malattia.", + "add": "Aggiungi un appuntamento" + }, + "dashboard": { + "nextVisit": "Prossimo appuntamento: {what} il {when}" } + }, + "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 a0e9c163a..2993b3262 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Schorzenie", "familyHistoryColAgeAtOnset": "Wiek zachorowania", "labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:", - "labsCatalogRangeLabel": "zapisany zakres" + "labsCatalogRangeLabel": "zapisany zakres", + "visitsTitle": "Wizyty lekarskie", + "visitsColDate": "Data", + "visitsColPractice": "Gabinet", + "visitsColKind": "Rodzaj", + "visitsColReason": "Powód", + "visitsColOutcome": "Wynik", + "visitsColConditions": "Choroby" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -478,7 +485,8 @@ "milestone": "Kamienie milowe", "ecg_new_recording": "Nowe zapisy EKG", "tension_window": "Okresy napięcia", - "same_time_baseline": "Aktywność o tej samej porze" + "same_time_baseline": "Aktywność o tej samej porze", + "upcoming_visit": "Wizyty" }, "layoutReset": "Przywróć wartości domyślne", "layoutResetTitle": "Zresetować układ pulpitu?", @@ -5996,7 +6004,9 @@ "rangeCustomEnd": "Do", "errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.", "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.", - "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta." + "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.", + "visitOffer": "{when} zaplanowano wizytę: {what}.", + "visitOfferApply": "Raport do tego dnia" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.", @@ -8171,7 +8181,8 @@ "openLink": "Otwórz badania" }, "showMeasurements": "Pokaż pomiary", - "loadError": "Nie udało się załadować przypomnień." + "loadError": "Nie udało się załadować przypomnień.", + "fileVisit": "Zapisz wizytę" }, "moduleList": { "viewToggleLabel": "Widok", @@ -9324,7 +9335,8 @@ "previewUnavailable": "Brak podglądu dla tego typu pliku", "uploadedMeta": "Dodano {date} · {size}", "share": "Udostępnij", - "labValuesLink": "Zobacz {count} wyników laboratoryjnych" + "labValuesLink": "Zobacz {count} wyników laboratoryjnych", + "visitsLabel": "Należy do wizyty" }, "dropOverlay": { "title": "Upuść pliki, aby je przesłać", @@ -9341,7 +9353,8 @@ "partialFailure": "Nie udało się zaktualizować {failed} z {total}", "failed": "Nie udało się wykonać akcji", "share": "Udostępnij", - "shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie." + "shareTooMany": "Link do udostępniania obejmuje maksymalnie {max} dokumentów. Odznacz kilka i spróbuj ponownie.", + "linkVisit": "Przypisz do wizyty" }, "episodeCard": { "title": "Dokumenty", @@ -9634,6 +9647,11 @@ "below": "Do {time} masz {today} kroków. W zwykły dzień o tej porze jesteś w okolicach {typical}.", "above": "Do {time} masz {today} kroków, powyżej zwykłych {typical} o tej porze." } + }, + "upcomingVisit": { + "title": "Zbliża się wizyta", + "bodyToday": "{what} — dzisiaj", + "bodyTomorrow": "{what} — jutro" } }, "action": { @@ -9729,7 +9747,8 @@ "sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.", "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.", "scopeCount": "{count} pozycji z {groups} obszarów", - "scopeSensitive": "w tym {names}" + "scopeSensitive": "w tym {names}", + "leafVisits": "Wizyty lekarskie" }, "recordSharing": { "banner": { @@ -10092,6 +10111,98 @@ "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.", + "suggestion": { + "single": "Czy to należy do tej wizyty?", + "choose": "Do której wizyty to należy?" + }, + "episodeCard": { + "title": "Powiązane wizyty", + "emptyLine": "Do tej choroby nie przypisano jeszcze żadnej wizyty.", + "add": "Dodaj wizytę" + }, + "dashboard": { + "nextVisit": "Najbliższa wizyta: {what}, {when}" } + }, + "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__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 7b7cbf95e..d9a137b7b 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(91); + expect(ALL_LEAF_IDS.length).toBe(92); expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); - expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15); }); it("places every leaf in exactly one group", () => { 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/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 38fe49fc5..60fbc51a3 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -939,6 +939,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.", @@ -1864,12 +1868,13 @@ const ACTOR_ROUTES: Record = { * managed-record configuration adds one more: 196 → 197. The bounded * 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 - * record and its address book bring twelve: 201 → 213. The mood prognosis, + * The mood day's linked figures, read-only, add one: 200 -> 201. The visit + * record and its address book bring twelve: 201 -> 213. The mood prognosis, * read-only and admitted on the whole record for the same reason as the linked - * figures, adds one: 213 → 214. + * figures, adds one: 213 -> 214. The visit-suggestion read, which serves the + * three incidental-linking moments from the same rule, adds one: 214 -> 215. */ -const FROZEN_ENTRY_COUNT = 214; +const FROZEN_ENTRY_COUNT = 215; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index 6adcd7b5b..d4c5e77ef 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({ deleteMany: vi.fn(), createMany: vi.fn(), }, + encounterDocumentLink: { + findMany: vi.fn(), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, documentContentIndex: { findUnique: vi.fn(), }, @@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({ illnessEpisode: { findMany: vi.fn(), }, + encounter: { + findMany: vi.fn(), + }, appSettings: { findUnique: vi.fn(), }, @@ -129,6 +137,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/documents/inbound/[id]/restore/route.ts b/src/app/api/documents/inbound/[id]/restore/route.ts index 1d43a8c60..68f847f48 100644 --- a/src/app/api/documents/inbound/[id]/restore/route.ts +++ b/src/app/api/documents/inbound/[id]/restore/route.ts @@ -15,7 +15,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; -import { loadConditionLinks } from "@/lib/documents/links"; +import { + loadConditionLinks, + loadDocumentEncounterLinks, +} from "@/lib/documents/links"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -83,9 +86,20 @@ export const POST = apiHandler( if (row.deletedAt === null) { // Already live — restoring twice is a no-op, not an error (the undo // toast may fire after a concurrent restore). - const links = await loadConditionLinks(user.id, [row.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [row.id]), + loadDocumentEncounterLinks(user.id, [row.id]), + ]); return apiSuccess( - serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []), + serialiseDocumentDetail( + row, + row.facts, + links.get(row.id) ?? [], + false, + null, + false, + visitLinks.get(row.id) ?? [], + ), ); } @@ -131,7 +145,10 @@ export const POST = apiHandler( omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const links = await loadConditionLinks(user.id, [restored.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [restored.id]), + loadDocumentEncounterLinks(user.id, [restored.id]), + ]); await auditLog("documents.inbound.restore", { userId: user.id, @@ -149,6 +166,10 @@ export const POST = apiHandler( restored, restored.facts, links.get(restored.id) ?? [], + false, + null, + false, + visitLinks.get(restored.id) ?? [], ), ); }, diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index cf0bd1f7c..22fef5f43 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -20,8 +20,11 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { loadConditionLinks, + loadDocumentEncounterLinks, + narrowOwnedEncounterIds, narrowOwnedEpisodeIds, replaceConditionLinks, + replaceEncounterLinks, } from "@/lib/documents/links"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; @@ -92,8 +95,9 @@ export const GET = apiHandler( }); } - const [links, contentIndex, thumbnail] = await Promise.all([ + const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, @@ -117,6 +121,7 @@ export const GET = apiHandler( contentIndex !== null, toContentIndexSource(contentIndex?.source), thumbnail !== null, + visitLinks.get(document.id) ?? [], ), ); }, @@ -169,6 +174,21 @@ export const PATCH = apiHandler( nextEpisodeIds = narrowed; } + // Replace-set visit links, same semantics and same refusal. + let nextEncounterIds: string[] | undefined; + if (parsed.data.encounterIds !== undefined) { + const narrowed = await narrowOwnedEncounterIds( + user.id, + parsed.data.encounterIds, + ); + if (narrowed === null) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + nextEncounterIds = narrowed; + } + // No mass assignment — each editable column is set explicitly only when // the client sent it. `userId` is never a body field. const data: Prisma.InboundDocumentUpdateInput = {}; @@ -189,14 +209,23 @@ export const PATCH = apiHandler( if (nextEpisodeIds !== undefined) { await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds); } + if (nextEncounterIds !== undefined) { + await replaceEncounterLinks( + prisma, + user.id, + existing.id, + nextEncounterIds, + ); + } const document = await prisma.inboundDocument.findFirstOrThrow({ where: { id: existing.id }, omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const [links, contentIndex, thumbnail] = await Promise.all([ + const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, @@ -210,6 +239,7 @@ export const PATCH = apiHandler( const touched = [ ...Object.keys(data), ...(nextEpisodeIds !== undefined ? ["episodeIds"] : []), + ...(nextEncounterIds !== undefined ? ["encounterIds"] : []), ]; await auditLog("documents.inbound.update", { @@ -234,6 +264,7 @@ export const PATCH = apiHandler( contentIndex !== null, toContentIndexSource(contentIndex?.source), thumbnail !== null, + visitLinks.get(document.id) ?? [], ), ); }, diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index 4b4f21999..dceb607f3 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -20,6 +20,8 @@ const { txCreateMany, txDocumentFindMany, txEpisodeFindMany, + txEncounterFindMany, + txEncounterLinkCreateMany, } = vi.hoisted(() => ({ txQueryRaw: vi.fn(), txCreate: vi.fn(), @@ -28,6 +30,8 @@ const { // writes, so the transaction double has to answer for them. txDocumentFindMany: vi.fn(), txEpisodeFindMany: vi.fn(), + txEncounterFindMany: vi.fn(), + txEncounterLinkCreateMany: vi.fn(), })); vi.mock("@/lib/db", () => { @@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => { $queryRaw: txQueryRaw, inboundDocument: { create: txCreate, findMany: txDocumentFindMany }, illnessEpisode: { findMany: txEpisodeFindMany }, + encounter: { findMany: txEncounterFindMany }, documentConditionLink: { createMany: txCreateMany }, + encounterDocumentLink: { createMany: txEncounterLinkCreateMany }, }; return { prisma: { @@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => { documentConditionLink: { findMany: vi.fn(), }, + encounterDocumentLink: { + findMany: vi.fn(), + }, documentContentIndex: { findMany: vi.fn(), }, @@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => { illnessEpisode: { findMany: vi.fn(), }, + encounter: { + findMany: vi.fn(), + }, appSettings: { findUnique: vi.fn(), }, @@ -183,6 +195,12 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + txEncounterFindMany.mockResolvedValue([]); + txEncounterLinkCreateMany.mockResolvedValue({ count: 0 }); vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts index 35c141934..a57f49df9 100644 --- a/src/app/api/documents/inbound/bulk/route.ts +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -1,9 +1,11 @@ /** * Document vault: bulk actions over up to 100 owner-scoped documents. * - * `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions: - * `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition - * links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A + * `{ ids[], action, kind?, episodeId?, encounterId? }` → a per-id result + * array. Actions: `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` + * (condition links), `linkEncounter` / `unlinkEncounter` (visit links, the + * same table the visit's own sheet writes from the other end), + * `delete` (tombstone, undo-able), `restore` (clear tombstone). A * partial failure never aborts the batch — each id reports `ok` or a short * machine reason. Every id is narrowed to the caller; a foreign id reads as * "notFound", exactly like the single-document routes. @@ -68,7 +70,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!parsed.success) { return returnAllZodIssues(parsed.error, 422); } - const { ids, action, kind, episodeId } = parsed.data; + const { ids, action, kind, episodeId, encounterId } = parsed.data; const uniqueIds = [...new Set(ids)]; // Episode actions: the target episode must be a LIVE episode of the caller. @@ -84,6 +86,21 @@ export const POST = apiHandler(async (request: NextRequest) => { } } + // Visit actions: the target visit must be a LIVE visit of the caller. Same + // shape as the episode gate above — the endpoint is checked once, before the + // batch, so a foreign id costs one refusal rather than a hundred. + if (action === "linkEncounter" || action === "unlinkEncounter") { + const encounter = await prisma.encounter.findFirst({ + where: { id: encounterId!, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!encounter) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + } + // One owner-scoped sweep resolves every id's current state; per-id work // then never touches a row the caller does not own. const owned = await prisma.inboundDocument.findMany({ @@ -136,6 +153,33 @@ export const POST = apiHandler(async (request: NextRequest) => { }); break; } + case "linkEncounter": { + if (row.deletedAt !== null) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + await linkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "encounter", + targetIds: [encounterId!], + }); + break; + } + case "unlinkEncounter": { + // No tombstone check, matching `unlinkEpisode`: clearing a filing on + // a document the account has since deleted must stay possible, or + // the filing is unreachable. + await unlinkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "encounter", + targetIds: [encounterId!], + }); + break; + } case "delete": { if (row.deletedAt !== null) { // Already tombstoned — deleting twice is a no-op success. diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 1a743ad68..60d26ffdf 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -39,6 +39,8 @@ import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; import { loadConditionLinks, + loadDocumentEncounterLinks, + narrowOwnedEncounterIds, narrowOwnedEpisodeIds, } from "@/lib/documents/links"; import { @@ -116,8 +118,9 @@ async function duplicateResponse( userId: string, existing: SerialisableDocument, ): Promise { - const [links, groups] = await Promise.all([ + const [links, visitLinks, groups] = await Promise.all([ loadConditionLinks(userId, [existing.id]), + loadDocumentEncounterLinks(userId, [existing.id]), prisma.extractedFact.groupBy({ by: ["status"], where: { userId, documentId: existing.id }, @@ -140,6 +143,10 @@ async function duplicateResponse( existing, { factCount, pendingCount }, links.get(existing.id) ?? [], + false, + null, + false, + visitLinks.get(existing.id) ?? [], ), error: null, meta: { duplicate: true }, @@ -212,17 +219,21 @@ async function processUpload( return apiError("Field 'file' must be a file", 422); } - // Optional metadata (title / kind / documentDate / episodeIds). The file is - // read separately; these are the form fields beside it. `episodeIds` may be - // repeated. + // Optional metadata (title / kind / documentDate / episodeIds / + // encounterIds). The file is read separately; these are the form fields + // beside it. Both id lists may be repeated. const rawEpisodeIds = formData .getAll("episodeIds") .filter((v): v is string => typeof v === "string"); + const rawEncounterIds = formData + .getAll("encounterIds") + .filter((v): v is string => typeof v === "string"); const parsed = documentCreateSchema.safeParse({ title: formData.get("title") ?? undefined, kind: formData.get("kind") ?? undefined, documentDate: formData.get("documentDate") ?? undefined, episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined, + encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined, }); if (!parsed.success) { return apiError("Invalid document metadata", 422, { @@ -268,6 +279,19 @@ async function processUpload( }); } + // Same for the visit ids the review step offered. A refusal rather than a + // silent drop: the person saw a visit named and would otherwise watch the + // upload succeed without the link they asked for. + const encounterIds = await narrowOwnedEncounterIds( + user.id, + parsed.data.encounterIds ?? [], + ); + if (encounterIds === null) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + // sha256 of the PLAINTEXT for same-user duplicate detection. const contentSha256 = createHash("sha256").update(buffer).digest("hex"); @@ -346,6 +370,15 @@ async function processUpload( targetIds: episodeIds, }); } + if (encounterIds.length > 0) { + await linkTargets(tx, { + userId: user.id, + sourceKind: "document", + sourceId: created.id, + targetKind: "encounter", + targetIds: encounterIds, + }); + } return created; }); } catch (err) { @@ -382,6 +415,7 @@ async function processUpload( byteSize: document.byteSize, servingClass: detected.servingClass, linked: episodeIds.length, + linkedVisits: encounterIds.length, }, }); @@ -406,12 +440,19 @@ async function processUpload( // because of it. Only fresh inserts reach here (a duplicate returns early). void enqueueDocumentSummary(user.id, document.id); - const links = await loadConditionLinks(user.id, [document.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), + ]); return apiSuccess( serialiseDocument( document, { factCount: 0, pendingCount: 0 }, links.get(document.id) ?? [], + false, + null, + false, + visitLinks.get(document.id) ?? [], ), 201, ); @@ -481,8 +522,19 @@ export const GET = apiHandler(async (request: Request) => { errorCode: "documents.inbound.invalidQuery", }); } - const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } = - parsed.data; + const { + q, + kind, + episodeId, + encounterId, + year, + from, + to, + sort, + order, + cursor, + limit, + } = parsed.data; const where: Prisma.InboundDocumentWhereInput = { userId: user.id, @@ -490,6 +542,10 @@ export const GET = apiHandler(async (request: Request) => { }; if (kind && kind.length > 0) where.kind = { in: kind }; if (episodeId) where.conditionLinks = { some: { episodeId } }; + // The visit filter reads the SAME table the visit's own sheet writes, from + // the document side. Filtering rather than post-filtering: a page of fifty + // documents narrowed in Node would page wrongly. + if (encounterId) where.encounterLinks = { some: { encounterId } }; if (year !== undefined) { where.documentDate = { gte: new Date(Date.UTC(year, 0, 1)), @@ -576,10 +632,16 @@ export const GET = apiHandler(async (request: Request) => { } // Condition links for the page in ONE grouped query (no N+1). - const linkMap = await loadConditionLinks( - user.id, - page.map((d) => d.id), - ); + const [linkMap, visitLinkMap] = await Promise.all([ + loadConditionLinks( + user.id, + page.map((d) => d.id), + ), + loadDocumentEncounterLinks( + user.id, + page.map((d) => d.id), + ), + ]); // Which of the page's documents have a content index (drives the searchable // status + the provenance the UI reads to tell an AI-read document from a @@ -610,7 +672,9 @@ export const GET = apiHandler(async (request: Request) => { count: page.length, sort, order, - filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year), + filtered: Boolean( + q || (kind && kind.length > 0) || episodeId || encounterId || year, + ), }, }); @@ -623,6 +687,7 @@ export const GET = apiHandler(async (request: Request) => { indexSources.has(doc.id), toContentIndexSource(indexSources.get(doc.id)), thumbnailIds.has(doc.id), + visitLinkMap.get(doc.id) ?? [], ), ), nextCursor, diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index ab5067df5..85797154e 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -36,6 +36,7 @@ import { ENCOUNTER_INCLUDE, actingDomainVisibility, applyEncounterLinks, + loadEncounterLinksForMany, closeCheckupForVisit, resolveClosableReminder, resolveOwnedPractitioner, @@ -50,7 +51,7 @@ import { } from "@/lib/export/restore-skips"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read", "profile"); + const { user, grantId } = await requireRecordAuth("read", "profile"); const params = new URL(request.url).searchParams; const parsed = encounterListQuerySchema.safeParse({ @@ -58,6 +59,7 @@ export const GET = apiHandler(async (request: NextRequest) => { to: params.get("to") ?? undefined, status: params.get("status") ?? undefined, practitionerId: params.get("practitionerId") ?? undefined, + episodeId: params.get("episodeId") ?? undefined, limit: params.get("limit") ?? undefined, }); if (!parsed.success) { @@ -75,6 +77,11 @@ export const GET = apiHandler(async (request: NextRequest) => { deletedAt: null, ...(query.status ? { status: query.status } : {}), ...(query.practitionerId ? { practitionerId: query.practitionerId } : {}), + // Filtered in SQL rather than in Node: the two arms are separately + // `take`-bounded, so narrowing after the read would page wrongly. + ...(query.episodeId + ? { conditionLinks: { some: { episodeId: query.episodeId } } } + : {}), ...(query.from || query.to ? { occurredAt: { @@ -118,14 +125,26 @@ export const GET = apiHandler(async (request: NextRequest) => { }), ]); + // The links ride the list. The card counts what a visit produced, and the + // edit sheet seeds its pickers from the row it was handed — a list without + // them makes the first read zero and the second unfile everything on save. + // Three grouped queries for the page, not three per row. + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinksForMany( + prisma, + user.id, + [...upcoming, ...past].map((row) => row.id), + visible, + ); + annotate({ action: { name: "encounter.visit.list", entity_type: "encounter" }, meta: { upcoming: upcoming.length, past: past.length }, }); const body: EncounterListDTO = { - upcoming: upcoming.map((row) => toEncounterDTO(row)), - past: past.map((row) => toEncounterDTO(row)), + upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), + past: past.map((row) => toEncounterDTO(row, links.get(row.id))), }; return apiSuccess(body); }); 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/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 905d6c272..c442523cf 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn() }, labResult: { findMany: vi.fn() }, illnessEpisode: { findMany: vi.fn() }, + encounter: { findMany: vi.fn() }, allergy: { findMany: vi.fn() }, familyHistoryEntry: { findMany: vi.fn() }, userHealthProfile: { findUnique: vi.fn() }, @@ -165,6 +166,7 @@ beforeEach(() => { vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index b8700562e..5fd00dc2b 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -39,6 +39,7 @@ import { linkOcrLabsToVaultDocument, type InsertedLabForLink, } from "@/lib/labs/vault-link"; +import { linkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome"; import { @@ -217,6 +218,23 @@ async function commitOcrRows(request: NextRequest) { ).catch(() => {}); } + // The visit the review step offered, if it offered one. ONE LINK PER RESULT + // ROW rather than one for the panel: a marker re-run on a different day + // belongs to its own visit, which is exactly why the join is m:n and not a + // scalar column on the result. Best-effort like the vault cross-link — the + // readings are the authoritative write and must not be lost to a link that + // could not be made. The link service narrows both ends to this record, so + // an id naming nothing writes nothing. + if (parsed.data.encounterId && linkable.length > 0) { + await linkTargets(prisma, { + userId: user.id, + sourceKind: "encounter", + sourceId: parsed.data.encounterId, + targetKind: "labResult", + targetIds: linkable.map((row) => row.labResultId), + }).catch(() => {}); + } + await auditLog("labs.ocr.commit", { userId: user.id, ipAddress: getClientIp(request), @@ -225,7 +243,11 @@ async function commitOcrRows(request: NextRequest) { annotate({ action: { name: "labs.ocr.committed" }, - meta: { inserted: inserted.length, skipped: skipped.length }, + meta: { + inserted: inserted.length, + skipped: skipped.length, + linkedVisit: parsed.data.encounterId ? 1 : 0, + }, }); // A lab panel just landed — resolve any "annual blood panel" reminders now diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 60e325499..b420b745e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -21,6 +21,7 @@ import { type ResolvedBiomarker, serialiseLabResult, } from "@/lib/labs/serialise"; +import { linkTargets } from "@/lib/links"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; import { @@ -190,6 +191,7 @@ async function postLabResult(request: NextRequest) { takenAt, note, source, + encounterId, } = parsed.data; // v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead @@ -267,6 +269,21 @@ async function postLabResult(request: NextRequest) { noteEncrypted: note ? encryptNoteToBytes(note) : null, }, }); + // The by-the-way half: file the reading against the visit the form offered, + // if it offered one. Best-effort by design — the reading is the write that + // matters, and a link that could not be made (a visit deleted between the + // suggestion and the save) must not lose it. The link service narrows both + // ends to this record, so an id naming nothing simply writes nothing. + if (encounterId) { + await linkTargets(prisma, { + userId: user.id, + sourceKind: "encounter", + sourceId: encounterId, + targetKind: "labResult", + targetIds: [created.id], + }).catch(() => {}); + } + invalidateUserHealthScore(user.id); const dto = serialiseLabResult(created, biomarker); 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..a9d754672 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/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index a07f4ac5b..080d30412 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -20,6 +20,7 @@ import Link from "next/link"; import { Activity, + CalendarCheck, Award, CalendarClock, Footprints, @@ -55,6 +56,7 @@ const KIND_ICON: Record = { ecg_new_recording: HeartPulse, tension_window: Activity, same_time_baseline: Footprints, + upcoming_visit: CalendarCheck, }; /** diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx index 2222b1800..2770facfe 100644 --- a/src/components/documents/__tests__/document-bulk-bar.test.tsx +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -4,22 +4,30 @@ import { renderToStaticMarkup } from "react-dom/server"; /** * The floating bulk bar, pinned via single-pass static renders: a labelled * toolbar carrying the selected count and the bulk verbs (set type, link - * condition, share, delete, clear). The link-condition menu only renders when - * the account actually has episodes to link — no dead affordance on an - * episode-free account. + * condition, file against a visit, share, delete, clear). Each link menu only + * renders when the account actually has something to link to — no dead + * affordance on an account with no episodes, and none on an account with no + * visits either. */ import { I18nProvider } from "@/lib/i18n/context"; import { DocumentBulkBar } from "../document-bulk-bar"; -function render(episodes: { id: string; label: string }[]) { +function render( + episodes: { id: string; label: string }[], + encounters: { id: string; label: string }[] = [ + { id: "v1", label: "Routine visit · 01/08/2026" }, + ], +) { return renderToStaticMarkup( {}} onLinkEpisode={() => {}} + onLinkEncounter={() => {}} onShare={() => {}} onDelete={() => {}} onClear={() => {}} @@ -36,6 +44,7 @@ describe("", () => { expect(html).toContain("3 selected"); expect(html).toContain("Change type"); expect(html).toContain("Link condition"); + expect(html).toContain("File against visit"); expect(html).toContain('data-slot="document-bulk-share"'); expect(html).toContain("Share"); expect(html).toContain("Delete"); @@ -47,4 +56,13 @@ describe("", () => { expect(html).not.toContain("Link condition"); expect(html).toContain("Change type"); }); + + it("omits the file-against-visit verb when the account has no visits", () => { + const html = render([{ id: "ep1", label: "Knee" }], []); + expect(html).not.toContain("File against visit"); + expect(html).not.toContain('data-slot="document-bulk-link-visit"'); + // The positive control: the neighbouring verb is still there, so the + // assertion above is about the visit menu and not about an empty render. + expect(html).toContain("Link condition"); + }); }); diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 68e39ec2f..0d4aab26a 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -30,6 +30,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/__tests__/episode-documents-card.test.tsx b/src/components/documents/__tests__/episode-documents-card.test.tsx index 500f78a3b..2d5547c69 100644 --- a/src/components/documents/__tests__/episode-documents-card.test.tsx +++ b/src/components/documents/__tests__/episode-documents-card.test.tsx @@ -50,6 +50,7 @@ function doc(id: string, title: string): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [{ episodeId: "ep1", name: "Knee" }], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 122d0afab..6947140aa 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -38,6 +38,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 3c677d12c..45d3a2069 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -4,8 +4,13 @@ * Floating bulk-action bar for the vault's multi-select mode. Appears once * at least one document is selected, pinned above the bottom nav on phones * and near the bottom edge on desktop. Carries the selected count and the - * bulk verbs — set type, link condition, share (one link for the whole - * selection), delete (undo-able), clear — all driving the page's handlers. + * bulk verbs — set type, link condition, file against a visit, share (one + * link for the whole selection), delete (undo-able), clear — all driving the + * page's handlers. + * + * The two link verbs are the same shape and stay that way: each renders only + * when the account HAS something to link to, so an empty menu is never + * offered. * * A `role="toolbar"` with proper labels; the destructive verb sits last * before Clear so a stray tap sequence never ends on Delete. Share sits just @@ -13,7 +18,14 @@ * shell (a floating toolbar is not a Card surface): dense-tile padding `p-3` * per the standards. */ -import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react"; +import { + CalendarClock, + FolderPlus, + Share2, + Tag, + Trash2, + X, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -32,12 +44,20 @@ export interface BulkEpisodeOption { label: string; } +/** One visit the selection can be filed against. Label resolved by the page. */ +export interface BulkEncounterOption { + id: string; + label: string; +} + export function DocumentBulkBar({ selectedCount, episodes, + encounters, busy, onSetKind, onLinkEpisode, + onLinkEncounter, onShare, onDelete, onClear, @@ -45,10 +65,13 @@ export function DocumentBulkBar({ selectedCount: number; /** The caller's live illness episodes — the link-condition targets. */ episodes: BulkEpisodeOption[]; + /** The caller's recent visits — the file-against-a-visit targets. */ + encounters: BulkEncounterOption[]; /** A bulk call is in flight — the verbs disable, Clear stays live. */ busy: boolean; onSetKind: (kind: InboundDocumentKindValue) => void; onLinkEpisode: (episodeId: string) => void; + onLinkEncounter: (encounterId: string) => void; /** * Share the whole selection as ONE documents-only link. The page caps the * selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint — @@ -135,6 +158,35 @@ export function DocumentBulkBar({ ) : null} + {encounters.length > 0 ? ( + + + + + + {encounters.map((encounter) => ( + onLinkEncounter(encounter.id)} + > + {encounter.label} + + ))} + + + ) : null} +