mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge pull request #747 from MBombeck/feat/encounter-phase-2
Visits become reachable: capture, incidental linking, and the report leaf
This commit is contained in:
103 files changed
+6718
-136
No files matched your search
+204
-7
@@ -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 `<kind>:...`. 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."
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+118
-7
@@ -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."
|
||||
}
|
||||
}
|
||||
+118
-7
@@ -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."
|
||||
}
|
||||
}
|
||||
+118
-7
@@ -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."
|
||||
}
|
||||
}
|
||||
+118
-7
@@ -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."
|
||||
}
|
||||
}
|
||||
+118
-7
@@ -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."
|
||||
}
|
||||
}
|
||||
+118
-7
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -939,6 +939,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
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<string, string> = {
|
||||
* 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` —
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* `GET /api/encounters/suggest?anchor=<iso>` — 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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
|
||||
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
|
||||
{t("checkups.tabVorsorge")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</TabsContent>
|
||||
<TabsContent value="visits">
|
||||
{/* Mounted only on the active tab, so the visits read does not fire
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <PageAuthGate />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PractitionerList enabled={isAuthenticated} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Activity,
|
||||
CalendarCheck,
|
||||
Award,
|
||||
CalendarClock,
|
||||
Footprints,
|
||||
@@ -55,6 +56,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
|
||||
ecg_new_recording: HeartPulse,
|
||||
tension_window: Activity,
|
||||
same_time_baseline: Footprints,
|
||||
upcoming_visit: CalendarCheck,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentBulkBar
|
||||
selectedCount={3}
|
||||
episodes={episodes}
|
||||
encounters={encounters}
|
||||
busy={false}
|
||||
onSetKind={() => {}}
|
||||
onLinkEpisode={() => {}}
|
||||
onLinkEncounter={() => {}}
|
||||
onShare={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClear={() => {}}
|
||||
@@ -36,6 +44,7 @@ describe("<DocumentBulkBar>", () => {
|
||||
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("<DocumentBulkBar>", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: false,
|
||||
contentIndexSource: null,
|
||||
|
||||
@@ -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({
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{encounters.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
data-slot="document-bulk-link-visit"
|
||||
aria-label={t("documents.bulk.linkVisit")}
|
||||
>
|
||||
<CalendarClock className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.bulk.linkVisit")}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{encounters.map((encounter) => (
|
||||
<DropdownMenuItem
|
||||
key={encounter.id}
|
||||
onSelect={() => onLinkEncounter(encounter.id)}
|
||||
>
|
||||
<span className="max-w-56 truncate">{encounter.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -61,7 +61,9 @@ import {
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
@@ -77,6 +79,7 @@ import {
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "./document-ai-section";
|
||||
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
|
||||
import { DocumentSummaryBlock } from "./document-summary-block";
|
||||
import type { DocumentAiTarget } from "./document-ai-transport";
|
||||
import { DocumentShareSheet } from "./document-share-sheet";
|
||||
@@ -96,6 +99,7 @@ type PatchInput = {
|
||||
kind?: InboundDocumentKindValue;
|
||||
documentDate?: string | null;
|
||||
episodeIds?: string[];
|
||||
encounterIds?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -396,6 +400,17 @@ export function DocumentDetailSheet({
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
/**
|
||||
* The visit this SESSION filed the document against, if any.
|
||||
*
|
||||
* Kept beside the server state rather than derived from it, because the two
|
||||
* answer different questions: `doc.encounterLinks` says what is filed, this
|
||||
* says what the person just did here — which is what an undo can take back
|
||||
* without touching a filing they made somewhere else.
|
||||
*/
|
||||
const [pickedEncounterId, setPickedEncounterId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
|
||||
null,
|
||||
@@ -499,6 +514,25 @@ export function DocumentDetailSheet({
|
||||
patch.mutate({ episodeIds: next });
|
||||
};
|
||||
|
||||
/**
|
||||
* File this document against a visit, or clear that filing again.
|
||||
*
|
||||
* A replace-set PATCH like the condition links, so the undo is the same
|
||||
* write with the id removed rather than a second endpoint. Nothing here can
|
||||
* fail the document: an unknown id is refused by the route before anything
|
||||
* is written, and the sheet keeps showing what the server last confirmed.
|
||||
*/
|
||||
const setSuggestedEncounter = (encounterId: string | null) => {
|
||||
if (!doc) return;
|
||||
const current = doc.encounterLinks.map((l) => l.encounterId);
|
||||
const next =
|
||||
encounterId === null
|
||||
? current.filter((id) => id !== pickedEncounterId)
|
||||
: [...new Set([...current, encounterId])];
|
||||
setPickedEncounterId(encounterId);
|
||||
patch.mutate({ encounterIds: next });
|
||||
};
|
||||
|
||||
// The affordance gate falls back to the capability probe when the sheet is
|
||||
// rendered without the usage-derived props (deep link before usage loads).
|
||||
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
|
||||
@@ -1001,6 +1035,47 @@ export function DocumentDetailSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DocumentEncounterSuggestion
|
||||
anchor={doc.reportDate ?? doc.documentDate}
|
||||
alreadyLinked={
|
||||
doc.encounterLinks.length > 0 && pickedEncounterId === null
|
||||
}
|
||||
disabled={!canManage}
|
||||
value={pickedEncounterId}
|
||||
onChange={setSuggestedEncounter}
|
||||
/>
|
||||
|
||||
{/* "Belongs to visit" — read-only here on purpose. The filing
|
||||
happens at the moment the document arrives, or from the
|
||||
visit's own sheet; a second editor for the same link would
|
||||
be a second place the set can be changed. Each row deep-links
|
||||
to the vault filtered to that visit. */}
|
||||
{doc.encounterLinks.length > 0 ? (
|
||||
<div className="space-y-1.5" data-slot="document-visit-links">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.visitsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.encounterLinks.map((link) => (
|
||||
<Link
|
||||
key={link.encounterId}
|
||||
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">
|
||||
{encounterKindText(t, link.kind as EncounterKind)}
|
||||
</span>
|
||||
{link.occurredAt ? (
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{format.date(link.occurredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{labsModuleEnabled && labFactCount > 0 ? (
|
||||
<Link
|
||||
href="/labs"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The document moment of the visit suggestion.
|
||||
*
|
||||
* The rule and the rendering both live elsewhere — `suggest-window.ts` on the
|
||||
* server and `EncounterSuggestionField` in the browser — so this file is only
|
||||
* the wiring: which date to anchor on, and where the chosen id goes.
|
||||
*
|
||||
* The anchor is `reportDate ?? documentDate`. `reportDate` is what the model
|
||||
* transcribed off the letter itself and is never inferred, so it is the truer
|
||||
* of the two; `documentDate` is the person's own filing date and is the
|
||||
* fallback.
|
||||
*
|
||||
* It renders nothing once the document already names a visit that this session
|
||||
* did not choose: an offer to file something already filed is noise.
|
||||
*/
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
|
||||
export function DocumentEncounterSuggestion({
|
||||
anchor,
|
||||
alreadyLinked,
|
||||
disabled,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
anchor: string | null;
|
||||
alreadyLinked: boolean;
|
||||
disabled?: boolean;
|
||||
value: string | null;
|
||||
onChange: (encounterId: string | null) => void;
|
||||
}) {
|
||||
if (alreadyLinked) return null;
|
||||
return (
|
||||
<EncounterSuggestionField
|
||||
anchor={anchor}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
slot="document-encounter-suggestion"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,10 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import {
|
||||
type CoachCloseIntent,
|
||||
useCoachLaunch,
|
||||
@@ -85,6 +88,13 @@ interface ListPage {
|
||||
}
|
||||
|
||||
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
|
||||
/**
|
||||
* How many visits the bulk bar offers. A menu is a picker, not a history: past
|
||||
* the first screenful a person scrolls rather than reads, and filing against a
|
||||
* visit older than this belongs on the visit's own sheet.
|
||||
*/
|
||||
const BULK_VISIT_OPTIONS = 12;
|
||||
|
||||
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
|
||||
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
|
||||
|
||||
@@ -226,6 +236,7 @@ export function closeDocumentSelectionAfterCoachHandoff(
|
||||
|
||||
export function DocumentsView() {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
||||
// about it are none of them delegated verbs. Inside somebody else's record
|
||||
@@ -476,6 +487,27 @@ export function DocumentsView() {
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
|
||||
|
||||
// The visits the bulk bar can file a selection against: the account's own,
|
||||
// read through the same list the checkups page shows. Labelled here rather
|
||||
// than in the bar so the label uses the reader's locale for the kind and the
|
||||
// reader's timezone for the date — both presentation, both this side's job.
|
||||
const visits = useEncounters(
|
||||
!inSharedRecord || sections === null || sections.includes("profile"),
|
||||
);
|
||||
const bulkEncounterOptions = useMemo(
|
||||
() =>
|
||||
[...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])]
|
||||
.slice(0, BULK_VISIT_OPTIONS)
|
||||
.map((visit) => ({
|
||||
id: visit.id,
|
||||
label: `${
|
||||
visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)
|
||||
} · ${format.date(visit.occurredAt)}`,
|
||||
})),
|
||||
[visits.data, t, format],
|
||||
);
|
||||
|
||||
// Year segmenter: years present in the loaded corpus (+ the active year).
|
||||
const years = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
@@ -589,7 +621,11 @@ export function DocumentsView() {
|
||||
const runBulk = useCallback(
|
||||
(
|
||||
action: Exclude<DocumentBulkAction, "delete" | "restore">,
|
||||
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
|
||||
extra: {
|
||||
kind?: InboundDocumentKindValue;
|
||||
episodeId?: string;
|
||||
encounterId?: string;
|
||||
},
|
||||
) => {
|
||||
const ids = [...selectedIds];
|
||||
bulk.mutate(
|
||||
@@ -957,6 +993,10 @@ export function DocumentsView() {
|
||||
busy={bulk.isPending}
|
||||
onSetKind={(kind) => runBulk("setKind", { kind })}
|
||||
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
|
||||
encounters={bulkEncounterOptions}
|
||||
onLinkEncounter={(encounterId) =>
|
||||
runBulk("linkEncounter", { encounterId })
|
||||
}
|
||||
onShare={openBulkShare}
|
||||
onDelete={() => deleteBulk([...selectedIds])}
|
||||
onClear={clearSelection}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
|
||||
|
||||
/**
|
||||
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
|
||||
* Parse the vault's URL search params (`?q&kind&episode&encounter&year`) into the
|
||||
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
|
||||
* page), `kind` accepts repeats AND comma-separated values, and the result is
|
||||
* normalised (kinds sorted, empties omitted) so the same view always produces
|
||||
@@ -42,6 +42,13 @@ export function parseVaultSearchParams(
|
||||
const episode = params.get("episode")?.trim();
|
||||
if (episode) filters.episodeId = episode.slice(0, 40);
|
||||
|
||||
// `?encounter=` is how a visit's own detail row reaches the vault filtered
|
||||
// to what that visit produced. No chip in the filter bar backs it: the facet
|
||||
// arrives by deep link from the visit and clears with the clear-all pill,
|
||||
// which is exactly what a one-way link needs and no more.
|
||||
const encounter = params.get("encounter")?.trim();
|
||||
if (encounter) filters.encounterId = encounter.slice(0, 40);
|
||||
|
||||
const yearRaw = params.get("year");
|
||||
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
|
||||
const year = Number(yearRaw);
|
||||
@@ -64,6 +71,7 @@ export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
|
||||
sp.set("kind", [...filters.kinds].sort().join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episode", filters.episodeId);
|
||||
if (filters.encounterId) sp.set("encounter", filters.encounterId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
return sp.toString();
|
||||
}
|
||||
@@ -74,6 +82,7 @@ export function countActiveFilters(filters: DocumentVaultFilters): number {
|
||||
if (filters.q) count += 1;
|
||||
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
|
||||
if (filters.episodeId) count += 1;
|
||||
if (filters.encounterId) count += 1;
|
||||
if (filters.year !== undefined) count += 1;
|
||||
return count;
|
||||
}
|
||||
@@ -95,6 +104,7 @@ export function buildVaultListApiSearch(
|
||||
sp.set("kind", filters.kinds.join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
|
||||
if (filters.encounterId) sp.set("encounterId", filters.encounterId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The one product constraint this whole feature exists to hold: **a visit
|
||||
* saves with a date and nothing else.**
|
||||
*
|
||||
* Three properties, each of which has an obvious way to get broken by a later
|
||||
* "improvement":
|
||||
*
|
||||
* - no field but the date can block Save;
|
||||
* - the status is DERIVED from the date on a new visit, never asked;
|
||||
* - a link picker whose module is off is BLANK, not empty — an empty picker
|
||||
* for a switched-off module advertises a feature that is not there.
|
||||
*/
|
||||
|
||||
let modules: Record<string, boolean> = {};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
EncounterForm,
|
||||
draftFromEncounter,
|
||||
derivedStatus,
|
||||
draftInstant,
|
||||
draftToBody,
|
||||
emptyDraft,
|
||||
} from "../encounter-form";
|
||||
|
||||
/**
|
||||
* The form body, rendered directly rather than through its sheet: the sheet is
|
||||
* a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a
|
||||
* test that went through it would assert against an empty string and pass for
|
||||
* the wrong reason.
|
||||
*/
|
||||
function renderForm(over?: {
|
||||
modules?: Record<string, boolean>;
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
modules = over?.modules ?? {};
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<EncounterForm
|
||||
draft={emptyDraft({ occurredAt: "2026-08-08T10:00" })}
|
||||
onChange={() => {}}
|
||||
isEdit={over?.isEdit ?? false}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("nothing but the date is required", () => {
|
||||
it("admits a draft carrying only a date", () => {
|
||||
// `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft
|
||||
// with no practitioner, no reason, no outcome and no link is saveable.
|
||||
const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" });
|
||||
expect(draft.practitioner).toBeNull();
|
||||
expect(draft.reason).toBe("");
|
||||
expect(draft.documentIds).toEqual([]);
|
||||
expect(draftInstant(draft.occurredAt)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses only when the date itself cannot be read", () => {
|
||||
expect(draftInstant("not a date")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the date field required and nothing else", () => {
|
||||
const html = renderForm();
|
||||
const required = html.match(/aria-required="true"/g) ?? [];
|
||||
expect(required).toHaveLength(1);
|
||||
expect(html).toContain('data-testid="encounter-occurred-at"');
|
||||
});
|
||||
|
||||
it("turns a date-only draft into a complete write body", () => {
|
||||
const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), {
|
||||
isEdit: false,
|
||||
});
|
||||
expect(body).not.toBeNull();
|
||||
expect(body?.practitionerId).toBeNull();
|
||||
expect(body?.reason).toBeNull();
|
||||
expect(body?.outcome).toBeNull();
|
||||
expect(body?.documentIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to build a body from a date that does not parse", () => {
|
||||
expect(
|
||||
draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing never unfiles what the visit already collected", () => {
|
||||
it("seeds the link arrays from the row's own links", () => {
|
||||
// The defect this pins: an edit seeded from a row that did not know its
|
||||
// links would save a replace-set of three EMPTY arrays and silently
|
||||
// unfile everything. The list carries the links so this cannot happen.
|
||||
const draft = draftFromEncounter({
|
||||
id: "e1",
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
links: {
|
||||
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
|
||||
labResults: [{ id: "l1", label: "LDL", date: null, redacted: false }],
|
||||
conditions: [{ id: "c1", label: "Knee", date: null, redacted: false }],
|
||||
},
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
});
|
||||
expect(draft.documentIds).toEqual(["d1"]);
|
||||
expect(draft.labResultIds).toEqual(["l1"]);
|
||||
expect(draft.episodeIds).toEqual(["c1"]);
|
||||
|
||||
const body = draftToBody(draft, { isEdit: true });
|
||||
expect(body?.documentIds).toEqual(["d1"]);
|
||||
expect(body?.labResultIds).toEqual(["l1"]);
|
||||
expect(body?.episodeIds).toEqual(["c1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the status follows the date on a new visit", () => {
|
||||
const now = Date.parse("2026-08-08T12:00:00.000Z");
|
||||
|
||||
it("reads a future instant as a booked appointment", () => {
|
||||
expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED");
|
||||
});
|
||||
|
||||
it("reads a past instant as a visit that happened", () => {
|
||||
expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE");
|
||||
});
|
||||
|
||||
it("never asks for the status on a new visit", () => {
|
||||
// The picker is what "asked" would look like; its absence is the property.
|
||||
expect(renderForm()).not.toContain('id="encounter-status"');
|
||||
});
|
||||
|
||||
it("carries the derived status into the body rather than the draft default", () => {
|
||||
const body = draftToBody(
|
||||
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }),
|
||||
{ isEdit: false },
|
||||
);
|
||||
expect(body?.status).toBe("PLANNED");
|
||||
});
|
||||
|
||||
it("uses the draft's own status on an edit, where it is editable", () => {
|
||||
const body = draftToBody(
|
||||
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }),
|
||||
{ isEdit: true },
|
||||
);
|
||||
expect(body?.status).toBe("CANCELLED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the link pickers follow the modules that own their targets", () => {
|
||||
it("renders no link block at all when all three modules are off", () => {
|
||||
const html = renderForm({ modules: {} });
|
||||
expect(html).not.toContain('data-slot="encounter-link-pickers"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-documents"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-conditions"');
|
||||
});
|
||||
|
||||
it("blanks the block of a disabled module while keeping its neighbours", () => {
|
||||
const html = renderForm({
|
||||
modules: { inboundDocuments: true, labs: false, illness: false },
|
||||
});
|
||||
expect(html).toContain('data-slot="encounter-link-documents"');
|
||||
// Not "renders an empty list" — absent.
|
||||
expect(html).not.toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).not.toContain('data-slot="encounter-link-conditions"');
|
||||
});
|
||||
|
||||
it("offers all three when all three modules are on", () => {
|
||||
const html = renderForm({
|
||||
modules: { inboundDocuments: true, labs: true, illness: true },
|
||||
});
|
||||
expect(html).toContain('data-slot="encounter-link-documents"');
|
||||
expect(html).toContain('data-slot="encounter-link-labs"');
|
||||
expect(html).toContain('data-slot="encounter-link-conditions"');
|
||||
// The positive control: without it the three assertions above would pass
|
||||
// against a component that never renders any block.
|
||||
expect(html).toContain('data-slot="encounter-link-pickers"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The visits surface's designed states, pinned by single-pass static renders.
|
||||
*
|
||||
* - an account with no visits paints the empty state and one action, not an
|
||||
* error and not a teaching wall;
|
||||
* - upcoming sorts above past, in the two lists the SERVER split — the
|
||||
* client renders the order it was given rather than re-deriving one;
|
||||
* - a soft-deleted visit never arrives, so it cannot be rendered. That is a
|
||||
* server property and the API test owns it; what this file pins is that
|
||||
* the section renders exactly what the response carried, with nothing
|
||||
* filtered or re-sorted on this side that could disagree with it.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
// Never resolves inside a synchronous render, so an unseeded query stays
|
||||
// pending and cannot accidentally satisfy an assertion.
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { VisitsSection } from "../visits-section";
|
||||
|
||||
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: "2026-08-01T09:00:00.000Z",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: "2026-08-01T09:00:00.000Z",
|
||||
updatedAt: "2026-08-01T09:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
list,
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<VisitsSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<VisitsSection>", () => {
|
||||
it("paints the empty state, with one action, when there is no visit", () => {
|
||||
const html = render({ upcoming: [], past: [] });
|
||||
expect(html).toContain("No visit recorded yet");
|
||||
expect(html).toContain("A date is enough");
|
||||
expect(html).toContain('data-slot="visits-add"');
|
||||
// An empty account is not an error, and the two states never share copy.
|
||||
expect(html).not.toContain("Visits could not be loaded.");
|
||||
expect(html).not.toContain('data-slot="visits-upcoming"');
|
||||
});
|
||||
|
||||
it("puts the upcoming list above the past one", () => {
|
||||
const html = render({
|
||||
upcoming: [visit({ id: "later", status: "PLANNED" })],
|
||||
past: [visit({ id: "earlier" })],
|
||||
});
|
||||
const upcomingAt = html.indexOf('data-slot="visits-upcoming"');
|
||||
const pastAt = html.indexOf('data-slot="visits-past"');
|
||||
expect(upcomingAt).toBeGreaterThan(-1);
|
||||
expect(pastAt).toBeGreaterThan(-1);
|
||||
expect(upcomingAt).toBeLessThan(pastAt);
|
||||
expect(html.indexOf('data-encounter-id="later"')).toBeLessThan(
|
||||
html.indexOf('data-encounter-id="earlier"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the server's order inside a list rather than re-sorting it", () => {
|
||||
// Two rows deliberately handed over in an order the client has no reason
|
||||
// to like. Re-sorting here would be the second place the ordering lives.
|
||||
const html = render({
|
||||
upcoming: [],
|
||||
past: [
|
||||
visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }),
|
||||
visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }),
|
||||
],
|
||||
});
|
||||
expect(html.indexOf('data-encounter-id="second"')).toBeLessThan(
|
||||
html.indexOf('data-encounter-id="first"'),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts what a visit produced, from the links the LIST carried", () => {
|
||||
// The counts come off `links`, and the list used to omit them — every card
|
||||
// then read zero for a visit with three documents. The fixture carries a
|
||||
// populated `links` because the response now does.
|
||||
const html = render({
|
||||
upcoming: [],
|
||||
past: [
|
||||
visit({
|
||||
id: "linked",
|
||||
links: {
|
||||
documents: [
|
||||
{ id: "d1", label: "Letter", date: null, redacted: false },
|
||||
{ id: "d2", label: "Scan", date: null, redacted: false },
|
||||
],
|
||||
labResults: [
|
||||
{ id: "l1", label: "LDL", date: null, redacted: false },
|
||||
],
|
||||
conditions: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(html).toContain("2 linked documents");
|
||||
expect(html).toContain("1 linked lab results");
|
||||
// The empty family renders no counter rather than a zero.
|
||||
expect(html).not.toContain("0 linked conditions");
|
||||
});
|
||||
|
||||
it("offers the address book beside the add action", () => {
|
||||
const html = render({ upcoming: [], past: [] });
|
||||
expect(html).toContain('href="/checkups/practitioners"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The visit form.
|
||||
*
|
||||
* **A visit saves with a date and nothing else.** No required practitioner, no
|
||||
* required kind, no required link, and no Save button disabled until something
|
||||
* is picked. That is the whole product constraint this feature exists to hold:
|
||||
* a required field on a form somebody fills after a doctor's appointment is the
|
||||
* bureaucracy it was built to avoid. The only thing that can block Save is a
|
||||
* missing or unparseable date, because a visit with no instant is not a record
|
||||
* of anything.
|
||||
*
|
||||
* Status is DERIVED from the date on a new visit, never asked: a future instant
|
||||
* means PLANNED and a past one means DONE. A person booking an appointment
|
||||
* should not have to also tell the app that it is in the future. On an existing
|
||||
* visit the status becomes editable, because moving a planned visit to done,
|
||||
* cancelled or a no-show is the transition that closes a checkup.
|
||||
*
|
||||
* The three link pickers are gated by the modules that own what they point at,
|
||||
* and the gate BLANKS the block rather than post-filtering it — an empty
|
||||
* picker for a module the account turned off advertises a feature that is not
|
||||
* there.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateTimeField } from "@/components/ui/date-time-field";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
|
||||
import type { Practitioner } from "@/hooks/use-practitioners";
|
||||
import {
|
||||
ENCOUNTER_KINDS,
|
||||
ENCOUNTER_STATUSES,
|
||||
encounterKindText,
|
||||
encounterStatusText,
|
||||
} from "./encounter-labels";
|
||||
import { PractitionerCombobox } from "./practitioner-combobox";
|
||||
import { EncounterLinkPickers } from "./encounter-link-pickers";
|
||||
|
||||
export interface EncounterDraft {
|
||||
/** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */
|
||||
occurredAt: string;
|
||||
status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
||||
kind: string;
|
||||
practitioner: Practitioner | null;
|
||||
reason: string;
|
||||
outcome: string;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
}
|
||||
|
||||
function localValue(instant: Date): string {
|
||||
const offset = instant.getTimezoneOffset();
|
||||
return new Date(instant.getTime() - offset * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
export function nowLocalValue(): string {
|
||||
return localValue(new Date());
|
||||
}
|
||||
|
||||
/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */
|
||||
export function draftInstant(value: string): string | null {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
export function emptyDraft(
|
||||
overrides?: Partial<EncounterDraft>,
|
||||
): EncounterDraft {
|
||||
return {
|
||||
occurredAt: nowLocalValue(),
|
||||
status: "DONE",
|
||||
kind: "OTHER",
|
||||
practitioner: null,
|
||||
reason: "",
|
||||
outcome: "",
|
||||
documentIds: [],
|
||||
labResultIds: [],
|
||||
episodeIds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function draftFromEncounter(row: Encounter): EncounterDraft {
|
||||
return {
|
||||
occurredAt: localValue(new Date(row.occurredAt)),
|
||||
status: row.status,
|
||||
kind: row.kind,
|
||||
practitioner: row.practitioner,
|
||||
reason: row.reason ?? "",
|
||||
outcome: row.outcome ?? "",
|
||||
documentIds: row.links?.documents.map((link) => link.id) ?? [],
|
||||
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
|
||||
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The status a NEW visit carries, read off its own date.
|
||||
*
|
||||
* Exported so the sheet and its tests agree on the derivation rather than each
|
||||
* spelling out the comparison.
|
||||
*/
|
||||
export function derivedStatus(
|
||||
occurredAt: string,
|
||||
now = Date.now(),
|
||||
): "PLANNED" | "DONE" {
|
||||
const parsed = new Date(occurredAt).getTime();
|
||||
return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE";
|
||||
}
|
||||
|
||||
/** The body a draft becomes, with the empty strings turned into absences. */
|
||||
export function draftToBody(
|
||||
draft: EncounterDraft,
|
||||
options: { isEdit: boolean; reminderId?: string | null },
|
||||
): EncounterWriteBody | null {
|
||||
const occurredAt = draftInstant(draft.occurredAt);
|
||||
if (!occurredAt) return null;
|
||||
return {
|
||||
occurredAt,
|
||||
status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt),
|
||||
kind: draft.kind,
|
||||
practitionerId: draft.practitioner?.id ?? null,
|
||||
reason: draft.reason.trim() || null,
|
||||
outcome: draft.outcome.trim() || null,
|
||||
...(options.reminderId ? { reminderId: options.reminderId } : {}),
|
||||
documentIds: draft.documentIds,
|
||||
labResultIds: draft.labResultIds,
|
||||
episodeIds: draft.episodeIds,
|
||||
};
|
||||
}
|
||||
|
||||
export function EncounterForm({
|
||||
draft,
|
||||
onChange,
|
||||
isEdit,
|
||||
}: {
|
||||
draft: EncounterDraft;
|
||||
onChange: (next: EncounterDraft) => void;
|
||||
isEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const patch = (part: Partial<EncounterDraft>) =>
|
||||
onChange({ ...draft, ...part });
|
||||
|
||||
const planned = useMemo(
|
||||
() =>
|
||||
isEdit
|
||||
? draft.status === "PLANNED"
|
||||
: derivedStatus(draft.occurredAt) === "PLANNED",
|
||||
[isEdit, draft.status, draft.occurredAt],
|
||||
);
|
||||
|
||||
// The outcome field starts collapsed on a planned visit: there is nothing to
|
||||
// write about a thing that has not happened. Derived rather than
|
||||
// synchronised — a visit that already carries an outcome is never collapsed
|
||||
// over it, and expressing that as an effect would be a second source of
|
||||
// truth for something the draft already knows.
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="encounter-form">
|
||||
<FieldGroup
|
||||
htmlFor="encounter-occurred-at"
|
||||
label={t("encounters.form.date")}
|
||||
hint={isEdit ? undefined : t("encounters.form.dateDerivesStatusHint")}
|
||||
>
|
||||
<DateTimeField
|
||||
id="encounter-occurred-at"
|
||||
value={draft.occurredAt}
|
||||
onChange={(value) => patch({ occurredAt: value })}
|
||||
required
|
||||
aria-required="true"
|
||||
data-testid="encounter-occurred-at"
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{isEdit ? (
|
||||
<FieldGroup
|
||||
htmlFor="encounter-status"
|
||||
label={t("encounters.form.status")}
|
||||
>
|
||||
<NativeSelect
|
||||
id="encounter-status"
|
||||
value={draft.status}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
status: event.target.value as EncounterDraft["status"],
|
||||
})
|
||||
}
|
||||
>
|
||||
{ENCOUNTER_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{encounterStatusText(t, status)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
) : null}
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="encounter-practitioner"
|
||||
label={t("encounters.form.practitioner")}
|
||||
hint={t("encounters.form.practitionerHint")}
|
||||
>
|
||||
<PractitionerCombobox
|
||||
value={draft.practitioner?.id ?? null}
|
||||
selected={draft.practitioner}
|
||||
onChange={(next) => patch({ practitioner: next })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup htmlFor="encounter-kind" label={t("encounters.form.kind")}>
|
||||
<NativeSelect
|
||||
id="encounter-kind"
|
||||
value={draft.kind}
|
||||
onChange={(event) => patch({ kind: event.target.value })}
|
||||
>
|
||||
{ENCOUNTER_KINDS.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{encounterKindText(t, kind)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="encounter-reason"
|
||||
label={t("encounters.form.reason")}
|
||||
>
|
||||
<Textarea
|
||||
id="encounter-reason"
|
||||
rows={2}
|
||||
value={draft.reason}
|
||||
onChange={(event) => patch({ reason: event.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{outcomeOpen ? (
|
||||
<FieldGroup
|
||||
htmlFor="encounter-outcome"
|
||||
label={t("encounters.form.outcome")}
|
||||
>
|
||||
<Textarea
|
||||
id="encounter-outcome"
|
||||
rows={3}
|
||||
value={draft.outcome}
|
||||
onChange={(event) => patch({ outcome: event.target.value })}
|
||||
/>
|
||||
</FieldGroup>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-outcome-reveal"
|
||||
onClick={() => setRevealed(true)}
|
||||
>
|
||||
{t("encounters.form.outcomeReveal")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<EncounterLinkPickers
|
||||
modules={user?.modules}
|
||||
documentIds={draft.documentIds}
|
||||
labResultIds={draft.labResultIds}
|
||||
episodeIds={draft.episodeIds}
|
||||
onChange={patch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Browser-side labels for a visit's kind and status.
|
||||
*
|
||||
* The server has its own `encounterKindLabel` and the two do not share code on
|
||||
* purpose: the server's answer becomes a push body in the RECORD OWNER's
|
||||
* language, and this one renders in the language of whoever is looking. Sharing
|
||||
* a resolver would force one of the two to read the wrong locale.
|
||||
*
|
||||
* Both switches spell out literal `t()` calls rather than interpolating a key.
|
||||
* An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
|
||||
* or missing label would ship as raw dot notation instead of failing CI.
|
||||
*/
|
||||
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
||||
|
||||
type Translate = (key: string) => string;
|
||||
|
||||
/** Every kind, in the order the picker offers them. */
|
||||
export const ENCOUNTER_KINDS: EncounterKind[] = [
|
||||
"ROUTINE",
|
||||
"ACUTE",
|
||||
"SPECIALIST",
|
||||
"PREVENTIVE",
|
||||
"EMERGENCY",
|
||||
"HOSPITAL",
|
||||
"THERAPY",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
/** Every status a visit can be edited into. */
|
||||
export const ENCOUNTER_STATUSES: EncounterStatus[] = [
|
||||
"PLANNED",
|
||||
"DONE",
|
||||
"CANCELLED",
|
||||
"NO_SHOW",
|
||||
];
|
||||
|
||||
export function encounterKindText(t: Translate, kind: EncounterKind): string {
|
||||
switch (kind) {
|
||||
case "ROUTINE":
|
||||
return t("encounters.kind.routine");
|
||||
case "ACUTE":
|
||||
return t("encounters.kind.acute");
|
||||
case "SPECIALIST":
|
||||
return t("encounters.kind.specialist");
|
||||
case "PREVENTIVE":
|
||||
return t("encounters.kind.preventive");
|
||||
case "EMERGENCY":
|
||||
return t("encounters.kind.emergency");
|
||||
case "HOSPITAL":
|
||||
return t("encounters.kind.hospital");
|
||||
case "THERAPY":
|
||||
return t("encounters.kind.therapy");
|
||||
default:
|
||||
return t("encounters.kind.other");
|
||||
}
|
||||
}
|
||||
|
||||
export function encounterStatusText(
|
||||
t: Translate,
|
||||
status: EncounterStatus,
|
||||
): string {
|
||||
switch (status) {
|
||||
case "PLANNED":
|
||||
return t("encounters.status.planned");
|
||||
case "DONE":
|
||||
return t("encounters.status.done");
|
||||
case "CANCELLED":
|
||||
return t("encounters.status.cancelled");
|
||||
default:
|
||||
return t("encounters.status.noShow");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The visit form's three optional link blocks: documents, lab results,
|
||||
* conditions.
|
||||
*
|
||||
* **The gate blanks the block, it does not post-filter it.** A module the
|
||||
* account turned off leaves nothing behind here — no heading, no empty list,
|
||||
* no "0 selected". An empty picker for a switched-off module advertises a
|
||||
* feature that is not there and invites a person to wonder what is wrong. This
|
||||
* is the same shape the dashboard snapshot uses for a disabled module.
|
||||
*
|
||||
* The encounter itself carries NO module gate. A visit is core, like the
|
||||
* checkups page it lives on; what it may point AT follows the modules that own
|
||||
* those things, which is why the gate lives here and not on the route.
|
||||
*
|
||||
* Nothing in this block can block a save. Every list starts empty and stays
|
||||
* valid empty.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** How many candidates one block offers before the person must use the sheet. */
|
||||
const OPTION_LIMIT = 25;
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
/** ISO-8601, or null for a target with no date of its own. */
|
||||
date: string | null;
|
||||
}
|
||||
|
||||
function LinkBlock({
|
||||
icon: Icon,
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
pending,
|
||||
slot,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
options: Option[];
|
||||
selected: string[];
|
||||
onToggle: (id: string) => void;
|
||||
pending: boolean;
|
||||
slot: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot={slot}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
{selected.length > 0 ? (
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{selected.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{pending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("encounters.form.linkNothingToOffer")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{options.map((option) => {
|
||||
const on = selected.includes(option.id);
|
||||
return (
|
||||
<li key={option.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() => onToggle(option.id)}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
on && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.date ? (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(option.date)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggle(list: string[], id: string): string[] {
|
||||
return list.includes(id)
|
||||
? list.filter((entry) => entry !== id)
|
||||
: [...list, id];
|
||||
}
|
||||
|
||||
interface LabListPage {
|
||||
results: Array<{
|
||||
id: string;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
takenAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DocumentListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
export function EncounterLinkPickers({
|
||||
modules,
|
||||
documentIds,
|
||||
labResultIds,
|
||||
episodeIds,
|
||||
onChange,
|
||||
}: {
|
||||
modules: Partial<Record<ModuleKey, boolean>> | undefined;
|
||||
documentIds: string[];
|
||||
labResultIds: string[];
|
||||
episodeIds: string[];
|
||||
onChange: (part: {
|
||||
documentIds?: string[];
|
||||
labResultIds?: string[];
|
||||
episodeIds?: string[];
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const documentsOn = modules?.inboundDocuments === true;
|
||||
const labsOn = modules?.labs === true;
|
||||
const illnessOn = modules?.illness === true;
|
||||
|
||||
const documents = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
|
||||
enabled: documentsOn,
|
||||
queryFn: () =>
|
||||
apiGet<DocumentListPage>(
|
||||
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
const labs = useQuery({
|
||||
queryKey: queryKeys.labResultsList({
|
||||
analyte: undefined,
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
}),
|
||||
enabled: labsOn,
|
||||
queryFn: () =>
|
||||
apiGet<LabListPage>(
|
||||
`/api/labs?limit=${OPTION_LIMIT}&offset=0&sortDir=desc`,
|
||||
),
|
||||
});
|
||||
|
||||
const episodes = useQuery({
|
||||
queryKey: queryKeys.illnessEpisodes(true),
|
||||
enabled: illnessOn,
|
||||
queryFn: () =>
|
||||
apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=true&limit=${OPTION_LIMIT}`,
|
||||
),
|
||||
});
|
||||
|
||||
if (!documentsOn && !labsOn && !illnessOn) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
|
||||
{documentsOn ? (
|
||||
<LinkBlock
|
||||
icon={FolderOpen}
|
||||
title={t("encounters.form.linkDocuments")}
|
||||
slot="encounter-link-documents"
|
||||
pending={documents.isPending}
|
||||
selected={documentIds}
|
||||
onToggle={(id) => onChange({ documentIds: toggle(documentIds, id) })}
|
||||
options={(documents.data?.documents ?? []).map((doc) => ({
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{labsOn ? (
|
||||
<LinkBlock
|
||||
icon={FlaskConical}
|
||||
title={t("encounters.form.linkLabResults")}
|
||||
slot="encounter-link-labs"
|
||||
pending={labs.isPending}
|
||||
selected={labResultIds}
|
||||
onToggle={(id) =>
|
||||
onChange({ labResultIds: toggle(labResultIds, id) })
|
||||
}
|
||||
options={(labs.data?.results ?? []).map((result) => ({
|
||||
id: result.id,
|
||||
label: result.panel
|
||||
? `${result.panel} · ${result.analyte}`
|
||||
: result.analyte,
|
||||
date: result.takenAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{illnessOn ? (
|
||||
<LinkBlock
|
||||
icon={Stethoscope}
|
||||
title={t("encounters.form.linkConditions")}
|
||||
slot="encounter-link-conditions"
|
||||
pending={episodes.isPending}
|
||||
selected={episodeIds}
|
||||
onToggle={(id) => onChange({ episodeIds: toggle(episodeIds, id) })}
|
||||
options={(episodes.data ?? []).map((episode) => ({
|
||||
id: episode.id,
|
||||
label: episode.label,
|
||||
date: episode.onsetAt,
|
||||
}))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The sheet the visit form lives in — create and edit, one surface.
|
||||
*
|
||||
* Save is disabled only when the date cannot be parsed. Nothing else on the
|
||||
* form can block it, which is the property the whole feature is checked
|
||||
* against.
|
||||
*
|
||||
* Two entry points pre-fill it and neither changes what is required:
|
||||
*
|
||||
* - `reminderId` — opened from a due checkup. Filing the visit as DONE closes
|
||||
* that checkup through the server's forward-only, idempotent path, so a
|
||||
* person who both ticks the box and files a visit gets one satisfaction and
|
||||
* no error.
|
||||
* - `episodeId` — opened from a condition's "Visits about this" card, with
|
||||
* the episode already linked.
|
||||
*
|
||||
* A delegate who may file the visit but not close the checkup gets the visit
|
||||
* saved and a plain sentence saying the checkup stayed open. That is reported
|
||||
* rather than skipped in silence: somebody who believes a checkup was closed
|
||||
* will not look at it again.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
|
||||
import {
|
||||
EncounterForm,
|
||||
draftFromEncounter,
|
||||
draftInstant,
|
||||
draftToBody,
|
||||
emptyDraft,
|
||||
nowLocalValue,
|
||||
type EncounterDraft,
|
||||
} from "./encounter-form";
|
||||
|
||||
export function EncounterSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
encounter,
|
||||
reminderId,
|
||||
episodeId,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** `null` creates a visit; a row edits it. */
|
||||
encounter: Encounter | null;
|
||||
/** The due checkup this visit is being filed against, when there is one. */
|
||||
reminderId?: string | null;
|
||||
/** A condition to pre-link, when the sheet was opened from an episode. */
|
||||
episodeId?: string | null;
|
||||
onSaved?: (saved: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update, remove } = useEncounterMutations();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
// Seeded once, at mount. The caller remounts this component on every open by
|
||||
// varying its `key`, so a second edit never shows the first one's values and
|
||||
// a create after an edit starts empty — without an effect that copies props
|
||||
// into state and briefly renders the stale pair.
|
||||
const [draft, setDraft] = useState<EncounterDraft>(() =>
|
||||
encounter
|
||||
? draftFromEncounter(encounter)
|
||||
: emptyDraft({
|
||||
occurredAt: nowLocalValue(),
|
||||
episodeIds: episodeId ? [episodeId] : [],
|
||||
}),
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = encounter !== null;
|
||||
const pending = create.isPending || update.isPending;
|
||||
// The one gate. A date that does not parse is not a visit; everything else
|
||||
// on this form is optional by design.
|
||||
const dateUsable = draftInstant(draft.occurredAt) !== null;
|
||||
|
||||
const submit = async () => {
|
||||
const body = draftToBody(draft, { isEdit, reminderId });
|
||||
if (!body) return;
|
||||
try {
|
||||
const saved = isEdit
|
||||
? await update.mutateAsync({ id: encounter.id, body })
|
||||
: await create.mutateAsync(body);
|
||||
// The server names what its grant could not reach. Today that is only a
|
||||
// checkup closure, and a delegate seeing nothing would believe it closed
|
||||
// — so a write that did part of what was asked reports `partial`, not a
|
||||
// green tick, through the one module that owns the affordance.
|
||||
const wroteEverything = (saved.skipped?.links ?? 0) === 0;
|
||||
toastWrittenOutcome(
|
||||
wroteEverything ? "success" : "partial",
|
||||
wroteEverything
|
||||
? t(isEdit ? "encounters.updated" : "encounters.created")
|
||||
: t("encounters.checkupNotClosed"),
|
||||
);
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("encounters.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(isEdit ? "encounters.editTitle" : "encounters.createTitle")}
|
||||
description={t("encounters.formDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-save"
|
||||
disabled={!dateUsable || pending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<EncounterForm draft={draft} onChange={setDraft} isEdit={isEdit} />
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isEdit ? (
|
||||
<AlertDialog
|
||||
open={confirmingDelete}
|
||||
onOpenChange={setConfirmingDelete}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="encounter-delete"
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("encounters.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
{/* Says what goes with it. Deleting a booked visit retires its
|
||||
reminder, and a person who booked one deserves to know the
|
||||
nudge stops rather than discover it by silence. */}
|
||||
<AlertDialogDescription>
|
||||
{t("encounters.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
remove.mutate(encounter.id, {
|
||||
onSuccess: () => onOpenChange(false),
|
||||
onError: () => toast.error(t("encounters.deleteFailed")),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Gehört zu Termin …" — the one rendering of the suggestion, for all three
|
||||
* moments that ask it.
|
||||
*
|
||||
* The RULE is not here. It lives in `src/lib/encounters/suggest-window.ts` and
|
||||
* is resolved server-side, so a document arriving in the vault, a lab panel
|
||||
* committed from an extraction and a panel typed by hand cannot answer the same
|
||||
* question differently. What this component owns is only how the three verdicts
|
||||
* look, and it owns that once:
|
||||
*
|
||||
* one candidate → pre-selected, visibly, with one undo;
|
||||
* two or more → a picker with NOTHING pre-selected;
|
||||
* none → nothing at all. No empty picker, no "create a visit?" nudge.
|
||||
*
|
||||
* The middle branch is the point. Pre-selecting the first of two is the failure
|
||||
* that teaches a person to stop reading suggestions, and the component cannot
|
||||
* do it: the "many" verdict carries no pre-selection and this file never picks
|
||||
* one.
|
||||
*
|
||||
* It never blocks a save — every caller treats the value as optional — and it
|
||||
* is never behind an AI call: the match is a date-window query over the
|
||||
* person's own rows and resolves on an account with no provider configured.
|
||||
*
|
||||
* Controlled. The caller owns where the chosen id goes: straight into a PATCH
|
||||
* on a document, or into the body of a lab write that has not happened yet.
|
||||
*/
|
||||
import { CalendarClock, Check, Undo2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounterSuggestion } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import type { EncounterSuggestion } from "@/lib/encounters/suggest-window";
|
||||
import { encounterKindText } from "./encounter-labels";
|
||||
|
||||
export function EncounterSuggestionField({
|
||||
anchor,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
slot = "encounter-suggestion",
|
||||
}: {
|
||||
/** The date to resolve around, ISO-8601. `null` means there is nothing to ask. */
|
||||
anchor: string | null;
|
||||
/** The visit currently chosen here, or null. */
|
||||
value: string | null;
|
||||
onChange: (encounterId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** `data-slot` prefix, so a caller's tests can address its own instance. */
|
||||
slot?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const suggestion = useEncounterSuggestion(anchor, !disabled);
|
||||
|
||||
if (disabled) return null;
|
||||
const result = suggestion.data;
|
||||
if (!result || result.kind === "none") return null;
|
||||
|
||||
const label = (candidate: EncounterSuggestion) =>
|
||||
`${
|
||||
candidate.practitionerName ??
|
||||
encounterKindText(t, candidate.kind as EncounterKind)
|
||||
} · ${format.date(candidate.occurredAt)}`;
|
||||
|
||||
const candidates =
|
||||
result.kind === "one" ? [result.encounter] : result.encounters;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-1.5"
|
||||
data-slot={slot}
|
||||
data-verdict={result.kind}
|
||||
// The count is on the element so a test can prove the rule reached the
|
||||
// surface rather than inferring it from how many buttons rendered.
|
||||
data-candidates={candidates.length}
|
||||
>
|
||||
{/* A content sentence, not meta (§3): it is the offer itself. */}
|
||||
<p className="text-foreground flex items-center gap-2 text-sm">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
{t(
|
||||
result.kind === "one"
|
||||
? "encounters.suggestion.single"
|
||||
: "encounters.suggestion.choose",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{candidates.map((candidate) => {
|
||||
const chosen = value === candidate.id;
|
||||
return (
|
||||
<Button
|
||||
key={candidate.id}
|
||||
type="button"
|
||||
variant={chosen ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
aria-pressed={chosen}
|
||||
data-slot={`${slot}-option`}
|
||||
onClick={() => onChange(chosen ? null : candidate.id)}
|
||||
>
|
||||
{result.kind === "one" ? (
|
||||
<Check
|
||||
className={cn("size-4", chosen ? "opacity-100" : "opacity-0")}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{label(candidate)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{value !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot={`${slot}-undo`}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<Undo2 className="size-4" aria-hidden />
|
||||
{t("common.undo")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The practice picker on the visit form.
|
||||
*
|
||||
* A combobox rather than a select, because the address book grows past the
|
||||
* length a dropdown reads well at and a person types the first three letters
|
||||
* of a practice name faster than they scroll to it. The search runs in SQL over
|
||||
* the two plaintext columns — that is why they are plaintext.
|
||||
*
|
||||
* "Add new" opens a nested sheet rather than navigating away. A person filling
|
||||
* a half-finished visit form who is sent to another page to add a doctor comes
|
||||
* back to an empty form, so the affordance that looks helpful is the one that
|
||||
* loses their work.
|
||||
*
|
||||
* The field is OPTIONAL and says so: a person can log "the emergency room on
|
||||
* the 3rd" before they know whose name was on the door.
|
||||
*/
|
||||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { usePractitioners, type Practitioner } from "@/hooks/use-practitioners";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PractitionerSheet } from "@/components/practitioners/practitioner-sheet";
|
||||
|
||||
export function PractitionerCombobox({
|
||||
value,
|
||||
onChange,
|
||||
/** The already-resolved entry, so the closed trigger names it without a read. */
|
||||
selected,
|
||||
disabled,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (next: Practitioner | null) => void;
|
||||
selected: Practitioner | null;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
// Bumped on each open so the nested sheet remounts empty rather than showing
|
||||
// whatever the last attempt left in it.
|
||||
const [session, setSession] = useState(0);
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
|
||||
const list = usePractitioners(debounced, open);
|
||||
const rows = list.data ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
data-slot="encounter-practitioner-trigger"
|
||||
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
selected ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{selected?.name ?? t("encounters.form.practitionerNone")}
|
||||
</span>
|
||||
<ChevronsUpDown
|
||||
className="size-4 shrink-0 opacity-50"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
|
||||
<Input
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={t("encounters.form.practitionerSearch")}
|
||||
aria-label={t("encounters.form.practitionerSearch")}
|
||||
data-slot="encounter-practitioner-search"
|
||||
/>
|
||||
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{list.isPending ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-3 text-sm">
|
||||
{t("encounters.form.practitionerNoMatch")}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
data-slot="encounter-practitioner-option"
|
||||
onClick={() => {
|
||||
onChange(row);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
row.id === value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate">
|
||||
{row.name}
|
||||
</span>
|
||||
{row.practice ? (
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{row.practice}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 min-h-11 w-full"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setAdding(true);
|
||||
setSession((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.form.practitionerAdd")}
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selected ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0"
|
||||
aria-label={t("encounters.form.practitionerClear")}
|
||||
onClick={() => onChange(null)}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Nested, so the half-filled visit form behind it stays mounted. */}
|
||||
<PractitionerSheet
|
||||
key={session}
|
||||
open={adding}
|
||||
onOpenChange={setAdding}
|
||||
practitioner={null}
|
||||
onSaved={(created) => onChange(created)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One visit, as a row in the list.
|
||||
*
|
||||
* The date, the practice and the reason are what the person wrote, so all three
|
||||
* are `text-foreground`; the counts and the relative distance are meta and
|
||||
* muted. Status is carried by the SECTION rather than repeated on every card —
|
||||
* a card in the upcoming list is planned by construction, and a badge saying so
|
||||
* on every row is noise that hides the two rows where the status is a surprise.
|
||||
* A cancelled or missed visit is the exception and says so, because those are
|
||||
* the ones a person is scanning for.
|
||||
*/
|
||||
import {
|
||||
CalendarClock,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { Encounter } from "@/hooks/use-encounters";
|
||||
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
||||
import { encounterKindText, encounterStatusText } from "./encounter-labels";
|
||||
|
||||
/** The two statuses worth calling out on a row; the rest the section implies. */
|
||||
function isNoteworthy(status: EncounterStatus): boolean {
|
||||
return status === "CANCELLED" || status === "NO_SHOW";
|
||||
}
|
||||
|
||||
export function VisitCard({
|
||||
encounter,
|
||||
onOpen,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
onOpen: (encounter: Encounter) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
const counts = [
|
||||
{
|
||||
icon: FileText,
|
||||
n: encounter.links?.documents.length ?? 0,
|
||||
label: t("encounters.card.documentCount"),
|
||||
},
|
||||
{
|
||||
icon: FlaskConical,
|
||||
n: encounter.links?.labResults.length ?? 0,
|
||||
label: t("encounters.card.labCount"),
|
||||
},
|
||||
{
|
||||
icon: Stethoscope,
|
||||
n: encounter.links?.conditions.length ?? 0,
|
||||
label: t("encounters.card.conditionCount"),
|
||||
},
|
||||
].filter((entry) => entry.n > 0);
|
||||
|
||||
// The practice, or the kind when there is none. A visit with neither is not
|
||||
// nameless — the kind always resolves, because it defaults to one.
|
||||
const heading =
|
||||
encounter.practitioner?.name ??
|
||||
encounterKindText(t, encounter.kind as EncounterKind);
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="visit-card"
|
||||
data-encounter-id={encounter.id}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(encounter)}
|
||||
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CalendarClock
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{heading}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{format.dateTime(encounter.occurredAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{encounterKindText(t, encounter.kind as EncounterKind)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{encounter.reason ? (
|
||||
<p className="text-foreground line-clamp-1 text-sm">
|
||||
{encounter.reason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ||
|
||||
counts.length ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{isNoteworthy(encounter.status as EncounterStatus) ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{encounterStatusText(t, encounter.status as EncounterStatus)}
|
||||
</span>
|
||||
) : null}
|
||||
{counts.map(({ icon: Icon, n, label }) => (
|
||||
<span
|
||||
key={label}
|
||||
className="text-muted-foreground flex items-center gap-1 text-xs tabular-nums"
|
||||
aria-label={`${n} ${label}`}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Termine" — the visits half of the checkups page.
|
||||
*
|
||||
* Two lists rather than one, and the split is the server's: upcoming reads
|
||||
* soonest-first, past reads newest-first, and the API resolves both so the
|
||||
* ordering lives in one place. A client that split and re-sorted one array
|
||||
* would be the second place it lives.
|
||||
*
|
||||
* No new nav entry and no new route. `/checkups` already owns exactly the right
|
||||
* sentence — when do I have to do what, where — and is deliberately not module
|
||||
* gated, which is also why a visit carries no module key of its own.
|
||||
*/
|
||||
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { EncounterSheet } from "./encounter-sheet";
|
||||
import { VisitCard } from "./visit-card";
|
||||
|
||||
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const list = useEncounters(enabled);
|
||||
|
||||
// `null` closed, `"new"` create, a row edits it.
|
||||
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
|
||||
const [session, setSession] = useState(0);
|
||||
const openSheet = (target: Encounter | "new") => {
|
||||
setEditing(target);
|
||||
setSession((n) => n + 1);
|
||||
};
|
||||
|
||||
const upcoming = list.data?.upcoming ?? [];
|
||||
const past = list.data?.past ?? [];
|
||||
const empty = upcoming.length === 0 && past.length === 0;
|
||||
|
||||
const addButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="visits-add"
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section aria-labelledby="visits-section-title" className="space-y-4">
|
||||
<PageHeader
|
||||
titleId="visits-section-title"
|
||||
title={t("encounters.sectionTitle")}
|
||||
description={t("encounters.sectionDescription")}
|
||||
actions={
|
||||
<>
|
||||
<Button asChild variant="outline" className="min-h-11">
|
||||
<Link href="/checkups/practitioners">
|
||||
<ContactRound className="size-4" aria-hidden />
|
||||
{t("practitioners.linkLabel")}
|
||||
</Link>
|
||||
</Button>
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : empty ? (
|
||||
<EmptyState
|
||||
icon={<CalendarClock className="size-6" aria-hidden />}
|
||||
title={t("encounters.emptyTitle")}
|
||||
description={t("encounters.emptyDescription")}
|
||||
action={addButton ?? undefined}
|
||||
ctaSize="lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{upcoming.length > 0 ? (
|
||||
<div className="space-y-2" data-slot="visits-upcoming">
|
||||
<SectionHeading
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.upcomingHeading")}
|
||||
/>
|
||||
{upcoming.map((encounter) => (
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{past.length > 0 ? (
|
||||
<div className="space-y-2" data-slot="visits-past">
|
||||
<SectionHeading
|
||||
icon={History}
|
||||
title={t("encounters.pastHeading")}
|
||||
/>
|
||||
{past.map((encounter) => (
|
||||
<VisitCard
|
||||
key={encounter.id}
|
||||
encounter={encounter}
|
||||
onOpen={openSheet}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* `key` changes on every open, so the sheet remounts with a fresh draft
|
||||
instead of copying props into state from an effect. It stays mounted
|
||||
while closing, which is what keeps the exit animation. */}
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
encounter={editing === "new" ? null : editing}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Termine dazu" — the visits filed against one condition episode.
|
||||
*
|
||||
* **From the episode side only, and that is a decision rather than an
|
||||
* omission.** An episode spans weeks and a visit is a point, so matching from
|
||||
* the VISIT side would produce false positives at a rate that teaches a person
|
||||
* to ignore every suggestion in the product. Here the person already has the
|
||||
* condition in front of them and is the one who knows; the card offers an
|
||||
* action instead of a guess.
|
||||
*
|
||||
* Modelled on the episode's documents card, which does exactly this for the
|
||||
* vault. Renders nothing when the illness module is off — a gated surface never
|
||||
* leaks, and an episode page is unreachable in that state anyway.
|
||||
*/
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [session, setSession] = useState(0);
|
||||
|
||||
const moduleEnabled = user?.modules?.illness === true;
|
||||
// The filter runs in SQL: the two arms of the response are separately
|
||||
// bounded, so narrowing here would page wrongly on a long history.
|
||||
const list = useEncounters(moduleEnabled, { episodeId });
|
||||
|
||||
if (!moduleEnabled) return null;
|
||||
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const visits = [...(list.data?.upcoming ?? []), ...(list.data?.past ?? [])];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card data-slot="episode-visits-card">
|
||||
<CardContent className="space-y-4">
|
||||
<TileHeader
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.episodeCard.title")}
|
||||
right={
|
||||
visits.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{visits.length}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : visits.length === 0 ? (
|
||||
// A quiet one-line absence, not a teaching empty state: the person
|
||||
// came here about the condition, not to be taught about visits.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("encounters.episodeCard.emptyLine")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{visits.map((visit) => (
|
||||
<li key={visit.id}>
|
||||
<ListRow asChild>
|
||||
<Link
|
||||
href="/checkups"
|
||||
data-encounter-id={visit.id}
|
||||
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<CalendarClock
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{visit.practitioner?.name ??
|
||||
encounterKindText(t, visit.kind as EncounterKind)}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(visit.occurredAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</ListRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
data-slot="episode-visits-add"
|
||||
onClick={() => {
|
||||
setSheetOpen(true);
|
||||
setSession((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("encounters.episodeCard.add")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManage ? (
|
||||
<EncounterSheet
|
||||
key={session}
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
encounter={null}
|
||||
// Pre-linked, so the person never has to find this condition again
|
||||
// in a picker they already navigated away from.
|
||||
episodeId={episodeId}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
|
||||
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -156,6 +157,12 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
nothing when the documents module is off for this account. */}
|
||||
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{/* Condition ⇄ visits linking, condition side ONLY. A visit never
|
||||
suggests an episode: an episode spans weeks and a visit is a point,
|
||||
so the reverse match would be wrong often enough to train the person
|
||||
to ignore every suggestion in the product. */}
|
||||
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{episode ? (
|
||||
<>
|
||||
<LogDaySheet
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -25,6 +25,7 @@ import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
|
||||
import { formatReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { formatLabValue } from "@/lib/labs/format-value";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -118,6 +119,21 @@ export function LabForm({
|
||||
const [value, setValue] = useState("");
|
||||
const [valueText, setValueText] = useState("");
|
||||
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
|
||||
/**
|
||||
* The visit this reading came out of, when the suggestion offered one.
|
||||
*
|
||||
* Deliberately NOT cleared by the keep-open reset below: a real lab report
|
||||
* shares one draw date and one visit across every analyte, so the next row
|
||||
* off the same report keeps both rather than asking again.
|
||||
*/
|
||||
const [visitId, setVisitId] = useState<string | null>(null);
|
||||
// The `DateTimeField` value is a local `yyyy-MM-ddTHH:mm`; the suggestion
|
||||
// asks the server in ISO-8601, so it is converted once here rather than in
|
||||
// the shared field, which must not know about this form's input format.
|
||||
const takenAtAnchor = useMemo(() => {
|
||||
const parsed = new Date(takenAt);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}, [takenAt]);
|
||||
const [note, setNote] = useState("");
|
||||
// The window the paper report printed for THIS reading. Optional and folded
|
||||
// away by default: most entries are typed against a catalog marker and never
|
||||
@@ -224,6 +240,7 @@ export function LabForm({
|
||||
: {}),
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
...(visitId ? { encounterId: visitId } : {}),
|
||||
});
|
||||
toast.success(
|
||||
t("labs.form.savedToast"),
|
||||
@@ -449,6 +466,15 @@ export function LabForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anchored on the draw date, not on now: backdating a reading moves
|
||||
the offer with it. */}
|
||||
<EncounterSuggestionField
|
||||
anchor={takenAtAnchor}
|
||||
value={visitId}
|
||||
onChange={setVisitId}
|
||||
slot="lab-form-encounter-suggestion"
|
||||
/>
|
||||
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { OcrRowEditor } from "./ocr-row-editor";
|
||||
@@ -128,6 +129,12 @@ export function OcrReviewDialog({
|
||||
// filed into the Documents vault and cross-linked to the committed labs. Text
|
||||
// mode keeps the image on-device, so nothing is retained there.
|
||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||
/**
|
||||
* The visit this panel came out of, if the review step offered one and the
|
||||
* person took the offer. Never blocks the save and never pre-fills anything
|
||||
* else: the panel commits with it unset exactly as before.
|
||||
*/
|
||||
const [visitId, setVisitId] = useState<string | null>(null);
|
||||
|
||||
// Text mode OCR's the image in the browser then POSTs the text; vision mode
|
||||
// uploads the image. Both resolve with the same proposed-rows DTO.
|
||||
@@ -143,10 +150,24 @@ export function OcrReviewDialog({
|
||||
[rows],
|
||||
);
|
||||
|
||||
/**
|
||||
* The draw date the whole panel hangs off — the earliest `takenAt` the scan
|
||||
* read. A panel is one draw; asking the suggestion about each row separately
|
||||
* would ask the same question a dozen times and could answer it a dozen ways.
|
||||
*/
|
||||
const panelAnchor = useMemo(() => {
|
||||
const dates = rows
|
||||
.map((row) => row.takenAt)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort();
|
||||
return dates.length > 0 ? `${dates[0]}T12:00:00.000Z` : null;
|
||||
}, [rows]);
|
||||
|
||||
function reset() {
|
||||
setStage("pick");
|
||||
setRows([]);
|
||||
setPickedFile(null);
|
||||
setVisitId(null);
|
||||
extract.reset();
|
||||
commit.reset();
|
||||
}
|
||||
@@ -183,7 +204,7 @@ export function OcrReviewDialog({
|
||||
return;
|
||||
}
|
||||
commit.mutate(
|
||||
{ rows: payload, file: pickedFile },
|
||||
{ rows: payload, file: pickedFile, encounterId: visitId },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
// The commit re-checks each confirmed row against what is already
|
||||
@@ -307,6 +328,16 @@ export function OcrReviewDialog({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("labs.ocr.foundCount", { count: rows.length })}
|
||||
</p>
|
||||
|
||||
{/* Anchored on the draw date the scan read, so the offer is about
|
||||
the panel rather than about today. */}
|
||||
<EncounterSuggestionField
|
||||
anchor={panelAnchor}
|
||||
value={visitId}
|
||||
onChange={setVisitId}
|
||||
slot="labs-ocr-encounter-suggestion"
|
||||
/>
|
||||
|
||||
{rows.map((row) => (
|
||||
<OcrRowEditor
|
||||
key={row.key}
|
||||
|
||||
@@ -143,6 +143,12 @@ export interface OcrCommitInput {
|
||||
* image stays on-device.
|
||||
*/
|
||||
file?: File | null;
|
||||
/**
|
||||
* The visit this panel came out of, when the review step offered one and the
|
||||
* person took the offer. Optional always — a commit with none named succeeds
|
||||
* unchanged.
|
||||
*/
|
||||
encounterId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,13 +172,14 @@ async function fileScanToVault(file: File): Promise<string | undefined> {
|
||||
export function useOcrCommit() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
|
||||
mutationFn: async ({ rows, file }: OcrCommitInput) => {
|
||||
mutationFn: async ({ rows, file, encounterId }: OcrCommitInput) => {
|
||||
const documentId = file
|
||||
? await fileScanToVault(file).catch(() => undefined)
|
||||
: undefined;
|
||||
return apiPost<OcrCommitResult>("/api/labs/ocr/commit", {
|
||||
rows,
|
||||
...(documentId ? { documentId } : {}),
|
||||
...(encounterId ? { encounterId } : {}),
|
||||
});
|
||||
},
|
||||
onSuccess: (_result, { file }) => {
|
||||
|
||||
@@ -20,7 +20,14 @@ import { useRouter } from "next/navigation";
|
||||
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import {
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
useDisplayTimezone,
|
||||
} from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -38,6 +45,15 @@ import {
|
||||
|
||||
const SUMMARY_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* How far ahead an appointment has to be before this card stops mentioning it.
|
||||
*
|
||||
* A dashboard summary answers "what is coming", and two weeks is the horizon a
|
||||
* person plans in. Further out and the line is noise that pushes the checkups
|
||||
* it sits under off the card.
|
||||
*/
|
||||
const APPOINTMENT_HORIZON_DAYS = 14;
|
||||
|
||||
function resolveLabel(
|
||||
reminder: MeasurementReminder,
|
||||
t: (key: string) => string,
|
||||
@@ -54,6 +70,7 @@ export function VorsorgeDashboardCard() {
|
||||
// Issue #490 — the same profile zone the checkups page derives its due
|
||||
// phrase in, so the two screens still agree when the device sits elsewhere.
|
||||
const displayTz = useDisplayTimezone();
|
||||
const format = useFormatters();
|
||||
const router = useRouter();
|
||||
// The same answer `/checkups` gives the same action. Satisfying a reminder
|
||||
// is not one of the verbs a delegation admits at any level
|
||||
@@ -72,6 +89,26 @@ export function VorsorgeDashboardCard() {
|
||||
const showLoading = !reminders && !isError;
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
/**
|
||||
* The next appointment, read from the VISIT list and never from the reminder
|
||||
* table.
|
||||
*
|
||||
* An ENCOUNTER-origin reminder is excluded from every preventive-care read,
|
||||
* which is correct — an appointment is not a checkup, and relaxing that
|
||||
* filter here would put one in the due list. But a notification about
|
||||
* something invisible in the app is worse than no notification, so the
|
||||
* appointment appears where a person looks, sourced from the record it
|
||||
* actually lives in. A diff in this file that touches `origin` is the wrong
|
||||
* fix.
|
||||
*/
|
||||
const visits = useEncounters();
|
||||
const nextAppointment = (visits.data?.upcoming ?? []).find(
|
||||
(visit) =>
|
||||
visit.status === "PLANNED" &&
|
||||
new Date(visit.occurredAt).getTime() - now <=
|
||||
APPOINTMENT_HORIZON_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
|
||||
const [captureFooterEl, setCaptureFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
@@ -183,6 +220,27 @@ export function VorsorgeDashboardCard() {
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Visually distinct from a due checkup: a quiet line under the block
|
||||
rather than a fourth row with an action, because there is nothing
|
||||
to do about an appointment except keep it. */}
|
||||
{nextAppointment ? (
|
||||
<Link
|
||||
href="/checkups"
|
||||
data-slot="vorsorge-dashboard-next-visit"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<CalendarClock className="size-3.5 shrink-0" aria-hidden />
|
||||
<span className="truncate">
|
||||
{t("encounters.dashboard.nextVisit", {
|
||||
what:
|
||||
nextAppointment.practitioner?.name ??
|
||||
encounterKindText(t, nextAppointment.kind as EncounterKind),
|
||||
when: format.date(nextAppointment.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<ResponsiveSheet
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Activity,
|
||||
CalendarClock,
|
||||
CalendarPlus,
|
||||
CheckCircle2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -50,6 +51,7 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
|
||||
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -238,6 +240,19 @@ export function VorsorgeSection({
|
||||
const [editing, setEditing] = useState<MeasurementReminder | "new" | null>(
|
||||
null,
|
||||
);
|
||||
/**
|
||||
* The due checkup a visit is being filed against, if any.
|
||||
*
|
||||
* The visit sheet does the rest: saving it as DONE satisfies this reminder
|
||||
* through the server's forward-only, idempotent path, so filing a visit AND
|
||||
* ticking the box is one satisfaction rather than an error.
|
||||
*/
|
||||
const [visitForReminder, setVisitForReminder] = useState<string | null>(null);
|
||||
const [visitSession, setVisitSession] = useState(0);
|
||||
const openVisitFor = (reminder: MeasurementReminder) => {
|
||||
setVisitForReminder(reminder.id);
|
||||
setVisitSession((n) => n + 1);
|
||||
};
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
// The value-capture sheet: holds the reminder whose measurement we are
|
||||
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
|
||||
@@ -738,6 +753,7 @@ export function VorsorgeSection({
|
||||
now={now}
|
||||
view={prefs.view}
|
||||
onPrimaryAction={() => onPrimaryAction(reminder)}
|
||||
onFileVisit={() => openVisitFor(reminder)}
|
||||
onRemove={() => remove.mutate(reminder.id)}
|
||||
onEdit={() => openEdit(reminder)}
|
||||
busy={
|
||||
@@ -749,6 +765,18 @@ export function VorsorgeSection({
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
<EncounterSheet
|
||||
key={visitSession}
|
||||
open={visitForReminder !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVisitForReminder(null);
|
||||
}}
|
||||
encounter={null}
|
||||
reminderId={visitForReminder}
|
||||
// The reminder list repaints from the encounter write's own
|
||||
// invalidation; nothing extra to do here.
|
||||
onSaved={() => setVisitForReminder(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -779,6 +807,7 @@ function VorsorgeCard({
|
||||
now,
|
||||
view,
|
||||
onPrimaryAction,
|
||||
onFileVisit,
|
||||
onRemove,
|
||||
onEdit,
|
||||
busy,
|
||||
@@ -787,6 +816,8 @@ function VorsorgeCard({
|
||||
now: number;
|
||||
view: "cards" | "list";
|
||||
onPrimaryAction: () => void;
|
||||
/** Open the visit sheet with this checkup pre-filled, closing it on save. */
|
||||
onFileVisit: () => void;
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
busy: boolean;
|
||||
@@ -912,6 +943,30 @@ function VorsorgeCard({
|
||||
// "do it now" wash; a button that changes colour by schedule state reads as
|
||||
// a different control). Due-ness is communicated by the discreet coloured
|
||||
// next-due text above.
|
||||
/**
|
||||
* "Termin eintragen" — the second way to close a checkup.
|
||||
*
|
||||
* Beside "erledigt" rather than instead of it, because the two answer
|
||||
* different questions: one says the thing happened, the other says WHERE it
|
||||
* happened and files the record of it. Saving the visit as DONE satisfies
|
||||
* this reminder through the same forward-only, idempotent primitive the
|
||||
* checkbox uses, so a person who does both gets one satisfaction and no
|
||||
* error.
|
||||
*/
|
||||
const fileVisitButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-11 w-full"
|
||||
data-slot="vorsorge-file-visit"
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" />
|
||||
{t("measurementReminders.fileVisit")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
const primaryButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1003,6 +1058,24 @@ function VorsorgeCard({
|
||||
: t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Icon-only in the dense list, labelled in the cards branch —
|
||||
the same action either way, never one branch offering what
|
||||
the other withholds. */}
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
data-slot="vorsorge-file-visit"
|
||||
aria-label={t("measurementReminders.fileVisit")}
|
||||
title={t("measurementReminders.fileVisit")}
|
||||
onClick={onFileVisit}
|
||||
disabled={busy}
|
||||
>
|
||||
<CalendarPlus className="h-4 w-4" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
{headerActions}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -1129,7 +1202,10 @@ function VorsorgeCard({
|
||||
|
||||
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
|
||||
{primaryButton ? (
|
||||
<div className="mt-auto pt-0">{primaryButton}</div>
|
||||
<div className="mt-auto space-y-1.5 pt-0">
|
||||
{primaryButton}
|
||||
{fileVisitButton}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The account's own address book of doctors and practices.
|
||||
*
|
||||
* **Record content, not configuration**, so it does not live in Settings: a
|
||||
* settings surface configures behaviour, and a list of one's own doctors
|
||||
* configures nothing. It sits beside the visits that name it.
|
||||
*
|
||||
* Deleting is a soft delete and the visits that referenced the entry keep it.
|
||||
* The confirmation says so in one sentence, because a person deleting a doctor
|
||||
* has every reason to fear their history goes with it.
|
||||
*/
|
||||
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
usePractitionerMutations,
|
||||
usePractitioners,
|
||||
type Practitioner,
|
||||
} from "@/hooks/use-practitioners";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebouncedValue(term, 250);
|
||||
const list = usePractitioners(debounced, enabled);
|
||||
const { remove } = usePractitionerMutations();
|
||||
|
||||
const [editing, setEditing] = useState<Practitioner | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Practitioner | null>(null);
|
||||
// Bumped on every open so the sheet remounts with a fresh draft rather than
|
||||
// copying props into state from an effect.
|
||||
const [session, setSession] = useState(0);
|
||||
const openSheet = (target: Practitioner | "new") => {
|
||||
setEditing(target);
|
||||
setSession((n) => n + 1);
|
||||
};
|
||||
|
||||
const rows = list.data ?? [];
|
||||
|
||||
const addButton = canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-11"
|
||||
data-slot="practitioner-add"
|
||||
onClick={() => openSheet("new")}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("practitioners.add")}
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<PageHeader
|
||||
title={t("practitioners.title")}
|
||||
description={t("practitioners.description")}
|
||||
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={term}
|
||||
onChange={(event) => setTerm(event.target.value)}
|
||||
placeholder={t("practitioners.searchPlaceholder")}
|
||||
aria-label={t("practitioners.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
data-slot="practitioner-search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("practitioners.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-16 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<ContactRound className="size-6" aria-hidden />}
|
||||
title={t(
|
||||
debounced
|
||||
? "practitioners.noMatchTitle"
|
||||
: "practitioners.emptyTitle",
|
||||
)}
|
||||
description={t(
|
||||
debounced
|
||||
? "practitioners.noMatchDescription"
|
||||
: "practitioners.emptyDescription",
|
||||
)}
|
||||
action={debounced ? undefined : (addButton ?? undefined)}
|
||||
ctaSize="lg"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2" data-slot="practitioner-list">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id}>
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{row.name}
|
||||
</p>
|
||||
{row.practice || row.specialty ? (
|
||||
<p className="text-foreground truncate text-sm">
|
||||
{[row.practice, row.specialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{row.location ? (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{row.location}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.edit")}
|
||||
onClick={() => openSheet(row)}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
aria-label={t("common.delete")}
|
||||
onClick={() => setDeleting(row)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<PractitionerSheet
|
||||
key={session}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
practitioner={editing === "new" ? null : editing}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("practitioners.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
{/* The one sentence that matters: the old visits stay. */}
|
||||
<AlertDialogDescription>
|
||||
{t("practitioners.deleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!deleting) return;
|
||||
const id = deleting.id;
|
||||
setDeleting(null);
|
||||
remove.mutate(id, {
|
||||
onError: () => toast.error(t("practitioners.deleteFailed")),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Add or correct one address-book entry.
|
||||
*
|
||||
* Six plaintext-ish fields and one encrypted note. `name` is the only required
|
||||
* one, for the same reason the visit only requires a date: a person typing a
|
||||
* practice into a form after a doctor's appointment should not be stopped by a
|
||||
* phone number they do not have to hand.
|
||||
*
|
||||
* Reachable two ways — from the address-book page and nested inside the visit
|
||||
* form's picker — so it owns no navigation of its own and reports what it
|
||||
* created back to whoever opened it.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FieldGroup } from "@/components/ui/field-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
usePractitionerMutations,
|
||||
type Practitioner,
|
||||
type PractitionerWriteBody,
|
||||
} from "@/hooks/use-practitioners";
|
||||
|
||||
interface Draft {
|
||||
name: string;
|
||||
specialty: string;
|
||||
practice: string;
|
||||
location: string;
|
||||
phone: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const EMPTY: Draft = {
|
||||
name: "",
|
||||
specialty: "",
|
||||
practice: "",
|
||||
location: "",
|
||||
phone: "",
|
||||
note: "",
|
||||
};
|
||||
|
||||
function draftFrom(row: Practitioner | null): Draft {
|
||||
if (!row) return EMPTY;
|
||||
return {
|
||||
name: row.name,
|
||||
specialty: row.specialty ?? "",
|
||||
practice: row.practice ?? "",
|
||||
location: row.location ?? "",
|
||||
phone: row.phone ?? "",
|
||||
note: row.note ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** An empty field clears the column rather than writing an empty string. */
|
||||
function orNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function PractitionerSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
practitioner,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** `null` creates; a row edits it. */
|
||||
practitioner: Practitioner | null;
|
||||
onSaved?: (row: Practitioner) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { create, update } = usePractitionerMutations();
|
||||
// Seeded once, at mount. The caller varies this component's `key` on every
|
||||
// open, so a second edit never shows the first one's values and a create
|
||||
// after an edit starts empty — without an effect that copies props into
|
||||
// state and briefly renders the stale pair.
|
||||
const [draft, setDraft] = useState<Draft>(() => draftFrom(practitioner));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const pending = create.isPending || update.isPending;
|
||||
const nameGiven = draft.name.trim().length > 0;
|
||||
|
||||
const submit = async () => {
|
||||
if (!nameGiven) return;
|
||||
const body: PractitionerWriteBody = {
|
||||
name: draft.name.trim(),
|
||||
specialty: orNull(draft.specialty),
|
||||
practice: orNull(draft.practice),
|
||||
location: orNull(draft.location),
|
||||
phone: orNull(draft.phone),
|
||||
note: orNull(draft.note),
|
||||
};
|
||||
try {
|
||||
const saved = practitioner
|
||||
? await update.mutateAsync({ id: practitioner.id, body })
|
||||
: await create.mutateAsync(body);
|
||||
onSaved?.(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("practitioners.saveFailed");
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (key: keyof Draft) => ({
|
||||
value: draft[key],
|
||||
onChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => setDraft((prev) => ({ ...prev, [key]: event.target.value })),
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(
|
||||
practitioner ? "practitioners.editTitle" : "practitioners.createTitle",
|
||||
)}
|
||||
description={t("practitioners.formDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!nameGiven || pending}
|
||||
onClick={() => void submit()}
|
||||
data-slot="practitioner-save"
|
||||
className="min-h-11"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="practitioner-form">
|
||||
<FieldGroup htmlFor="practitioner-name" label={t("practitioners.name")}>
|
||||
<Input
|
||||
id="practitioner-name"
|
||||
required
|
||||
aria-required="true"
|
||||
autoComplete="off"
|
||||
{...field("name")}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-specialty"
|
||||
label={t("practitioners.specialty")}
|
||||
>
|
||||
<Input id="practitioner-specialty" {...field("specialty")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-practice"
|
||||
label={t("practitioners.practice")}
|
||||
>
|
||||
<Input id="practitioner-practice" {...field("practice")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-location"
|
||||
label={t("practitioners.location")}
|
||||
>
|
||||
<Input id="practitioner-location" {...field("location")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-phone"
|
||||
label={t("practitioners.phone")}
|
||||
>
|
||||
<Input id="practitioner-phone" type="tel" {...field("phone")} />
|
||||
</FieldGroup>
|
||||
|
||||
<FieldGroup
|
||||
htmlFor="practitioner-note"
|
||||
label={t("practitioners.note")}
|
||||
hint={t("practitioners.noteHint")}
|
||||
>
|
||||
<Textarea id="practitioner-note" rows={3} {...field("note")} />
|
||||
</FieldGroup>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", async () => {
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
// The panel's visit offer reads the encounter list. Pending forever here:
|
||||
// this file is about the panel's own state, and a resolved read would put
|
||||
// an offer in the markup every assertion below would have to know about.
|
||||
useQuery: () => ({ data: undefined, isPending: true, isError: false }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -188,7 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
expect(groupChip(html, "body")).toBe("0/12");
|
||||
expect(groupChip(html, "labs")).toBe("0/1");
|
||||
expect(groupChip(html, "medications")).toBe("0/4");
|
||||
expect(groupChip(html, "history")).toBe("0/2");
|
||||
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
|
||||
expect(groupChip(html, "history")).toBe("0/3");
|
||||
expect(groupChip(html, "cardio")).toBe("0/14");
|
||||
expect(groupChip(html, "activity")).toBe("0/8");
|
||||
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
|
||||
|
||||
@@ -187,6 +187,7 @@ const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
|
||||
ecg_new_recording: "dashboard.heroItems.ecg_new_recording",
|
||||
tension_window: "dashboard.heroItems.tension_window",
|
||||
same_time_baseline: "dashboard.heroItems.same_time_baseline",
|
||||
upcoming_visit: "dashboard.heroItems.upcoming_visit",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The report's reason, pinned on the two properties that make it trustworthy:
|
||||
*
|
||||
* - it appears for an appointment inside the horizon and for nothing else;
|
||||
* - it NEVER auto-applies. Applying is a press, and the component calls
|
||||
* nothing on render — a window control that moves on its own is what makes
|
||||
* an export panel untrustworthy.
|
||||
*
|
||||
* The fixtures are relative to the moment the test runs, so the horizon cannot
|
||||
* rot out from under them as the calendar moves.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: {} },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return { ...actual, apiGet: () => new Promise(() => {}) };
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { ReportVisitOffer } from "../report-visit-offer";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
|
||||
return {
|
||||
occurredAt: new Date(Date.now() + 5 * DAY_MS).toISOString(),
|
||||
status: "PLANNED",
|
||||
kind: "SPECIALIST",
|
||||
practitioner: null,
|
||||
reason: null,
|
||||
outcome: null,
|
||||
reminderNextDueAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(upcoming: EncounterDTO[], onUse = () => {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(
|
||||
queryKeys.encounterList(null, null, undefined, undefined),
|
||||
{
|
||||
upcoming,
|
||||
past: [],
|
||||
},
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ReportVisitOffer onUseVisitDate={onUse} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<ReportVisitOffer>", () => {
|
||||
it("offers an appointment five days out, naming its own source", () => {
|
||||
const html = render([visit({ id: "soon" })]);
|
||||
expect(html).toContain('data-slot="report-visit-offer"');
|
||||
expect(html).toContain("An appointment is coming up");
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
});
|
||||
|
||||
it("stays silent for an appointment thirty days out", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "far",
|
||||
occurredAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
|
||||
}),
|
||||
]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("stays silent when there is no appointment at all", () => {
|
||||
expect(render([])).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("ignores a cancelled appointment inside the horizon", () => {
|
||||
const html = render([visit({ id: "off", status: "CANCELLED" })]);
|
||||
expect(html).not.toContain('data-slot="report-visit-offer"');
|
||||
});
|
||||
|
||||
it("changes the window only when pressed", () => {
|
||||
const onUse = vi.fn();
|
||||
const html = render([visit({ id: "soon" })], onUse);
|
||||
// The offer rendered — so the assertion below is about restraint, not
|
||||
// about a component that produced nothing.
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
expect(onUse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Bericht für den Termin am … erstellen" — the report knows why it is being
|
||||
* generated.
|
||||
*
|
||||
* The cheapest integration in this milestone and the one that changes the most:
|
||||
* a person opening the export panel a week before an appointment is almost
|
||||
* always opening it BECAUSE of the appointment, and the window they want ends
|
||||
* on that day. One query, one prefilled field, one dismissible offer.
|
||||
*
|
||||
* **It never auto-applies.** The window is not silently changed and then left
|
||||
* for the person to discover; the offer names its own source ("weil am … ein
|
||||
* Termin ansteht") and does nothing until pressed. A control that moves on its
|
||||
* own is the thing that makes an export panel untrustworthy.
|
||||
*
|
||||
* Nothing about the stored selection changes: this sets the custom-range end
|
||||
* date and nothing else.
|
||||
*/
|
||||
import { CalendarClock, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* How far ahead a booked visit is treated as the reason for this report.
|
||||
*
|
||||
* Two weeks: far enough that somebody preparing in advance is offered it, near
|
||||
* enough that a routine check-up six months out does not colonise the panel.
|
||||
*/
|
||||
export const REPORT_VISIT_HORIZON_DAYS = 14;
|
||||
|
||||
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
|
||||
function localDay(iso: string): string {
|
||||
const at = new Date(iso);
|
||||
const offset = at.getTimezoneOffset();
|
||||
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function ReportVisitOffer({
|
||||
onUseVisitDate,
|
||||
}: {
|
||||
/** Sets the custom window's END date. Called only on an explicit press. */
|
||||
onUseVisitDate: (endDay: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const visits = useEncounters();
|
||||
|
||||
// Read once, at mount. A clock read during render is impure — and here it
|
||||
// would also mean the offer could vanish mid-interaction as a visit crossed
|
||||
// the horizon between two renders.
|
||||
const [now] = useState(() => Date.now());
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const horizon = now + REPORT_VISIT_HORIZON_DAYS * 24 * 60 * 60 * 1000;
|
||||
const next = (visits.data?.upcoming ?? []).find((visit) => {
|
||||
if (visit.status !== "PLANNED") return false;
|
||||
const at = new Date(visit.occurredAt).getTime();
|
||||
return at > now && at <= horizon;
|
||||
});
|
||||
if (!next) return null;
|
||||
|
||||
const what =
|
||||
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="report-visit-offer"
|
||||
className="border-border flex flex-wrap items-center gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{/* Content, not meta: it is the offer, and it names its own source. */}
|
||||
<p className="text-foreground flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="report-visit-offer-apply"
|
||||
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
|
||||
>
|
||||
{t("settings.healthRecord.visitOfferApply")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 sm:size-9"
|
||||
aria-label={t("common.dismiss")}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { ReportVisitOffer } from "./report-visit-offer";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
@@ -253,6 +254,15 @@ export function HealthRecordExportPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Never auto-applies: pressing it is what opens the custom window and
|
||||
sets its end date. */}
|
||||
<ReportVisitOffer
|
||||
onUseVisitDate={(endDay) => {
|
||||
setCustomRange(true);
|
||||
setEndDate(endDay);
|
||||
}}
|
||||
/>
|
||||
|
||||
{customRange ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Data hooks for visits and the address book behind them.
|
||||
*
|
||||
* Every value the surfaces render arrives resolved from the server — the
|
||||
* practitioner as an object, the links with a label and a date, the
|
||||
* appointment's next-due instant computed. Nothing here re-derives one. A
|
||||
* client that recomputed a server-side derivation drifts from it the first
|
||||
* time the derivation changes on one side only.
|
||||
*
|
||||
* Writes reach two roots (`encounterDependentKeys`), because renaming a
|
||||
* practice changes the label every visit that names it renders.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
encounterDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import type { EncounterSuggestionResult } from "@/lib/encounters/suggest-window";
|
||||
|
||||
export type Encounter = EncounterDTO;
|
||||
export type EncounterList = EncounterListDTO;
|
||||
|
||||
export interface EncounterWriteBody {
|
||||
occurredAt?: string;
|
||||
status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
||||
kind?: string;
|
||||
practitionerId?: string | null;
|
||||
reason?: string | null;
|
||||
outcome?: string | null;
|
||||
/** The checkup this visit closes, when it was filed from a due one. */
|
||||
reminderId?: string | null;
|
||||
documentIds?: string[];
|
||||
labResultIds?: string[];
|
||||
episodeIds?: string[];
|
||||
}
|
||||
|
||||
const BASE = "/api/encounters";
|
||||
|
||||
export function useEncounters(
|
||||
enabled = true,
|
||||
window?: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
status?: string;
|
||||
/** Only visits filed against this condition episode. */
|
||||
episodeId?: string;
|
||||
},
|
||||
) {
|
||||
const from = window?.from ?? null;
|
||||
const to = window?.to ?? null;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.encounterList(
|
||||
from,
|
||||
to,
|
||||
window?.status,
|
||||
window?.episodeId,
|
||||
),
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (from) sp.set("from", from);
|
||||
if (to) sp.set("to", to);
|
||||
if (window?.status) sp.set("status", window.status);
|
||||
if (window?.episodeId) sp.set("episodeId", window.episodeId);
|
||||
const qs = sp.toString();
|
||||
return apiGet<EncounterList>(qs ? `${BASE}?${qs}` : BASE);
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* "Does this belong to a visit?" for one anchor date.
|
||||
*
|
||||
* The verdict is resolved server-side so the document moment and the two lab
|
||||
* moments cannot answer the question differently. `anchor` being null means
|
||||
* the surface has no date yet and there is nothing to ask about.
|
||||
*/
|
||||
export function useEncounterSuggestion(anchor: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.encounterSuggestion(anchor ?? ""),
|
||||
queryFn: () =>
|
||||
apiGet<EncounterSuggestionResult>(
|
||||
`${BASE}/suggest?anchor=${encodeURIComponent(anchor ?? "")}`,
|
||||
),
|
||||
enabled: enabled && Boolean(anchor),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEncounterMutations() {
|
||||
const qc = useQueryClient();
|
||||
/**
|
||||
* A visit write reaches further than the visit list.
|
||||
*
|
||||
* Filing one against a due checkup satisfies that reminder, which moves its
|
||||
* next-due date on the preventive-care list, on the dashboard card and on the
|
||||
* Today rail — three surfaces that read the reminder table and would
|
||||
* otherwise keep showing it due until their own poll. Booking one moves the
|
||||
* same reads the other way: a new appointment is a row the digest surfaces.
|
||||
*
|
||||
* So the fan-out is the visit roots PLUS the reminder reads, through the same
|
||||
* helper the Vorsorge surfaces use, rather than a second sweep that could
|
||||
* drift from it.
|
||||
*/
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
invalidateKeys(qc, encounterDependentKeys),
|
||||
invalidateReminderReads(qc),
|
||||
]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.encounterCreate(),
|
||||
mutationFn: (body: EncounterWriteBody) => apiPost<Encounter>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationKey: queryKeys.encounterUpdate(),
|
||||
mutationFn: ({ id, body }: { id: string; body: EncounterWriteBody }) =>
|
||||
apiPatch<Encounter>(`${BASE}/${id}`, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.encounterDelete(),
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Data hooks for the account's own address book of doctors and practices.
|
||||
*
|
||||
* The search runs in SQL over the two plaintext columns rather than in the
|
||||
* browser over a downloaded list: the columns are plaintext precisely so the
|
||||
* picker can search and sort them there, and a client-side filter would need
|
||||
* the whole book in memory to do it.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
encounterDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type { PractitionerDTO } from "@/lib/practitioners/dto";
|
||||
|
||||
export type Practitioner = PractitionerDTO;
|
||||
|
||||
export interface PractitionerWriteBody {
|
||||
name?: string;
|
||||
specialty?: string | null;
|
||||
practice?: string | null;
|
||||
location?: string | null;
|
||||
phone?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
const BASE = "/api/practitioners";
|
||||
|
||||
export function usePractitioners(q?: string, enabled = true) {
|
||||
const term = q?.trim() || undefined;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.practitionerList(term),
|
||||
queryFn: () =>
|
||||
apiGet<Practitioner[]>(
|
||||
term ? `${BASE}?q=${encodeURIComponent(term)}` : BASE,
|
||||
),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePractitionerMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => invalidateKeys(qc, encounterDependentKeys);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.practitionerCreate(),
|
||||
mutationFn: (body: PractitionerWriteBody) =>
|
||||
apiPost<Practitioner>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationKey: queryKeys.practitionerUpdate(),
|
||||
mutationFn: ({ id, body }: { id: string; body: PractitionerWriteBody }) =>
|
||||
apiPatch<Practitioner>(`${BASE}/${id}`, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationKey: queryKeys.practitionerDelete(),
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<{ deleted: boolean }>(`${BASE}/${id}`),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
@@ -172,6 +172,7 @@ const fakeDb = vi.hoisted(() => {
|
||||
ecgRecording: { findFirst: async () => null },
|
||||
personalRecord: { findMany: none },
|
||||
arrivalReaction: { findMany: none },
|
||||
encounter: { findFirst: async () => null },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock("@/lib/db", () => ({
|
||||
dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
personalRecord: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
encounter: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -126,6 +126,21 @@ export interface DailyDigestPreventiveDue {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A booked visit falling today or tomorrow.
|
||||
*
|
||||
* Read off the VISIT table, never off the reminder engine: an appointment's
|
||||
* one-shot reminder is excluded from every preventive-care read, and this is
|
||||
* how the appointment reaches the rail without relaxing that exclusion. The
|
||||
* kind rides as the enum constant so the reader's own bundle names it.
|
||||
*/
|
||||
export interface DailyDigestUpcomingVisit {
|
||||
id: string;
|
||||
kind: string;
|
||||
occurredAt: string;
|
||||
practitionerName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* S11 — a confident elevated-at-rest ("tension") window for the day, already
|
||||
* detected server-side (`loadIntradayPulse`) under its full confidence gate.
|
||||
@@ -251,6 +266,8 @@ export interface DailyDigestInput {
|
||||
morningRefreshedToday: boolean;
|
||||
syncIssues: DailyDigestSyncIssue[];
|
||||
preventiveDue: DailyDigestPreventiveDue[];
|
||||
/** The booked visit falling today or tomorrow, or null. Honest-absent. */
|
||||
upcomingVisit?: DailyDigestUpcomingVisit | null;
|
||||
/** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */
|
||||
coachPlans: DailyDigestCoachPlan[];
|
||||
/**
|
||||
@@ -504,6 +521,44 @@ function buildPreventiveCareItem(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The booked visit falling today or tomorrow, as one rail line.
|
||||
*
|
||||
* No module gate: a visit is core, like the checkups page it lives on. No
|
||||
* action beyond opening the page either — there is nothing to do about an
|
||||
* appointment except keep it, and a "mark done" here would duplicate the
|
||||
* visit's own status field with a second place to set it.
|
||||
*/
|
||||
function buildUpcomingVisitItem(
|
||||
visit: DailyDigestUpcomingVisit | null,
|
||||
now: Date,
|
||||
t: Translate,
|
||||
): PriorityItem | null {
|
||||
if (!visit) return null;
|
||||
const at = new Date(visit.occurredAt);
|
||||
if (Number.isNaN(at.getTime())) return null;
|
||||
// Today vs tomorrow is decided on the SAME instants the read window used, so
|
||||
// a visit admitted by the query can never be described as neither.
|
||||
const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000);
|
||||
const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`);
|
||||
return {
|
||||
kind: "upcoming_visit",
|
||||
title: t("daily.item.upcomingVisit.title"),
|
||||
body:
|
||||
hoursAway <= 24
|
||||
? t("daily.item.upcomingVisit.bodyToday", { what })
|
||||
: t("daily.item.upcomingVisit.bodyTomorrow", { what }),
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* S12 — the calm reward card. Emits ONE `milestone` PriorityItem when a durable
|
||||
* state was reached TODAY (the IO seam already applied the reached-once gate).
|
||||
@@ -880,6 +935,16 @@ export function buildDailyDigest(
|
||||
if (ecg) worthALook.push(ecg);
|
||||
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
|
||||
if (preventive) worthALook.push(preventive);
|
||||
// Beside the checkups rather than among them: the two are neighbours on the
|
||||
// rail because a person reads "what am I meant to arrange" and "where am I
|
||||
// meant to be" in one glance, and they stay separate items because one is
|
||||
// overdue and the other is simply scheduled.
|
||||
const visit = buildUpcomingVisitItem(
|
||||
input.upcomingVisit ?? null,
|
||||
input.now,
|
||||
t,
|
||||
);
|
||||
if (visit) worthALook.push(visit);
|
||||
// S11 — the calm, informational tension marker sits last: it is context, not
|
||||
// an action that expires, so a time-sensitive dose / sync / check-in wins the
|
||||
// bounded rail ahead of it.
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type DailyDigestCoachPlan,
|
||||
type DailyDigestEcg,
|
||||
type DailyDigestPreventiveDue,
|
||||
type DailyDigestUpcomingVisit,
|
||||
type DailyDigestScore,
|
||||
type DailyDigestSameTime,
|
||||
type DailyDigestSyncIssue,
|
||||
@@ -63,6 +64,16 @@ import {
|
||||
/** Integration states that mean "your action is needed to keep data flowing". */
|
||||
const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const;
|
||||
|
||||
/**
|
||||
* How far ahead the rail mentions a booked appointment: today or tomorrow.
|
||||
*
|
||||
* The rail is a "what about today" surface, so anything further out is not a
|
||||
* thing to act on this morning. Two days rather than one because an evening
|
||||
* reader wants tomorrow's 08:00 appointment, and a 24-hour window would hide it
|
||||
* from them.
|
||||
*/
|
||||
const UPCOMING_VISIT_WINDOW_MS = 2 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Defensive cap on how many overdue reminders we read for the rail summary. */
|
||||
const PREVENTIVE_DUE_READ_LIMIT = 20;
|
||||
|
||||
@@ -355,6 +366,7 @@ export async function loadDailyDigest(
|
||||
planRows,
|
||||
ecgRow,
|
||||
arrivalRows,
|
||||
nextVisitRow,
|
||||
] = await Promise.all([
|
||||
readDashboardSnapshotCached(user),
|
||||
resolveModuleMap(user.id),
|
||||
@@ -428,6 +440,27 @@ export async function loadDailyDigest(
|
||||
generatedAt: true,
|
||||
},
|
||||
}),
|
||||
// The next PLANNED visit inside the two-day horizon. Read straight off the
|
||||
// visit table, never off the reminder engine — see the mapping below for
|
||||
// why the exclusion above must stay untouched.
|
||||
prisma.encounter.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: "PLANNED",
|
||||
occurredAt: {
|
||||
gt: now,
|
||||
lte: new Date(now.getTime() + UPCOMING_VISIT_WINDOW_MS),
|
||||
},
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
occurredAt: true,
|
||||
practitioner: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const score: DailyDigestScore | null = snapshot.healthScore
|
||||
@@ -463,6 +496,29 @@ export async function loadDailyDigest(
|
||||
label: row.label,
|
||||
}));
|
||||
|
||||
/**
|
||||
* The appointment falling today or tomorrow, read from the VISIT table.
|
||||
*
|
||||
* Not from the reminder table. An ENCOUNTER-origin reminder is excluded from
|
||||
* every preventive-care read, which is right — an appointment is not a
|
||||
* checkup — but a notification about something the app never shows is worse
|
||||
* than none. So the appointment reaches the rail from the record it lives
|
||||
* in, and the exclusion above stays exactly as it is. A diff here that
|
||||
* touches `origin` is the wrong fix.
|
||||
*
|
||||
* `occurredAt > now` deliberately, not `>= todayStart`: an appointment that
|
||||
* has already happened today is not something to be reminded about, and the
|
||||
* person is more likely to be at it than looking at this.
|
||||
*/
|
||||
const upcomingVisit: DailyDigestUpcomingVisit | null = nextVisitRow
|
||||
? {
|
||||
id: nextVisitRow.id,
|
||||
kind: nextVisitRow.kind,
|
||||
occurredAt: nextVisitRow.occurredAt.toISOString(),
|
||||
practitionerName: nextVisitRow.practitioner?.name ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
|
||||
// builder decides "new" and gates on the `insights` module. An ECG row only
|
||||
// ever carries an AFib-screening verdict; the shared enum's other members
|
||||
@@ -563,6 +619,7 @@ export async function loadDailyDigest(
|
||||
morningRefreshedToday,
|
||||
syncIssues,
|
||||
preventiveDue,
|
||||
upcomingVisit,
|
||||
coachPlans,
|
||||
milestone,
|
||||
tensionWindow,
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PRIORITY_ITEM_KINDS = [
|
||||
"ecg_new_recording",
|
||||
"tension_window",
|
||||
"same_time_baseline",
|
||||
"upcoming_visit",
|
||||
] as const;
|
||||
|
||||
export type PriorityItemKind = (typeof PRIORITY_ITEM_KINDS)[number];
|
||||
@@ -52,7 +53,7 @@ export function isPriorityItemKind(value: unknown): value is PriorityItemKind {
|
||||
* action; the user can only
|
||||
* acknowledge them, so they're the only kinds the Today rail lets you
|
||||
* dismiss. The remaining kinds (`dose_window`, `sync_issue`,
|
||||
* `preventive_care`, `coach_checkin`) are ACTIONABLE — they clear on their
|
||||
* `preventive_care`, `coach_checkin`, `upcoming_visit`) are ACTIONABLE — they clear on their
|
||||
* own once the user acts (logs the dose, reconnects the integration, …) and
|
||||
* are never dismissible; offering a dismiss on them would just let a user
|
||||
* silence a still-open action item.
|
||||
|
||||
@@ -217,6 +217,73 @@ export function buildClinicalRecordsNotesSection(
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// Visits inside the window — what happened at a practice, and what came out
|
||||
// of it. Present only when the VISITS leaf was selected AND the window held a
|
||||
// visit (the aggregator gates `data.visits`); the leaf carries no module of
|
||||
// its own, because a visit is core and the whole report is already gated by
|
||||
// `doctorReport`. Same calm factual register as the illness table above: no
|
||||
// colour, no status tint. The encrypted reason and outcome decrypt at render
|
||||
// and never enter the stored selection.
|
||||
if (data.visits && data.visits.length > 0) {
|
||||
y = ensureSpace(y, 6 + 18);
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text(t("doctorReport.visitsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const visitRows = data.visits.map((visit) => [
|
||||
fmtDate(visit.occurredAt),
|
||||
// The practice, with its specialty when there is one; an em dash when
|
||||
// the visit named neither, which is honest rather than invented.
|
||||
[visit.practitionerName, visit.practitionerSpecialty]
|
||||
.filter(Boolean)
|
||||
.join(" · ") || "—",
|
||||
t(`encounters.kind.${visit.kind}`),
|
||||
visit.reason ?? "—",
|
||||
visit.outcome ?? "—",
|
||||
visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—",
|
||||
]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.visitsColDate"),
|
||||
t("doctorReport.visitsColPractice"),
|
||||
t("doctorReport.visitsColKind"),
|
||||
t("doctorReport.visitsColReason"),
|
||||
t("doctorReport.visitsColOutcome"),
|
||||
t("doctorReport.visitsColConditions"),
|
||||
],
|
||||
],
|
||||
body: visitRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 3,
|
||||
textColor: [30, 30, 30],
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.3,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [245, 245, 245],
|
||||
textColor: [30, 30, 30],
|
||||
fontStyle: "bold",
|
||||
},
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: {
|
||||
left: margin,
|
||||
right: margin,
|
||||
top: margin,
|
||||
bottom: tableBottomMargin,
|
||||
},
|
||||
});
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// v1.27.x — structured allergy / intolerance records. Reference data
|
||||
// (not time-windowed) the aggregator populates when the `allergies`
|
||||
// toggle is ON (default) and rows exist. Stored fields only — substance,
|
||||
|
||||
@@ -274,6 +274,28 @@ export interface DoctorReportData {
|
||||
* module is off or no episode overlaps the window; both builders then skip
|
||||
* the section. Optional so pre-v1.18.1 fixtures still typecheck.
|
||||
*/
|
||||
/**
|
||||
* Visits inside the window — the contacts with the healthcare system this
|
||||
* period held. Null when the leaf was not selected or none fell inside the
|
||||
* window; both builders then skip the section. Optional so pre-v1.38
|
||||
* fixtures still typecheck.
|
||||
*
|
||||
* `kind` rides as the enum constant: the renderer names it in the REPORT's
|
||||
* language, which is the reading clinician's, not whoever generated it.
|
||||
* `reason` and `outcome` are the decrypted free text and are null on a
|
||||
* key-rotation gap — an absence rather than a fabricated line.
|
||||
*/
|
||||
visits?: Array<{
|
||||
occurredAt: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
practitionerName: string | null;
|
||||
practitionerSpecialty: string | null;
|
||||
reason: string | null;
|
||||
outcome: string | null;
|
||||
/** Labels of the conditions this visit was filed against. */
|
||||
conditionLabels: string[];
|
||||
}> | null;
|
||||
illnessEpisodes?: Array<{
|
||||
/** User-facing condition label (e.g. "Erkältung"). */
|
||||
label: string;
|
||||
|
||||
@@ -216,6 +216,17 @@ function seed() {
|
||||
resolvedAt: new Date("2026-01-20T00:00:00.000Z"),
|
||||
},
|
||||
];
|
||||
rows.encounter = [
|
||||
{
|
||||
occurredAt: new Date("2026-02-05T09:00:00.000Z"),
|
||||
kind: "SPECIALIST",
|
||||
status: "DONE",
|
||||
reasonEncrypted: null,
|
||||
outcomeEncrypted: null,
|
||||
practitioner: { name: "Sample practice", specialty: "Cardiology" },
|
||||
conditionLinks: [],
|
||||
},
|
||||
];
|
||||
rows.allergy = [
|
||||
{
|
||||
substance: "Penicillin",
|
||||
@@ -262,6 +273,7 @@ describe("per-leaf gating sweep", () => {
|
||||
expect(allPayload.allergies).not.toBeNull();
|
||||
expect(allPayload.familyHistory).not.toBeNull();
|
||||
expect(allPayload.illnessEpisodes).not.toBeNull();
|
||||
expect(allPayload.visits).not.toBeNull();
|
||||
expect(allPayload.mood).not.toBeNull();
|
||||
expect(allPayload.cycle).not.toBeNull();
|
||||
expect(allPayload.anamnesis).not.toBeNull();
|
||||
@@ -415,6 +427,7 @@ describe("zero read for unchosen leaves", () => {
|
||||
["ALLERGIES", "allergy"],
|
||||
["LAB_RESULTS", "labResult"],
|
||||
["ILLNESS_EPISODES", "illnessEpisode"],
|
||||
["VISITS", "encounter"],
|
||||
["ANAMNESIS", "userHealthProfile"],
|
||||
["ANAMNESIS", "healthProfileFactRevision"],
|
||||
];
|
||||
@@ -474,6 +487,7 @@ describe("the empty selection serves nothing", () => {
|
||||
expect(payload.allergies).toBeNull();
|
||||
expect(payload.familyHistory).toBeNull();
|
||||
expect(payload.illnessEpisodes).toBeNull();
|
||||
expect(payload.visits).toBeNull();
|
||||
expect(payload.anamnesis).toBeNull();
|
||||
expect(payload.cycle).toBeNull();
|
||||
expect(payload.mood).toBeNull();
|
||||
|
||||
@@ -122,6 +122,78 @@ export async function loadIllnessEpisodes(
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits inside the window, with the conditions each was filed against.
|
||||
*
|
||||
* The two free-text columns are `Bytes` ciphertext and decrypt fail-soft, the
|
||||
* same stance the allergy reaction takes: a key-rotation gap on one row reads
|
||||
* as a visit with no reason recorded rather than as a failed report. `kind` and
|
||||
* `status` cross as enum constants — the renderer names them in the REPORT's
|
||||
* language, which is the reading clinician's.
|
||||
*
|
||||
* The condition labels come off the link rows directly rather than through the
|
||||
* link service: this is a read of the OWNER's own record for the owner's own
|
||||
* report, with no grant to narrow against and no redaction to apply, and the
|
||||
* service's per-family visibility predicate has no meaning here.
|
||||
*/
|
||||
export async function loadVisits(
|
||||
userId: string,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<DoctorReportData["visits"]> {
|
||||
const rows = await prisma.encounter.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: start, lte: end },
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
select: {
|
||||
occurredAt: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
reasonEncrypted: true,
|
||||
outcomeEncrypted: true,
|
||||
practitioner: { select: { name: true, specialty: true } },
|
||||
conditionLinks: {
|
||||
select: { episode: { select: { label: true, deletedAt: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const decrypt = (
|
||||
value: Uint8Array | null,
|
||||
field: "reason" | "outcome",
|
||||
): string | null => {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(value);
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
`doctor-report: visit ${field} decrypt failed for ${userId}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const mapped = rows.map((row) => ({
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
practitionerSpecialty: row.practitioner?.specialty ?? null,
|
||||
reason: decrypt(row.reasonEncrypted, "reason"),
|
||||
outcome: decrypt(row.outcomeEncrypted, "outcome"),
|
||||
// A condition the person has since deleted is not named in a clinical
|
||||
// document: the link row survives the soft delete, and printing a label
|
||||
// they removed would put a withdrawn diagnosis in front of a doctor.
|
||||
conditionLabels: row.conditionLinks
|
||||
.filter((link) => link.episode?.deletedAt === null)
|
||||
.map((link) => link.episode.label),
|
||||
}));
|
||||
return mapped.length > 0 ? mapped : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured allergy records. Reference data, not time-windowed — a
|
||||
* penicillin allergy does not expire with the report window.
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
loadAnamnesis,
|
||||
loadFamilyHistory,
|
||||
loadIllnessEpisodes,
|
||||
loadVisits,
|
||||
loadLabResults,
|
||||
} from "./clinical-records";
|
||||
|
||||
@@ -498,6 +499,7 @@ export async function collectDoctorReportData(
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
allergies,
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
@@ -511,6 +513,9 @@ export async function collectDoctorReportData(
|
||||
gate.admits("ILLNESS_EPISODES")
|
||||
? loadIllnessEpisodes(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("VISITS")
|
||||
? loadVisits(userId, start, end)
|
||||
: Promise.resolve(null),
|
||||
gate.admits("ALLERGIES") ? loadAllergies(userId) : Promise.resolve(null),
|
||||
gate.admits("FAMILY_HISTORY")
|
||||
? loadFamilyHistory(userId)
|
||||
@@ -579,6 +584,7 @@ export async function collectDoctorReportData(
|
||||
cycle,
|
||||
labResults,
|
||||
illnessEpisodes,
|
||||
visits,
|
||||
allergies,
|
||||
familyHistory,
|
||||
anamnesis,
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listTargetsBySource, replaceTargets } from "@/lib/links";
|
||||
import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents";
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
DocumentEncounterLinkDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Load the condition links for a page of documents in ONE grouped query.
|
||||
@@ -39,6 +42,82 @@ export async function loadConditionLinks(
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the VISIT links for a page of documents in ONE grouped query.
|
||||
*
|
||||
* Unlike the condition chips this filters tombstoned visits out: the condition
|
||||
* behaviour is a long-standing one this refactor deliberately did not change,
|
||||
* while a visit link is new and starts from the honest default — a deleted
|
||||
* visit is not something the document still belongs to.
|
||||
*/
|
||||
export async function loadDocumentEncounterLinks(
|
||||
userId: string,
|
||||
documentIds: string[],
|
||||
): Promise<Map<string, DocumentEncounterLinkDto[]>> {
|
||||
const map = new Map<string, DocumentEncounterLinkDto[]>();
|
||||
if (documentIds.length === 0) return map;
|
||||
const byDocument = await listTargetsBySource(prisma, {
|
||||
userId,
|
||||
sourceKind: "document",
|
||||
sourceIds: documentIds,
|
||||
targetKind: "encounter",
|
||||
});
|
||||
for (const [documentId, targets] of byDocument) {
|
||||
map.set(
|
||||
documentId,
|
||||
targets.map((target) => ({
|
||||
encounterId: target.id,
|
||||
kind: target.label,
|
||||
occurredAt: target.date,
|
||||
})),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a client-sent visit-id list to the caller's LIVE visits.
|
||||
*
|
||||
* Same shape and same refusal as {@link narrowOwnedEpisodeIds}: null when any
|
||||
* id names nothing, so an attacker probing another account's visit ids learns
|
||||
* only "not found".
|
||||
*/
|
||||
export async function narrowOwnedEncounterIds(
|
||||
userId: string,
|
||||
encounterIds: string[],
|
||||
): Promise<string[] | null> {
|
||||
const unique = [...new Set(encounterIds)];
|
||||
if (unique.length === 0) return [];
|
||||
const owned = await prisma.encounter.findMany({
|
||||
where: { id: { in: unique }, userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (owned.length !== unique.length) return null;
|
||||
return unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace-set a document's visit links inside the given client.
|
||||
*
|
||||
* Goes through the link service in the `document → encounter` direction, which
|
||||
* is the same table the visit's own sheet writes from the other end, so the two
|
||||
* surfaces cannot disagree about what is filed where.
|
||||
*/
|
||||
export async function replaceEncounterLinks(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
documentId: string,
|
||||
encounterIds: string[],
|
||||
): Promise<void> {
|
||||
await replaceTargets(tx, {
|
||||
userId,
|
||||
sourceKind: "document",
|
||||
sourceId: documentId,
|
||||
targetKind: "encounter",
|
||||
targetIds: encounterIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a client-sent episode-id list to the caller's LIVE episodes.
|
||||
* Returns the deduplicated owned ids, or null when any id is unknown /
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
DocumentEncounterLinkDto,
|
||||
DocumentContentIndexSourceValue,
|
||||
DocumentSummaryStateValue,
|
||||
ExtractedFactDto,
|
||||
@@ -199,6 +200,7 @@ export function serialiseDocument(
|
||||
hasContentIndex = false,
|
||||
contentIndexSource: DocumentContentIndexSourceValue | null = null,
|
||||
hasThumbnail = false,
|
||||
encounterLinks: DocumentEncounterLinkDto[] = [],
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id: doc.id,
|
||||
@@ -219,6 +221,7 @@ export function serialiseDocument(
|
||||
factCount: counts.factCount,
|
||||
pendingCount: counts.pendingCount,
|
||||
conditionLinks,
|
||||
encounterLinks,
|
||||
servingClass: servingClassFor(doc.mimeType),
|
||||
hasContentIndex,
|
||||
contentIndexSource: hasContentIndex ? contentIndexSource : null,
|
||||
@@ -251,6 +254,7 @@ export function serialiseDocumentDetail(
|
||||
hasContentIndex = false,
|
||||
contentIndexSource: DocumentContentIndexSourceValue | null = null,
|
||||
hasThumbnail = false,
|
||||
encounterLinks: DocumentEncounterLinkDto[] = [],
|
||||
): InboundDocumentDetailDto {
|
||||
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
|
||||
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
|
||||
@@ -286,6 +290,7 @@ export function serialiseDocumentDetail(
|
||||
hasContentIndex,
|
||||
contentIndexSource,
|
||||
hasThumbnail,
|
||||
encounterLinks,
|
||||
),
|
||||
facts: facts.map(serialiseFact),
|
||||
summary,
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* The suggestion rule, pinned.
|
||||
*
|
||||
* Two properties carry the whole feature and both are easy to break without
|
||||
* noticing:
|
||||
*
|
||||
* - the window is ±7 days and not "recently";
|
||||
* - two candidates offer a picker with nothing pre-selected, and never the
|
||||
* first one.
|
||||
*
|
||||
* Every fixture is RELATIVE to the moment the test runs (`now − 3 days`, never
|
||||
* `2026-03-14`). A fixed-date fixture slides out of a relative window as the
|
||||
* calendar moves and the test rots into a false green; this repository has
|
||||
* shipped that bug before.
|
||||
*
|
||||
* The fake below honours the `where` it is given. A fake that ignores it would
|
||||
* return every seeded row for every query, so the window assertions would pass
|
||||
* against a module with no window at all — a check that cannot fail is worse
|
||||
* than none.
|
||||
*
|
||||
* Mutation checks (both run, both seen red):
|
||||
* - widen `ENCOUNTER_SUGGEST_WINDOW_DAYS` to 90 → the out-of-window cases go
|
||||
* red naming the 30-day visit that started matching;
|
||||
* - make the two-candidate branch return `{ kind: "one" }` with the first row
|
||||
* → "offers a picker with nothing pre-selected" goes red.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
ENCOUNTER_SUGGEST_WINDOW_DAYS,
|
||||
suggestEncounterForDate,
|
||||
} from "../suggest-window";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
userId: string;
|
||||
occurredAt: Date;
|
||||
status: string;
|
||||
kind: string;
|
||||
deletedAt: Date | null;
|
||||
practitioner: { name: string } | null;
|
||||
}
|
||||
|
||||
function row(over: Partial<Row> & { id: string; occurredAt: Date }): Row {
|
||||
return {
|
||||
userId: "u1",
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
deletedAt: null,
|
||||
practitioner: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory `encounter.findMany` that actually applies the filter.
|
||||
*
|
||||
* It evaluates every clause the module writes — the record id, the tombstone,
|
||||
* the status set and both window edges — so a module that dropped one of them
|
||||
* returns rows this fake would have excluded, and the assertion below fails.
|
||||
*/
|
||||
function fakeTx(rows: Row[]): Prisma.TransactionClient {
|
||||
return {
|
||||
encounter: {
|
||||
findMany: async (args: {
|
||||
where: {
|
||||
userId: string;
|
||||
deletedAt: null;
|
||||
status: { in: string[] };
|
||||
occurredAt: { gte: Date; lte: Date };
|
||||
};
|
||||
take?: number;
|
||||
}) => {
|
||||
const { where } = args;
|
||||
const matched = rows
|
||||
.filter((r) => r.userId === where.userId)
|
||||
.filter((r) =>
|
||||
where.deletedAt === null ? r.deletedAt === null : true,
|
||||
)
|
||||
.filter((r) => where.status.in.includes(r.status))
|
||||
.filter(
|
||||
(r) =>
|
||||
r.occurredAt.getTime() >= where.occurredAt.gte.getTime() &&
|
||||
r.occurredAt.getTime() <= where.occurredAt.lte.getTime(),
|
||||
)
|
||||
.sort((a, b) => a.occurredAt.getTime() - b.occurredAt.getTime());
|
||||
return matched.slice(0, args.take ?? matched.length);
|
||||
},
|
||||
},
|
||||
} as unknown as Prisma.TransactionClient;
|
||||
}
|
||||
|
||||
const anchor = new Date();
|
||||
const ago = (days: number, extraMs = 0) =>
|
||||
new Date(anchor.getTime() - days * DAY_MS - extraMs);
|
||||
const ahead = (days: number, extraMs = 0) =>
|
||||
new Date(anchor.getTime() + days * DAY_MS + extraMs);
|
||||
|
||||
describe("the window", () => {
|
||||
it("is seven days wide on each side", () => {
|
||||
expect(ENCOUNTER_SUGGEST_WINDOW_DAYS).toBe(7);
|
||||
});
|
||||
|
||||
it("admits a visit exactly seven days before the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(7) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
kind: "one",
|
||||
encounter: expect.objectContaining({ id: "e1" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("admits a visit exactly seven days after the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ahead(7) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
kind: "one",
|
||||
encounter: expect.objectContaining({ id: "e1" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a visit seven days and one minute before the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(7, MINUTE_MS) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("refuses a visit seven days and one minute after the anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ahead(7, MINUTE_MS) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("refuses a visit thirty days out", async () => {
|
||||
// The widened-window mutation lands here first: at ±90 days this row
|
||||
// starts matching and the verdict stops being "none".
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "far", occurredAt: ahead(30) })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the verdict", () => {
|
||||
it("offers nothing when there is no candidate", async () => {
|
||||
const result = await suggestEncounterForDate(fakeTx([]), {
|
||||
userId: "u1",
|
||||
anchor,
|
||||
});
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("pre-selects the single candidate, resolved", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([
|
||||
row({
|
||||
id: "e1",
|
||||
occurredAt: ago(3),
|
||||
kind: "SPECIALIST",
|
||||
practitioner: { name: "Praxis am Park" },
|
||||
}),
|
||||
]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
kind: "one",
|
||||
encounter: {
|
||||
id: "e1",
|
||||
occurredAt: ago(3).toISOString(),
|
||||
status: "DONE",
|
||||
kind: "SPECIALIST",
|
||||
practitionerName: "Praxis am Park",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a picker with nothing pre-selected for two candidates", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([
|
||||
row({ id: "e1", occurredAt: ago(3) }),
|
||||
row({ id: "e2", occurredAt: ago(1) }),
|
||||
]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
// The shape is the assertion: there is no field on a "many" verdict a
|
||||
// caller could read as a pre-selection.
|
||||
expect(result.kind).toBe("many");
|
||||
expect(result).not.toHaveProperty("encounter");
|
||||
if (result.kind !== "many") throw new Error("unreachable");
|
||||
expect(result.encounters.map((e) => e.id)).toEqual(["e1", "e2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what is never a candidate", () => {
|
||||
it("a soft-deleted visit", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(1), deletedAt: new Date() })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("a cancelled visit, and a no-show", async () => {
|
||||
// Neither happened, so neither produced the record being filed. Admitting
|
||||
// them would widen the set with rows that are wrong most of the time.
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([
|
||||
row({ id: "e1", occurredAt: ago(1), status: "CANCELLED" }),
|
||||
row({ id: "e2", occurredAt: ago(2), status: "NO_SHOW" }),
|
||||
]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("another account's visit", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(1), userId: "u2" })]),
|
||||
{ userId: "u1", anchor },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("an unusable anchor", async () => {
|
||||
const result = await suggestEncounterForDate(
|
||||
fakeTx([row({ id: "e1", occurredAt: ago(1) })]),
|
||||
{ userId: "u1", anchor: new Date("not a date") },
|
||||
);
|
||||
expect(result).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,15 @@ export interface EncounterDTO {
|
||||
* happened, or once the one-shot reminder is spent.
|
||||
*/
|
||||
reminderNextDueAt: string | null;
|
||||
/** Present on the detail response; omitted from the list. */
|
||||
/**
|
||||
* The three link families, resolved.
|
||||
*
|
||||
* Present on the list as well as the detail. Omitting them from the list
|
||||
* would break two things at once: the card counts what a visit produced, and
|
||||
* the edit sheet seeds its pickers from the row it was handed — an edit
|
||||
* opened from a linkless row would save three empty arrays and unfile
|
||||
* everything. The list pays three grouped queries for the whole page.
|
||||
*/
|
||||
links?: EncounterLinksDTO;
|
||||
/**
|
||||
* What the write could not do, named.
|
||||
@@ -130,7 +138,8 @@ export function toEncounterDTO(
|
||||
* Two arrays rather than one sorted list, resolved server-side: "what is
|
||||
* coming up" and "what happened" are read in opposite directions, and making
|
||||
* the client split and re-sort one array would put that decision in two
|
||||
* places.
|
||||
* places. Each row carries its links, for the reasons stated at
|
||||
* `EncounterDTO.links`.
|
||||
*/
|
||||
export interface EncounterListDTO {
|
||||
upcoming: EncounterDTO[];
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
satisfyReminder,
|
||||
type SatisfiableReminder,
|
||||
} from "@/lib/measurement-reminders/satisfy";
|
||||
import { listTargets, replaceTargets, type LinkTargetKind } from "@/lib/links";
|
||||
import {
|
||||
listTargets,
|
||||
listTargetsBySource,
|
||||
replaceTargets,
|
||||
type LinkTargetKind,
|
||||
} from "@/lib/links";
|
||||
import { grantCoversDomain } from "@/lib/sharing/grants";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
|
||||
@@ -75,11 +80,21 @@ export async function actingDomainVisibility(
|
||||
return (domain) => grantCoversDomain(grant, domain);
|
||||
}
|
||||
|
||||
/** Which section of the record each link family's TARGET belongs to. */
|
||||
/**
|
||||
* Which section of the record each link family's TARGET belongs to.
|
||||
*
|
||||
* Exhaustive over `LinkTargetKind` by the compiler, so a link kind added to
|
||||
* the service cannot reach a visit's link list without somebody deciding which
|
||||
* domain governs its names. `encounter` is here for that reason and is never
|
||||
* read from this side — a visit does not point at another visit — but a
|
||||
* `Partial` here would turn "nobody classified it" into "undefined", and an
|
||||
* undefined domain read as a falsy check is a leak.
|
||||
*/
|
||||
const TARGET_DOMAIN: Record<LinkTargetKind, ShareDomain> = {
|
||||
document: "documents",
|
||||
labResult: "labs",
|
||||
conditionEpisode: "illness",
|
||||
encounter: "profile",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -134,6 +149,71 @@ export async function loadEncounterLinks(
|
||||
return { documents, labResults, conditions };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same three families for a PAGE of visits, in three grouped queries.
|
||||
*
|
||||
* Two grouped reads per family rather than two per visit — `listTargetsBySource`
|
||||
* is the batched arm of the same service, so the list pays six round trips for
|
||||
* a page instead of six per row.
|
||||
*
|
||||
* The list carries links for two reasons that are both defects when it does
|
||||
* not. The card shows counts of what a visit produced, and a card reading zero
|
||||
* for a visit with three documents is a lie the person cannot see past. And the
|
||||
* edit sheet seeds its pickers from the row it was handed: an edit opened from a
|
||||
* list row that did not know its links would save a replace-set of three empty
|
||||
* arrays and silently unfile everything the visit had collected.
|
||||
*
|
||||
* Redaction is the same rule, applied per family, from the same predicate.
|
||||
*/
|
||||
export async function loadEncounterLinksForMany(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
encounterIds: string[],
|
||||
visible: (domain: ShareDomain) => boolean,
|
||||
): Promise<Map<string, EncounterLinksDTO>> {
|
||||
const out = new Map<string, EncounterLinksDTO>();
|
||||
if (encounterIds.length === 0) return out;
|
||||
|
||||
const read = async (targetKind: LinkTargetKind) =>
|
||||
listTargetsBySource(tx, {
|
||||
userId,
|
||||
sourceKind: "encounter",
|
||||
sourceIds: encounterIds,
|
||||
targetKind,
|
||||
});
|
||||
const [documents, labResults, conditions] = await Promise.all([
|
||||
read("document"),
|
||||
read("labResult"),
|
||||
read("conditionEpisode"),
|
||||
]);
|
||||
|
||||
const family = (
|
||||
map: Map<string, { id: string; label: string; date: string | null }[]>,
|
||||
id: string,
|
||||
targetKind: LinkTargetKind,
|
||||
) => {
|
||||
const rows = map.get(id) ?? [];
|
||||
if (visible(TARGET_DOMAIN[targetKind])) {
|
||||
return rows.map((row) => ({ ...row, redacted: false }));
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
label: null,
|
||||
date: null,
|
||||
redacted: true,
|
||||
}));
|
||||
};
|
||||
|
||||
for (const id of encounterIds) {
|
||||
out.set(id, {
|
||||
documents: family(documents, id, "document"),
|
||||
labResults: family(labResults, id, "labResult"),
|
||||
conditions: family(conditions, id, "conditionEpisode"),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply whichever of the three link arrays the request actually named.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* "Gehört das zu einem Termin?" — one rule, one constant, one module.
|
||||
*
|
||||
* Three moments ask it: a document arriving in the vault, a lab panel being
|
||||
* committed from an extraction, and a lab panel typed by hand. They must ask
|
||||
* the same question, so none of them owns the answer.
|
||||
*
|
||||
* ── The rule ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* candidates = the caller's own live visits, DONE or PLANNED, whose instant
|
||||
* is within ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days of the
|
||||
* anchor
|
||||
*
|
||||
* exactly one → pre-select it, visibly, with one undo
|
||||
* two or more → offer a picker with NOTHING pre-selected
|
||||
* zero → offer nothing at all
|
||||
*
|
||||
* The middle branch is the whole point. Pre-selecting the first of two is the
|
||||
* failure that teaches a person to stop reading suggestions, which costs more
|
||||
* than the suggestion ever saved. This module therefore returns the SHAPE of
|
||||
* the answer rather than a best guess, and a caller cannot collapse "many" into
|
||||
* "one" without writing the collapse itself.
|
||||
*
|
||||
* ── What it is not ──────────────────────────────────────────────────────────
|
||||
*
|
||||
* It is not an AI call. The match is a date-window query over the person's own
|
||||
* rows: it costs one index scan, cannot hallucinate, and resolves on an account
|
||||
* with no provider configured — which matters, because the document assist
|
||||
* itself refuses without one.
|
||||
*
|
||||
* It never blocks. Every caller treats the verdict as an offer; a save works
|
||||
* with it ignored, dismissed, or never rendered.
|
||||
*
|
||||
* ── Why CANCELLED and NO_SHOW are not candidates ────────────────────────────
|
||||
*
|
||||
* Both name an appointment that did not happen, so neither produced the letter
|
||||
* or the panel being filed. Admitting them would widen the candidate set with
|
||||
* rows that are wrong most of the time, which pushes the honest single-match
|
||||
* case into the picker branch and trains exactly the distrust the two-candidate
|
||||
* rule exists to prevent.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* The half-width of the match window, in days, exported so the three call
|
||||
* sites and the tests read the same number. A literal at three call sites is
|
||||
* three numbers that agree until one of them is edited.
|
||||
*/
|
||||
export const ENCOUNTER_SUGGEST_WINDOW_DAYS = 7;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A bound on how many candidates are read. Anything above one already means
|
||||
* "offer a picker", so the exact count past this cap changes no decision — and
|
||||
* an unbounded read on a busy account would load a page of rows to answer a
|
||||
* yes/no question.
|
||||
*/
|
||||
const MAX_CANDIDATES = 20;
|
||||
|
||||
/**
|
||||
* One candidate, resolved. Enough to render a row without a second round trip
|
||||
* and without the client re-deriving a label the server already holds.
|
||||
*/
|
||||
export interface EncounterSuggestion {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
status: EncounterStatus;
|
||||
kind: EncounterKind;
|
||||
/** The practice, when the visit names one. `null` is an absence, not a gap. */
|
||||
practitionerName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict. A discriminated union rather than an array plus a boolean,
|
||||
* because "one" and "many" are different offers and a caller that receives an
|
||||
* array will eventually take `[0]`.
|
||||
*/
|
||||
export type EncounterSuggestionResult =
|
||||
| { kind: "one"; encounter: EncounterSuggestion }
|
||||
| { kind: "many"; encounters: EncounterSuggestion[] }
|
||||
| { kind: "none" };
|
||||
|
||||
/** The statuses a visit can carry and still be the source of a filed record. */
|
||||
const CANDIDATE_STATUSES: EncounterStatus[] = ["DONE", "PLANNED"];
|
||||
|
||||
/**
|
||||
* The window around an anchor instant, inclusive on both edges.
|
||||
*
|
||||
* Exported so a caller that needs the bounds for a different query — the
|
||||
* report offer reads the same idea forward-only — states them from here rather
|
||||
* than re-deriving the arithmetic.
|
||||
*/
|
||||
export function encounterSuggestWindow(anchor: Date): { from: Date; to: Date } {
|
||||
const half = ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
|
||||
return {
|
||||
from: new Date(anchor.getTime() - half),
|
||||
to: new Date(anchor.getTime() + half),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the visit a record dated `anchor` most plausibly belongs to.
|
||||
*
|
||||
* `userId` is the caller's resolved record id and is fed straight to the
|
||||
* `where`; this module never accepts one from a request body.
|
||||
*/
|
||||
export async function suggestEncounterForDate(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: { userId: string; anchor: Date },
|
||||
): Promise<EncounterSuggestionResult> {
|
||||
if (Number.isNaN(input.anchor.getTime())) return { kind: "none" };
|
||||
|
||||
const { from, to } = encounterSuggestWindow(input.anchor);
|
||||
|
||||
const rows = await tx.encounter.findMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
deletedAt: null,
|
||||
status: { in: CANDIDATE_STATUSES },
|
||||
occurredAt: { gte: from, lte: to },
|
||||
},
|
||||
// Nearest to the anchor would be the kinder order, but Prisma cannot sort
|
||||
// by a computed distance; the caller renders a picker in chronological
|
||||
// order, which is how a person reads their own week anyway.
|
||||
orderBy: { occurredAt: "asc" },
|
||||
take: MAX_CANDIDATES,
|
||||
select: {
|
||||
id: true,
|
||||
occurredAt: true,
|
||||
status: true,
|
||||
kind: true,
|
||||
practitioner: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const encounters: EncounterSuggestion[] = rows.map((row) => ({
|
||||
id: row.id,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
status: row.status,
|
||||
kind: row.kind,
|
||||
practitionerName: row.practitioner?.name ?? null,
|
||||
}));
|
||||
|
||||
if (encounters.length === 0) return { kind: "none" };
|
||||
if (encounters.length === 1) return { kind: "one", encounter: encounters[0] };
|
||||
return { kind: "many", encounters };
|
||||
}
|
||||
@@ -261,6 +261,14 @@ export function buildFhirDocumentBundle(
|
||||
|
||||
// --- Condition + Encounter per illness/condition episode (opt-in) ------
|
||||
// v1.18.1 — present only when the illness module is enabled AND the window
|
||||
// No visits section, and the absence is a decision written at the
|
||||
// `Encounter` model in `prisma/schema.prisma`: FHIR splits the future from
|
||||
// the past across `Appointment` and `Encounter`, which is a serialisation
|
||||
// question this release does not answer. `FHIR_REST_RESOURCE_TYPES` stays
|
||||
// untouched for the same reason — a capability statement naming a type with
|
||||
// no route module lies. The PDF carries the visits; the Bundle does not, and
|
||||
// a reader who notices the gap should find this comment rather than a bug.
|
||||
//
|
||||
// held an episode (the aggregator gates `data.illnessEpisodes`). Each
|
||||
// episode emits a patient-reported Condition (generic SNOMED root, label on
|
||||
// `code.text`) plus a bounding Encounter that references it.
|
||||
|
||||
@@ -106,6 +106,8 @@ describe("evaluateMeasurementReminderDue — window boundary", () => {
|
||||
|
||||
interface FakeReminder {
|
||||
id: string;
|
||||
/** Absent on a checkup fixture; `"ENCOUNTER"` on an appointment's row. */
|
||||
origin?: string;
|
||||
measurementType: string | null;
|
||||
intervalDays: number | null;
|
||||
rrule: string | null;
|
||||
@@ -248,6 +250,46 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect(advanced.getTime()).toBeGreaterThan(NINE_LOCAL.getTime());
|
||||
});
|
||||
|
||||
it("dispatches an appointment nudge with its completion affordance suppressed", async () => {
|
||||
// `measure_done` / `measure_later` and the iOS "Erledigt" action all
|
||||
// resolve the reminder by id, and every by-id surface refuses an
|
||||
// ENCOUNTER-origin row. The producer knows the origin, so it is the
|
||||
// producer that says the dispatch carries no action; the channels drop
|
||||
// their own affordance from the flag.
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [
|
||||
reminder({ origin: "ENCOUNTER", measurementType: null, id: "appt-1" }),
|
||||
],
|
||||
measurementMatch: null,
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const payload = dispatch.mock.calls[0][0];
|
||||
expect(payload.suppressActions).toBe(true);
|
||||
// The id still rides the dispatch — it identifies the nudge for the
|
||||
// client-managed suppression tag. What is withheld is the offer to act.
|
||||
expect(payload.metadata?.reminderId).toBe("appt-1");
|
||||
});
|
||||
|
||||
it("leaves a checkup nudge actionable", async () => {
|
||||
// The negative control. Without it the assertion above would still pass
|
||||
// against a producer that suppressed every reminder's affordance.
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [reminder({})],
|
||||
measurementMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => {
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [reminder({ measurementType: null })],
|
||||
|
||||
@@ -338,12 +338,20 @@ export async function runMeasurementReminderTick(
|
||||
return { title: rendered.title, message: rendered.body };
|
||||
};
|
||||
|
||||
// An appointment nudge is dispatched without its Done / Later
|
||||
// affordance. It rides the same event type as a checkup — one engine,
|
||||
// one cron, deliberately — but every by-id reminder surface refuses an
|
||||
// ENCOUNTER-origin row: satisfying one would mark an appointment
|
||||
// attended from a notification, and postponing one would move a date the
|
||||
// visit record still believes it owns. The channels each drop their own
|
||||
// affordance from this flag rather than re-deriving the origin.
|
||||
const outcome = await dispatchImpl({
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
userId: reminder.user.id,
|
||||
title,
|
||||
message: body,
|
||||
renderForRecipient,
|
||||
...(reminder.origin === "ENCOUNTER" ? { suppressActions: true } : {}),
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
reminderId: reminder.id,
|
||||
|
||||
@@ -15,7 +15,8 @@ export const MAX_TARGETS_PER_CALL = 100;
|
||||
export type LinkSourceKind = "encounter" | "document";
|
||||
|
||||
/** The record a link points at. */
|
||||
export type LinkTargetKind = "document" | "labResult" | "conditionEpisode";
|
||||
export type LinkTargetKind =
|
||||
"document" | "labResult" | "conditionEpisode" | "encounter";
|
||||
|
||||
export interface LinkRequest {
|
||||
userId: string;
|
||||
@@ -168,6 +169,20 @@ const LAB_RESULT_ENDPOINT: EndpointSpec = {
|
||||
date: (row) => asDate(row.takenAt),
|
||||
};
|
||||
|
||||
const ENCOUNTER_ENDPOINT: EndpointSpec = {
|
||||
delegate: (tx) => narrow<OwnedRowDelegate>(tx.encounter),
|
||||
select: { id: true, kind: true, occurredAt: true },
|
||||
// The visit's KIND, as the enum constant, and this is the one endpoint whose
|
||||
// label is deliberately not prose. A document title and an analyte name are
|
||||
// the person's own words and mean the same thing in every language; a visit
|
||||
// kind is one of eight closed values whose human name is a presentation
|
||||
// choice. Publishing the constant is what lets a German reader and an
|
||||
// English one each see it named correctly from their own bundle, which a
|
||||
// server-side string chosen once could not do.
|
||||
label: (row) => String(row.kind),
|
||||
date: (row) => asDate(row.occurredAt),
|
||||
};
|
||||
|
||||
const CONDITION_ENDPOINT: EndpointSpec = {
|
||||
delegate: (tx) => narrow<OwnedRowDelegate>(tx.illnessEpisode),
|
||||
select: { id: true, label: true, onsetAt: true },
|
||||
@@ -211,6 +226,18 @@ const LINK_TABLES: Partial<
|
||||
targetColumn: "episodeId",
|
||||
target: CONDITION_ENDPOINT,
|
||||
},
|
||||
// The SAME table as `encounter:document`, read from the other end. A
|
||||
// document's detail sheet answers "which visits is this filed against"
|
||||
// and the visit's own sheet answers "what did this visit produce"; both
|
||||
// questions live in `encounter_document_links`, so this is a second
|
||||
// direction rather than a fourth table. The three-table ceiling counts
|
||||
// tables.
|
||||
"document:encounter": {
|
||||
delegate: (tx) => narrow<LinkRowDelegate>(tx.encounterDocumentLink),
|
||||
sourceColumn: "documentId",
|
||||
targetColumn: "encounterId",
|
||||
target: ENCOUNTER_ENDPOINT,
|
||||
},
|
||||
};
|
||||
|
||||
function tableFor(
|
||||
|
||||
@@ -15,10 +15,22 @@ import {
|
||||
* fail closed in chrome until it has been intentionally classified.
|
||||
*/
|
||||
export const SHARED_RECORD_DOMAIN_ROUTE_FAMILIES = {
|
||||
measurements: ["/measurements"],
|
||||
// `/checkups` carries content from two domains and is listed under both.
|
||||
// The preventive-care list has always been a `measurements` read
|
||||
// (`/api/measurement-reminders` declares it), and the page was in neither
|
||||
// list — under a whole-record grant it painted anyway, because a null
|
||||
// section set admits everything, so the omission stayed invisible for as
|
||||
// long as a scoped grant had no reason to open it. The visits section makes
|
||||
// it visible: a delegate scoped to `profile` is granted the visit data by
|
||||
// the API and would be denied the only page that shows it. The table is
|
||||
// built by a flatMap over (domain, href) pairs, so one href under two
|
||||
// domains means a grant naming either presents the page — and the handler
|
||||
// still evaluates the grant on every read, because this is a presentation
|
||||
// inventory and not an authorization table.
|
||||
measurements: ["/measurements", "/checkups"],
|
||||
medications: ["/medications"],
|
||||
labs: ["/labs"],
|
||||
profile: ["/profile"],
|
||||
profile: ["/profile", "/checkups"],
|
||||
illness: ["/illness"],
|
||||
mind: ["/mood", "/mental-wellbeing"],
|
||||
cycle: ["/cycle"],
|
||||
@@ -78,7 +90,10 @@ export function resolveSharedRecordNavigation(
|
||||
);
|
||||
|
||||
return {
|
||||
destinationHrefs: routeFamilies.map((family) => family.href),
|
||||
// Deduplicated: an href listed under two domains is one destination, and a
|
||||
// grant holding both would otherwise offer it twice — and make
|
||||
// `sharedRecordLandingHref` count a page as two doorways.
|
||||
destinationHrefs: [...new Set(routeFamilies.map((family) => family.href))],
|
||||
allowsPath(pathname: string): boolean {
|
||||
return routeFamilies.some((family) =>
|
||||
matchesRouteFamily(pathname, family.href),
|
||||
|
||||
@@ -674,6 +674,32 @@ describe("sendViaApns — dispatcher fan-out", () => {
|
||||
expect(note.payload.replyMarkup).toBeUndefined();
|
||||
});
|
||||
|
||||
it("withholds the category and the reminder id when actions are suppressed", async () => {
|
||||
// The category IS the action row on iOS, and the id is what the action
|
||||
// would POST. An appointment nudge has neither: the by-id reminder family
|
||||
// refuses an ENCOUNTER-origin row, so both are dropped and the push
|
||||
// arrives as a plain alert. The event type still routes it.
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
|
||||
] as never);
|
||||
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
|
||||
await sendViaApns("u-1", {
|
||||
title: "t",
|
||||
message: "m",
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
suppressActions: true,
|
||||
metadata: {
|
||||
reminderId: "rem-1",
|
||||
scheduledAt: "2026-08-08T09:00:00.000Z",
|
||||
},
|
||||
});
|
||||
const note = sendMock.mock.calls[0][0];
|
||||
expect(note.category).toBeUndefined();
|
||||
expect(note.payload.reminderId).toBeUndefined();
|
||||
expect(note.payload.eventType).toBe("MEASUREMENT_REMINDER");
|
||||
expect(note.payload.scheduledAt).toBe("2026-08-08T09:00:00.000Z");
|
||||
});
|
||||
|
||||
it("forwards MOOD_REMINDER eventType as aps.category too", async () => {
|
||||
// SR-2 contract: the mood-reminder daily job emits this event-type;
|
||||
// the iOS app registers a `MOOD_REMINDER` category whose only
|
||||
|
||||
@@ -318,6 +318,23 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no keyboard on a MEASUREMENT_REMINDER whose actions are suppressed", async () => {
|
||||
// An appointment nudge. The reminder id is present and the event type
|
||||
// matches, so only the flag stands between the person and two buttons
|
||||
// that can answer nothing but "not found".
|
||||
await sendViaTelegram(config, {
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
userId: "user-1",
|
||||
title: "t",
|
||||
message: "m",
|
||||
suppressActions: true,
|
||||
metadata: { reminderId: "rem-1" },
|
||||
});
|
||||
const opts = sendTelegramMessageMock.mock.calls[0][3];
|
||||
expect(opts.replyMarkup).toBeUndefined();
|
||||
expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a plain MEASUREMENT_REMINDER (no keyboard, no self-clean) without a reminderId", async () => {
|
||||
await sendViaTelegram(config, {
|
||||
eventType: "MEASUREMENT_REMINDER",
|
||||
|
||||
@@ -592,6 +592,8 @@ export async function sendViaApns(
|
||||
metadata?: Record<string, unknown>;
|
||||
discreet?: boolean;
|
||||
urgent?: boolean;
|
||||
/** The dispatch offers no completion action — see `NotificationPayload`. */
|
||||
suppressActions?: boolean;
|
||||
recordUserId?: string;
|
||||
recipientUserId?: string;
|
||||
},
|
||||
@@ -732,12 +734,21 @@ export async function sendViaApns(
|
||||
// an iOS action identifier or deep link that can mutate it.
|
||||
// Keep the ordinary event envelope so opening the notification
|
||||
// follows the normal authorised record-selection flow.
|
||||
// `suppressActions` removes the act-on-it keys for the same
|
||||
// reason it clears the category below: the affordance the app
|
||||
// would build from them resolves to a refusal.
|
||||
...pickIosMetadata(
|
||||
managedDelivery
|
||||
? undefined
|
||||
: payload.discreet
|
||||
? stripCycleMetadata(payload.metadata)
|
||||
: payload.metadata,
|
||||
: payload.suppressActions
|
||||
? stripActionMetadata(
|
||||
payload.discreet
|
||||
? stripCycleMetadata(payload.metadata)
|
||||
: payload.metadata,
|
||||
)
|
||||
: payload.discreet
|
||||
? stripCycleMetadata(payload.metadata)
|
||||
: payload.metadata,
|
||||
),
|
||||
},
|
||||
threadId: routingEvent,
|
||||
@@ -746,7 +757,13 @@ export async function sendViaApns(
|
||||
// ignores categories it doesn't know about, so adding the key
|
||||
// here is safe for event-types that don't have an actionable
|
||||
// category registered yet — they fall back to a plain alert.
|
||||
category: managedDelivery ? undefined : routingEvent,
|
||||
// A suppressed-action dispatch drops the category for the same
|
||||
// reason a managed delivery does: the category IS the action row,
|
||||
// and an appointment nudge has no action the server would accept.
|
||||
category:
|
||||
managedDelivery || payload.suppressActions
|
||||
? undefined
|
||||
: routingEvent,
|
||||
// Future-proof for an iOS Notification Service Extension. iOS
|
||||
// ignores the flag when no extension is registered, so it's a
|
||||
// no-op on today's binary but unblocks NSE work without a
|
||||
@@ -1028,6 +1045,32 @@ function pickIosMetadata(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata keys that exist so the iOS app can ACT on the notification rather
|
||||
* than merely read it. Dropped when the producer says the dispatch carries no
|
||||
* completion affordance.
|
||||
*/
|
||||
const ACTION_METADATA_KEYS = new Set(["reminderId"]);
|
||||
|
||||
/**
|
||||
* Strip the act-on-it keys from a payload whose actions the server will refuse.
|
||||
*
|
||||
* An appointment nudge rides the same `MEASUREMENT_REMINDER` event type as a
|
||||
* checkup, so the iOS app would offer the registered "Erledigt" action and
|
||||
* POST a completion the by-id reminder family answers 404 for. Removing the
|
||||
* id is what removes the action: the handler has nothing to complete.
|
||||
*/
|
||||
function stripActionMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!metadata) return metadata;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(metadata)) {
|
||||
if (!ACTION_METADATA_KEYS.has(key)) out[key] = metadata[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Cycle-semantic metadata keys that must never ride a discreet payload. */
|
||||
const CYCLE_METADATA_KEYS = new Set(["phase"]);
|
||||
|
||||
|
||||
@@ -150,7 +150,15 @@ export async function sendViaTelegram(
|
||||
interactiveKeyboard = buildMoodKeyboard(
|
||||
await resolveSenderLocale(userId),
|
||||
);
|
||||
} else if (payload.eventType === "MEASUREMENT_REMINDER" && reminderId) {
|
||||
} else if (
|
||||
payload.eventType === "MEASUREMENT_REMINDER" &&
|
||||
reminderId &&
|
||||
// An appointment nudge is dispatched with the affordance suppressed:
|
||||
// `measure_done` / `measure_later` both refuse an ENCOUNTER-origin row,
|
||||
// so offering the pair would put two buttons in the chat that can only
|
||||
// answer "not found".
|
||||
!payload.suppressActions
|
||||
) {
|
||||
interactiveKeyboard = buildMeasurementKeyboard(
|
||||
reminderId,
|
||||
await resolveSenderLocale(userId),
|
||||
|
||||
@@ -319,6 +319,23 @@ export interface NotificationPayload {
|
||||
* Normal events leave `urgent` unset and behave exactly as before.
|
||||
*/
|
||||
urgent?: boolean;
|
||||
/**
|
||||
* The nudge carries no completion affordance.
|
||||
*
|
||||
* A reminder push normally ships a Done / Later pair — the Telegram inline
|
||||
* keyboard and the APNs category whose actions the iOS app registers. Both
|
||||
* resolve the reminder by id and both are refused for an appointment: an
|
||||
* appointment reminder belongs to a visit, is managed through the visit
|
||||
* routes, and every by-id reminder surface answers "not found" for one. An
|
||||
* offered button the server will refuse is worse than no button, so the
|
||||
* producer that knows the origin says so here and each channel drops its own
|
||||
* affordance rather than each channel re-deriving the rule.
|
||||
*
|
||||
* The reminder's id still rides `metadata` — it identifies the dispatch for
|
||||
* the client-managed suppression tag and the wide event; what this flag
|
||||
* removes is the OFFER to act on it.
|
||||
*/
|
||||
suppressActions?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,13 +91,13 @@ inboundFactEditSchema.meta({
|
||||
documentUpdateSchema.meta({
|
||||
id: "DocumentUpdateRequest",
|
||||
description:
|
||||
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
|
||||
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404), `encounterIds` (the same replace-set semantics over the caller's visits). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
|
||||
});
|
||||
|
||||
documentBulkSchema.meta({
|
||||
id: "DocumentBulkRequest",
|
||||
description:
|
||||
"One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.",
|
||||
"One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `linkEncounter` / `unlinkEncounter` (require `encounterId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.",
|
||||
});
|
||||
|
||||
const factProvenance = z.object({
|
||||
@@ -137,6 +137,18 @@ const conditionLink = z
|
||||
"A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label.",
|
||||
});
|
||||
|
||||
const encounterLink = z
|
||||
.object({
|
||||
encounterId: z.string(),
|
||||
kind: z.string(),
|
||||
occurredAt: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "DocumentEncounterLink",
|
||||
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.",
|
||||
});
|
||||
|
||||
const inboundDocument = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -153,6 +165,7 @@ const inboundDocument = z
|
||||
factCount: z.number(),
|
||||
pendingCount: z.number(),
|
||||
conditionLinks: z.array(conditionLink),
|
||||
encounterLinks: z.array(encounterLink),
|
||||
servingClass: z.enum(["inline", "attachment"]),
|
||||
hasContentIndex: z.boolean(),
|
||||
contentIndexSource: z
|
||||
@@ -256,6 +269,13 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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",
|
||||
@@ -356,6 +376,10 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller.",
|
||||
}),
|
||||
encounterIds: z.array(z.string()).optional().meta({
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
encounterCreateSchema,
|
||||
encounterUpdateSchema,
|
||||
encounterListQuerySchema,
|
||||
encounterSuggestQuerySchema,
|
||||
encounterLinkSchema,
|
||||
encounterStatusEnum,
|
||||
encounterKindEnum,
|
||||
@@ -53,7 +54,13 @@ encounterUpdateSchema.meta({
|
||||
encounterListQuerySchema.meta({
|
||||
id: "ListEncountersQuery",
|
||||
description:
|
||||
"Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, and `limit` (1–200, default 100).",
|
||||
"Window and filters for the visit list: `from` / `to` (ISO-8601 instants), `status`, `practitionerId`, `episodeId` (only visits filed against that condition episode — the condition side of the link, and the only direction it is offered in), and `limit` (1–200, default 100).",
|
||||
});
|
||||
|
||||
encounterSuggestQuerySchema.meta({
|
||||
id: "SuggestEncounterQuery",
|
||||
description:
|
||||
"The date to resolve around, as an ISO-8601 instant with an offset. A document uses `reportDate ?? documentDate`; a lab panel uses `takenAt`.",
|
||||
});
|
||||
|
||||
encounterLinkSchema.meta({
|
||||
@@ -77,7 +84,7 @@ practitionerUpdateSchema.meta({
|
||||
practitionerListQuerySchema.meta({
|
||||
id: "ListPractitionersQuery",
|
||||
description:
|
||||
"Query params for the address book: `q` (case-insensitive substring of the name) and `limit` (1–200, default 100). Name-ordered.",
|
||||
"Query params for the address book: `q` (case-insensitive substring of the name OR the practice — a person looks one up by whichever of the two they remember) and `limit` (1–200, default 100). Name-ordered.",
|
||||
});
|
||||
|
||||
const practitioner = z
|
||||
@@ -120,7 +127,7 @@ const encounterLinks = z
|
||||
.meta({
|
||||
id: "EncounterLinks",
|
||||
description:
|
||||
"The three link families of one visit, each ordered by when it was filed. Present on the detail response, absent from the list.",
|
||||
"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.",
|
||||
});
|
||||
|
||||
const skipReport = z
|
||||
@@ -172,6 +179,35 @@ const encounterList = z
|
||||
"Visits split server-side rather than client-side. `upcoming` reads soonest-first and `past` reads newest-first — opposite directions, resolved once here so the ordering lives in one place.",
|
||||
});
|
||||
|
||||
const suggestionCandidate = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
occurredAt: z.string(),
|
||||
status: encounterStatusEnum,
|
||||
kind: encounterKindEnum,
|
||||
practitionerName: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "EncounterSuggestionCandidate",
|
||||
description:
|
||||
"One visit a record might belong to, resolved far enough to render a row without a second round trip. `practitionerName` is null when the visit names no practice, which is an absence rather than a gap.",
|
||||
});
|
||||
|
||||
const encounterSuggestion = z
|
||||
.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("one"), encounter: suggestionCandidate }),
|
||||
z.object({
|
||||
kind: z.literal("many"),
|
||||
encounters: z.array(suggestionCandidate),
|
||||
}),
|
||||
z.object({ kind: z.literal("none") }),
|
||||
])
|
||||
.meta({
|
||||
id: "EncounterSuggestion",
|
||||
description:
|
||||
"The verdict, as a shape rather than a best guess. There is deliberately no field on the `many` arm a client could read as a pre-selection: collapsing two candidates into one is the failure this union exists to make impossible.",
|
||||
});
|
||||
|
||||
const encounterNotFound = {
|
||||
"404": {
|
||||
description: "Visit not found (or owned by another user).",
|
||||
@@ -232,6 +268,30 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/encounters/suggest": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
summary: "Which visit does a record dated around this day belong to?",
|
||||
description:
|
||||
"Resolves the caller's live DONE or PLANNED visits within ±7 days of `anchor` into a verdict, so the three capture moments that ask the question — a document arriving, a lab panel committed from an extraction, a lab panel typed by hand — all read the same answer. `one` means pre-select it visibly with one undo; `many` means offer a picker with NOTHING pre-selected, because pre-selecting the first of two teaches a person to stop reading suggestions; `none` means offer nothing at all. Cancelled visits and no-shows are never candidates: neither happened, so neither produced the record being filed. No AI provider is involved and none is required.",
|
||||
requestParams: { query: encounterSuggestQuerySchema },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The verdict.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
encounterSuggestion,
|
||||
"SuggestEncounterEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/encounters/{id}": {
|
||||
get: {
|
||||
tags: ["Records"],
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
createLabResultSchema.meta({
|
||||
id: "CreateLabResultRequest",
|
||||
description:
|
||||
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted.',
|
||||
'Record a single biomarker reading (HbA1c, LDL, ferritin, TSH, …). A reading is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`, e.g. "negativ" / "positiv") — exactly one, never both, never neither. `analyte` + `unit` are free-form (a lab prints its own naming); a qualitative reading needs no unit / bounds. When both numeric bounds are present `referenceLow` must not exceed `referenceHigh`. `takenAt` is a backdatable ISO instant (no future, ≤ 50 years past). The optional `note` is encrypted at rest. The optional `source` records provenance: `"OCR"` for an on-device-OCR capture (the raw image never reaches the server on this path), defaulting to `"MANUAL"` when omitted. The optional `encounterId` files the reading against a visit the caller owns — always optional, and a link that could not be made never fails the reading.',
|
||||
});
|
||||
|
||||
updateLabResultSchema.meta({
|
||||
|
||||
@@ -22,7 +22,7 @@ import { dataEnvelope, stdResponses } from "./shared";
|
||||
ocrCommitSchema.meta({
|
||||
id: "OcrCommitRequest",
|
||||
description:
|
||||
"The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value).",
|
||||
"The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value). The optional `encounterId` files the panel against a visit the caller owns — ONE link per written result row, so a marker re-run on a different day belongs to its own visit; always optional, and a link that could not be made never fails the commit.",
|
||||
});
|
||||
|
||||
const capabilityResponse = z
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface DocumentVaultFilters {
|
||||
kinds?: InboundDocumentKindValue[];
|
||||
/** Only documents linked to this illness/condition episode. */
|
||||
episodeId?: string;
|
||||
/** Only documents linked to this visit. Arrives by deep link from it. */
|
||||
encounterId?: string;
|
||||
/** Only documents whose filing date falls in this calendar year. */
|
||||
year?: number;
|
||||
}
|
||||
|
||||
@@ -31,9 +31,29 @@ export const encounterKeys = {
|
||||
* year at a time, and two windows are two different answers rather than one
|
||||
* answer that overwrites the other.
|
||||
*/
|
||||
encounterList: (from: string | null, to: string | null, status?: string) =>
|
||||
["encounters", "list", from, to, status ?? null] as const,
|
||||
encounterList: (
|
||||
from: string | null,
|
||||
to: string | null,
|
||||
status?: string,
|
||||
episodeId?: string,
|
||||
) =>
|
||||
[
|
||||
"encounters",
|
||||
"list",
|
||||
from,
|
||||
to,
|
||||
status ?? null,
|
||||
episodeId ?? null,
|
||||
] as const,
|
||||
encounter: (id: string) => ["encounters", "detail", id] as const,
|
||||
/**
|
||||
* The "which visit does this belong to" verdict for one anchor date. The
|
||||
* anchor is part of the key because it IS the question: a document dated the
|
||||
* 3rd and one dated the 20th get different answers, and one overwriting the
|
||||
* other would pre-select the wrong visit on the second review step.
|
||||
*/
|
||||
encounterSuggestion: (anchor: string) =>
|
||||
["encounters", "suggest", anchor] as const,
|
||||
/**
|
||||
* The address book. Under its own root rather than nested beneath
|
||||
* `["encounters"]`: a practitioner outlives the visits that name it, and a
|
||||
@@ -43,4 +63,13 @@ export const encounterKeys = {
|
||||
practitionerList: (q?: string) =>
|
||||
["practitioners", "list", q ?? null] as const,
|
||||
practitioner: (id: string) => ["practitioners", "detail", id] as const,
|
||||
|
||||
/* ── mutation keys ──────────────────────────────────────────────── */
|
||||
|
||||
encounterCreate: () => ["encounters", "create"] as const,
|
||||
encounterUpdate: () => ["encounters", "update"] as const,
|
||||
encounterDelete: () => ["encounters", "delete"] as const,
|
||||
practitionerCreate: () => ["practitioners", "create"] as const,
|
||||
practitionerUpdate: () => ["practitioners", "update"] as const,
|
||||
practitionerDelete: () => ["practitioners", "delete"] as const,
|
||||
};
|
||||
@@ -238,6 +238,25 @@ export const medicationDependentKeys = [
|
||||
*/
|
||||
export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a visit or an address-book entry changes.
|
||||
*
|
||||
* Both roots ride together in one bundle rather than two, because the two
|
||||
* writes reach each other: renaming a practice changes the label every visit
|
||||
* that names it renders, and deleting one nulls the reference on visits that
|
||||
* stay. A bundle that evicted only the surface being written would leave the
|
||||
* other showing the pre-write name.
|
||||
*
|
||||
* `useEncounterMutations` and `usePractitionerMutations` are its consumers.
|
||||
* It briefly had none — the roots landed a release before the surfaces that
|
||||
* read them — and was removed on trunk for that reason; it is back here
|
||||
* together with the writes that need it.
|
||||
*/
|
||||
export const encounterDependentKeys = [
|
||||
queryKeys.encounters(),
|
||||
queryKeys.practitioners(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
|
||||
@@ -52,8 +52,8 @@ function resolves(key: string): boolean {
|
||||
describe("report selection catalogue", () => {
|
||||
it("carries every measurement type and every structured leaf", () => {
|
||||
expect(MEASUREMENT_LEAF_IDS).toHaveLength(77);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(14);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(91);
|
||||
expect(STRUCTURED_LEAF_IDS).toHaveLength(15);
|
||||
expect(ALL_LEAF_IDS).toHaveLength(92);
|
||||
});
|
||||
|
||||
it("resolves a label for every leaf", () => {
|
||||
|
||||
@@ -46,6 +46,7 @@ export type StructuredLeafId =
|
||||
| "GLP1_THERAPY"
|
||||
| "ALLERGIES"
|
||||
| "ILLNESS_EPISODES"
|
||||
| "VISITS"
|
||||
| "FAMILY_HISTORY"
|
||||
| "MOOD"
|
||||
| "CYCLE"
|
||||
@@ -199,13 +200,14 @@ export const STRUCTURED_LEAF_GROUP: Record<StructuredLeafId, ReportGroupId> = {
|
||||
GLP1_THERAPY: "medications",
|
||||
ALLERGIES: "history",
|
||||
ILLNESS_EPISODES: "history",
|
||||
VISITS: "history",
|
||||
FAMILY_HISTORY: "sensitive",
|
||||
MOOD: "sensitive",
|
||||
CYCLE: "sensitive",
|
||||
ANAMNESIS: "sensitive",
|
||||
};
|
||||
|
||||
/** i18n label keys for the 14 structured leaves. */
|
||||
/** i18n label keys for the 15 structured leaves. */
|
||||
export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
PATIENT_IDENTITY: "reportSelection.leafPatientIdentity",
|
||||
INSURANCE: "reportSelection.leafInsurance",
|
||||
@@ -217,6 +219,7 @@ export const STRUCTURED_LEAF_LABEL_KEYS: Record<StructuredLeafId, string> = {
|
||||
GLP1_THERAPY: "reportSelection.leafGlp1Therapy",
|
||||
ALLERGIES: "reportSelection.leafAllergies",
|
||||
ILLNESS_EPISODES: "reportSelection.leafIllnessEpisodes",
|
||||
VISITS: "reportSelection.leafVisits",
|
||||
FAMILY_HISTORY: "reportSelection.leafFamilyHistory",
|
||||
MOOD: "reportSelection.leafMood",
|
||||
CYCLE: "reportSelection.leafCycle",
|
||||
@@ -306,6 +309,10 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
|
||||
MEDICATION_ADMINISTRATIONS: "medications",
|
||||
MEDICATION_COMPLIANCE: "medications",
|
||||
GLP1_THERAPY: "medications",
|
||||
// VISITS is deliberately absent, like ALLERGIES beside it. A visit carries
|
||||
// no module of its own — it is core, like the checkups page it lives on —
|
||||
// and the whole report is already gated by `doctorReport`. Reading the
|
||||
// absence as an oversight is the mistake this comment exists to prevent.
|
||||
};
|
||||
|
||||
/** Every measurement leaf id, in enum order. */
|
||||
|
||||
@@ -138,6 +138,15 @@ export const encounterListQuerySchema = z
|
||||
to: z.iso.datetime({ offset: true }).optional(),
|
||||
status: encounterStatusEnum.optional(),
|
||||
practitionerId: id.optional(),
|
||||
/**
|
||||
* Only visits filed against this condition episode.
|
||||
*
|
||||
* The condition side of the link, and the only direction it is offered in:
|
||||
* an episode spans weeks and a visit is a point, so matching from the visit
|
||||
* side would produce false positives at a rate that trains a person to
|
||||
* ignore every suggestion in the product.
|
||||
*/
|
||||
episodeId: id.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
})
|
||||
.strict()
|
||||
@@ -148,6 +157,22 @@ export const encounterListQuerySchema = z
|
||||
|
||||
export type EncounterListQuery = z.infer<typeof encounterListQuerySchema>;
|
||||
|
||||
/**
|
||||
* The suggestion query.
|
||||
*
|
||||
* One input, and it is a date rather than an id: the answer is derived from the
|
||||
* caller's own visits and the anchor only says where to look. Bounded by the
|
||||
* same instant rule the visit itself carries, so an anchor a client mis-parsed
|
||||
* into the year 12000 is a 422 rather than a full-table scan.
|
||||
*/
|
||||
export const encounterSuggestQuerySchema = z
|
||||
.object({
|
||||
anchor: visitInstant,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type EncounterSuggestQuery = z.infer<typeof encounterSuggestQuerySchema>;
|
||||
|
||||
/* ── links ────────────────────────────────────────────────────────── */
|
||||
|
||||
export const encounterLinkTargetEnum = z.enum([
|
||||
|
||||
@@ -290,6 +290,24 @@ export interface DocumentConditionLinkDto {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One visit link on a document DTO — "this letter belongs to that appointment".
|
||||
*
|
||||
* Carries the visit's KIND as the enum constant rather than a rendered name.
|
||||
* A condition's label is the person's own words and reads the same in every
|
||||
* language; 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. The date and the id
|
||||
* are resolved server-side as usual.
|
||||
*/
|
||||
export interface DocumentEncounterLinkDto {
|
||||
encounterId: string;
|
||||
/** `ROUTINE` | `ACUTE` | … — the client renders the name. */
|
||||
kind: string;
|
||||
/** ISO-8601 instant of the visit. */
|
||||
occurredAt: string | null;
|
||||
}
|
||||
|
||||
/** The document DTO (list + detail). */
|
||||
export interface InboundDocumentDto {
|
||||
id: string;
|
||||
@@ -312,6 +330,8 @@ export interface InboundDocumentDto {
|
||||
pendingCount: number;
|
||||
/** Linked illness/condition episodes (empty when unlinked). */
|
||||
conditionLinks: DocumentConditionLinkDto[];
|
||||
/** Visits this document is filed against (empty when unlinked). */
|
||||
encounterLinks: DocumentEncounterLinkDto[];
|
||||
/** How the serve route delivers the original: render inline or download. */
|
||||
servingClass: "inline" | "attachment";
|
||||
/**
|
||||
@@ -484,19 +504,36 @@ const episodeIdList = z
|
||||
.array(z.string().trim().min(1).max(40))
|
||||
.max(DOCUMENT_MAX_EPISODE_LINKS);
|
||||
|
||||
/**
|
||||
* Max visit links a single document may carry / receive per request.
|
||||
*
|
||||
* The same bound as the condition links, and for the same reason: a document
|
||||
* filed against twenty visits is a client defect rather than a large but
|
||||
* legitimate batch.
|
||||
*/
|
||||
export const DOCUMENT_MAX_ENCOUNTER_LINKS = 20;
|
||||
|
||||
const encounterIdList = z
|
||||
.array(z.string().trim().min(1).max(40))
|
||||
.max(DOCUMENT_MAX_ENCOUNTER_LINKS);
|
||||
|
||||
/**
|
||||
* The store-only upload metadata (the multipart form fields beside the file).
|
||||
* Every field is optional — a bare file upload is valid and lands as a STORED
|
||||
* document with no title / category / filing date. `episodeIds` pre-links the
|
||||
* document to the caller's illness/condition episodes (repeated `episodeIds`
|
||||
* form fields; ownership is re-checked in the route). No `userId` field; it
|
||||
* is always narrowed from the session.
|
||||
* form fields; ownership is re-checked in the route), and `encounterIds` does
|
||||
* the same for the caller's visits — the review step offers the visit the
|
||||
* document's own date falls near, and the link rides the upload rather than
|
||||
* becoming a second trip. No `userId` field; it is always narrowed from the
|
||||
* session.
|
||||
*/
|
||||
export const documentCreateSchema = z.object({
|
||||
title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
documentDate: isoDateString.optional(),
|
||||
episodeIds: episodeIdList.optional(),
|
||||
encounterIds: encounterIdList.optional(),
|
||||
});
|
||||
|
||||
export type DocumentCreateInput = z.infer<typeof documentCreateSchema>;
|
||||
@@ -524,13 +561,16 @@ export const documentUpdateSchema = z
|
||||
* is re-checked in the route against the caller's episodes.
|
||||
*/
|
||||
episodeIds: episodeIdList.optional(),
|
||||
/** Replace-set visit links, same semantics as `episodeIds`. */
|
||||
encounterIds: encounterIdList.optional(),
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
d.title !== undefined ||
|
||||
d.kind !== undefined ||
|
||||
d.documentDate !== undefined ||
|
||||
d.episodeIds !== undefined,
|
||||
d.episodeIds !== undefined ||
|
||||
d.encounterIds !== undefined,
|
||||
{ message: "Provide at least one field to update" },
|
||||
);
|
||||
|
||||
@@ -561,6 +601,8 @@ export const documentListQuerySchema = z.object({
|
||||
kind: z.array(z.enum(INBOUND_DOCUMENT_KINDS)).max(16).optional(),
|
||||
/** Only documents linked to this illness/condition episode. */
|
||||
episodeId: z.string().trim().min(1).max(40).optional(),
|
||||
/** Only documents linked to this visit. */
|
||||
encounterId: z.string().trim().min(1).max(40).optional(),
|
||||
/** Only documents whose filing date falls in this calendar year (UTC). */
|
||||
year: z.coerce.number().int().min(1900).max(9999).optional(),
|
||||
from: isoDateString.optional(),
|
||||
@@ -587,6 +629,8 @@ export const DOCUMENT_BULK_ACTIONS = [
|
||||
"setKind",
|
||||
"linkEpisode",
|
||||
"unlinkEpisode",
|
||||
"linkEncounter",
|
||||
"unlinkEncounter",
|
||||
"delete",
|
||||
"restore",
|
||||
] as const;
|
||||
@@ -608,6 +652,7 @@ export const documentBulkSchema = z
|
||||
action: z.enum(DOCUMENT_BULK_ACTIONS),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
episodeId: z.string().trim().min(1).max(40).optional(),
|
||||
encounterId: z.string().trim().min(1).max(40).optional(),
|
||||
})
|
||||
.refine((d) => d.action !== "setKind" || d.kind !== undefined, {
|
||||
message: "Action 'setKind' requires a kind",
|
||||
@@ -621,6 +666,15 @@ export const documentBulkSchema = z
|
||||
message: "Episode actions require an episodeId",
|
||||
path: ["episodeId"],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(d) =>
|
||||
(d.action !== "linkEncounter" && d.action !== "unlinkEncounter") ||
|
||||
d.encounterId !== undefined,
|
||||
{
|
||||
message: "Visit actions require an encounterId",
|
||||
path: ["encounterId"],
|
||||
},
|
||||
);
|
||||
|
||||
export type DocumentBulkInput = z.infer<typeof documentBulkSchema>;
|
||||
|
||||
@@ -209,6 +209,17 @@ export const ocrCommitSchema = z.object({
|
||||
* is verified server-side before any link is written.
|
||||
*/
|
||||
documentId: z.string().trim().min(1).max(64).optional(),
|
||||
/**
|
||||
* The visit this panel came out of, when the review step offered one.
|
||||
*
|
||||
* Optional, always. A panel commits with no visit named and the commit
|
||||
* succeeds unchanged — the link is the by-the-way half of the flow, never
|
||||
* a condition of the write. Ownership is verified server-side, and one link
|
||||
* is written PER RESULT ROW rather than one for the panel: a re-run panel on
|
||||
* a different day links to its own visit, which is why the join is m:n and
|
||||
* not a scalar column on the result.
|
||||
*/
|
||||
encounterId: z.string().trim().min(1).max(40).optional(),
|
||||
});
|
||||
|
||||
export type OcrCommitInput = z.infer<typeof ocrCommitSchema>;
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user