diff --git a/CHANGELOG.md b/CHANGELOG.md index 0388fd56e..5ba2aa447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ### Added +- The mood module can now say what a day would have looked like on your own + past patterns, and how far the day you recorded sits from that. It is a + second, separate reading: your own rating stays the leading one, it is + stored exactly as you gave it, and nothing overwrites it. The comparison + appears only once there are enough days to say anything honest, it always + carries the number of days it was built from and a range rather than a + single figure, and it names which of the things you recorded weighed most on + that day. It describes a connection between things you wrote down. It never + claims a cause. + + Below fifteen days there is nothing at all. Between fifteen and thirty the + page says how far along it is instead of guessing. Between thirty and sixty + the comparison is there and is labelled as provisional. Where the days are + there but nothing in them is steady enough to compare against, it says that + too, rather than producing a number to fill the space. The comparison is + rebuilt overnight from your entries, and if you delete enough of them it + goes away again instead of standing there with a fresh date on it. + - A mood entry can now carry the shape of the day around it. Four sections sit below the sliders — work, contacts, leisure, and one notable event — and every one of them is closed until you open it, so a quick check-in is still diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b18ff18e1..7f54e76f5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -932,7 +932,7 @@ paths: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -1037,7 +1037,7 @@ paths: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -8357,6 +8357,29 @@ paths: "403": *a6 "422": *a3 "429": *a4 + /api/mood/prognosis: + get: + tags: + - Mood + summary: The day's own rating and what the account's days imply (v1.38) + description: "The newest stored comparison for the account, with the person's own rating for that day beside it. Written + nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings + separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error + — the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's + context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a + section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood + module is off." + responses: + "200": + description: The day's two readings, or an explained absence. + content: + application/json: + schema: + $ref: "#/components/schemas/MoodPrognosisEnvelope" + "401": *a1 + "403": *a7 + "422": *a3 + "429": *a4 /api/mood-entries/bulk: post: tags: @@ -9176,6 +9199,10 @@ paths: enum: - MANUAL - OCR + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - takenAt responses: @@ -9477,6 +9504,10 @@ paths: type: string minLength: 1 maxLength: 64 + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - rows responses: @@ -9713,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: @@ -9825,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: @@ -11397,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 @@ -11515,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: @@ -11618,6 +11697,8 @@ paths: - setKind - linkEpisode - unlinkEpisode + - linkEncounter + - unlinkEncounter - delete - restore kind: @@ -11636,6 +11717,10 @@ paths: type: string minLength: 1 maxLength: 40 + encounterId: + type: string + minLength: 1 + maxLength: 40 required: - ids - action @@ -11720,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. @@ -15647,7 +15739,7 @@ components: type: number const: 2 leaves: - maxItems: 91 + maxItems: 92 type: array items: type: string @@ -16919,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 @@ -16932,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 @@ -32815,6 +32908,184 @@ components: - generatedAt additionalProperties: false description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis. + MoodPrognosisEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MoodPrognosis" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodPrognosis: + anyOf: + - type: object + properties: + present: + type: boolean + const: true + date: + type: string + description: The local day this is about, `YYYY-MM-DD`. + predicted: + type: number + description: The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual, + never a measurement — and never to be displayed without `n` and the band. + ciLow: + anyOf: + - type: number + - type: "null" + ciHigh: + anyOf: + - type: number + - type: "null" + description: The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal + to present the value cautiously. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and + one built from 300 look identical otherwise." + modelVersion: + type: string + computedAt: + 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)))$ + ageDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Whole days between that day and the reader's today. + current: + type: boolean + description: Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is + about. + provisional: + type: boolean + description: "True below 60 days of history: label it as provisional." + stage: + type: string + enum: + - provisional + - regular + - seasonal + entries: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days of history behind the ladder. + seasonalUnlocked: + type: boolean + selfAssessment: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: "The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on + any surface that shows both, and it is never merged with `predicted`." + deviation: + anyOf: + - type: string + enum: + - within + - above + - below + - type: "null" + description: Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the + account's own past days suggest. + deviationAmount: + anyOf: + - type: number + - type: "null" + description: How far outside the band, in scale points. `0` when inside. + contributions: + type: array + items: + $ref: "#/components/schemas/MoodPrognosisContribution" + required: + - present + - date + - predicted + - ciLow + - ciHigh + - n + - modelVersion + - computedAt + - ageDays + - current + - provisional + - stage + - entries + - seasonalUnlocked + - selfAssessment + - deviation + - deviationAmount + - contributions + additionalProperties: false + - type: object + properties: + present: + type: boolean + const: false + reason: + type: string + enum: + - no-output-yet + - learning-phase + - no-pattern + description: "`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare. + `no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a + zero." + entries: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + nextThreshold: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Days at which the next step unlocks, when a count is missing. + required: + - present + - reason + - entries + - nextThreshold + additionalProperties: false + description: "Two separate readings of one day: what the person recorded, and what a comparison with their own past days + would have expected. They are never merged — the distance between them is the finding, and a single blended + figure destroys it. The comparison is a regularised regression over the account's own history: observational + only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have + been expected), must carry `n`, and must not say that anything caused anything." + MoodPrognosisContribution: + type: object + properties: + feature: + type: string + description: "Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`. + A client that does not recognise one skips it — an older row can name a feature a later model dropped." + contribution: + type: number + description: "Signed: how far this feature moved the expected value away from the account's own average day. Ordered by + absolute size, largest first." + required: + - feature + - contribution + additionalProperties: false BulkMoodResponseEnvelope: type: object properties: @@ -34845,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: @@ -34923,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: @@ -36358,6 +36724,10 @@ components: type: array items: $ref: "#/components/schemas/DocumentConditionLink" + encounterLinks: + type: array + items: + $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: @@ -36395,6 +36765,7 @@ components: - factCount - pendingCount - conditionLinks + - encounterLinks - servingClass - hasContentIndex - contentIndexSource @@ -36424,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: @@ -36681,6 +37072,10 @@ components: type: array items: $ref: "#/components/schemas/DocumentConditionLink" + encounterLinks: + type: array + items: + $ref: "#/components/schemas/DocumentEncounterLink" servingClass: type: string enum: @@ -36738,6 +37133,7 @@ components: - factCount - pendingCount - conditionLinks + - encounterLinks - servingClass - hasContentIndex - contentIndexSource @@ -38434,6 +38830,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit description: Closed rail-item kind. itemKey: description: Stable dismiss identity, namespaced `:...`. Present only on the observational kinds (milestone / @@ -38716,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 @@ -38729,6 +39126,7 @@ components: - ecg_new_recording - tension_window - same_time_baseline + - upcoming_visit updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." diff --git a/e2e/visits.spec.ts b/e2e/visits.spec.ts new file mode 100644 index 000000000..76daf12c0 --- /dev/null +++ b/e2e/visits.spec.ts @@ -0,0 +1,102 @@ +/** + * Visits — the incidental-linking promise, proven end to end once. + * + * The flow this milestone is checked against, in one script: + * + * 1. create a visit with ONLY a date — nothing else touched, Save enabled; + * 2. it appears in the list; + * 3. open a document dated inside the ±7-day window; + * 4. the suggestion offers that visit; + * 5. take the offer; + * 6. the document's detail names the visit it now belongs to. + * + * Every assertion addresses a stable `data-slot`, never viewport text: the + * copy is i18n-driven and the mobile project renders the same slots at a + * different size, so a text assertion would break for the wrong reason. + * + * The visit is created through the API rather than by driving the sheet twice: + * step 1 is about the SAVE being possible with only a date, which the sheet + * assertion below covers, and the linking half needs a visit whose instant is + * pinned relative to the seeded document's date. + */ +import { expect, test } from "./setup/test"; + +import { ensureVaultFixture, MRT_DOC_ID } from "./setup/vault-fixture"; + +test.beforeEach(async () => { + await ensureVaultFixture(); +}); + +test.describe("visits", () => { + test("a visit saves with a date and nothing else", async ({ page }) => { + await page.goto("/checkups"); + await page.getByTestId("checkups-tab-visits").click(); + + const add = page.locator('[data-slot="visits-add"]').first(); + await expect(add).toBeVisible(); + await add.click(); + + // The date is pre-filled with now, and NOTHING else has been touched. + const save = page.locator('[data-slot="encounter-save"]').first(); + await expect(save).toBeVisible(); + // The whole product constraint: not disabled, with no practitioner, no + // reason, no outcome and no link chosen. + await expect(save).toBeEnabled(); + + await save.click(); + + // It appears in the list. A visit dated now is in the past arm. + await expect(page.locator('[data-slot="visit-card"]').first()).toBeVisible({ + timeout: 15_000, + }); + }); + + test("a document offers the visit its date falls near, and files itself against it", async ({ + page, + request, + }) => { + // The seeded MRI report carries a fixed filing date; the visit is placed + // two days before it so the ±7-day window resolves to exactly one + // candidate — the branch that pre-selects. + const document = await request + .get(`/api/documents/inbound/${MRT_DOC_ID}`) + .then((res) => res.json()); + const anchorDay: string = + document.data.reportDate ?? document.data.documentDate; + expect( + anchorDay, + "the fixture document carries a filing date", + ).toBeTruthy(); + const occurredAt = new Date(`${anchorDay}T09:00:00.000Z`); + occurredAt.setUTCDate(occurredAt.getUTCDate() - 2); + + const created = await request.post("/api/encounters", { + data: { + occurredAt: occurredAt.toISOString(), + status: "DONE", + kind: "SPECIALIST", + }, + }); + expect(created.status()).toBe(201); + + await page.goto(`/documents?doc=${MRT_DOC_ID}`); + + const suggestion = page + .locator('[data-slot="document-encounter-suggestion"]') + .first(); + await expect(suggestion).toBeVisible({ timeout: 15_000 }); + // One candidate, so the single-candidate branch: a pre-selectable offer + // rather than a picker. + await expect(suggestion).toHaveAttribute("data-verdict", "one"); + + await page + .locator('[data-slot="document-encounter-suggestion-option"]') + .first() + .click(); + + // The filing landed: the read-only "belongs to visit" row appears. + await expect( + page.locator('[data-slot="document-visit-links"]').first(), + ).toBeVisible({ timeout: 15_000 }); + }); +}); diff --git a/messages/de.json b/messages/de.json index 0f4263acf..fab1de3ff 100644 --- a/messages/de.json +++ b/messages/de.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Erkrankung", "familyHistoryColAgeAtOnset": "Alter bei Beginn", "labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:", - "labsCatalogRangeLabel": "gespeicherter Bereich" + "labsCatalogRangeLabel": "gespeicherter Bereich", + "visitsTitle": "Arztbesuche", + "visitsColDate": "Datum", + "visitsColPractice": "Praxis", + "visitsColKind": "Art", + "visitsColReason": "Anlass", + "visitsColOutcome": "Ergebnis", + "visitsColConditions": "Erkrankungen" }, "errorBoundary": { "title": "Da ist etwas schiefgelaufen", @@ -478,7 +485,8 @@ "milestone": "Meilensteine", "ecg_new_recording": "Neue EKG-Aufzeichnungen", "tension_window": "Anspannungsphasen", - "same_time_baseline": "Aktivität zur gleichen Tageszeit" + "same_time_baseline": "Aktivität zur gleichen Tageszeit", + "upcoming_visit": "Termine" }, "layoutReset": "Auf Defaults zurücksetzen", "layoutResetTitle": "Dashboard-Layout zurücksetzen?", @@ -3764,6 +3772,25 @@ "statement": "An Tagen mit „{label}“ lag die Stimmung im Durchschnitt {direction}: {withAvg} gegenüber {withoutAvg}.", "counts": "{withDays} Tage mit, {withoutDays} Tage ohne", "caveat": "Das zeigt einen Zusammenhang, keine Ursache." + }, + "prognosis": { + "title": "Deine Einschätzung und was deine bisherigen Tage nahelegen", + "selfLabel": "Deine Einschätzung", + "expectedLabel": "Auf Basis deiner Muster erwartet", + "band": "Bereich {low} bis {high}", + "statement": "Auf Basis deiner bisherigen Muster wäre für {date} ein Wert um {value} zu erwarten gewesen. Ermittelt aus {n} Tagen, die alles enthalten, was der Vergleich braucht.", + "provisional": "Noch vorläufig: das Muster stützt sich auf eine kurze Strecke von Tagen.", + "notToday": "Das beschreibt {date}, nicht heute.", + "deviationWithin": "Deine eigene Einschätzung liegt in diesem Bereich.", + "deviationAbove": "Deine eigene Einschätzung liegt {amount} Punkte über diesem Bereich.", + "deviationBelow": "Deine eigene Einschätzung liegt {amount} Punkte unter diesem Bereich.", + "weightedTitle": "Was an diesem Tag am stärksten gewichtet wurde", + "weightedUp": "{label} — zählte in Richtung eines höheren erwarteten Werts", + "weightedDown": "{label} — zählte in Richtung eines niedrigeren erwarteten Werts", + "caveat": "Das ist ein Vergleich mit deinen eigenen bisherigen Tagen, keine Aussage darüber, wie ein Tag zustande gekommen ist.", + "learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.", + "noPattern": "In den bisher erfassten {entries} Tagen ist nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wer den Kontext eines Tages mit erfasst, macht mehr davon vergleichbar.", + "featureValue": "{field}: {value}" } }, "derived": { @@ -5941,7 +5968,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.", @@ -8107,7 +8136,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", @@ -9261,7 +9291,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", @@ -9278,7 +9309,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", @@ -9571,6 +9603,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": { @@ -9666,7 +9703,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": { @@ -10027,6 +10065,98 @@ "hospital": "Krankenhaus", "therapy": "Therapietermin", "other": "Arzttermin" + }, + "sectionTitle": "Termine", + "sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.", + "add": "Termin", + "loadError": "Termine konnten nicht geladen werden.", + "emptyTitle": "Noch kein Termin erfasst", + "emptyDescription": "Ein Datum genügt. Alles andere ist freiwillig.", + "upcomingHeading": "Anstehend", + "pastHeading": "Vergangen", + "createTitle": "Termin erfassen", + "editTitle": "Termin bearbeiten", + "formDescription": "Nur das Datum ist Pflicht.", + "created": "Termin gespeichert.", + "updated": "Termin aktualisiert.", + "saveFailed": "Termin konnte nicht gespeichert werden.", + "checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen — dafür reicht deine Freigabe nicht.", + "status": { + "planned": "Geplant", + "done": "Stattgefunden", + "cancelled": "Abgesagt", + "noShow": "Nicht wahrgenommen" + }, + "card": { + "documentCount": "verknüpfte Dokumente", + "labCount": "verknüpfte Laborwerte", + "conditionCount": "verknüpfte Erkrankungen" + }, + "form": { + "date": "Datum und Uhrzeit", + "dateDerivesStatusHint": "Ein Datum in der Zukunft ist ein gebuchter Termin.", + "status": "Status", + "practitioner": "Praxis oder Ärztin", + "practitionerHint": "Optional — du kannst den Namen später nachtragen.", + "practitionerNone": "Keine Angabe", + "practitionerSearch": "Name oder Praxis suchen", + "practitionerNoMatch": "Kein Eintrag gefunden.", + "practitionerAdd": "Neu anlegen", + "practitionerClear": "Auswahl entfernen", + "kind": "Art", + "reason": "Anlass", + "outcome": "Ergebnis", + "outcomeReveal": "Ergebnis eintragen", + "linkDocuments": "Dokumente", + "linkLabResults": "Laborwerte", + "linkConditions": "Erkrankungen", + "linkNothingToOffer": "Nichts zum Verknüpfen vorhanden." + }, + "deleteTitle": "Termin löschen?", + "deleteDescription": "Der Eintrag wird entfernt. Ist der Termin gebucht, entfällt auch die Erinnerung dazu.", + "deleteFailed": "Termin konnte nicht gelöscht werden.", + "suggestion": { + "single": "Gehört zu diesem Termin?", + "choose": "Zu welchem Termin gehört das?" + }, + "episodeCard": { + "title": "Termine dazu", + "emptyLine": "Noch kein Termin mit dieser Erkrankung verknüpft.", + "add": "Termin hinzufügen" + }, + "dashboard": { + "nextVisit": "Nächster Termin: {what} am {when}" } + }, + "checkups": { + "viewSwitchLabel": "Vorsorge oder Termine anzeigen", + "tabVorsorge": "Vorsorge", + "tabVisits": "Termine" + }, + "practitioners": { + "title": "Praxen und Ärzte", + "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.", + "linkLabel": "Adressbuch", + "add": "Eintrag", + "searchPlaceholder": "Name oder Praxis suchen", + "loadError": "Adressbuch konnte nicht geladen werden.", + "emptyTitle": "Noch kein Eintrag", + "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.", + "noMatchTitle": "Kein Treffer", + "noMatchDescription": "Zu dieser Suche gibt es keinen Eintrag.", + "createTitle": "Eintrag anlegen", + "editTitle": "Eintrag bearbeiten", + "formDescription": "Nur der Name ist Pflicht.", + "name": "Name", + "specialty": "Fachrichtung", + "practice": "Praxis", + "location": "Adresse", + "phone": "Telefon", + "note": "Notiz", + "noteHint": "Verschlüsselt gespeichert.", + "saveFailed": "Eintrag konnte nicht gespeichert werden.", + "deleteTitle": "Eintrag löschen?", + "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", + "deleteFailed": "Eintrag konnte nicht gelöscht werden." } } diff --git a/messages/en.json b/messages/en.json index 3fb6bdf44..f82c27e99 100644 --- a/messages/en.json +++ b/messages/en.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Condition", "familyHistoryColAgeAtOnset": "Age at onset", "labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:", - "labsCatalogRangeLabel": "saved range" + "labsCatalogRangeLabel": "saved range", + "visitsTitle": "Visits", + "visitsColDate": "Date", + "visitsColPractice": "Practice", + "visitsColKind": "Kind", + "visitsColReason": "Reason", + "visitsColOutcome": "Outcome", + "visitsColConditions": "Conditions" }, "errorBoundary": { "title": "Something went wrong", @@ -478,7 +485,8 @@ "milestone": "Milestones", "ecg_new_recording": "New ECG recordings", "tension_window": "Tension windows", - "same_time_baseline": "Same-time activity" + "same_time_baseline": "Same-time activity", + "upcoming_visit": "Appointments" }, "layoutReset": "Reset to defaults", "layoutResetTitle": "Reset the dashboard layout?", @@ -3764,6 +3772,25 @@ "statement": "On days with “{label}” the mood averaged {direction}: {withAvg} against {withoutAvg}.", "counts": "{withDays} days with, {withoutDays} days without", "caveat": "That shows a connection, not a cause." + }, + "prognosis": { + "title": "Your rating, and what your own days suggest", + "selfLabel": "Your rating", + "expectedLabel": "Expected on your own pattern", + "band": "Range {low} to {high}", + "statement": "On your own patterns so far, a value around {value} would have been expected for {date}. Built from {n} days that carried everything the comparison needs.", + "provisional": "Still provisional: the pattern rests on a short stretch of days.", + "notToday": "This describes {date}, not today.", + "deviationWithin": "Your own rating sits inside that range.", + "deviationAbove": "Your own rating sits {amount} points above that range.", + "deviationBelow": "Your own rating sits {amount} points below that range.", + "weightedTitle": "What weighed most in this day's comparison", + "weightedUp": "{label} — counted towards a higher expected value", + "weightedDown": "{label} — counted towards a lower expected value", + "caveat": "This is a comparison with your own past days, not a statement about how a day came to go the way it did.", + "learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.", + "noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. Recording a day's context makes more of it comparable.", + "featureValue": "{field}: {value}" } }, "derived": { @@ -5941,7 +5968,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.", @@ -8107,7 +8136,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", @@ -9261,7 +9291,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", @@ -9278,7 +9309,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", @@ -9571,6 +9603,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": { @@ -9666,7 +9703,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": { @@ -10027,6 +10065,98 @@ "hospital": "Hospital", "therapy": "Therapy session", "other": "Appointment" + }, + "sectionTitle": "Visits", + "sectionDescription": "Doctor's visits and booked appointments in one place.", + "add": "Visit", + "loadError": "Visits could not be loaded.", + "emptyTitle": "No visit recorded yet", + "emptyDescription": "A date is enough. Everything else is optional.", + "upcomingHeading": "Upcoming", + "pastHeading": "Past", + "createTitle": "Record a visit", + "editTitle": "Edit visit", + "formDescription": "Only the date is required.", + "created": "Visit saved.", + "updated": "Visit updated.", + "saveFailed": "The visit could not be saved.", + "checkupNotClosed": "Visit saved. The checkup stays open — your access does not cover that.", + "status": { + "planned": "Planned", + "done": "Attended", + "cancelled": "Cancelled", + "noShow": "Missed" + }, + "card": { + "documentCount": "linked documents", + "labCount": "linked lab results", + "conditionCount": "linked conditions" + }, + "form": { + "date": "Date and time", + "dateDerivesStatusHint": "A date in the future is a booked appointment.", + "status": "Status", + "practitioner": "Practice or doctor", + "practitionerHint": "Optional — you can add the name later.", + "practitionerNone": "Not specified", + "practitionerSearch": "Search name or practice", + "practitionerNoMatch": "No entry found.", + "practitionerAdd": "Add new", + "practitionerClear": "Clear selection", + "kind": "Kind", + "reason": "Reason", + "outcome": "Outcome", + "outcomeReveal": "Add an outcome", + "linkDocuments": "Documents", + "linkLabResults": "Lab results", + "linkConditions": "Conditions", + "linkNothingToOffer": "Nothing here to link yet." + }, + "deleteTitle": "Delete this visit?", + "deleteDescription": "The entry is removed. If the visit is booked, its reminder stops with it.", + "deleteFailed": "The visit could not be deleted.", + "suggestion": { + "single": "Does this belong to this visit?", + "choose": "Which visit does this belong to?" + }, + "episodeCard": { + "title": "Visits about this", + "emptyLine": "No visit linked to this condition yet.", + "add": "Add a visit" + }, + "dashboard": { + "nextVisit": "Next appointment: {what} on {when}" } + }, + "checkups": { + "viewSwitchLabel": "Show checkups or visits", + "tabVorsorge": "Checkups", + "tabVisits": "Visits" + }, + "practitioners": { + "title": "Practices and doctors", + "description": "Your own address book — type a practice once, pick it everywhere.", + "linkLabel": "Address book", + "add": "Entry", + "searchPlaceholder": "Search name or practice", + "loadError": "The address book could not be loaded.", + "emptyTitle": "No entry yet", + "emptyDescription": "Add a practice so you can pick it when you record a visit.", + "noMatchTitle": "No match", + "noMatchDescription": "No entry matches this search.", + "createTitle": "Add an entry", + "editTitle": "Edit entry", + "formDescription": "Only the name is required.", + "name": "Name", + "specialty": "Specialty", + "practice": "Practice", + "location": "Address", + "phone": "Phone", + "note": "Note", + "noteHint": "Stored encrypted.", + "saveFailed": "The entry could not be saved.", + "deleteTitle": "Delete this entry?", + "deleteDescription": "Your existing visits stay, and they keep this name.", + "deleteFailed": "The entry could not be deleted." } } diff --git a/messages/es.json b/messages/es.json index 80c5f542a..f9f9f29f2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Afección", "familyHistoryColAgeAtOnset": "Edad de inicio", "labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:", - "labsCatalogRangeLabel": "rango guardado" + "labsCatalogRangeLabel": "rango guardado", + "visitsTitle": "Visitas médicas", + "visitsColDate": "Fecha", + "visitsColPractice": "Consulta", + "visitsColKind": "Tipo", + "visitsColReason": "Motivo", + "visitsColOutcome": "Resultado", + "visitsColConditions": "Enfermedades" }, "errorBoundary": { "title": "Algo ha salido mal", @@ -478,7 +485,8 @@ "milestone": "Hitos", "ecg_new_recording": "Nuevos registros de ECG", "tension_window": "Periodos de tensión", - "same_time_baseline": "Actividad a la misma hora" + "same_time_baseline": "Actividad a la misma hora", + "upcoming_visit": "Citas" }, "layoutReset": "Restablecer valores por defecto", "layoutResetTitle": "¿Restablecer el diseño del panel?", @@ -3764,6 +3772,25 @@ "statement": "En los días con «{label}» el ánimo promedió {direction}: {withAvg} frente a {withoutAvg}.", "counts": "{withDays} días con, {withoutDays} días sin", "caveat": "Esto muestra una relación, no una causa." + }, + "prognosis": { + "title": "Tu valoración y lo que sugieren tus días anteriores", + "selfLabel": "Tu valoración", + "expectedLabel": "Esperado según tus propios patrones", + "band": "Rango de {low} a {high}", + "statement": "Según tus patrones hasta ahora, para {date} cabría esperar un valor en torno a {value}. Calculado a partir de {n} días que reúnen todo lo que la comparación necesita.", + "provisional": "Todavía provisional: el patrón se apoya en pocos días.", + "notToday": "Esto describe {date}, no hoy.", + "deviationWithin": "Tu propia valoración está dentro de ese rango.", + "deviationAbove": "Tu propia valoración está {amount} puntos por encima de ese rango.", + "deviationBelow": "Tu propia valoración está {amount} puntos por debajo de ese rango.", + "weightedTitle": "Lo que más pesó en la comparación de este día", + "weightedUp": "{label}: contó hacia un valor esperado más alto", + "weightedDown": "{label}: contó hacia un valor esperado más bajo", + "caveat": "Es una comparación con tus propios días anteriores, no una afirmación sobre cómo llegó a transcurrir un día.", + "learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.", + "noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Registrar el contexto del día hace comparable una parte mayor.", + "featureValue": "{field}: {value}" } }, "derived": { @@ -5941,7 +5968,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.", @@ -8107,7 +8136,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", @@ -9261,7 +9291,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", @@ -9278,7 +9309,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", @@ -9571,6 +9603,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": { @@ -9666,7 +9703,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": { @@ -10027,6 +10065,98 @@ "hospital": "Hospital", "therapy": "Sesión de terapia", "other": "Cita médica" + }, + "sectionTitle": "Citas", + "sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.", + "add": "Cita", + "loadError": "No se pudieron cargar las citas.", + "emptyTitle": "Todavía no hay ninguna cita", + "emptyDescription": "Basta con una fecha. Todo lo demás es opcional.", + "upcomingHeading": "Próximas", + "pastHeading": "Pasadas", + "createTitle": "Registrar una cita", + "editTitle": "Editar la cita", + "formDescription": "Solo la fecha es obligatoria.", + "created": "Cita guardada.", + "updated": "Cita actualizada.", + "saveFailed": "No se pudo guardar la cita.", + "checkupNotClosed": "Cita guardada. La revisión sigue pendiente: tu acceso no lo permite.", + "status": { + "planned": "Prevista", + "done": "Realizada", + "cancelled": "Cancelada", + "noShow": "No acudida" + }, + "card": { + "documentCount": "documentos vinculados", + "labCount": "resultados de laboratorio vinculados", + "conditionCount": "enfermedades vinculadas" + }, + "form": { + "date": "Fecha y hora", + "dateDerivesStatusHint": "Una fecha futura es una cita reservada.", + "status": "Estado", + "practitioner": "Consulta o médico", + "practitionerHint": "Opcional: puedes añadir el nombre más tarde.", + "practitionerNone": "Sin indicar", + "practitionerSearch": "Buscar nombre o consulta", + "practitionerNoMatch": "No se encontró ninguna entrada.", + "practitionerAdd": "Añadir nueva", + "practitionerClear": "Quitar la selección", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Resultado", + "outcomeReveal": "Añadir un resultado", + "linkDocuments": "Documentos", + "linkLabResults": "Resultados de laboratorio", + "linkConditions": "Enfermedades", + "linkNothingToOffer": "Todavía no hay nada que vincular." + }, + "deleteTitle": "¿Eliminar esta cita?", + "deleteDescription": "Se elimina la entrada. Si la cita está reservada, su recordatorio también desaparece.", + "deleteFailed": "No se pudo eliminar la cita.", + "suggestion": { + "single": "¿Pertenece a esta cita?", + "choose": "¿A qué cita pertenece?" + }, + "episodeCard": { + "title": "Citas relacionadas", + "emptyLine": "Todavía no hay ninguna cita vinculada a esta enfermedad.", + "add": "Añadir una cita" + }, + "dashboard": { + "nextVisit": "Próxima cita: {what} el {when}" } + }, + "checkups": { + "viewSwitchLabel": "Mostrar revisiones o citas", + "tabVorsorge": "Revisiones", + "tabVisits": "Citas" + }, + "practitioners": { + "title": "Consultas y médicos", + "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.", + "linkLabel": "Agenda", + "add": "Entrada", + "searchPlaceholder": "Buscar nombre o consulta", + "loadError": "No se pudo cargar la agenda.", + "emptyTitle": "Todavía no hay entradas", + "emptyDescription": "Añade una consulta para poder elegirla al registrar una cita.", + "noMatchTitle": "Sin coincidencias", + "noMatchDescription": "Ninguna entrada coincide con esta búsqueda.", + "createTitle": "Añadir una entrada", + "editTitle": "Editar la entrada", + "formDescription": "Solo el nombre es obligatorio.", + "name": "Nombre", + "specialty": "Especialidad", + "practice": "Consulta", + "location": "Dirección", + "phone": "Teléfono", + "note": "Nota", + "noteHint": "Se guarda cifrada.", + "saveFailed": "No se pudo guardar la entrada.", + "deleteTitle": "¿Eliminar esta entrada?", + "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", + "deleteFailed": "No se pudo eliminar la entrada." } } diff --git a/messages/fr.json b/messages/fr.json index 1c0d89f63..03865270c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Affection", "familyHistoryColAgeAtOnset": "Âge au début", "labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :", - "labsCatalogRangeLabel": "plage enregistrée" + "labsCatalogRangeLabel": "plage enregistrée", + "visitsTitle": "Consultations", + "visitsColDate": "Date", + "visitsColPractice": "Cabinet", + "visitsColKind": "Type", + "visitsColReason": "Motif", + "visitsColOutcome": "Résultat", + "visitsColConditions": "Maladies" }, "errorBoundary": { "title": "Une erreur est survenue", @@ -478,7 +485,8 @@ "milestone": "Étapes franchies", "ecg_new_recording": "Nouveaux enregistrements ECG", "tension_window": "Périodes de tension", - "same_time_baseline": "Activité à la même heure" + "same_time_baseline": "Activité à la même heure", + "upcoming_visit": "Rendez-vous" }, "layoutReset": "Rétablir les valeurs par défaut", "layoutResetTitle": "Réinitialiser la disposition du tableau de bord ?", @@ -3764,6 +3772,25 @@ "statement": "Les jours avec « {label} », l'humeur était en moyenne {direction} : {withAvg} contre {withoutAvg}.", "counts": "{withDays} jours avec, {withoutDays} jours sans", "caveat": "Cela montre un lien, pas une cause." + }, + "prognosis": { + "title": "Ton évaluation et ce que suggèrent tes journées passées", + "selfLabel": "Ton évaluation", + "expectedLabel": "Attendu selon tes propres habitudes", + "band": "Plage de {low} à {high}", + "statement": "D'après tes habitudes jusqu'ici, une valeur autour de {value} aurait été attendue pour {date}. Établi à partir de {n} journées réunissant tout ce dont la comparaison a besoin.", + "provisional": "Encore provisoire : le motif repose sur une courte série de journées.", + "notToday": "Cela décrit {date}, pas aujourd'hui.", + "deviationWithin": "Ta propre évaluation se situe dans cette plage.", + "deviationAbove": "Ta propre évaluation se situe {amount} points au-dessus de cette plage.", + "deviationBelow": "Ta propre évaluation se situe {amount} points en dessous de cette plage.", + "weightedTitle": "Ce qui a le plus pesé dans la comparaison de cette journée", + "weightedUp": "{label} — comptait vers une valeur attendue plus élevée", + "weightedDown": "{label} — comptait vers une valeur attendue plus basse", + "caveat": "C'est une comparaison avec tes propres journées passées, pas une affirmation sur la manière dont une journée s'est déroulée.", + "learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.", + "noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Noter le contexte d'une journée en rend davantage comparable.", + "featureValue": "{field} : {value}" } }, "derived": { @@ -5941,7 +5968,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.", @@ -8107,7 +8136,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", @@ -9261,7 +9291,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", @@ -9278,7 +9309,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", @@ -9571,6 +9603,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": { @@ -9666,7 +9703,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": { @@ -10027,6 +10065,98 @@ "hospital": "Hôpital", "therapy": "Séance de thérapie", "other": "Rendez-vous médical" + }, + "sectionTitle": "Rendez-vous", + "sectionDescription": "Consultations et rendez-vous pris, au même endroit.", + "add": "Rendez-vous", + "loadError": "Impossible de charger les rendez-vous.", + "emptyTitle": "Aucun rendez-vous enregistré", + "emptyDescription": "Une date suffit. Le reste est facultatif.", + "upcomingHeading": "À venir", + "pastHeading": "Passés", + "createTitle": "Enregistrer un rendez-vous", + "editTitle": "Modifier le rendez-vous", + "formDescription": "Seule la date est obligatoire.", + "created": "Rendez-vous enregistré.", + "updated": "Rendez-vous mis à jour.", + "saveFailed": "Le rendez-vous n'a pas pu être enregistré.", + "checkupNotClosed": "Rendez-vous enregistré. Le dépistage reste ouvert : votre accès ne le couvre pas.", + "status": { + "planned": "Prévu", + "done": "Effectué", + "cancelled": "Annulé", + "noShow": "Non honoré" + }, + "card": { + "documentCount": "documents liés", + "labCount": "résultats de laboratoire liés", + "conditionCount": "maladies liées" + }, + "form": { + "date": "Date et heure", + "dateDerivesStatusHint": "Une date à venir correspond à un rendez-vous pris.", + "status": "Statut", + "practitioner": "Cabinet ou médecin", + "practitionerHint": "Facultatif : vous pourrez ajouter le nom plus tard.", + "practitionerNone": "Non précisé", + "practitionerSearch": "Rechercher un nom ou un cabinet", + "practitionerNoMatch": "Aucune entrée trouvée.", + "practitionerAdd": "Créer une entrée", + "practitionerClear": "Retirer la sélection", + "kind": "Type", + "reason": "Motif", + "outcome": "Résultat", + "outcomeReveal": "Ajouter un résultat", + "linkDocuments": "Documents", + "linkLabResults": "Résultats de laboratoire", + "linkConditions": "Maladies", + "linkNothingToOffer": "Rien à lier pour le moment." + }, + "deleteTitle": "Supprimer ce rendez-vous ?", + "deleteDescription": "L'entrée est supprimée. Si le rendez-vous est pris, son rappel disparaît avec elle.", + "deleteFailed": "Le rendez-vous n'a pas pu être supprimé.", + "suggestion": { + "single": "Cela concerne-t-il ce rendez-vous ?", + "choose": "À quel rendez-vous cela se rattache-t-il ?" + }, + "episodeCard": { + "title": "Rendez-vous liés", + "emptyLine": "Aucun rendez-vous n'est encore rattaché à cette maladie.", + "add": "Ajouter un rendez-vous" + }, + "dashboard": { + "nextVisit": "Prochain rendez-vous : {what} le {when}" } + }, + "checkups": { + "viewSwitchLabel": "Afficher les dépistages ou les rendez-vous", + "tabVorsorge": "Dépistages", + "tabVisits": "Rendez-vous" + }, + "practitioners": { + "title": "Cabinets et médecins", + "description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.", + "linkLabel": "Carnet d'adresses", + "add": "Entrée", + "searchPlaceholder": "Rechercher un nom ou un cabinet", + "loadError": "Impossible de charger le carnet d'adresses.", + "emptyTitle": "Aucune entrée", + "emptyDescription": "Ajoutez un cabinet pour pouvoir le choisir lors d'un rendez-vous.", + "noMatchTitle": "Aucun résultat", + "noMatchDescription": "Aucune entrée ne correspond à cette recherche.", + "createTitle": "Ajouter une entrée", + "editTitle": "Modifier l'entrée", + "formDescription": "Seul le nom est obligatoire.", + "name": "Nom", + "specialty": "Spécialité", + "practice": "Cabinet", + "location": "Adresse", + "phone": "Téléphone", + "note": "Note", + "noteHint": "Enregistrée chiffrée.", + "saveFailed": "L'entrée n'a pas pu être enregistrée.", + "deleteTitle": "Supprimer cette entrée ?", + "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", + "deleteFailed": "L'entrée n'a pas pu être supprimée." } } diff --git a/messages/it.json b/messages/it.json index 6e485828c..09c27c2be 100644 --- a/messages/it.json +++ b/messages/it.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Condizione", "familyHistoryColAgeAtOnset": "Età di esordio", "labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:", - "labsCatalogRangeLabel": "intervallo salvato" + "labsCatalogRangeLabel": "intervallo salvato", + "visitsTitle": "Visite mediche", + "visitsColDate": "Data", + "visitsColPractice": "Studio", + "visitsColKind": "Tipo", + "visitsColReason": "Motivo", + "visitsColOutcome": "Esito", + "visitsColConditions": "Malattie" }, "errorBoundary": { "title": "Qualcosa è andato storto", @@ -478,7 +485,8 @@ "milestone": "Traguardi", "ecg_new_recording": "Nuove registrazioni ECG", "tension_window": "Periodi di tensione", - "same_time_baseline": "Attività alla stessa ora" + "same_time_baseline": "Attività alla stessa ora", + "upcoming_visit": "Appuntamenti" }, "layoutReset": "Ripristina i valori predefiniti", "layoutResetTitle": "Reimpostare il layout della dashboard?", @@ -3764,6 +3772,25 @@ "statement": "Nei giorni con «{label}» l'umore è stato in media {direction}: {withAvg} contro {withoutAvg}.", "counts": "{withDays} giorni con, {withoutDays} giorni senza", "caveat": "Questo mostra una relazione, non una causa." + }, + "prognosis": { + "title": "La tua valutazione e ciò che suggeriscono le tue giornate passate", + "selfLabel": "La tua valutazione", + "expectedLabel": "Atteso in base alle tue abitudini", + "band": "Intervallo da {low} a {high}", + "statement": "In base alle tue abitudini finora, per {date} ci si sarebbe attesi un valore intorno a {value}. Ricavato da {n} giornate che contengono tutto ciò che serve al confronto.", + "provisional": "Ancora provvisorio: lo schema si appoggia su poche giornate.", + "notToday": "Questo descrive {date}, non oggi.", + "deviationWithin": "La tua valutazione rientra in questo intervallo.", + "deviationAbove": "La tua valutazione è {amount} punti sopra questo intervallo.", + "deviationBelow": "La tua valutazione è {amount} punti sotto questo intervallo.", + "weightedTitle": "Che cosa ha pesato di più nel confronto di questa giornata", + "weightedUp": "{label} — contava verso un valore atteso più alto", + "weightedDown": "{label} — contava verso un valore atteso più basso", + "caveat": "È un confronto con le tue giornate passate, non un'affermazione su come una giornata sia andata a finire così.", + "learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.", + "noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Registrare il contesto della giornata rende confrontabile una parte maggiore.", + "featureValue": "{field}: {value}" } }, "derived": { @@ -5941,7 +5968,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.", @@ -8107,7 +8136,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", @@ -9261,7 +9291,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", @@ -9278,7 +9309,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", @@ -9571,6 +9603,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": { @@ -9666,7 +9703,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": { @@ -10027,6 +10065,98 @@ "hospital": "Ospedale", "therapy": "Seduta di terapia", "other": "Appuntamento medico" + }, + "sectionTitle": "Appuntamenti", + "sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.", + "add": "Appuntamento", + "loadError": "Impossibile caricare gli appuntamenti.", + "emptyTitle": "Nessun appuntamento registrato", + "emptyDescription": "Basta una data. Tutto il resto è facoltativo.", + "upcomingHeading": "In arrivo", + "pastHeading": "Passati", + "createTitle": "Registra un appuntamento", + "editTitle": "Modifica appuntamento", + "formDescription": "Solo la data è obbligatoria.", + "created": "Appuntamento salvato.", + "updated": "Appuntamento aggiornato.", + "saveFailed": "Non è stato possibile salvare l'appuntamento.", + "checkupNotClosed": "Appuntamento salvato. Il controllo resta aperto: il tuo accesso non lo comprende.", + "status": { + "planned": "Previsto", + "done": "Effettuato", + "cancelled": "Annullato", + "noShow": "Non presentato" + }, + "card": { + "documentCount": "documenti collegati", + "labCount": "esami di laboratorio collegati", + "conditionCount": "malattie collegate" + }, + "form": { + "date": "Data e ora", + "dateDerivesStatusHint": "Una data futura è un appuntamento fissato.", + "status": "Stato", + "practitioner": "Studio o medico", + "practitionerHint": "Facoltativo: puoi aggiungere il nome più tardi.", + "practitionerNone": "Non indicato", + "practitionerSearch": "Cerca nome o studio", + "practitionerNoMatch": "Nessuna voce trovata.", + "practitionerAdd": "Aggiungi nuova", + "practitionerClear": "Rimuovi la selezione", + "kind": "Tipo", + "reason": "Motivo", + "outcome": "Esito", + "outcomeReveal": "Aggiungi un esito", + "linkDocuments": "Documenti", + "linkLabResults": "Esami di laboratorio", + "linkConditions": "Malattie", + "linkNothingToOffer": "Per ora non c'è nulla da collegare." + }, + "deleteTitle": "Eliminare questo appuntamento?", + "deleteDescription": "La voce viene rimossa. Se l'appuntamento è fissato, anche il promemoria sparisce.", + "deleteFailed": "Non è stato possibile eliminare l'appuntamento.", + "suggestion": { + "single": "Appartiene a questo appuntamento?", + "choose": "A quale appuntamento appartiene?" + }, + "episodeCard": { + "title": "Appuntamenti collegati", + "emptyLine": "Nessun appuntamento ancora collegato a questa malattia.", + "add": "Aggiungi un appuntamento" + }, + "dashboard": { + "nextVisit": "Prossimo appuntamento: {what} il {when}" } + }, + "checkups": { + "viewSwitchLabel": "Mostra controlli o appuntamenti", + "tabVorsorge": "Controlli", + "tabVisits": "Appuntamenti" + }, + "practitioners": { + "title": "Studi e medici", + "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.", + "linkLabel": "Rubrica", + "add": "Voce", + "searchPlaceholder": "Cerca nome o studio", + "loadError": "Impossibile caricare la rubrica.", + "emptyTitle": "Nessuna voce", + "emptyDescription": "Aggiungi uno studio per poterlo scegliere quando registri un appuntamento.", + "noMatchTitle": "Nessuna corrispondenza", + "noMatchDescription": "Nessuna voce corrisponde a questa ricerca.", + "createTitle": "Aggiungi una voce", + "editTitle": "Modifica la voce", + "formDescription": "Solo il nome è obbligatorio.", + "name": "Nome", + "specialty": "Specializzazione", + "practice": "Studio", + "location": "Indirizzo", + "phone": "Telefono", + "note": "Nota", + "noteHint": "Salvata cifrata.", + "saveFailed": "Non è stato possibile salvare la voce.", + "deleteTitle": "Eliminare questa voce?", + "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", + "deleteFailed": "Non è stato possibile eliminare la voce." } } diff --git a/messages/pl.json b/messages/pl.json index 4b433e175..dc06dc2cc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -283,7 +283,14 @@ "familyHistoryColCondition": "Schorzenie", "familyHistoryColAgeAtOnset": "Wiek zachorowania", "labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:", - "labsCatalogRangeLabel": "zapisany zakres" + "labsCatalogRangeLabel": "zapisany zakres", + "visitsTitle": "Wizyty lekarskie", + "visitsColDate": "Data", + "visitsColPractice": "Gabinet", + "visitsColKind": "Rodzaj", + "visitsColReason": "Powód", + "visitsColOutcome": "Wynik", + "visitsColConditions": "Choroby" }, "errorBoundary": { "title": "Coś poszło nie tak", @@ -478,7 +485,8 @@ "milestone": "Kamienie milowe", "ecg_new_recording": "Nowe zapisy EKG", "tension_window": "Okresy napięcia", - "same_time_baseline": "Aktywność o tej samej porze" + "same_time_baseline": "Aktywność o tej samej porze", + "upcoming_visit": "Wizyty" }, "layoutReset": "Przywróć wartości domyślne", "layoutResetTitle": "Zresetować układ pulpitu?", @@ -3764,6 +3772,25 @@ "statement": "W dniach z „{label}” nastrój był średnio {direction}: {withAvg} wobec {withoutAvg}.", "counts": "{withDays} dni z, {withoutDays} dni bez", "caveat": "To pokazuje związek, nie przyczynę." + }, + "prognosis": { + "title": "Twoja ocena i to, co sugerują twoje dotychczasowe dni", + "selfLabel": "Twoja ocena", + "expectedLabel": "Oczekiwane na podstawie twoich wzorców", + "band": "Zakres od {low} do {high}", + "statement": "Na podstawie twoich dotychczasowych wzorców dla {date} należałoby oczekiwać wartości około {value}. Wyliczone z {n} dni, które zawierają wszystko, czego potrzebuje porównanie.", + "provisional": "Jeszcze wstępne: wzorzec opiera się na krótkim odcinku dni.", + "notToday": "To opisuje {date}, nie dzisiaj.", + "deviationWithin": "Twoja własna ocena mieści się w tym zakresie.", + "deviationAbove": "Twoja własna ocena leży {amount} punktów powyżej tego zakresu.", + "deviationBelow": "Twoja własna ocena leży {amount} punktów poniżej tego zakresu.", + "weightedTitle": "Co najbardziej zaważyło w porównaniu tego dnia", + "weightedUp": "{label} — liczyło się w stronę wyższej oczekiwanej wartości", + "weightedDown": "{label} — liczyło się w stronę niższej oczekiwanej wartości", + "caveat": "To porównanie z twoimi wcześniejszymi dniami, nie stwierdzenie, jak doszło do takiego przebiegu dnia.", + "learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.", + "noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z tym dzień. Zapisywanie kontekstu dnia sprawia, że więcej da się porównać.", + "featureValue": "{field}: {value}" } }, "derived": { @@ -5941,7 +5968,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.", @@ -8107,7 +8136,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", @@ -9261,7 +9291,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ć", @@ -9278,7 +9309,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", @@ -9571,6 +9603,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": { @@ -9666,7 +9703,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": { @@ -10027,6 +10065,98 @@ "hospital": "Szpital", "therapy": "Sesja terapeutyczna", "other": "Wizyta lekarska" + }, + "sectionTitle": "Wizyty", + "sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.", + "add": "Wizyta", + "loadError": "Nie udało się wczytać wizyt.", + "emptyTitle": "Brak zapisanych wizyt", + "emptyDescription": "Wystarczy data. Reszta jest opcjonalna.", + "upcomingHeading": "Nadchodzące", + "pastHeading": "Minione", + "createTitle": "Zapisz wizytę", + "editTitle": "Edytuj wizytę", + "formDescription": "Wymagana jest tylko data.", + "created": "Wizyta zapisana.", + "updated": "Wizyta zaktualizowana.", + "saveFailed": "Nie udało się zapisać wizyty.", + "checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte — twój dostęp tego nie obejmuje.", + "status": { + "planned": "Zaplanowana", + "done": "Odbyta", + "cancelled": "Odwołana", + "noShow": "Niewykorzystana" + }, + "card": { + "documentCount": "powiązane dokumenty", + "labCount": "powiązane wyniki badań", + "conditionCount": "powiązane choroby" + }, + "form": { + "date": "Data i godzina", + "dateDerivesStatusHint": "Data w przyszłości oznacza umówiony termin.", + "status": "Status", + "practitioner": "Gabinet lub lekarz", + "practitionerHint": "Opcjonalne — nazwę możesz dodać później.", + "practitionerNone": "Nie podano", + "practitionerSearch": "Szukaj nazwiska lub gabinetu", + "practitionerNoMatch": "Nie znaleziono wpisu.", + "practitionerAdd": "Dodaj nowy", + "practitionerClear": "Usuń wybór", + "kind": "Rodzaj", + "reason": "Powód", + "outcome": "Wynik", + "outcomeReveal": "Dodaj wynik", + "linkDocuments": "Dokumenty", + "linkLabResults": "Wyniki badań", + "linkConditions": "Choroby", + "linkNothingToOffer": "Na razie nie ma nic do powiązania." + }, + "deleteTitle": "Usunąć tę wizytę?", + "deleteDescription": "Wpis zostanie usunięty. Jeśli wizyta jest umówiona, przypomnienie o niej też zniknie.", + "deleteFailed": "Nie udało się usunąć wizyty.", + "suggestion": { + "single": "Czy to należy do tej wizyty?", + "choose": "Do której wizyty to należy?" + }, + "episodeCard": { + "title": "Powiązane wizyty", + "emptyLine": "Do tej choroby nie przypisano jeszcze żadnej wizyty.", + "add": "Dodaj wizytę" + }, + "dashboard": { + "nextVisit": "Najbliższa wizyta: {what}, {when}" } + }, + "checkups": { + "viewSwitchLabel": "Pokaż badania profilaktyczne lub wizyty", + "tabVorsorge": "Profilaktyka", + "tabVisits": "Wizyty" + }, + "practitioners": { + "title": "Gabinety i lekarze", + "description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.", + "linkLabel": "Książka adresowa", + "add": "Wpis", + "searchPlaceholder": "Szukaj nazwiska lub gabinetu", + "loadError": "Nie udało się wczytać książki adresowej.", + "emptyTitle": "Brak wpisów", + "emptyDescription": "Dodaj gabinet, aby móc go wybrać przy zapisywaniu wizyty.", + "noMatchTitle": "Brak wyników", + "noMatchDescription": "Żaden wpis nie pasuje do tego wyszukiwania.", + "createTitle": "Dodaj wpis", + "editTitle": "Edytuj wpis", + "formDescription": "Wymagana jest tylko nazwa.", + "name": "Nazwa", + "specialty": "Specjalizacja", + "practice": "Gabinet", + "location": "Adres", + "phone": "Telefon", + "note": "Notatka", + "noteHint": "Zapisywana w postaci zaszyfrowanej.", + "saveFailed": "Nie udało się zapisać wpisu.", + "deleteTitle": "Usunąć ten wpis?", + "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", + "deleteFailed": "Nie udało się usunąć wpisu." } } diff --git a/prisma/migrations/0312_mood_prediction/migration.sql b/prisma/migrations/0312_mood_prediction/migration.sql new file mode 100644 index 000000000..941713ec1 --- /dev/null +++ b/prisma/migrations/0312_mood_prediction/migration.sql @@ -0,0 +1,58 @@ +-- v1.38 — the expected value of a day, beside the day's own rating. +-- +-- One row per account per local day, holding what the account's own past days +-- say a day like this one usually looks like. It is a second reading of a day, +-- never a correction of the first: the self-assessment on `mood_entries` is +-- stored exactly as the person set it and nothing here touches it. The +-- distance between the two is the whole point of having both. +-- +-- Its own table rather than four more columns on `mood_entries`, and the +-- reason is a permission rather than a shape: a value the user must not be +-- able to overwrite has no business sitting in the row their own edit form +-- writes. One writer, one table. +-- +-- Not carried in a disaster-recovery backup. Every value here is recomputable +-- from `mood_entries` + `mood_contexts` and the measurement tables, and the +-- nightly job rebuilds it. The verdict is written down in `DERIVED_MODELS` +-- (`src/lib/export/backup-plan.ts`) where the completeness guard reads it, +-- so a restore that comes back without these rows is doing what it was told. +-- +-- No CHECK constraints on the ranges, matching `mood_contexts`: the bounds are +-- server-side, and a later change to a scale must not strand rows written +-- under the old one. + +CREATE TABLE "mood_predictions" ( + "user_id" TEXT NOT NULL, + -- YYYY-MM-DD in the account's own zone, the same key `mood_entries.date` + -- carries, so the two readings of one day join without a second timezone + -- derivation. + "date" TEXT NOT NULL, + + -- The expected pleasantness (A1), 0-10, and the band around it. The band + -- comes from the rolling-origin validation residuals, so it widens when + -- the model is bad instead of staying confidently narrow. + "predicted" DOUBLE PRECISION NOT NULL, + "ci_low" DOUBLE PRECISION, + "ci_high" DOUBLE PRECISION, + -- Complete days the fit was built from. Displayed wherever the value is. + "n" INTEGER NOT NULL, + + -- Bumped when the feature set or the fit changes, so a re-fit is legible + -- in the stored rows rather than silently changing what they mean. + "model_version" TEXT NOT NULL, + -- JSON array of { feature, contribution }: the standardised coefficient + -- times that day's standardised value. Stored so the explanation line is + -- generated from what the model actually used. + "features" TEXT NOT NULL, + "computed_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "mood_predictions_pkey" PRIMARY KEY ("user_id", "date") +); + +-- The account cascade every other user-scoped table here carries. The primary +-- key already leads with `user_id`, so the per-account sweep the job and the +-- wipe both run needs no second index. +ALTER TABLE "mood_predictions" + ADD CONSTRAINT "mood_predictions_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f3f292071..f6bb517dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -751,6 +751,7 @@ model User { googleHealthOAuthStates GoogleHealthOAuthState[] moodEntries MoodEntry[] moodContexts MoodContext[] + moodPredictions MoodPrediction[] mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") @@ -4242,6 +4243,68 @@ model MoodContext { @@map("mood_contexts") } +/// What the account's own past days say a day like this one usually looks +/// like — one row per local day, written by a job and never by a person. +/// +/// Four things about this table that a later reader would otherwise have to +/// infer, and would infer wrongly: +/// +/// 1. **Written by the `mood-prognosis-refresh` job only. Never by a route, +/// and the user cannot overwrite it.** The self-assessment on `MoodEntry` +/// and the value here are two separate readings of the same day and they +/// stay separate: the deviation between them is the entire finding, and a +/// surface that let somebody correct the prognosis into agreement would +/// destroy it. Two tables make that structural rather than a convention, +/// and `src/__tests__/mood-prediction-write-surface-guard.test.ts` holds +/// the writer set to one file. +/// 2. **`features` holds the evidence, not a hint.** It is the JSON list of +/// the standardised contributions the fit actually used for that day, so +/// the "what the model weighted" line is generated from what happened +/// rather than recomputed at render time against a model that may since +/// have been re-fitted. +/// 3. **Deliberately not in the disaster-recovery backup.** It is a derived +/// artefact, recomputable in full from `MoodEntry` + `MoodContext` and the +/// linked modules, and the nightly job rebuilds it after a restore. The +/// verdict is written down in `DERIVED_MODELS` +/// (`src/lib/export/backup-plan.ts`), where the completeness guard reads +/// it, so this is a decision rather than an omission. It IS in the wipe +/// plan: a derived row is still a statement about somebody's days. +/// 4. **`n` and the band are part of the value.** A consumer that renders +/// `predicted` without `ciLow` / `ciHigh` / `n` is a defect: a forecast +/// built from thirty days and one built from three hundred look identical +/// on screen and mean very different things. +model MoodPrediction { + userId String @map("user_id") + /// YYYY-MM-DD in the account's own zone — the same day key and the same + /// anchoring `MoodEntry.date` carries, so the two readings of a day join + /// without a second timezone derivation. + date String + + /// The expected pleasantness (A1) for that day, 0-10, on the account's own + /// past pattern. A counterfactual, never a measurement. + predicted Float + /// The band around it, from the rolling-origin validation residuals. Null + /// only when the validation produced no residuals at all, which the write + /// path treats as "no forecast" rather than as a bare point estimate. + ciLow Float? @map("ci_low") + ciHigh Float? @map("ci_high") + /// Complete days the fit was built from. Shown wherever `predicted` is. + n Int + + /// Bumped whenever the feature set or the fit changes, so a re-fit is + /// legible in the stored rows instead of silently changing their meaning. + modelVersion String @map("model_version") + /// JSON array of `{ feature, contribution }` — the standardised coefficient + /// times that day's standardised value, per feature. + features String + computedAt DateTime @map("computed_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, date]) + @@map("mood_predictions") +} + // ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ──────────────────────── // // Structured, icon-bearing tag catalog modelled on the standalone mood diff --git a/src/__tests__/copy-matches-behaviour-guard.test.ts b/src/__tests__/copy-matches-behaviour-guard.test.ts index 345f18467..da87e9644 100644 --- a/src/__tests__/copy-matches-behaviour-guard.test.ts +++ b/src/__tests__/copy-matches-behaviour-guard.test.ts @@ -1,8 +1,11 @@ /** * Copy-matches-behaviour guard. * - * Three classes of copy that drifted away from the code they describe, each - * pinned here so the pair fails loudly if either half moves again: + * Four classes of copy that must not drift away from the code they describe, + * each pinned here so the pair fails loudly if either half moves. The first + * three are repairs — copy that had already drifted. The fourth arrives with + * the feature it governs, because a wording rule that depends on review is a + * wording rule that survives until the fifth locale. * * 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the * consequence. Four dialogs promised permanence on surfaces that render @@ -224,3 +227,150 @@ describe("the glucose reference band control is translated everywhere", () => { expect(copy).toContain("never inferred"); }); }); + +/** + * 4. THE MOOD PROGNOSIS. The forecast is a comparison with somebody's own past + * days and it may never be phrased as a cause. "A value around 5.4 would + * have been expected" is what the arithmetic supports; "overtime lowered + * your mood" is not, and the difference is not a matter of taste — the model + * is a correlation over a dozen columns and a causal sentence would be a + * claim it cannot make about somebody's health. + * + * The second half is the count. A forecast built from thirty days and one + * built from three hundred render identically, so every statement that + * quotes a value has to quote what it rests on. Both halves are a test here + * rather than a review habit, because a review habit does not survive the + * fifth locale. + */ +describe("the mood prognosis never states a cause", () => { + /** + * Causal constructions, per locale. Multi-word where a single word would + * over-match — Italian `causa` is a noun in ordinary use and only `a causa + * di` is the construction being banned. + */ + const CAUSAL_PHRASES: Record = { + de: [ + "weil", + "verursacht", + "führt zu", + "sorgt für", + "wegen", + "verbessert deine", + "verschlechtert deine", + "senkt deine", + "hebt deine", + ], + en: [ + "because", + "causes", + "caused", + "leads to", + "due to", + "makes you", + "lowers your", + "raises your", + "improves your", + "worsens your", + ], + es: [ + "porque", + "provoca", + "debido a", + "a causa de", + "mejora tu", + "empeora tu", + ], + fr: [ + "parce que", + "provoque", + "en raison de", + "à cause de", + "améliore ton", + "dégrade ton", + ], + it: [ + "perché", + "provoca", + "a causa di", + "migliora il tuo", + "peggiora il tuo", + ], + pl: ["ponieważ", "powoduje", "z powodu", "wywołuje", "poprawia twój"], + }; + + /** Every leaf string under `insights.mood.prognosis`, flattened. */ + function prognosisStrings(locale: string): Array<[string, string]> { + const root = ( + bundle(locale).insights as Record> + ).mood.prognosis as Record; + expect( + root, + `${locale} carries no insights.mood.prognosis block`, + ).toBeTypeOf("object"); + return Object.entries(root).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ); + } + + for (const locale of LOCALES) { + it(`${locale} carries the block, and every string in it`, () => { + const strings = prognosisStrings(locale); + // The positive control. Every assertion below passes on an empty list, + // so the count is the evidence and the empty offender set is only the + // verdict. + expect(strings.length).toBeGreaterThanOrEqual(17); + for (const [key, value] of strings) { + expect(value.length, `${locale}/${key} is empty`).toBeGreaterThan(0); + } + }); + + it(`${locale} names no cause`, () => { + const offenders = prognosisStrings(locale) + .filter(([, value]) => + CAUSAL_PHRASES[locale].some((phrase) => + value.toLowerCase().includes(phrase), + ), + ) + .map(([key]) => key); + expect( + offenders, + `${locale} prognosis copy states a cause; the forecast is a comparison with the account's own past days and cannot support one`, + ).toEqual([]); + }); + } + + /** + * Statements that quote a value must quote what it rests on. The list is + * explicit rather than derived, so adding a statement key forces a decision + * here instead of inheriting silence. + */ + const COUNTED_STATEMENTS: Record = { + statement: ["{value}", "{n}"], + learning: ["{entries}", "{threshold}"], + noPattern: ["{entries}"], + band: ["{low}", "{high}"], + }; + + for (const locale of LOCALES) { + it(`${locale} keeps the count beside the claim`, () => { + const strings = new Map(prognosisStrings(locale)); + for (const [key, placeholders] of Object.entries(COUNTED_STATEMENTS)) { + const copy = strings.get(key); + expect(copy, `${locale}/${key} is missing`).toBeTypeOf("string"); + for (const placeholder of placeholders) { + expect( + copy, + `${locale}/${key} quotes a value without ${placeholder}; a forecast without the days behind it cannot be weighed by the reader`, + ).toContain(placeholder); + } + } + }); + } + + it("the forecast is phrased as a counterfactual, not as a measurement (EN + DE)", () => { + const en = resolve(bundle("en"), "insights.mood.prognosis.statement"); + expect(en.toLowerCase()).toContain("would have been expected"); + const de = resolve(bundle("de"), "insights.mood.prognosis.statement"); + expect(de.toLowerCase()).toContain("zu erwarten gewesen"); + }); +}); diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 504c2b256..347983310 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -381,6 +381,26 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["AlertDialog"], }, + // ── Visits and the address book ───────────────────────────────────────── + { + file: "hooks/use-encounters.ts", + destroys: + "a visit with its reason, outcome and the links it collected — and, for a booked one, the appointment reminder that would have nudged about it", + triggers: ["components/encounters/encounter-sheet.tsx"], + // The route soft-deletes and a restore route exists, but nothing in the + // product reaches it yet, so from a person's side the visit is gone. This + // becomes "restorable" the day a surface offers the undo, not before. + recovery: "tombstoned-no-restore", + confirm: ["AlertDialog"], + }, + { + file: "hooks/use-practitioners.ts", + destroys: "one address-book entry", + triggers: ["components/practitioners/practitioner-list.tsx"], + recovery: "tombstoned-no-restore", + confirm: ["AlertDialog"], + }, + // ── Cycle ─────────────────────────────────────────────────────────────── { file: "components/cycle/use-cycle.ts", diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 7b7cbf95e..d9a137b7b 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => { it("reads a plausible catalogue", () => { // Sanity floor: a silently degraded catalogue would pass everything below // vacuously. - expect(ALL_LEAF_IDS.length).toBe(91); + expect(ALL_LEAF_IDS.length).toBe(92); expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77); - expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(14); + expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(15); }); it("places every leaf in exactly one group", () => { diff --git a/src/__tests__/mood-prediction-write-surface-guard.test.ts b/src/__tests__/mood-prediction-write-surface-guard.test.ts new file mode 100644 index 000000000..af83e70d0 --- /dev/null +++ b/src/__tests__/mood-prediction-write-surface-guard.test.ts @@ -0,0 +1,158 @@ +/** + * `MoodPrediction` has exactly one writer, and it is not a request. + * + * The product rule underneath this guard: a day carries two readings — the + * one the person gave it and the one their own past days imply — and they are + * never merged. The self-assessment leads and is stored exactly as it was + * given; the prognosis is computed, carries a band and a case count, and the + * user cannot overwrite it. A route that wrote `moodPrediction` would make the + * second value editable, and an editable prognosis is not a prognosis: the + * deviation between the two, which is the only thing either number is for, + * would quietly become a number somebody could tune to zero. + * + * The second failure the single writer prevents is the one the cycle module + * already paid for: a forecast written as a side effect of a page view makes + * the output a function of browsing rather than of data + * (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`). + * + * **Every matcher here asserts a non-zero match count.** Legs (a) and (b) + * expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look + * identical from the outside — which is precisely how the bearer-scope guard + * stayed green for months while matching nothing at all. Each leg therefore + * pins a separate positive: the number of files SCANNED, and the write calls + * the legitimate writer really does make. `describe("the matcher sees what it + * looks for")` proves the regex against synthetic sources, including the + * broken-across-lines form that defeated an earlier guard in this repository. + * + * **What this guard structurally cannot see, stated like the session-surface + * guard states its own limit.** The matcher keys on a literal `moodPrediction` + * delegate access. The "Delete All Data" wipe DOES delete this table — the + * model is in `WIPE_MODELS` (`src/lib/data-wipe/wipe-plan.ts`) — but it does so + * through a delegate resolved dynamically from the model name (`client[key] + * .deleteMany`), so no `moodPrediction.deleteMany(` literal exists for the + * matcher to catch, and that deletion is correctly NOT counted as a writer. + * The wipe path's own completeness is proven elsewhere, by + * `data-wipe-completeness.test.ts` enumerating the schema; this guard is only + * the one about request-time writers, and it cannot be the gate on a dynamic + * delegate. A future writer that reached the table through a computed property + * would slip this matcher for the same reason. + */ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const SRC = join(process.cwd(), "src"); + +/** + * A write against the `moodPrediction` delegate, on any receiver. + * + * Whitespace-tolerant in every joint: Prettier breaks a long call across + * lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line + * is a matcher that stops matching the day the line gets long enough. The + * receiver is left open (`prisma`, `tx`, `db`, the worker client) because + * pinning one name would let a rename walk straight past. + */ +const WRITE_CALL = + /\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g; + +/** The one file allowed to write, relative to `src/`. */ +const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts"); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "generated" || entry.name === "node_modules") continue; + out.push(...sourceFiles(full)); + continue; + } + if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { + out.push(full); + } + } + return out; +} + +function writeCallsIn(source: string): string[] { + return [...source.matchAll(WRITE_CALL)].map((m) => m[1]); +} + +const ALL_FILES = sourceFiles(SRC); +const PRODUCTION_FILES = ALL_FILES.filter( + (f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"), +); + +describe("MoodPrediction is written by one file", () => { + it("scans a plausible number of source files", () => { + // The positive control for legs (a) and (b) below, both of which pass on + // an empty result. If this collapses, they are asserting nothing. + expect(ALL_FILES.length).toBeGreaterThan(1000); + expect(PRODUCTION_FILES.length).toBeGreaterThan(800); + }); + + it("(a) no route, page or component writes it", () => { + const offenders = PRODUCTION_FILES.filter( + (f) => + f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")), + ).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0); + + expect( + offenders.map((f) => f.slice(SRC.length + 1)), + "a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it", + ).toEqual([]); + }); + + it("(b) the writer set is exactly the refresh job", () => { + const writers = PRODUCTION_FILES.filter( + (f) => writeCallsIn(readFileSync(f, "utf8")).length > 0, + ).map((f) => f.slice(SRC.length + 1)); + + expect(writers.sort()).toEqual([SOLE_WRITER]); + }); + + it("(c) that writer really does write — the matcher is not matching nothing", () => { + expect( + existsSync(join(SRC, SOLE_WRITER)), + `${SOLE_WRITER} does not exist — the table has a guard and no writer`, + ).toBe(true); + const source = readFileSync(join(SRC, SOLE_WRITER), "utf8"); + const calls = writeCallsIn(source); + expect( + calls.length, + "the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth", + ).toBeGreaterThan(0); + // It replaces rather than accumulates: a pass writes the current window + // and clears whatever fell out of it. + expect(calls).toContain("deleteMany"); + }); +}); + +describe("the matcher sees what it looks for", () => { + it("matches a call Prettier broke across lines", () => { + const wrapped = [ + "await prisma.moodPrediction", + " .upsert(", + " { where: { userId_date: { userId, date } } },", + " );", + ].join("\n"); + expect(writeCallsIn(wrapped)).toEqual(["upsert"]); + }); + + it("matches through an aliased client", () => { + expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([ + "deleteMany", + ]); + }); + + it("does not match a read", () => { + expect( + writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"), + ).toEqual([]); + }); + + it("does not match a different model", () => { + expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]); + }); +}); diff --git a/src/__tests__/shared-record-navigation.test.tsx b/src/__tests__/shared-record-navigation.test.tsx index 3413dc1a3..65cf82ca5 100644 --- a/src/__tests__/shared-record-navigation.test.tsx +++ b/src/__tests__/shared-record-navigation.test.tsx @@ -44,12 +44,44 @@ describe("shared-record navigation", () => { it("keeps profile facts outside Settings and makes profile the scoped doorway", () => { const navigation = resolveSharedRecordNavigation(["profile"]); - expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual(["/profile"]); - expect(navigation.destinationHrefs).toEqual(["/profile"]); + expect(SHARED_RECORD_DOMAIN_ROUTE_FAMILIES.profile).toEqual([ + "/profile", + "/checkups", + ]); + expect(navigation.destinationHrefs).toEqual(["/profile", "/checkups"]); expect(navigation.allowsPath("/profile")).toBe(true); expect(navigation.allowsPath("/settings/anamnesis")).toBe(false); }); + it("presents the checkups page under both domains whose content it shows", () => { + // The page holds two things: the preventive-care list, which is a + // `measurements` read, and the visits section, which is `profile`. A grant + // naming either one must be able to reach it; a grant naming neither must + // not. Before the visits section the `measurements` omission was invisible, + // because a whole-record grant admits every path regardless. + expect( + resolveSharedRecordNavigation(["measurements"]).allowsPath("/checkups"), + ).toBe(true); + expect( + resolveSharedRecordNavigation(["profile"]).allowsPath("/checkups"), + ).toBe(true); + expect(resolveSharedRecordNavigation(null).allowsPath("/checkups")).toBe( + true, + ); + expect( + resolveSharedRecordNavigation(["labs"]).allowsPath("/checkups"), + ).toBe(false); + + // Listed twice, offered once: a grant holding both domains must not put + // two identical entries in the navigation. + expect( + resolveSharedRecordNavigation([ + "measurements", + "profile", + ]).destinationHrefs.filter((href) => href === "/checkups"), + ).toEqual(["/checkups"]); + }); + it("uses the active record scope instead of the actor's module flags", () => { const actorModules = { medications: false, diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 4af6c2557..60fbc51a3 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -891,6 +891,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "What the sleep, activity, vitals and illness modules already hold for one of the record's days, read-only. It is reached from a mood surface and it is NOT a mind read: the payload is sleep minutes, steps, active energy, resting heart rate, heart-rate variability and an illness day-log with its symptom keys. Declared `mind` it let a mood-only delegate walk arbitrary dates and read two sections the owner had declined to share, with no entry needing to exist for the date. Admitted on the whole-record grant only, like the other cross-section reads; a scoped grant is refused rather than served a filtered day.", }, + "app/api/mood/prognosis/route.ts": { + domain: "record", + why: "The day's two readings — the record's own rating and what its past days imply — read-only. It is NOT a mind read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and the payload names the features that carried weight, so a reader sees that sleep or steps or resting heart rate mattered on a given day. Declared `mind` it would hand a mood-only delegate a derived view of sections the owner declined to share. Admitted on the whole-record grant only, like the linked-day read beside it; a scoped grant is refused rather than served a filtered contribution list, which would falsely read as \"the model weighted only the mood fields\".", + }, "app/api/mood-entries/[id]/route.ts": { domain: "mind", why: "One mood entry of the record, fetch-then-guard against the resolved user.", @@ -935,6 +939,10 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "One visit of the record, fetch-then-guard against the resolved user. The links it resolves are read through the link service, which narrows both ends to the same resolved id.", }, + "app/api/encounters/suggest/route.ts": { + domain: "profile", + why: "Which of the record's visits a document or lab panel dated around a given day belongs to. A read over the same rows the visit list serves, reduced to a verdict — the caller learns nothing about the record it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.", + }, "app/api/practitioners/route.ts": { domain: "profile", why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.", @@ -1860,9 +1868,13 @@ const ACTOR_ROUTES: Record = { * managed-record configuration adds one more: 196 → 197. The bounded * read-only profile summary makes the next explicit admission: 197 → 198. * The two initial Cycle reads follow as separate, scoped admissions: 198 → 200. - * The mood day's linked figures, read-only, add one: 200 → 201. + * The 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. The visit-suggestion read, which serves the + * three incidental-linking moments from the same rule, adds one: 214 -> 215. */ -const FROZEN_ENTRY_COUNT = 213; +const FROZEN_ENTRY_COUNT = 215; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts index 6adcd7b5b..d4c5e77ef 100644 --- a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -23,6 +23,11 @@ vi.mock("@/lib/db", () => ({ deleteMany: vi.fn(), createMany: vi.fn(), }, + encounterDocumentLink: { + findMany: vi.fn(), + deleteMany: vi.fn(), + createMany: vi.fn(), + }, documentContentIndex: { findUnique: vi.fn(), }, @@ -32,6 +37,9 @@ vi.mock("@/lib/db", () => ({ illnessEpisode: { findMany: vi.fn(), }, + encounter: { + findMany: vi.fn(), + }, appSettings: { findUnique: vi.fn(), }, @@ -129,6 +137,10 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/documents/inbound/[id]/restore/route.ts b/src/app/api/documents/inbound/[id]/restore/route.ts index 1d43a8c60..68f847f48 100644 --- a/src/app/api/documents/inbound/[id]/restore/route.ts +++ b/src/app/api/documents/inbound/[id]/restore/route.ts @@ -15,7 +15,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; -import { loadConditionLinks } from "@/lib/documents/links"; +import { + loadConditionLinks, + loadDocumentEncounterLinks, +} from "@/lib/documents/links"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -83,9 +86,20 @@ export const POST = apiHandler( if (row.deletedAt === null) { // Already live — restoring twice is a no-op, not an error (the undo // toast may fire after a concurrent restore). - const links = await loadConditionLinks(user.id, [row.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [row.id]), + loadDocumentEncounterLinks(user.id, [row.id]), + ]); return apiSuccess( - serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []), + serialiseDocumentDetail( + row, + row.facts, + links.get(row.id) ?? [], + false, + null, + false, + visitLinks.get(row.id) ?? [], + ), ); } @@ -131,7 +145,10 @@ export const POST = apiHandler( omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const links = await loadConditionLinks(user.id, [restored.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [restored.id]), + loadDocumentEncounterLinks(user.id, [restored.id]), + ]); await auditLog("documents.inbound.restore", { userId: user.id, @@ -149,6 +166,10 @@ export const POST = apiHandler( restored, restored.facts, links.get(restored.id) ?? [], + false, + null, + false, + visitLinks.get(restored.id) ?? [], ), ); }, diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index cf0bd1f7c..22fef5f43 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -20,8 +20,11 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { loadConditionLinks, + loadDocumentEncounterLinks, + narrowOwnedEncounterIds, narrowOwnedEpisodeIds, replaceConditionLinks, + replaceEncounterLinks, } from "@/lib/documents/links"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; @@ -92,8 +95,9 @@ export const GET = apiHandler( }); } - const [links, contentIndex, thumbnail] = await Promise.all([ + const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, @@ -117,6 +121,7 @@ export const GET = apiHandler( contentIndex !== null, toContentIndexSource(contentIndex?.source), thumbnail !== null, + visitLinks.get(document.id) ?? [], ), ); }, @@ -169,6 +174,21 @@ export const PATCH = apiHandler( nextEpisodeIds = narrowed; } + // Replace-set visit links, same semantics and same refusal. + let nextEncounterIds: string[] | undefined; + if (parsed.data.encounterIds !== undefined) { + const narrowed = await narrowOwnedEncounterIds( + user.id, + parsed.data.encounterIds, + ); + if (narrowed === null) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + nextEncounterIds = narrowed; + } + // No mass assignment — each editable column is set explicitly only when // the client sent it. `userId` is never a body field. const data: Prisma.InboundDocumentUpdateInput = {}; @@ -189,14 +209,23 @@ export const PATCH = apiHandler( if (nextEpisodeIds !== undefined) { await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds); } + if (nextEncounterIds !== undefined) { + await replaceEncounterLinks( + prisma, + user.id, + existing.id, + nextEncounterIds, + ); + } const document = await prisma.inboundDocument.findFirstOrThrow({ where: { id: existing.id }, omit: { contentEncrypted: true }, include: { facts: { orderBy: { createdAt: "asc" } } }, }); - const [links, contentIndex, thumbnail] = await Promise.all([ + const [links, visitLinks, contentIndex, thumbnail] = await Promise.all([ loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), prisma.documentContentIndex.findUnique({ where: { documentId: document.id }, select: { source: true }, @@ -210,6 +239,7 @@ export const PATCH = apiHandler( const touched = [ ...Object.keys(data), ...(nextEpisodeIds !== undefined ? ["episodeIds"] : []), + ...(nextEncounterIds !== undefined ? ["encounterIds"] : []), ]; await auditLog("documents.inbound.update", { @@ -234,6 +264,7 @@ export const PATCH = apiHandler( contentIndex !== null, toContentIndexSource(contentIndex?.source), thumbnail !== null, + visitLinks.get(document.id) ?? [], ), ); }, diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts index 4b4f21999..dceb607f3 100644 --- a/src/app/api/documents/inbound/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -20,6 +20,8 @@ const { txCreateMany, txDocumentFindMany, txEpisodeFindMany, + txEncounterFindMany, + txEncounterLinkCreateMany, } = vi.hoisted(() => ({ txQueryRaw: vi.fn(), txCreate: vi.fn(), @@ -28,6 +30,8 @@ const { // writes, so the transaction double has to answer for them. txDocumentFindMany: vi.fn(), txEpisodeFindMany: vi.fn(), + txEncounterFindMany: vi.fn(), + txEncounterLinkCreateMany: vi.fn(), })); vi.mock("@/lib/db", () => { @@ -35,7 +39,9 @@ vi.mock("@/lib/db", () => { $queryRaw: txQueryRaw, inboundDocument: { create: txCreate, findMany: txDocumentFindMany }, illnessEpisode: { findMany: txEpisodeFindMany }, + encounter: { findMany: txEncounterFindMany }, documentConditionLink: { createMany: txCreateMany }, + encounterDocumentLink: { createMany: txEncounterLinkCreateMany }, }; return { prisma: { @@ -50,6 +56,9 @@ vi.mock("@/lib/db", () => { documentConditionLink: { findMany: vi.fn(), }, + encounterDocumentLink: { + findMany: vi.fn(), + }, documentContentIndex: { findMany: vi.fn(), }, @@ -59,6 +68,9 @@ vi.mock("@/lib/db", () => { illnessEpisode: { findMany: vi.fn(), }, + encounter: { + findMany: vi.fn(), + }, appSettings: { findUnique: vi.fn(), }, @@ -183,6 +195,12 @@ beforeEach(() => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); + txEncounterFindMany.mockResolvedValue([]); + txEncounterLinkCreateMany.mockResolvedValue({ count: 0 }); vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue( [] as never, ); diff --git a/src/app/api/documents/inbound/bulk/route.ts b/src/app/api/documents/inbound/bulk/route.ts index 35c141934..a57f49df9 100644 --- a/src/app/api/documents/inbound/bulk/route.ts +++ b/src/app/api/documents/inbound/bulk/route.ts @@ -1,9 +1,11 @@ /** * Document vault: bulk actions over up to 100 owner-scoped documents. * - * `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions: - * `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition - * links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A + * `{ ids[], action, kind?, episodeId?, encounterId? }` → a per-id result + * array. Actions: `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` + * (condition links), `linkEncounter` / `unlinkEncounter` (visit links, the + * same table the visit's own sheet writes from the other end), + * `delete` (tombstone, undo-able), `restore` (clear tombstone). A * partial failure never aborts the batch — each id reports `ok` or a short * machine reason. Every id is narrowed to the caller; a foreign id reads as * "notFound", exactly like the single-document routes. @@ -68,7 +70,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (!parsed.success) { return returnAllZodIssues(parsed.error, 422); } - const { ids, action, kind, episodeId } = parsed.data; + const { ids, action, kind, episodeId, encounterId } = parsed.data; const uniqueIds = [...new Set(ids)]; // Episode actions: the target episode must be a LIVE episode of the caller. @@ -84,6 +86,21 @@ export const POST = apiHandler(async (request: NextRequest) => { } } + // Visit actions: the target visit must be a LIVE visit of the caller. Same + // shape as the episode gate above — the endpoint is checked once, before the + // batch, so a foreign id costs one refusal rather than a hundred. + if (action === "linkEncounter" || action === "unlinkEncounter") { + const encounter = await prisma.encounter.findFirst({ + where: { id: encounterId!, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!encounter) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + } + // One owner-scoped sweep resolves every id's current state; per-id work // then never touches a row the caller does not own. const owned = await prisma.inboundDocument.findMany({ @@ -136,6 +153,33 @@ export const POST = apiHandler(async (request: NextRequest) => { }); break; } + case "linkEncounter": { + if (row.deletedAt !== null) { + results.push({ id, ok: false, error: "notFound" }); + continue; + } + await linkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "encounter", + targetIds: [encounterId!], + }); + break; + } + case "unlinkEncounter": { + // No tombstone check, matching `unlinkEpisode`: clearing a filing on + // a document the account has since deleted must stay possible, or + // the filing is unreachable. + await unlinkTargets(prisma, { + userId: user.id, + sourceKind: "document", + sourceId: id, + targetKind: "encounter", + targetIds: [encounterId!], + }); + break; + } case "delete": { if (row.deletedAt !== null) { // Already tombstoned — deleting twice is a no-op success. diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 1a743ad68..60d26ffdf 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -39,6 +39,8 @@ import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; import { loadConditionLinks, + loadDocumentEncounterLinks, + narrowOwnedEncounterIds, narrowOwnedEpisodeIds, } from "@/lib/documents/links"; import { @@ -116,8 +118,9 @@ async function duplicateResponse( userId: string, existing: SerialisableDocument, ): Promise { - const [links, groups] = await Promise.all([ + const [links, visitLinks, groups] = await Promise.all([ loadConditionLinks(userId, [existing.id]), + loadDocumentEncounterLinks(userId, [existing.id]), prisma.extractedFact.groupBy({ by: ["status"], where: { userId, documentId: existing.id }, @@ -140,6 +143,10 @@ async function duplicateResponse( existing, { factCount, pendingCount }, links.get(existing.id) ?? [], + false, + null, + false, + visitLinks.get(existing.id) ?? [], ), error: null, meta: { duplicate: true }, @@ -212,17 +219,21 @@ async function processUpload( return apiError("Field 'file' must be a file", 422); } - // Optional metadata (title / kind / documentDate / episodeIds). The file is - // read separately; these are the form fields beside it. `episodeIds` may be - // repeated. + // Optional metadata (title / kind / documentDate / episodeIds / + // encounterIds). The file is read separately; these are the form fields + // beside it. Both id lists may be repeated. const rawEpisodeIds = formData .getAll("episodeIds") .filter((v): v is string => typeof v === "string"); + const rawEncounterIds = formData + .getAll("encounterIds") + .filter((v): v is string => typeof v === "string"); const parsed = documentCreateSchema.safeParse({ title: formData.get("title") ?? undefined, kind: formData.get("kind") ?? undefined, documentDate: formData.get("documentDate") ?? undefined, episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined, + encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined, }); if (!parsed.success) { return apiError("Invalid document metadata", 422, { @@ -268,6 +279,19 @@ async function processUpload( }); } + // Same for the visit ids the review step offered. A refusal rather than a + // silent drop: the person saw a visit named and would otherwise watch the + // upload succeed without the link they asked for. + const encounterIds = await narrowOwnedEncounterIds( + user.id, + parsed.data.encounterIds ?? [], + ); + if (encounterIds === null) { + return apiError("Visit not found", 404, { + errorCode: "documents.inbound.encounterNotFound", + }); + } + // sha256 of the PLAINTEXT for same-user duplicate detection. const contentSha256 = createHash("sha256").update(buffer).digest("hex"); @@ -346,6 +370,15 @@ async function processUpload( targetIds: episodeIds, }); } + if (encounterIds.length > 0) { + await linkTargets(tx, { + userId: user.id, + sourceKind: "document", + sourceId: created.id, + targetKind: "encounter", + targetIds: encounterIds, + }); + } return created; }); } catch (err) { @@ -382,6 +415,7 @@ async function processUpload( byteSize: document.byteSize, servingClass: detected.servingClass, linked: episodeIds.length, + linkedVisits: encounterIds.length, }, }); @@ -406,12 +440,19 @@ async function processUpload( // because of it. Only fresh inserts reach here (a duplicate returns early). void enqueueDocumentSummary(user.id, document.id); - const links = await loadConditionLinks(user.id, [document.id]); + const [links, visitLinks] = await Promise.all([ + loadConditionLinks(user.id, [document.id]), + loadDocumentEncounterLinks(user.id, [document.id]), + ]); return apiSuccess( serialiseDocument( document, { factCount: 0, pendingCount: 0 }, links.get(document.id) ?? [], + false, + null, + false, + visitLinks.get(document.id) ?? [], ), 201, ); @@ -481,8 +522,19 @@ export const GET = apiHandler(async (request: Request) => { errorCode: "documents.inbound.invalidQuery", }); } - const { q, kind, episodeId, year, from, to, sort, order, cursor, limit } = - parsed.data; + const { + q, + kind, + episodeId, + encounterId, + year, + from, + to, + sort, + order, + cursor, + limit, + } = parsed.data; const where: Prisma.InboundDocumentWhereInput = { userId: user.id, @@ -490,6 +542,10 @@ export const GET = apiHandler(async (request: Request) => { }; if (kind && kind.length > 0) where.kind = { in: kind }; if (episodeId) where.conditionLinks = { some: { episodeId } }; + // The visit filter reads the SAME table the visit's own sheet writes, from + // the document side. Filtering rather than post-filtering: a page of fifty + // documents narrowed in Node would page wrongly. + if (encounterId) where.encounterLinks = { some: { encounterId } }; if (year !== undefined) { where.documentDate = { gte: new Date(Date.UTC(year, 0, 1)), @@ -576,10 +632,16 @@ export const GET = apiHandler(async (request: Request) => { } // Condition links for the page in ONE grouped query (no N+1). - const linkMap = await loadConditionLinks( - user.id, - page.map((d) => d.id), - ); + const [linkMap, visitLinkMap] = await Promise.all([ + loadConditionLinks( + user.id, + page.map((d) => d.id), + ), + loadDocumentEncounterLinks( + user.id, + page.map((d) => d.id), + ), + ]); // Which of the page's documents have a content index (drives the searchable // status + the provenance the UI reads to tell an AI-read document from a @@ -610,7 +672,9 @@ export const GET = apiHandler(async (request: Request) => { count: page.length, sort, order, - filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year), + filtered: Boolean( + q || (kind && kind.length > 0) || episodeId || encounterId || year, + ), }, }); @@ -623,6 +687,7 @@ export const GET = apiHandler(async (request: Request) => { indexSources.has(doc.id), toContentIndexSource(indexSources.get(doc.id)), thumbnailIds.has(doc.id), + visitLinkMap.get(doc.id) ?? [], ), ), nextCursor, diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index ab5067df5..85797154e 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -36,6 +36,7 @@ import { ENCOUNTER_INCLUDE, actingDomainVisibility, applyEncounterLinks, + loadEncounterLinksForMany, closeCheckupForVisit, resolveClosableReminder, resolveOwnedPractitioner, @@ -50,7 +51,7 @@ import { } from "@/lib/export/restore-skips"; export const GET = apiHandler(async (request: NextRequest) => { - const { user } = await requireRecordAuth("read", "profile"); + const { user, grantId } = await requireRecordAuth("read", "profile"); const params = new URL(request.url).searchParams; const parsed = encounterListQuerySchema.safeParse({ @@ -58,6 +59,7 @@ export const GET = apiHandler(async (request: NextRequest) => { to: params.get("to") ?? undefined, status: params.get("status") ?? undefined, practitionerId: params.get("practitionerId") ?? undefined, + episodeId: params.get("episodeId") ?? undefined, limit: params.get("limit") ?? undefined, }); if (!parsed.success) { @@ -75,6 +77,11 @@ export const GET = apiHandler(async (request: NextRequest) => { deletedAt: null, ...(query.status ? { status: query.status } : {}), ...(query.practitionerId ? { practitionerId: query.practitionerId } : {}), + // Filtered in SQL rather than in Node: the two arms are separately + // `take`-bounded, so narrowing after the read would page wrongly. + ...(query.episodeId + ? { conditionLinks: { some: { episodeId: query.episodeId } } } + : {}), ...(query.from || query.to ? { occurredAt: { @@ -118,14 +125,26 @@ export const GET = apiHandler(async (request: NextRequest) => { }), ]); + // The links ride the list. The card counts what a visit produced, and the + // edit sheet seeds its pickers from the row it was handed — a list without + // them makes the first read zero and the second unfile everything on save. + // Three grouped queries for the page, not three per row. + const visible = await actingDomainVisibility(prisma, grantId); + const links = await loadEncounterLinksForMany( + prisma, + user.id, + [...upcoming, ...past].map((row) => row.id), + visible, + ); + annotate({ action: { name: "encounter.visit.list", entity_type: "encounter" }, meta: { upcoming: upcoming.length, past: past.length }, }); const body: EncounterListDTO = { - upcoming: upcoming.map((row) => toEncounterDTO(row)), - past: past.map((row) => toEncounterDTO(row)), + upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), + past: past.map((row) => toEncounterDTO(row, links.get(row.id))), }; return apiSuccess(body); }); diff --git a/src/app/api/encounters/suggest/route.ts b/src/app/api/encounters/suggest/route.ts new file mode 100644 index 000000000..84914950c --- /dev/null +++ b/src/app/api/encounters/suggest/route.ts @@ -0,0 +1,59 @@ +/** + * `GET /api/encounters/suggest?anchor=` — which visit does a record dated + * `anchor` most plausibly belong to? + * + * The three capture moments that ask this question live in the browser: the + * document upload review, the lab extraction review and the manual lab form. + * The rule they ask about — the ±7-day window, the DONE / PLANNED filter, and + * the one-pre-selects / two-offer-a-picker verdict — lives in + * `src/lib/encounters/suggest-window.ts` and is answered here so all three read + * the same answer. Handing the client a windowed list and letting it decide + * would put the rule in two places, and the copy in the browser is the one + * nobody re-reads when the rule changes. + * + * A read of the record's own visits, so it declares the same domain the visit + * list does. `userId` is narrowed from auth; the anchor is the only input and + * it is a date, not an id. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { encounterSuggestQuerySchema } from "@/lib/validations/encounters"; +import { suggestEncounterForDate } from "@/lib/encounters/suggest-window"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireRecordAuth("read", "profile"); + + const params = new URL(request.url).searchParams; + const parsed = encounterSuggestQuerySchema.safeParse({ + anchor: params.get("anchor") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "encounter.invalid", + }); + } + + const result = await suggestEncounterForDate(prisma, { + userId: user.id, + anchor: new Date(parsed.data.anchor), + }); + + annotate({ + action: { name: "encounter.visit.suggest", entity_type: "encounter" }, + meta: { + verdict: result.kind, + candidates: + result.kind === "many" + ? result.encounters.length + : result.kind === "one" + ? 1 + : 0, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 905d6c272..c442523cf 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({ moodEntry: { findMany: vi.fn() }, labResult: { findMany: vi.fn() }, illnessEpisode: { findMany: vi.fn() }, + encounter: { findMany: vi.fn() }, allergy: { findMany: vi.fn() }, familyHistoryEntry: { findMany: vi.fn() }, userHealthProfile: { findUnique: vi.fn() }, @@ -165,6 +166,7 @@ beforeEach(() => { vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never); vi.mocked(prisma.allergy.findMany).mockResolvedValue([] as never); vi.mocked(prisma.familyHistoryEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userHealthProfile.findUnique).mockResolvedValue( diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts index b8700562e..5fd00dc2b 100644 --- a/src/app/api/labs/ocr/commit/route.ts +++ b/src/app/api/labs/ocr/commit/route.ts @@ -39,6 +39,7 @@ import { linkOcrLabsToVaultDocument, type InsertedLabForLink, } from "@/lib/labs/vault-link"; +import { linkTargets } from "@/lib/links"; import { annotate } from "@/lib/logging/context"; import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome"; import { @@ -217,6 +218,23 @@ async function commitOcrRows(request: NextRequest) { ).catch(() => {}); } + // The visit the review step offered, if it offered one. ONE LINK PER RESULT + // ROW rather than one for the panel: a marker re-run on a different day + // belongs to its own visit, which is exactly why the join is m:n and not a + // scalar column on the result. Best-effort like the vault cross-link — the + // readings are the authoritative write and must not be lost to a link that + // could not be made. The link service narrows both ends to this record, so + // an id naming nothing writes nothing. + if (parsed.data.encounterId && linkable.length > 0) { + await linkTargets(prisma, { + userId: user.id, + sourceKind: "encounter", + sourceId: parsed.data.encounterId, + targetKind: "labResult", + targetIds: linkable.map((row) => row.labResultId), + }).catch(() => {}); + } + await auditLog("labs.ocr.commit", { userId: user.id, ipAddress: getClientIp(request), @@ -225,7 +243,11 @@ async function commitOcrRows(request: NextRequest) { annotate({ action: { name: "labs.ocr.committed" }, - meta: { inserted: inserted.length, skipped: skipped.length }, + meta: { + inserted: inserted.length, + skipped: skipped.length, + linkedVisit: parsed.data.encounterId ? 1 : 0, + }, }); // A lab panel just landed — resolve any "annual blood panel" reminders now diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 60e325499..b420b745e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -21,6 +21,7 @@ import { type ResolvedBiomarker, serialiseLabResult, } from "@/lib/labs/serialise"; +import { linkTargets } from "@/lib/links"; import { encryptNoteToBytes } from "@/lib/labs/store"; import { annotate } from "@/lib/logging/context"; import { @@ -190,6 +191,7 @@ async function postLabResult(request: NextRequest) { takenAt, note, source, + encounterId, } = parsed.data; // v1.18.9 — a qualitative reading carries `valueText` ("negativ" / …) instead @@ -267,6 +269,21 @@ async function postLabResult(request: NextRequest) { noteEncrypted: note ? encryptNoteToBytes(note) : null, }, }); + // The by-the-way half: file the reading against the visit the form offered, + // if it offered one. Best-effort by design — the reading is the write that + // matters, and a link that could not be made (a visit deleted between the + // suggestion and the save) must not lose it. The link service narrows both + // ends to this record, so an id naming nothing simply writes nothing. + if (encounterId) { + await linkTargets(prisma, { + userId: user.id, + sourceKind: "encounter", + sourceId: encounterId, + targetKind: "labResult", + targetIds: [created.id], + }).catch(() => {}); + } + invalidateUserHealthScore(user.id); const dto = serialiseLabResult(created, biomarker); diff --git a/src/app/api/mood/prognosis/route.ts b/src/app/api/mood/prognosis/route.ts new file mode 100644 index 000000000..8296d7ef3 --- /dev/null +++ b/src/app/api/mood/prognosis/route.ts @@ -0,0 +1,74 @@ +/** + * `GET /api/mood/prognosis` — the day's two readings, side by side. + * + * The person's own rating leads and is returned exactly as it was stored. The + * forecast rides beside it with its band, the number of days it was built + * from, and how old it is. The two are never merged: the distance between them + * is the whole finding, and a single blended number would destroy it. + * + * **This route declares `record`, not `mind`, and that is the security + * decision on it.** The payload is not only mood: the fit reads the day's + * level-B context AND the figures the sleep, activity and recovery modules + * hold, and the contributions it publishes name those features one by one — a + * reader can see that sleep or steps or resting heart rate carried weight on a + * given day, and roughly how much. Declared `mind` it would be a hole with a + * mood-shaped label on it: a delegate holding only the mind section would + * receive a derived view of two sections the owner had declined to share. The + * convention for a read that crosses sections is `record` + * (`/api/mood/linked-context`, `/api/dashboard/snapshot`, `/api/daily/digest` + * are the precedents) and a scoped grant never reaches one, so a scoped + * delegate loses the forecast entirely rather than receiving a filtered + * version of it. A filtered version would be worse: a contribution list with + * the linked features quietly removed would tell the reader that the model + * weighted only the mood fields, which is false. + * + * Nothing here writes. The rows come from the nightly job and the user cannot + * overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts` + * holds that, and this file is one of the ones it scans. + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +// The parameter is declared although the handler body reads nothing off it, +// and the reason is the exported type rather than the runtime. `apiHandler` +// infers the type of `GET` from this callback, so a zero-argument handler +// would type `GET` as taking no arguments — and Next.js, and the integration +// tests, call it WITH the request. The wide event's method and path, and the +// read-vs-write check `requireRecordAuth` runs, both come from the request the +// wrapper receives from Next.js (not from this parameter), and both fall back +// to `GET` when no request is present — so an absent request keeps a read a +// read rather than escalating it. +export const GET = apiHandler(async (_request: NextRequest) => { + // `record`: see the file header. A scoped grant is refused here by + // construction rather than served a narrowed payload. + const { user } = await requireRecordAuth("read", "record"); + + const gate = await requireModuleEnabled(user.id, "mood"); + if (!gate.enabled) return gate.response; + + const reading = await readMoodPrognosis(user.id, new Date(), user.timezone); + + annotate({ + action: { name: "mood.prognosis.read" }, + meta: reading.present + ? { + present: true, + day: reading.date, + fit_rows: reading.n, + age_days: reading.ageDays, + current: reading.current, + provisional: reading.provisional, + deviation: reading.deviation, + } + : { present: false, reason: reading.reason, entries: reading.entries }, + }); + + return apiSuccess(reading); +}); diff --git a/src/app/api/practitioners/route.ts b/src/app/api/practitioners/route.ts index 04a495b5a..bac66da4b 100644 --- a/src/app/api/practitioners/route.ts +++ b/src/app/api/practitioners/route.ts @@ -50,8 +50,27 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null, + // Both plaintext columns, because a person looks a practice up by + // whichever of the two they remember: the doctor's name on the referral + // or the practice name on the door. Searching only the name made the + // second attempt return nothing and read as "not in the address book". ...(parsed.data.q - ? { name: { contains: parsed.data.q, mode: "insensitive" as const } } + ? { + OR: [ + { + name: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + { + practice: { + contains: parsed.data.q, + mode: "insensitive" as const, + }, + }, + ], + } : {}), }, orderBy: { name: "asc" }, diff --git a/src/app/checkups/page.tsx b/src/app/checkups/page.tsx index 2105fb54b..a9d754672 100644 --- a/src/app/checkups/page.tsx +++ b/src/app/checkups/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; @@ -8,8 +8,11 @@ import { useMounted } from "@/hooks/use-mounted"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section"; -import { queryKeys } from "@/lib/query-keys"; +import { VisitsSection } from "@/components/encounters/visits-section"; +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; /** * v1.17.1 — Checkups (preventive-care) reminders page. The dedicated @@ -19,17 +22,30 @@ import { queryKeys } from "@/lib/query-keys"; * v1.18.1 — pull-to-refresh parity with every peer page (labs, mood, * measurements): a pull invalidates the measurement-reminders read so the * list + server-computed next-due repaint. + * + * The page now holds two things, split by a segmented control rather than by a + * second nav entry: the preventive-care list, and the visits. They answer the + * same question from two directions — what do I still have to arrange, and what + * did I actually attend — and the page's own sentence already covers both. A + * visit carries no module gate for the same reason this page never had one. + * + * The pull invalidates both reads. Refreshing one and leaving the other stale + * is how a page ends up showing a visit that closed a checkup still listed as + * due. */ export default function CheckupsPage() { const { isAuthenticated, isLoading } = useAuth(); const mounted = useMounted(); const queryClient = useQueryClient(); + const { t } = useTranslations(); + const [view, setView] = useState("vorsorge"); const refresh = useCallback( () => - queryClient.invalidateQueries({ - queryKey: queryKeys.measurementReminders(), - }), + invalidateKeys(queryClient, [ + queryKeys.measurementReminders(), + queryKeys.encounters(), + ]), [queryClient], ); const pull = usePullToRefresh({ onRefresh: refresh }); @@ -41,7 +57,24 @@ export default function CheckupsPage() { return (
- + + + + {t("checkups.tabVorsorge")} + + + {t("checkups.tabVisits")} + + + + + + + {/* Mounted only on the active tab, so the visits read does not fire + for a person who never opens it. */} + + +
); } diff --git a/src/app/checkups/practitioners/page.tsx b/src/app/checkups/practitioners/page.tsx new file mode 100644 index 000000000..1a4d27e7f --- /dev/null +++ b/src/app/checkups/practitioners/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { PageAuthGate } from "@/components/ui/page-auth-gate"; +import { PractitionerList } from "@/components/practitioners/practitioner-list"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; + +/** + * The address book, one level under the checkups page the visits live on. + * + * Deliberately not in Settings. The Settings IA rule is that a settings surface + * configures behaviour; a list of one's own doctors configures nothing and is + * record content like every other clinical list. Sitting under `/checkups` + * also means it inherits that page's route-family classification, so a + * `profile`-scoped delegate reaches it exactly as they reach the visits. + */ +export default function PractitionersPage() { + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + + if (!mounted || isLoading) return ; + + return ( +
+ +
+ ); +} diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 6573fff92..50886b099 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -36,6 +36,21 @@ const MoodChart = dynamic( { ssr: false, loading: () => }, ); +/** + * The day's two readings. Deferred like every other block on this route that + * is not the first paint: it reads its own endpoint and renders below the + * calendar, so nothing about it belongs in the eager graph of a page whose + * first paint is a heatmap. This route sits close to its bundle ceiling and + * every addition to it goes through the same treatment. + */ +const MoodPrognosisCard = dynamic( + () => + import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({ + default: mod.MoodPrognosisCard, + })), + { ssr: false, loading: () => null }, +); + interface ComprehensiveMoodData { moodSummary: { count: number } | null; } @@ -137,6 +152,12 @@ export default function InsightsStimmungPage() { + {/* The day's own rating beside what the account's past days imply. It + sits high because the self-assessment is the leading value on this + page, and directly under the line chart because that is where a + reader has just seen the rating it leads with. */} + + {/* The better-days Einschätzung sits directly under the Ziel card, ahead of the classification tiles and breakdowns. */} diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index a07f4ac5b..080d30412 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -20,6 +20,7 @@ import Link from "next/link"; import { Activity, + CalendarCheck, Award, CalendarClock, Footprints, @@ -55,6 +56,7 @@ const KIND_ICON: Record = { ecg_new_recording: HeartPulse, tension_window: Activity, same_time_baseline: Footprints, + upcoming_visit: CalendarCheck, }; /** diff --git a/src/components/documents/__tests__/document-bulk-bar.test.tsx b/src/components/documents/__tests__/document-bulk-bar.test.tsx index 2222b1800..2770facfe 100644 --- a/src/components/documents/__tests__/document-bulk-bar.test.tsx +++ b/src/components/documents/__tests__/document-bulk-bar.test.tsx @@ -4,22 +4,30 @@ import { renderToStaticMarkup } from "react-dom/server"; /** * The floating bulk bar, pinned via single-pass static renders: a labelled * toolbar carrying the selected count and the bulk verbs (set type, link - * condition, share, delete, clear). The link-condition menu only renders when - * the account actually has episodes to link — no dead affordance on an - * episode-free account. + * condition, file against a visit, share, delete, clear). Each link menu only + * renders when the account actually has something to link to — no dead + * affordance on an account with no episodes, and none on an account with no + * visits either. */ import { I18nProvider } from "@/lib/i18n/context"; import { DocumentBulkBar } from "../document-bulk-bar"; -function render(episodes: { id: string; label: string }[]) { +function render( + episodes: { id: string; label: string }[], + encounters: { id: string; label: string }[] = [ + { id: "v1", label: "Routine visit · 01/08/2026" }, + ], +) { return renderToStaticMarkup( {}} onLinkEpisode={() => {}} + onLinkEncounter={() => {}} onShare={() => {}} onDelete={() => {}} onClear={() => {}} @@ -36,6 +44,7 @@ describe("", () => { expect(html).toContain("3 selected"); expect(html).toContain("Change type"); expect(html).toContain("Link condition"); + expect(html).toContain("File against visit"); expect(html).toContain('data-slot="document-bulk-share"'); expect(html).toContain("Share"); expect(html).toContain("Delete"); @@ -47,4 +56,13 @@ describe("", () => { expect(html).not.toContain("Link condition"); expect(html).toContain("Change type"); }); + + it("omits the file-against-visit verb when the account has no visits", () => { + const html = render([{ id: "ep1", label: "Knee" }], []); + expect(html).not.toContain("File against visit"); + expect(html).not.toContain('data-slot="document-bulk-link-visit"'); + // The positive control: the neighbouring verb is still there, so the + // assertion above is about the visit menu and not about an empty render. + expect(html).toContain("Link condition"); + }); }); diff --git a/src/components/documents/__tests__/document-card.test.tsx b/src/components/documents/__tests__/document-card.test.tsx index 68e39ec2f..0d4aab26a 100644 --- a/src/components/documents/__tests__/document-card.test.tsx +++ b/src/components/documents/__tests__/document-card.test.tsx @@ -30,6 +30,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/__tests__/episode-documents-card.test.tsx b/src/components/documents/__tests__/episode-documents-card.test.tsx index 500f78a3b..2d5547c69 100644 --- a/src/components/documents/__tests__/episode-documents-card.test.tsx +++ b/src/components/documents/__tests__/episode-documents-card.test.tsx @@ -50,6 +50,7 @@ function doc(id: string, title: string): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [{ episodeId: "ep1", name: "Knee" }], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index 122d0afab..6947140aa 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -38,6 +38,7 @@ function doc(overrides: Partial = {}): InboundDocumentDto { factCount: 0, pendingCount: 0, conditionLinks: [], + encounterLinks: [], servingClass: "inline", hasContentIndex: false, contentIndexSource: null, diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx index 3c677d12c..45d3a2069 100644 --- a/src/components/documents/document-bulk-bar.tsx +++ b/src/components/documents/document-bulk-bar.tsx @@ -4,8 +4,13 @@ * Floating bulk-action bar for the vault's multi-select mode. Appears once * at least one document is selected, pinned above the bottom nav on phones * and near the bottom edge on desktop. Carries the selected count and the - * bulk verbs — set type, link condition, share (one link for the whole - * selection), delete (undo-able), clear — all driving the page's handlers. + * bulk verbs — set type, link condition, file against a visit, share (one + * link for the whole selection), delete (undo-able), clear — all driving the + * page's handlers. + * + * The two link verbs are the same shape and stay that way: each renders only + * when the account HAS something to link to, so an empty menu is never + * offered. * * A `role="toolbar"` with proper labels; the destructive verb sits last * before Clear so a stray tap sequence never ends on Delete. Share sits just @@ -13,7 +18,14 @@ * shell (a floating toolbar is not a Card surface): dense-tile padding `p-3` * per the standards. */ -import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react"; +import { + CalendarClock, + FolderPlus, + Share2, + Tag, + Trash2, + X, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -32,12 +44,20 @@ export interface BulkEpisodeOption { label: string; } +/** One visit the selection can be filed against. Label resolved by the page. */ +export interface BulkEncounterOption { + id: string; + label: string; +} + export function DocumentBulkBar({ selectedCount, episodes, + encounters, busy, onSetKind, onLinkEpisode, + onLinkEncounter, onShare, onDelete, onClear, @@ -45,10 +65,13 @@ export function DocumentBulkBar({ selectedCount: number; /** The caller's live illness episodes — the link-condition targets. */ episodes: BulkEpisodeOption[]; + /** The caller's recent visits — the file-against-a-visit targets. */ + encounters: BulkEncounterOption[]; /** A bulk call is in flight — the verbs disable, Clear stays live. */ busy: boolean; onSetKind: (kind: InboundDocumentKindValue) => void; onLinkEpisode: (episodeId: string) => void; + onLinkEncounter: (encounterId: string) => void; /** * Share the whole selection as ONE documents-only link. The page caps the * selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint — @@ -135,6 +158,35 @@ export function DocumentBulkBar({ ) : null} + {encounters.length > 0 ? ( + + + + + + {encounters.map((encounter) => ( + onLinkEncounter(encounter.id)} + > + {encounter.label} + + ))} + + + ) : null} +