diff --git a/.github/workflows/coach-eval.yml b/.github/workflows/coach-eval.yml index 01e82c450..356ee8432 100644 --- a/.github/workflows/coach-eval.yml +++ b/.github/workflows/coach-eval.yml @@ -27,6 +27,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 + # Map the opt-in secret to a job-level env so the live-judge steps can gate + # on it. The `secrets` context is NOT available in `if:` expressions — using + # it there is a workflow-file validation error that surfaces as a 0-second + # startup failure on every push. `env` is allowed in `if:`, so gate on that. + env: + COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }} + steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -51,14 +58,12 @@ jobs: # non-blocking either way. With the secret absent the script no-ops with a # clear line and exits 0. - name: Live judge (opt-in, non-blocking) - if: ${{ secrets.COACH_EVAL_API_KEY != '' }} + if: ${{ env.COACH_EVAL_API_KEY != '' }} continue-on-error: true - env: - COACH_EVAL_API_KEY: ${{ secrets.COACH_EVAL_API_KEY }} run: pnpm dlx tsx scripts/run-coach-eval.ts # When the secret is absent, surface a clear no-op line so the run log # explains why the live judge did not execute. - name: Live judge skipped (no secret) - if: ${{ secrets.COACH_EVAL_API_KEY == '' }} + if: ${{ env.COACH_EVAL_API_KEY == '' }} run: echo "COACH_EVAL_API_KEY not set — live Coach judge skipped (deterministic floor still gated above)." diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d2c8c15..0c24b4d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ ## [Unreleased] +## [1.25.1] — 2026-06-29 — Documents library, tidier settings + +A patch release that turns the document inbox into a real library, reorganises the settings, closes a cross-account data-isolation gap on shared browsers, and carries a broad quality pass across the new surfaces. One additive migration (`0222`) applies automatically on start. + +### Added + +- A documents library: file any clinical document — a report, discharge letter, lab result, imaging, prescription, and more — without needing an AI provider. Give it a title, a category, and a date, then search, filter, sort, and browse it grouped by date. AI fact-extraction is now a separate, optional step you run on a document you have already filed, and you can view or download the original at any time. +- Richer reference descriptions for every biomarker — a few plain sentences on what each marker measures and what it relates to, in all six languages. + +### Changed + +- Settings are reorganised into nine clear groups — Account, Tracking, Display, Integrations, Notifications, AI & Coach, API & Access, Data & Privacy, and About — with in-page tabs. Far fewer entries down the left, and every page keeps its own address. Sign-in security — two-factor, passkeys, active sessions, and trusted devices — now lives together under Account. +- "Inbound documents" is now simply "Documents". +- Several repeated medical disclaimers were removed in favour of one clear, non-diagnostic statement, and the mental-wellbeing check-in now stands on its own page. +- The daily briefing can draw on grip strength, waist, and pain alongside your other signals, and the grip-strength and waist reference bands are age- and sex-aware. + +### Fixed + +- Signing out and back in as a different account on a shared browser no longer shows the previous account's data: the in-memory cache is now cleared at every sign-in and sign-out, alongside the offline caches. +- A once-weekly medication no longer triggers a false "low adherence" alert — the alert now uses the same cadence-aware calculation as the rest of the app. +- A lab value read from a filed document is no longer saved under the wrong unit: a unit mismatch is refused and reported rather than silently mis-recorded. +- Moving between insight pages now starts at the top; scroll resets on navigation throughout the app. +- A broad pass of accessibility, mobile-layout, date-format, and wording refinements across documents, settings, and the biomarker pages. + +### Security + +- Cross-account data isolation on a shared browser: no cached reading from one account can survive into another's session. + ## [1.25.0] — 2026-06-28 — Clinical depth A milestone release. It broadens tracking into clinical territory — validated mental-wellbeing self-assessments, grip strength, a pain score, waist measurements, a longevity lab panel, and respiratory rate as a first-class vital — adds structured allergy and family-history records, an optional weather-and-daylight context module, and the ability to file a doctor's letter and pull its facts into your record after you review them. It also makes the Coach's check-ins warmer, restores assistant-connector sign-in, and carries a broad correctness, performance, security, and accessibility pass. Additive migrations (`0208`–`0220`) apply automatically on start. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5697416df..c901fc196 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.25.0 + version: 1.25.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -8595,12 +8595,82 @@ paths: get: tags: - Documents - summary: List inbound clinical documents - description: The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on - the opt-in `inboundDocuments` module. + summary: List stored documents + description: The caller's stored documents with title/filename search, category filter, a `documentDate` range, sort, + and keyset pagination. Owner-scoped; gated on the opt-in `inboundDocuments` module. + parameters: + - name: q + in: query + required: false + schema: + type: string + maxLength: 100 + description: Case-insensitive search over title + filename. + - name: kind + in: query + required: false + schema: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION + - OTHER + description: Category filter. + - name: from + in: query + required: false + schema: + type: string + format: date + description: Inclusive lower bound on documentDate (YYYY-MM-DD). + - name: to + in: query + required: false + schema: + type: string + format: date + description: Inclusive upper bound on documentDate (YYYY-MM-DD). + - name: sort + in: query + required: false + schema: + type: string + enum: + - documentDate + - createdAt + - title + default: documentDate + - name: order + in: query + required: false + schema: + type: string + enum: + - asc + - desc + default: desc + - name: cursor + in: query + required: false + schema: + type: string + description: Keyset cursor (the `nextCursor` from a prior page). + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 responses: "200": - description: The document list. + description: A page of documents. content: application/json: schema: @@ -8611,14 +8681,12 @@ paths: post: tags: - Documents - summary: Upload + extract a clinical document - description: 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw - document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. - VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus - an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the - extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what - the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores - here; the confirm route is the only write path.' + summary: Store a document (no extraction) + description: "STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction + — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider + configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB) + plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in + action — see `POST /api/documents/inbound/{id}/extract`." requestBody: required: true content: @@ -8629,22 +8697,31 @@ paths: file: type: string format: binary - description: The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + description: The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + title: + description: Optional user label. + type: string kind: - description: Optional document-type label. + description: Optional category. type: string enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER + documentDate: + description: Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library + sort/filter/display stay aligned. + type: string required: - file - application/json: - schema: - $ref: "#/components/schemas/InboundTextUploadRequest" responses: "201": - description: The created document + staging summary. + description: The stored document. content: application/json: schema: @@ -8674,10 +8751,60 @@ paths: "401": *a1 "422": *a3 "429": *a4 + patch: + tags: + - Documents + summary: Edit a document's metadata + description: Rename / recategorise / set the user filing date on a stored document. Owner-scoped; no mass assignment. + Returns the updated document + its staged facts. + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + anyOf: + - type: string + maxLength: 200 + - type: "null" + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION + - OTHER + documentDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + responses: + "200": + description: The updated document. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentUpdateEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 delete: tags: - Documents - summary: Discard an inbound document + summary: Discard a document description: Soft-deletes the document + its staging. Facts already approved into the structured stores are independent rows and are NOT affected. parameters: @@ -8696,6 +8823,79 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/documents/inbound/{id}/original: + get: + tags: + - Documents + summary: View / download the original document + description: "Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under + `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are + constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served + `Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`. + Fails closed (500) on a decrypt error — it never returns the ciphertext." + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: The decrypted original document bytes with the stored MIME type. + content: + application/pdf: + schema: + type: string + format: binary + image/*: + schema: + type: string + format: binary + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}/extract: + post: + tags: + - Documents + summary: Extract facts from a stored document + description: "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the + stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider + configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only + the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original + (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` + structures browser-OCR'd text. Extraction reproduces what the document states — it never interprets. Nothing + reaches the structured stores here; the confirm route is the only write path." + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/InboundTextExtractRequest" + responses: + "200": + description: The document with its newly staged facts. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundExtractEnvelope" + "401": *a1 + "409": + description: "Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` = + `documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and + duplicate committed records, so the user must finish reviewing or discard the document first." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -10629,7 +10829,7 @@ components: required: - mode - text - InboundTextUploadRequest: + InboundTextExtractRequest: type: object properties: mode: @@ -10642,6 +10842,11 @@ components: enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER required: - mode @@ -24907,9 +25112,16 @@ components: type: array items: $ref: "#/components/schemas/InboundDocument" + nextCursor: + anyOf: + - type: string + - type: "null" required: - documents + - nextCursor additionalProperties: false + description: A page of documents plus an opaque `nextCursor` (the id to pass back as `cursor` for the next page; null + when the list is exhausted). InboundDocument: type: object properties: @@ -24920,7 +25132,16 @@ components: enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER + title: + anyOf: + - type: string + - type: "null" filename: anyOf: - type: string @@ -24932,6 +25153,7 @@ components: status: type: string enum: + - STORED - EXTRACTING - EXTRACTED - FAILED @@ -24945,6 +25167,10 @@ components: anyOf: - type: string - type: "null" + documentDate: + anyOf: + - type: string + - type: "null" errorReason: anyOf: - type: string @@ -24960,20 +25186,24 @@ components: required: - id - kind + - title - filename - mimeType - byteSize - status - providerType - reportDate + - documentDate - errorReason - factCount - pendingCount - createdAt - updatedAt additionalProperties: false - description: An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the - metadata + staging summary. + description: A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label + (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until + extraction runs). InboundDocumentEnvelope: type: object properties: @@ -25018,7 +25248,16 @@ components: enum: - DOCTOR_REPORT - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - INSURANCE + - VACCINATION - OTHER + title: + anyOf: + - type: string + - type: "null" filename: anyOf: - type: string @@ -25030,6 +25269,7 @@ components: status: type: string enum: + - STORED - EXTRACTING - EXTRACTED - FAILED @@ -25043,6 +25283,10 @@ components: anyOf: - type: string - type: "null" + documentDate: + anyOf: + - type: string + - type: "null" errorReason: anyOf: - type: string @@ -25062,12 +25306,14 @@ components: required: - id - kind + - title - filename - mimeType - byteSize - status - providerType - reportDate + - documentDate - errorReason - factCount - pendingCount @@ -25140,6 +25386,23 @@ components: with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited). + InboundDocumentUpdateEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false InboundDiscardEnvelope: type: object properties: @@ -25166,6 +25429,23 @@ components: - data - error additionalProperties: false + InboundExtractEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ExtractedFactEnvelope: type: object properties: diff --git a/e2e/insights-scroll-restoration.spec.ts b/e2e/insights-scroll-restoration.spec.ts index 011d93d25..554ccaa0d 100644 --- a/e2e/insights-scroll-restoration.spec.ts +++ b/e2e/insights-scroll-restoration.spec.ts @@ -24,14 +24,35 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; test.describe("v1.4.28 — insights scroll restoration", () => { test.use({ storageState: STORAGE_STATE_PATH }); - test.fixme("returning to /insights from a sub-page lands at the top", async ({ + test("returning to /insights from a sub-page lands at the top", async ({ page, }) => { await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + // v1.25.1 — the app-wide scroll-to-top resets the `#main-content` + // scroll container on every route change (auth-shell), with a + // `window.scrollTo(0,0)` fallback for the body-scrolled shells. The + // authenticated insights surface scrolls `#main-content`, so the + // regression lock reads that container, not `window`. + const readScrollTop = () => + page.evaluate(() => { + const el = document.getElementById("main-content"); + return el && el.scrollHeight > el.clientHeight + ? el.scrollTop + : window.scrollY; + }); + // Force the mother page tall enough that scrolling has somewhere // to go even on a sparse seed; the rendered Insights overview // already runs ~1500 px tall on the demo seed. - await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" })); + await page.evaluate(() => { + const el = document.getElementById("main-content"); + if (el && el.scrollHeight > el.clientHeight) { + el.scrollTop = 600; + } else { + window.scrollTo({ top: 600, behavior: "auto" }); + } + }); // Click the first available sub-page pill. const subPagePill = page @@ -45,11 +66,10 @@ test.describe("v1.4.28 — insights scroll restoration", () => { await page.locator("[data-slot='insights-tab-strip-pill']").first().click(); await page.waitForURL(/\/insights\/?$/); - // The mother page's mount-effect deferred a `scrollTo(0)` to the - // next animation frame. Wait a tick for it to land. - await page.waitForTimeout(50); - const y = await page.evaluate(() => window.scrollY); - expect(y).toBeLessThan(50); + // The shell's route-change effect resets the scroll on the next tick; + // `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it + // to settle, then assert the container is back at the top. + await expect.poll(readScrollTop, { timeout: 2000 }).toBeLessThan(50); }); test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({ diff --git a/messages/de.json b/messages/de.json index a90fecd6a..b9e620b72 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4018,16 +4018,46 @@ "disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen." }, "shell": { - "sectionsNav": "Einstellungs-Bereiche" + "sectionsNav": "Einstellungs-Bereiche", + "subSectionsNav": "Einstellungs-Unterbereiche" + }, + "groups": { + "account": { + "title": "Konto" + }, + "tracking": { + "title": "Tracking" + }, + "display": { + "title": "Darstellung" + }, + "integrations": { + "title": "Integrationen" + }, + "notifications": { + "title": "Benachrichtigungen" + }, + "ai": { + "title": "KI & Coach" + }, + "access": { + "title": "API & Zugriff" + }, + "data": { + "title": "Daten & Datenschutz" + }, + "about": { + "title": "Über" + } }, "sections": { "account": { - "title": "Konto", + "title": "Profil & Einstellungen", "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.", "subtitle": "Dein Profil, deine Identität und deine Anmeldedaten." }, "integrations": { - "title": "Integrationen", + "title": "Verbindungen", "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", "subtitle": "Datenquellen und Geräte verbinden und verwalten." }, @@ -4036,11 +4066,10 @@ "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.", "channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter", "channelsHintLink": "Integrationen → Kanäle", - "inboxLink": "Posteingang öffnen", "subtitle": "Wähle, welche Erinnerungen du erhältst." }, "layout": { - "title": "Dashboard", + "title": "Darstellung", "description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.", "backToHub": "Zurück zu Layout & Personalisierung", "dashboard": { @@ -4319,9 +4348,9 @@ "subtitle": "Zwei-Faktor-Authentifizierung, Wiederherstellungscodes und Passkeys." }, "privacy": { - "title": "Daten & Datenschutz", - "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.", - "description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität." + "title": "Datenschutz & Aufbewahrung", + "subtitle": "Deine Daten, Aufbewahrung und Verschlüsselung an einem Ort.", + "description": "Export, Löschung, Aufbewahrung und Verschlüsselungsstatus." }, "mcp": { "title": "MCP-Connector", @@ -4665,7 +4694,9 @@ "focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung …", "completeness": "{answered} von {total} beantwortet", "questionsTitle": "Der Coach hat Rückfragen", - "questionsOpenCoach": "Im Coach beantworten" + "questionsOpenCoach": "Im Coach beantworten", + "recordsLink": "Allergien, Familienanamnese und deine Vorerkrankungen gehören zu deiner Anamnese.", + "recordsLinkCta": "Anamnese öffnen" }, "responseTimeoutHeading": "Antwort-Timeout", "responseTimeoutBody": "Wie lange auf die Antwort der KI gewartet wird, bevor abgebrochen wird. Erhöhe den Wert für ein lokales oder selbst gehostetes Modell, das beim ersten Aufruf Zeit zum Laden braucht.", @@ -5286,6 +5317,11 @@ "description": "Steuere, wie viele deiner Daten an KI-Funktionen und die freiwillige Forschung weitergegeben werden.", "openAi": "KI-Datenschutzmodus", "openResearch": "Forschungsmodus" + }, + "signIn": { + "title": "Anmeldung & Geräte", + "description": "Aktive Sitzungen, vertraute Geräte und Anmeldeaktivität findest du jetzt in den Sicherheitseinstellungen deines Kontos.", + "openSecurity": "Anmeldung & Sicherheit verwalten" } }, "mcp": { @@ -7061,9 +7097,14 @@ "restoredToast": "Wert wiederhergestellt", "restoreError": "Wert konnte nicht wiederhergestellt werden", "detail": { - "history": "Verlauf", "emptyTitle": "Noch keine Werte", - "emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen." + "emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen.", + "title": "Biomarker", + "genericDescription": "Ein Laborwert, den du verfolgst. Erfasse Messwerte, um den Verlauf gegenüber dem Referenzbereich zu sehen.", + "valuesTitle": "{marker} — alle Werte", + "valuesDescription": "Jeder Messwert für diesen Biomarker. Bearbeite oder entferne einen Eintrag direkt.", + "backToMarker": "Zurück zu {marker}", + "latestResult": "Letztes Ergebnis" }, "chart": { "empty": "Keine Werte in diesem Zeitraum.", @@ -7172,36 +7213,36 @@ "bloodCount": "Blutbild" }, "desc": { - "total-cholesterol": "Gesamtmenge des im Blut transportierten Cholesterins, Summe aus LDL-, HDL- und weiteren Lipoproteinfraktionen.", - "ldl": "Cholesterin in Lipoproteinen geringer Dichte, der Anteil, der sich in den Arterienwänden ablagert.", - "hdl": "Cholesterin in Lipoproteinen hoher Dichte, die Cholesterin zurück zur Leber transportieren.", - "triglycerides": "Das wichtigste Blutfett, aus der Nahrung aufgenommen und in der Leber gebildet.", - "fasting-glucose": "Nüchtern gemessener Blutzucker, der die grundlegende Glukoseregulation widerspiegelt.", - "hba1c": "Anteil des an Glukose gebundenen Hämoglobins, Spiegel des Blutzuckers der letzten zwei bis drei Monate.", - "tsh": "Hypophysenhormon, das die Schilddrüse steuert; der wichtigste Suchtest der Schilddrüsenfunktion.", - "ft3": "Die freie, aktive Form des Trijodthyronins, des stärker wirksamen Schilddrüsenhormons.", - "ft4": "Die freie Form des Thyroxins, des Haupthormons, das die Schilddrüse ins Blut abgibt.", - "ferritin": "Das wichtigste Eisenspeicherprotein des Körpers, Maß für die Eisenreserven.", - "transferrin-saturation": "Anteil des Eisentransportproteins Transferrin, der aktuell Eisen trägt.", - "vitamin-d": "Zirkulierendes 25-Hydroxy-Vitamin-D, der Standardmarker für den Vitamin-D-Status.", - "vitamin-b12": "Vitamin, das für die Bildung roter Blutkörperchen und die Nervenfunktion notwendig ist.", - "folate": "B-Vitamin, das für die Bildung roter Blutkörperchen und die DNA-Synthese benötigt wird.", - "hs-crp": "Ein Leberprotein, das bei Entzündungen ansteigt, hochempfindlich gemessen.", - "creatinine": "Abbauprodukt der Muskulatur, das über die Nieren ausgeschieden wird; Maß der Nierenfunktion.", - "egfr": "Geschätzte Filtrationsrate der Nieren, aus dem Kreatinin berechnet.", - "alt": "Leberenzym, das bei Schädigung der Leberzellen ins Blut freigesetzt wird.", - "ast": "Enzym aus Leber und Muskulatur, das bei Schädigung dieser Gewebe ansteigt.", - "ggt": "Enzym aus Leber und Gallenwegen, das bei Gallenstau oder Alkoholbelastung ansteigt.", - "sodium": "Wichtigster Elektrolyt für den Flüssigkeitshaushalt und das Blutvolumen.", - "potassium": "Elektrolyt, der für die Nervenleitung und die Herzmuskelfunktion unentbehrlich ist.", - "hemoglobin": "Das sauerstofftransportierende Protein in den roten Blutkörperchen.", - "hematocrit": "Der Anteil der roten Blutkörperchen am Blutvolumen.", - "wbc": "Gesamtzahl der weißen Blutkörperchen, der zirkulierenden Abwehr des Immunsystems.", - "platelets": "Zellfragmente, die sich zusammenlagern und die Blutgerinnung bewirken.", - "apob": "Zählt atherogene Partikel direkt; oft ein besserer Risikomarker als LDL-C, wenn beide auseinandergehen.", - "lp-a": "Ein weitgehend vererbter, lebenslanger Risikofaktor — meist einmal gemessen. In molaren Einheiten (nmol/L) angeben.", - "fasting-insulin": "Ergänzt den Nüchtern-Blutzucker zur Abschätzung der Insulinresistenz (HOMA-IR).", - "omega-3-index": "Anteil von EPA+DHA in den roten Blutkörperchen; 8–12 % gelten als kardioprotektiver Bereich." + "total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe – deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.", + "ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin – die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.", + "hdl": "Das HDL-Cholesterin ist das von High-Density-Lipoprotein-Partikeln transportierte Cholesterin, die überschüssiges Cholesterin aus den Geweben aufnehmen und zur Wiederverwertung oder Ausscheidung zur Leber zurückführen. Wegen dieser Transportfunktion wird es oft als die schützende Fraktion bezeichnet. Sehr niedrige Werte gehen tendenziell mit höherem kardiovaskulärem Risiko einher; ein künstliches Anheben hat das Risiko jedoch nicht zuverlässig gesenkt, weshalb man es als Marker und nicht als Zielgröße liest. In der Regel wird es zusammen mit LDL und Triglyzeriden beurteilt.", + "triglycerides": "Triglyzeride sind die wichtigste Form von Fett im Blut, die sowohl aus der Nahrung stammt als auch von der Leber gebildet wird, und sie dienen als zentraler Energiespeicher. Nach Mahlzeiten steigen die Werte, daher wird die Probe für die Vergleichbarkeit meist nüchtern entnommen. Dauerhaft hohe Triglyzeride werden mit metabolischem und kardiovaskulärem Risiko in Verbindung gebracht und treten häufig gemeinsam mit Insulinresistenz und niedrigem HDL auf. Sie werden als ein Bestandteil des gesamten Lipidbilds gelesen.", + "fasting-glucose": "Der Nüchternblutzucker ist die Zuckerkonzentration im Blut nach einer nächtlichen Nüchternphase und spiegelt wider, wie der Körper seinen Grundblutzucker ohne kürzliche Nahrungsaufnahme hält. Glukose ist der wichtigste unmittelbare Brennstoff des Körpers, und Insulin hält sie normalerweise in einem engen Bereich. Eine Nüchternmessung schließt den Effekt kürzlicher Mahlzeiten aus und ist damit ein Standardscreening dafür, wie die Glukoseregulation funktioniert. Er gehört zu den Markern, mit denen die Stoffwechselgesundheit über die Zeit beurteilt wird.", + "hba1c": "Der HbA1c ist der Anteil des Hämoglobins in den roten Blutkörperchen, der an Glukose gebunden ist und sich im Verhältnis zur durchschnittlichen Blutzuckerbelastung anreichert. Da rote Blutkörperchen etwa drei Monate leben, gibt er den durchschnittlichen Blutzucker der vorangegangenen zwei bis drei Monate wieder und nicht einen einzelnen Augenblick. Das macht ihn zu einem stabilen, mahlzeitenunabhängigen Maß der Blutzuckereinstellung. Seine Aussagekraft kann durch Erkrankungen beeinträchtigt sein, die die Lebensdauer der roten Blutkörperchen verändern.", + "tsh": "Das TSH (Thyreoidea-stimulierendes Hormon) wird von der Hirnanhangdrüse ausgeschüttet, um die Schilddrüse zur Hormonproduktion anzuregen, und es passt sich nach oben oder unten an, um diese im Gleichgewicht zu halten. Aufgrund dieses Regelkreises ist das TSH das empfindlichste Erstlinien-Screening der Schilddrüsenfunktion: Es bewegt sich meist, bevor die Schilddrüsenhormone selbst abweichen. Ein erhöhter oder erniedrigter Wert deutet auf eine Unter- oder Überfunktion hin und führt in der Regel zur direkten Bestimmung der Schilddrüsenhormone. Schwangerschaft und Alter verschieben den erwarteten Bereich.", + "ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons – der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.", + "ft4": "Das freie T4 (Thyroxin) ist die ungebundene Form des wichtigsten Hormons, das die Schilddrüse ins Blut abgibt; es dient als Reservoir, das die Gewebe bei Bedarf in das aktivere T3 umwandeln. Der freie Anteil spiegelt wider, was dem Körper tatsächlich zur Verfügung steht, unabhängig von der Menge der Trägerproteine. In der Regel wird es zusammen mit dem TSH bestimmt, um Probleme der Schilddrüse von Störungen der hypophysären Steuerung zu unterscheiden. Schwangerschaft und bestimmte Medikamente können es verschieben.", + "ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen – oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.", + "transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins – des Proteins, das Eisen durch das Blut transportiert –, der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.", + "vitamin-d": "Vitamin D wird als 25-Hydroxy-Vitamin-D gemessen, der zirkulierenden Speicherform und dem Standardmarker des gesamten Vitamin-D-Status. Der Körper bildet es in der dem Sonnenlicht ausgesetzten Haut und nimmt einen Teil über die Nahrung auf; es unterstützt den Kalziumhaushalt, die Knochengesundheit und die Immunfunktion. Die Werte schwanken mit Jahreszeit, geografischer Breite, Hauttyp und Nahrungsergänzung. Erfasst wird es, um über die Zeit einzuschätzen, ob die Speicher ausreichend gefüllt sind.", + "vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel – durch geringe Zufuhr oder gestörte Aufnahme – kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.", + "folate": "Folat (Vitamin B9) ist ein B-Vitamin, das in grünem Blattgemüse, Hülsenfrüchten und angereicherten Lebensmitteln vorkommt und für die Bildung roter Blutkörperchen und die DNA-Synthese zentral ist. Es arbeitet eng mit Vitamin B12 zusammen, und ein Mangel an einem von beiden kann eine ähnliche Form der Blutarmut hervorrufen. Eine ausreichende Folatversorgung ist besonders rund um die frühe Schwangerschaft für die gesunde Entwicklung des Kindes wichtig. Gemessen wird es zur Beurteilung des Ernährungsstatus und der Ursache bestimmter Anämien.", + "hs-crp": "Das hochsensitive CRP misst das C-reaktive Protein, einen Marker, den die Leber als Reaktion auf Entzündungen ausschüttet, mit einem Verfahren, das auf die für das kardiovaskuläre Risiko relevanten niedrigen Konzentrationen abgestimmt ist. Anders als das Standard-CRP für akute Infektionen erfasst die hochsensitive Variante die feine, chronische Entzündung, die mit Gefäßerkrankungen verbunden ist. Da jede kürzliche Infektion oder Verletzung den Wert anhebt, wird er außerhalb akuter Erkrankung beurteilt und oft mit einer Wiederholungsprobe bestätigt. Er wird als ein Beitrag zum gesamten kardiovaskulären Risiko gelesen.", + "creatinine": "Kreatinin ist ein Abfallprodukt des normalen Muskelstoffwechsels, das die Nieren mit recht gleichbleibender Rate aus dem Blut filtern. Da die Produktion verhältnismäßig konstant ist, dient der Blutwert als praktisches Maß dafür, wie gut die Nieren Abfallstoffe ausscheiden. Die Werte werden auch von Muskelmasse, Flüssigkeitshaushalt und Ernährung beeinflusst, weshalb sie meist gemeinsam mit der geschätzten Filtrationsrate beurteilt werden. Es ist ein zentraler Marker der Nierenfunktion.", + "egfr": "Die eGFR (geschätzte glomeruläre Filtrationsrate) schätzt, wie viel Blut die Nieren pro Minute filtern, berechnet aus dem Kreatinin zusammen mit Alter und Geschlecht. Sie übersetzt einen rohen Kreatininwert in ein unmittelbarer verständliches Maß der Nierenfunktion. Eine über die Zeit sinkende eGFR ist der wichtigste Weg, um chronische Nierenveränderungen einzustufen und zu verfolgen. Als Schätzwert wird sie als Trend und nicht als einzelner exakter Zahlenwert gelesen.", + "alt": "Die ALT (Alanin-Aminotransferase) ist ein vorwiegend in den Leberzellen konzentriertes Enzym, das ins Blut freigesetzt wird, wenn diese Zellen belastet oder geschädigt sind. Da sie verhältnismäßig leberspezifisch ist, gehört sie zu den wichtigsten Markern, um eine Leberschädigung oder -entzündung zu erkennen. Die Werte können bei Fettleber, Virushepatitis, Alkohol oder bestimmten Medikamenten steigen. In der Regel wird sie zusammen mit der AST und den übrigen Markern des Leberpanels gelesen.", + "ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt – ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.", + "ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist – etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.", + "sodium": "Natrium ist der wichtigste Elektrolyt in der Flüssigkeit außerhalb der Körperzellen und der zentrale Regler von Flüssigkeitshaushalt und Blutvolumen. Der Körper steuert es eng über Durst, Hormone und die Nieren, sodass der Blutwert eher das Gleichgewicht zwischen Wasser und Salz widerspiegelt als die Zufuhr allein. Abweichende Werte signalisieren meist eine Störung des Wasserhaushalts und nicht des Natriums selbst. Es ist ein Kernbestandteil des grundlegenden Stoffwechsel- und Elektrolytpanels.", + "potassium": "Kalium ist der wichtigste Elektrolyt innerhalb der Körperzellen und für die Nervenleitung und den gleichmäßigen Rhythmus des Herzmuskels unverzichtbar. Sein Blutwert wird durch die Nieren und hormonelle Signale in einem engen Bereich gehalten, da sowohl hohe als auch niedrige Werte die elektrische Aktivität des Herzens stören können. Die Werte können durch Nierenfunktion, Flüssigkeitshaushalt und mehrere gängige Medikamente beeinflusst werden. Es ist ein Kernmaß des Elektrolytpanels.", + "hemoglobin": "Hämoglobin ist das eisenhaltige Protein in den roten Blutkörperchen, das Sauerstoff in der Lunge bindet und zu den Geweben transportiert. Seine Konzentration ist das wichtigste Maß für die Sauerstofftransportkapazität des Blutes und der zentrale Marker zum Erkennen einer Blutarmut. Niedrige Werte können auf Blutverlust, Eisen- oder Vitaminmangel oder eine verringerte Bildung hinweisen, hohe Werte können auf Flüssigkeitsmangel oder sauerstoffarme Bedingungen folgen. Es ist ein Eckpfeiler des großen Blutbilds.", + "hematocrit": "Der Hämatokrit ist der Anteil der roten Blutkörperchen am Blutvolumen, ausgedrückt als Prozentwert. Er bewegt sich eng mit dem Hämoglobin und gibt einen ergänzenden Blick auf die Erythrozytenmasse und die Sauerstofftransportkapazität des Blutes. Der Flüssigkeitshaushalt beeinflusst ihn, da Veränderungen des Plasmavolumens den Anteil verschieben, selbst wenn die Zahl der roten Blutkörperchen stabil bleibt. Er wird zusammen mit dem Hämoglobin und dem übrigen Blutbild gelesen.", + "wbc": "Die Leukozytenzahl ist die Gesamtzahl der im Blut zirkulierenden Immunzellen, der mobilen Abwehr des Körpers gegen Infektionen. Sie steigt als Reaktion auf Infektion, Entzündung oder Stress und kann fallen, wenn das Knochenmark unterdrückt ist oder bei manchen Immunerkrankungen. Da sie mehrere unterschiedliche Zelltypen zusammenfasst, wird eine deutliche Veränderung oft durch ein Differenzialblutbild aufgeschlüsselt. Sie ist ein Kernbestandteil des großen Blutbilds.", + "platelets": "Thrombozyten sind kleine Zellfragmente im Blut, die sich zusammenlagern und den ersten Pfropf bilden, der eine Blutung aus einem geschädigten Gefäß stoppt. Ihre Zahl spiegelt das Gleichgewicht zwischen Bildung im Knochenmark und Verbrauch oder Abbau wider. Eine niedrige Zahl kann die Blutungsneigung erhöhen, eine hohe Zahl kann auf eine Entzündung oder andere Reize folgen. Sie sind Teil des großen Blutbilds und ein grundlegendes Maß der Gerinnungsfähigkeit.", + "apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht – besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.", + "lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.", + "fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins – des Hormons, das Glukose aus dem Blut in die Zellen schleust –, gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.", + "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA – den beiden langkettigen marinen Omega-3-Fettsäuren – an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", @@ -7318,8 +7359,8 @@ "description": "Tägliches Wetter und Tageslicht für deinen Ort einbeziehen, um Zusammenhänge mit Stimmung, Schlaf und Vitalwerten zu erkennen. Standardmäßig aus." }, "inboundDocuments": { - "label": "Eingehende Dokumente", - "description": "Lade einen Arztbericht oder Entlassbrief hoch; prüfe die erkannten Angaben, bevor sie gespeichert werden. Standardmäßig aus." + "label": "Dokumente", + "description": "Speichere deine medizinischen Dokumente; optional ihre Angaben auslesen und prüfen, bevor sie gespeichert werden. Standardmäßig aus." }, "mentalHealth": { "label": "Seelisches Wohlbefinden", @@ -7558,8 +7599,7 @@ "mentalHealth": { "pageTitle": "Check-in zum seelischen Wohlbefinden", "pageDescription": "Zwei validierte, freiwillige Selbsttests — PHQ-9 (Stimmung) und GAD-7 (Sorgen) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.", - "disclaimer": "Dies ist ein Screening-Selbsttest, keine Diagnose. Ein hoher oder niedriger Wert bedeutet nicht, dass eine Erkrankung vorliegt oder nicht. Nur eine qualifizierte Fachperson kann eine Diagnose stellen. Bei Sorgen sprich bitte mit deiner Ärztin oder deinem Arzt.", - "optInNote": "Freiwillig und privat: Deine einzelnen Antworten sind verschlüsselt, bleiben aus dem KI-Coach und aus Exporten heraus und werden nie geteilt.", + "infoTooltip": "Dieses ist ein Screening-Selbsttest, keine Diagnose.", "attributionLabel": "Quelle", "choosePrompt": "Check-in auswählen", "start": "Starten", @@ -7774,6 +7814,15 @@ "updatedToast": "Eintrag aktualisiert", "saveError": "Der Eintrag konnte nicht gespeichert werden." } + }, + "conditions": { + "cardTitle": "Vorerkrankungen", + "cardDescription": "Chronische Erkrankungen und worauf der Coach achten soll — aus deinem persönlichen Kontext.", + "conditionsLabel": "Chronische Erkrankungen", + "focusLabel": "Worauf der Coach achtet", + "empty": "Noch nichts hinterlegt.", + "editCta": "Im persönlichen Kontext bearbeiten", + "loadError": "Vorerkrankungen konnten nicht geladen werden." } }, "environment": { @@ -7788,37 +7837,50 @@ } }, "documents": { - "title": "Eingehende Dokumente", - "subtitle": "Lade ein klinisches Dokument hoch und prüfe die Angaben, die wir daraus gelesen haben.", - "disclaimer": { - "title": "Wir lesen, wir deuten nicht", - "body": "Wir übernehmen nur das, was dein Dokument angibt — Werte, Codes und Daten — und stellen keine Diagnose, deuten nichts und bewerten nichts. Nichts wird gespeichert, bevor du es geprüft und bestätigt hast. Nur deine Ärztin oder dein Arzt kann diese Ergebnisse beurteilen." - }, + "title": "Dokumente", + "subtitle": "Speichere deine medizinischen Dokumente und prüfe, was darin steht.", "upload": { - "label": "Dokument hochladen", - "kindLabel": "Dokumenttyp", + "label": "Dokument hinzufügen", + "kindLabel": "Kategorie", "button": "Hochladen", - "uploading": "Wird gelesen …", - "fileLabel": "Dokumentdatei" + "uploading": "Wird hochgeladen…", + "fileLabel": "Datei (erforderlich)", + "titleLabel": "Titel (optional)", + "titlePlaceholder": "z. B. Kardiologie-Bericht", + "dateLabel": "Dokumentdatum (optional)", + "fileRequired": "Wähle eine Datei zum Hochladen.", + "accepts": "JPEG, PNG, WebP oder PDF · bis 12 MB" }, "kind": { "DOCTOR_REPORT": "Arztbericht", - "DISCHARGE_LETTER": "Entlassbrief", - "OTHER": "Sonstiges" + "DISCHARGE_LETTER": "Entlassungsbrief", + "OTHER": "Sonstiges", + "LAB_RESULT": "Laborbefund", + "IMAGING": "Bildgebung", + "PRESCRIPTION": "Rezept", + "INSURANCE": "Versicherung", + "VACCINATION": "Impfung" }, "list": { - "empty": "Noch keine Dokumente." + "empty": "Noch keine Dokumente.", + "noResults": "Keine Dokumente passen zu deinen Filtern.", + "loadMore": "Mehr laden", + "loadingMore": "Wird geladen…", + "pending": "{count} zu prüfen", + "facts": "{count} Angaben", + "clearFilters": "Filter zurücksetzen", + "loadError": "Deine Dokumente konnten nicht geladen werden." }, "status": { "EXTRACTING": "Wird gelesen", "EXTRACTED": "Bereit zur Prüfung", "FAILED": "Konnte nicht gelesen werden", "CONFIRMED": "Geprüft", - "DISCARDED": "Verworfen" + "DISCARDED": "Verworfen", + "STORED": "Gespeichert" }, "review": { "title": "Erkannte Angaben prüfen", - "discard": "Verwerfen", "confirm": "Bestätigte Angaben speichern", "confirming": "Wird gespeichert …", "needsReview": "Prüfung nötig", @@ -7826,7 +7888,14 @@ "reject": "Ablehnen", "edit": "Bearbeiten", "cancel": "Abbrechen", - "save": "Speichern" + "save": "Speichern", + "needsReviewHint": "Bearbeite diese Angabe, um sie zu übernehmen.", + "confidenceLabel": "Erkennungssicherheit", + "commitError": { + "unitMismatch": "die Einheit stimmt nicht mit der gespeicherten überein", + "unitRequired": "ein Zahlenwert braucht eine Einheit", + "generic": "es konnte nicht gespeichert werden" + } }, "empty": { "noFacts": "Nichts mehr zu prüfen." @@ -7844,21 +7913,72 @@ "dose": "Dosis" }, "toast": { - "uploaded": "Dokument gelesen. Prüfe die Angaben unten.", - "uploadFailed": "Dokument konnte nicht gelesen werden.", + "uploaded": "Dokument gespeichert.", + "uploadFailed": "Dokument konnte nicht gespeichert werden.", "confirmed": "Bestätigte Angaben gespeichert.", + "confirmedPartial": "Gespeichert, aber {count} konnten nicht gespeichert werden — {reason}.", "confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.", "saved": "Angabe aktualisiert.", - "discarded": "Dokument verworfen." + "discarded": "Dokument verworfen.", + "extracted": "Angaben ausgelesen. Prüfe sie unten.", + "extractFailed": "Angaben konnten nicht ausgelesen werden.", + "localOcrDisabled": "Aktiviere lokale OCR in den Einstellungen, um dieses Dokument auf dem Gerät zu lesen.", + "updated": "Dokument aktualisiert.", + "updateFailed": "Dokument konnte nicht aktualisiert werden.", + "discardFailed": "Dokument konnte nicht verworfen werden.", + "uploadTooLarge": "Datei ist zu groß (max. 12 MB).", + "uploadFileType": "Lade eine JPEG-, PNG-, WebP- oder PDF-Datei hoch.", + "uploadRateLimited": "Zu viele Uploads. Versuch es später erneut.", + "uploadInvalidMetadata": "Prüfe Titel, Kategorie oder Datum und versuch es erneut." + }, + "toolbar": { + "searchLabel": "Suche", + "searchPlaceholder": "Titel oder Dateiname", + "kindFilterLabel": "Kategorie", + "allKinds": "Alle Kategorien", + "sortLabel": "Sortieren nach", + "sort": { + "documentDate": "Dokumentdatum", + "createdAt": "Hinzugefügt am", + "title": "Titel" + }, + "orderAsc": "Aufsteigend", + "orderDesc": "Absteigend", + "fromLabel": "Von", + "toLabel": "Bis" + }, + "detail": { + "viewOriginal": "Original ansehen", + "edit": "Bearbeiten", + "extract": "Angaben auslesen", + "extracting": "Wird ausgelesen…", + "discard": "Verwerfen", + "notExtracted": "Noch keine Angaben ausgelesen. Nutze „Angaben auslesen“, um dieses Dokument zu lesen.", + "viewOriginalNewTab": "(öffnet in einem neuen Tab)", + "discardConfirmTitle": "Dieses Dokument verwerfen?", + "discardConfirmBody": "Das löscht das Dokument, die Originaldatei und alle vorbereiteten Angaben dauerhaft. Das lässt sich nicht rückgängig machen.", + "discardConfirmAction": "Verwerfen", + "discardCancel": "Abbrechen" + }, + "edit": { + "title": "Dokument bearbeiten", + "subtitle": "Umbenennen, neu kategorisieren oder das Dokumentdatum setzen.", + "titleLabel": "Titel", + "kindLabel": "Kategorie", + "dateLabel": "Dokumentdatum", + "save": "Speichern", + "saving": "Wird gespeichert…", + "cancel": "Abbrechen" + }, + "note": { + "providerUnsupported": "Richte in den KI-Einstellungen einen Anbieter für Dokumentenscans ein, um Angaben automatisch auszulesen.", + "alreadyConfirmed": "Dieses Dokument wurde bereits geprüft und kann nicht erneut ausgelesen werden." + }, + "committed": { + "title": "Aus diesem Dokument übernommen" } }, "selfDescription": { - "intendedPurpose": "HealthLog ist ein persönliches Werkzeug zur Gesundheitsdokumentation und zum Wohlbefinden. Es hilft dir, deine eigenen Gesundheitsdaten zu erfassen, zu visualisieren und zu exportieren. Es diagnostiziert, behandelt, heilt oder verhindert keine Krankheit und ist kein Medizinprodukt.", - "coach": "Der Coach arbeitet mit deinen eigenen erfassten Daten. Er ist kein Arzt und gibt keine medizinische Beratung — für Diagnose oder Behandlung wende dich an medizinisches Fachpersonal.", - "insights": "Das sind beschreibende Muster aus deinen eigenen erfassten Daten, keine klinische Beurteilung und keine Diagnose.", - "labs": "Das sind deine eigenen Aufzeichnungen, keine medizinische Diagnose. Referenzbereiche unterscheiden sich je nach Labor und Person — nur deine Ärztin oder dein Arzt kann sie für dich einordnen.", - "medications": "Das erfasst die von dir dokumentierten Medikamente und deine eigene Einnahmetreue. Es beurteilt weder Dosierung noch Wechselwirkungen und ist keine medizinische Beratung — kläre das mit Apotheke oder Arztpraxis.", - "emergency": "Falls das ein Notfall sein könnte, wende dich jetzt an deine örtliche Notrufnummer oder an eine ärztliche Stelle. HealthLog kann im Notfall nicht helfen.", "dataPosture": "HealthLog wird selbst gehostet: Deine Gesundheitsdaten bleiben auf deiner eigenen Instanz und sind im Ruhezustand verschlüsselt. KI-Funktionen nutzen einen von dir mitgebrachten Anbieter-Schlüssel oder ein lokales Modell — nichts verlässt deine Instanz über den von dir konfigurierten Anbieter hinaus, und es gibt keine Drittanbieter-Analyse deiner Gesundheitsdaten." } } diff --git a/messages/en.json b/messages/en.json index 511493b1c..2d527bcfd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4018,16 +4018,46 @@ "disableError": "Could not disable Research Mode. Please try again." }, "shell": { - "sectionsNav": "Settings sections" + "sectionsNav": "Settings sections", + "subSectionsNav": "Settings sub-sections" + }, + "groups": { + "account": { + "title": "Account" + }, + "tracking": { + "title": "Tracking" + }, + "display": { + "title": "Display" + }, + "integrations": { + "title": "Integrations" + }, + "notifications": { + "title": "Notifications" + }, + "ai": { + "title": "AI & Coach" + }, + "access": { + "title": "API & Access" + }, + "data": { + "title": "Data & Privacy" + }, + "about": { + "title": "About" + } }, "sections": { "account": { - "title": "Account", + "title": "Profile & preferences", "description": "Profile, password, passkeys, and your onboarding tour.", "subtitle": "Your profile, identity, and sign-in details." }, "integrations": { - "title": "Integrations", + "title": "Connections", "description": "Connected services, delivery channels, and source priority in one place.", "subtitle": "Connect and manage your data sources and devices." }, @@ -4036,11 +4066,10 @@ "description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.", "channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under", "channelsHintLink": "Integrations → Channels", - "inboxLink": "Open the inbox", "subtitle": "Choose which reminders you receive." }, "layout": { - "title": "Dashboard", + "title": "Display", "description": "Arrange how the dashboard and insights are laid out.", "backToHub": "Back to Layout & Personalization", "dashboard": { @@ -4319,9 +4348,9 @@ "subtitle": "Two-factor authentication, recovery codes, and passkeys." }, "privacy": { - "title": "Data & Privacy", - "subtitle": "Your data, retention, encryption, and account security in one place.", - "description": "Export, deletion, retention, encryption status, sessions, and security activity." + "title": "Privacy & retention", + "subtitle": "Your data, retention, and encryption in one place.", + "description": "Export, deletion, retention, and encryption status." }, "mcp": { "title": "MCP Connector", @@ -4665,7 +4694,9 @@ "focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…", "completeness": "{answered} of {total} answered", "questionsTitle": "The Coach has follow-up questions", - "questionsOpenCoach": "Answer them in the Coach" + "questionsOpenCoach": "Answer them in the Coach", + "recordsLink": "Allergies, family history and your pre-existing conditions are part of your medical history.", + "recordsLinkCta": "Open medical history" }, "responseTimeoutHeading": "Response timeout", "responseTimeoutBody": "How long to wait for the AI to respond before giving up. Raise it for a local or self-hosted model that needs time to load on the first request.", @@ -5286,6 +5317,11 @@ "description": "Control how much your data is shared with AI features and opt-in research.", "openAi": "AI privacy mode", "openResearch": "Research mode" + }, + "signIn": { + "title": "Sign-in & devices", + "description": "Active sessions, trusted devices, and login activity now live in your account security settings.", + "openSecurity": "Manage sign-in & security" } }, "mcp": { @@ -7061,9 +7097,14 @@ "restoredToast": "Reading restored", "restoreError": "Could not restore the reading", "detail": { - "history": "History", "emptyTitle": "No readings yet", - "emptyDescription": "Add a value to start tracking this biomarker over time." + "emptyDescription": "Add a value to start tracking this biomarker over time.", + "title": "Biomarker", + "genericDescription": "A lab biomarker you track. Add readings to follow how it trends against its reference range.", + "valuesTitle": "{marker} — all readings", + "valuesDescription": "Every reading for this biomarker. Edit or remove an entry directly.", + "backToMarker": "Back to {marker}", + "latestResult": "Latest result" }, "chart": { "empty": "No readings in this period.", @@ -7155,8 +7196,8 @@ "ggt": "GGT", "sodium": "Sodium", "potassium": "Potassium", - "hemoglobin": "Hemoglobin", - "hematocrit": "Hematocrit", + "hemoglobin": "Haemoglobin", + "hematocrit": "Haematocrit", "wbc": "White blood cells", "platelets": "Platelets", "panel": { @@ -7172,36 +7213,36 @@ "bloodCount": "Blood count" }, "desc": { - "total-cholesterol": "Total amount of cholesterol carried in the blood, summing the LDL, HDL, and other lipoprotein fractions.", - "ldl": "Cholesterol carried by low-density lipoproteins, the fraction that deposits in artery walls.", - "hdl": "Cholesterol carried by high-density lipoproteins, which transport cholesterol back to the liver.", - "triglycerides": "The main fat circulating in the blood, drawn from diet and produced by the liver.", - "fasting-glucose": "Blood sugar measured after an overnight fast, reflecting baseline glucose regulation.", - "hba1c": "The share of haemoglobin bound to glucose, reflecting average blood sugar over the past two to three months.", - "tsh": "Pituitary hormone that signals the thyroid; the primary screen of thyroid function.", - "ft3": "The unbound, active form of triiodothyronine, the more potent thyroid hormone.", - "ft4": "The unbound form of thyroxine, the main hormone the thyroid releases into the blood.", - "ferritin": "The body's main iron-storage protein, reflecting total iron reserves.", - "transferrin-saturation": "Share of the iron-transport protein transferrin that is currently carrying iron.", - "vitamin-d": "Circulating 25-hydroxyvitamin D, the standard marker of vitamin D status.", - "vitamin-b12": "Vitamin essential for red blood cell formation and nerve function.", - "folate": "B-vitamin needed for red blood cell formation and DNA synthesis.", - "hs-crp": "A liver protein that rises with inflammation, measured at high sensitivity.", - "creatinine": "Muscle waste product cleared by the kidneys, used to gauge kidney function.", - "egfr": "Estimated rate at which the kidneys filter blood, derived from creatinine.", - "alt": "Liver enzyme released into the blood when liver cells are damaged.", - "ast": "Enzyme found in liver and muscle that rises when those tissues are injured.", - "ggt": "Liver and bile-duct enzyme that rises with bile-flow obstruction or alcohol exposure.", - "sodium": "Principal electrolyte governing the body's fluid balance and blood volume.", - "potassium": "Electrolyte essential for nerve signalling and heart-muscle function.", - "hemoglobin": "The oxygen-carrying protein in red blood cells.", - "hematocrit": "The fraction of blood volume made up of red blood cells.", - "wbc": "Total count of white blood cells, the immune system's circulating defence.", - "platelets": "Cell fragments that clump together to form blood clots.", - "apob": "Counts atherogenic particles directly; often a better risk marker than LDL-C when the two disagree.", - "lp-a": "A largely inherited, lifelong risk factor — usually measured once. Report molar units (nmol/L).", - "fasting-insulin": "Pairs with fasting glucose to estimate insulin resistance (HOMA-IR).", - "omega-3-index": "Red-blood-cell EPA+DHA share; 8–12% is the commonly cited cardioprotective range." + "total-cholesterol": "Total cholesterol is the sum of the cholesterol carried across all lipoprotein particles in the blood, including the LDL, HDL, and remnant fractions. Cholesterol itself is an essential building block for cell membranes, hormones, and bile acids. As a single number it is a coarse summary, because both a healthy HDL share and a high LDL share lift the total — so it is usually read alongside the individual fractions. It is tracked mainly as part of long-term cardiovascular risk assessment.", + "ldl": "LDL cholesterol is the cholesterol carried by low-density lipoprotein particles, the fraction that delivers cholesterol to tissues but can also deposit it in artery walls. Over years this deposition contributes to atherosclerotic plaque, which is why LDL is one of the most closely watched lipid markers. Lower long-term exposure generally tracks with lower cardiovascular risk. It is interpreted by risk category rather than against one fixed normal value.", + "hdl": "HDL cholesterol is the cholesterol carried by high-density lipoprotein particles, which collect surplus cholesterol from tissues and return it to the liver for recycling or removal. Because of this transport role it is often described as the protective fraction. Very low levels tend to accompany higher cardiovascular risk, though raising it artificially has not reliably reduced risk, so it is read as a marker rather than a target. It is usually interpreted together with LDL and triglycerides.", + "triglycerides": "Triglycerides are the main form of fat circulating in the blood, supplied both by the diet and produced by the liver, and they serve as a key energy store. Levels rise after meals, so the sample is usually taken fasting for comparability. Persistently high triglycerides are associated with metabolic and cardiovascular risk and often travel with insulin resistance and low HDL. They are read as one component of the overall lipid picture.", + "fasting-glucose": "Fasting glucose is the concentration of sugar in the blood measured after an overnight fast, reflecting how the body holds its baseline blood-sugar level without recent food. Glucose is the body's primary immediate fuel, and insulin normally keeps it within a narrow range. A fasting measurement removes the effect of recent meals, making it a standard screen for how glucose regulation is functioning. It is one of the markers used to assess metabolic health over time.", + "hba1c": "HbA1c is the share of haemoglobin in red blood cells that has become bound to glucose, which accumulates in proportion to average blood-sugar exposure. Because red cells live for about three months, it reflects average glucose over the preceding two to three months rather than a single moment. This makes it a stable, meal-independent view of glucose control. Its accuracy can be affected by conditions that change red-cell lifespan.", + "tsh": "TSH (thyroid-stimulating hormone) is released by the pituitary gland to signal the thyroid to produce its hormones, and it adjusts up or down to keep them in balance. Because of this feedback loop, TSH is the most sensitive first-line screen of thyroid function: it tends to move before the thyroid hormones themselves drift. A raised or lowered TSH points toward an under- or over-active thyroid and usually prompts measurement of the thyroid hormones directly. Pregnancy and age shift the expected range.", + "ft3": "Free T3 (triiodothyronine) is the unbound, biologically active form of the more potent thyroid hormone, the fraction available to act on tissues. Most circulating T3 is bound to carrier proteins and inactive, so the free portion is what reflects true hormone availability. It is measured to characterise thyroid function, especially alongside TSH and free T4 when results are unclear. Levels are strongly assay-dependent and read in clinical context.", + "ft4": "Free T4 (thyroxine) is the unbound form of the main hormone the thyroid releases into the blood, serving as a reservoir that tissues convert to the more active T3 as needed. The free fraction reflects what is actually available to the body, independent of carrier-protein levels. It is usually measured together with TSH to separate thyroid-gland problems from pituitary signalling. Pregnancy and certain medications can shift it.", + "ferritin": "Ferritin is the body's main iron-storage protein, and its blood level broadly mirrors total iron reserves. A low value points toward depleted stores, while a high value can reflect iron overload — or simply inflammation, because ferritin also rises as an acute-phase reactant. For that reason it is often interpreted alongside other iron markers and signs of inflammation. It is one of the core measures used to assess iron status.", + "transferrin-saturation": "Transferrin saturation is the percentage of transferrin — the protein that ferries iron through the blood — that is currently loaded with iron. It links the amount of iron in transit to the body's iron-carrying capacity, giving a snapshot of iron availability that complements ferritin's view of storage. A low saturation suggests iron is in short supply, while a high value can indicate overload. It is read together with ferritin and the full iron panel.", + "vitamin-d": "Vitamin D is measured as 25-hydroxyvitamin D, the circulating storage form and the standard marker of overall vitamin D status. The body makes it in skin exposed to sunlight and absorbs some from food, and it supports calcium balance, bone health, and immune function. Levels vary with season, latitude, skin tone, and supplementation. It is tracked to gauge whether stores are adequate over time.", + "vitamin-b12": "Vitamin B12 (cobalamin) is an essential vitamin obtained mainly from animal-source foods and stored largely in the liver. It is required for red-blood-cell formation, DNA synthesis, and the health of the nervous system. Prolonged shortfall — from low intake or impaired absorption — can lead to anaemia and nerve-related symptoms. It is measured to assess nutritional status, particularly where intake or absorption may be limited.", + "folate": "Folate (vitamin B9) is a B-vitamin found in leafy greens, legumes, and fortified foods, central to red-blood-cell formation and DNA synthesis. It works closely with vitamin B12, and a shortfall in either can produce a similar type of anaemia. Adequate folate is especially important around early pregnancy for healthy fetal development. It is measured to assess nutritional status and the cause of certain anaemias.", + "hs-crp": "High-sensitivity CRP measures C-reactive protein, a marker the liver releases in response to inflammation, using an assay refined to detect the low concentrations relevant to cardiovascular risk. Unlike standard CRP used for acute infection, the high-sensitivity version captures the subtle, chronic inflammation linked to vascular disease. Because any recent infection or injury can raise it, it is interpreted away from acute illness and often confirmed with a repeat sample. It is read as one contributor to overall cardiovascular risk.", + "creatinine": "Creatinine is a waste product of normal muscle metabolism that the kidneys filter out of the blood at a fairly steady rate. Because production is relatively constant, its blood level serves as a practical gauge of how well the kidneys are clearing waste. Values are also influenced by muscle mass, hydration, and diet, so it is usually interpreted alongside the estimated filtration rate. It is a core marker of kidney function.", + "egfr": "eGFR (estimated glomerular filtration rate) estimates how much blood the kidneys filter each minute, calculated from creatinine together with age and sex. It translates a raw creatinine value into a more directly interpretable measure of kidney function. A declining eGFR over time is the main way chronic kidney changes are staged and tracked. As an estimate it is read as a trend rather than a single exact figure.", + "alt": "ALT (alanine aminotransferase) is an enzyme concentrated in liver cells, released into the blood when those cells are stressed or damaged. Because it is relatively liver-specific, it is one of the primary markers used to detect liver injury or inflammation. Levels can rise with fatty liver, viral hepatitis, alcohol, or certain medications. It is usually read together with AST and the other liver-panel markers.", + "ast": "AST (aspartate aminotransferase) is an enzyme found in the liver but also in muscle, heart, and red blood cells, released when any of those tissues is injured. Because it is less liver-specific than ALT, the two are interpreted together — their ratio can hint at the source and nature of the problem. Vigorous exercise or muscle injury can raise it independently of the liver. It is one of the standard liver-panel enzymes.", + "ggt": "GGT (gamma-glutamyl transferase) is an enzyme found in the liver and the bile ducts that rises particularly when bile flow is obstructed or with alcohol exposure. It is often used to clarify why another liver marker is elevated — for example, to distinguish a liver or bile-duct source. It is sensitive but not very specific, so it is read in context with the rest of the liver panel. It is one of several markers used to assess liver and biliary health.", + "sodium": "Sodium is the principal electrolyte in the fluid outside the body's cells and the main driver of fluid balance and blood volume. The body regulates it tightly through thirst, hormones, and the kidneys, so its blood level reflects the balance between water and salt more than dietary intake alone. Abnormal values usually signal a disturbance of water balance rather than of sodium itself. It is a core part of the basic metabolic and electrolyte panel.", + "potassium": "Potassium is the main electrolyte inside the body's cells and is essential for nerve signalling and the steady rhythm of the heart muscle. Its blood level is held within a narrow range by the kidneys and hormonal signals, because both high and low values can disturb the heart's electrical activity. Levels can be affected by kidney function, hydration, and several common medications. It is a core measure in the electrolyte panel.", + "hemoglobin": "Haemoglobin is the iron-containing protein in red blood cells that binds oxygen in the lungs and carries it to the tissues. Its concentration is the main measure of the blood's oxygen-carrying capacity and the central marker for detecting anaemia. Low values can reflect blood loss, iron or vitamin shortfalls, or reduced production, while high values can follow dehydration or low-oxygen conditions. It is a cornerstone of the complete blood count.", + "hematocrit": "Haematocrit is the proportion of blood volume made up of red blood cells, expressed as a percentage. It moves closely with haemoglobin and gives a complementary view of red-cell mass and the blood's oxygen-carrying capacity. Hydration affects it, since plasma-volume changes shift the proportion even when red-cell numbers are stable. It is read alongside haemoglobin and the rest of the blood count.", + "wbc": "The white blood cell count is the total number of immune cells circulating in the blood, the body's mobile defence against infection. It rises in response to infection, inflammation, or stress and can fall when the bone marrow is suppressed or in some immune conditions. Because it sums several distinct cell types, a notable change is often followed up with a differential that breaks it down. It is a core component of the complete blood count.", + "platelets": "Platelets are small cell fragments in the blood that clump together and form the initial plug that stops bleeding from a damaged vessel. Their count reflects the balance between production in the bone marrow and consumption or breakdown. A low count can raise bleeding tendency, while a high count can follow inflammation or other stimuli. It is part of the complete blood count and a basic gauge of clotting capacity.", + "apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses — particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.", + "lp-a": "Lipoprotein(a) is an LDL-like particle with an extra attached protein, and its level is largely set by genetics and stays fairly stable through life. Elevated Lp(a) is an independent, inherited risk factor for cardiovascular disease and aortic-valve narrowing. Because it changes little, it is usually measured once to establish a person's lifelong risk rather than tracked repeatedly. It is reported in molar units and read as part of overall cardiovascular risk.", + "fasting-insulin": "Fasting insulin is the level of insulin — the hormone that moves glucose from the blood into cells — measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.", + "omega-3-index": "The omega-3 index is the share of EPA and DHA — the two long-chain marine omega-3 fatty acids — among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", @@ -7318,8 +7359,8 @@ "description": "Pull in daily weather and daylight for your location to correlate with mood, sleep, and vitals. Off by default." }, "inboundDocuments": { - "label": "Inbound documents", - "description": "Upload a doctor report or discharge letter; review the extracted facts before they're saved. Off by default." + "label": "Documents", + "description": "Store your clinical documents; optionally extract and review their facts before they're saved. Off by default." }, "mentalHealth": { "label": "Mental wellbeing", @@ -7558,8 +7599,7 @@ "mentalHealth": { "pageTitle": "Mental wellbeing check-in", "pageDescription": "Two validated, opt-in self-assessments — PHQ-9 (mood) and GAD-7 (worry) — that sit beside your mood tracking. The value is the trend over time, not any single number.", - "disclaimer": "This is a screening self-assessment, not a diagnosis. A high or low score does not mean you do or do not have a condition. Only a qualified health professional can diagnose. If you have concerns, please talk to your doctor.", - "optInNote": "Opt-in and private: your individual answers are encrypted, kept out of the AI Coach and exports, and never shared.", + "infoTooltip": "This is a screening self-test, not a diagnosis.", "attributionLabel": "Attribution", "choosePrompt": "Choose a check-in", "start": "Start", @@ -7774,6 +7814,15 @@ "updatedToast": "Entry updated", "saveError": "Couldn't save the entry." } + }, + "conditions": { + "cardTitle": "Pre-existing conditions", + "cardDescription": "Chronic conditions and what the Coach watches — from your personal context.", + "conditionsLabel": "Chronic conditions", + "focusLabel": "What the Coach watches", + "empty": "Nothing noted yet.", + "editCta": "Edit in personal context", + "loadError": "Couldn't load your conditions." } }, "environment": { @@ -7788,37 +7837,50 @@ } }, "documents": { - "title": "Inbound documents", - "subtitle": "Upload a clinical document and review the facts we read from it.", - "disclaimer": { - "title": "We read, we don't interpret", - "body": "We only transcribe what your document states — values, codes, and dates — and never diagnose, interpret, or flag anything. Nothing is saved until you review and approve it. Only your clinician can interpret these results." - }, + "title": "Documents", + "subtitle": "Store your clinical documents and review what they say.", "upload": { - "label": "Upload a document", - "kindLabel": "Document type", + "label": "Add a document", + "kindLabel": "Category", "button": "Upload", - "uploading": "Reading…", - "fileLabel": "Document file" + "uploading": "Uploading…", + "fileLabel": "File (required)", + "titleLabel": "Title (optional)", + "titlePlaceholder": "e.g. Cardiology report", + "dateLabel": "Document date (optional)", + "fileRequired": "Choose a file to upload.", + "accepts": "JPEG, PNG, WebP or PDF · up to 12 MB" }, "kind": { "DOCTOR_REPORT": "Doctor report", "DISCHARGE_LETTER": "Discharge letter", - "OTHER": "Other" + "OTHER": "Other", + "LAB_RESULT": "Lab result", + "IMAGING": "Imaging", + "PRESCRIPTION": "Prescription", + "INSURANCE": "Insurance", + "VACCINATION": "Vaccination" }, "list": { - "empty": "No documents yet." + "empty": "No documents yet.", + "noResults": "No documents match your filters.", + "loadMore": "Load more", + "loadingMore": "Loading…", + "pending": "{count} to review", + "facts": "{count} facts", + "clearFilters": "Clear filters", + "loadError": "Couldn't load your documents." }, "status": { "EXTRACTING": "Reading", "EXTRACTED": "Ready to review", "FAILED": "Couldn't read", "CONFIRMED": "Reviewed", - "DISCARDED": "Discarded" + "DISCARDED": "Discarded", + "STORED": "Stored" }, "review": { "title": "Review extracted facts", - "discard": "Discard", "confirm": "Save approved facts", "confirming": "Saving…", "needsReview": "Needs review", @@ -7826,7 +7888,14 @@ "reject": "Reject", "edit": "Edit", "cancel": "Cancel", - "save": "Save" + "save": "Save", + "needsReviewHint": "Edit this fact to approve it.", + "confidenceLabel": "Extraction confidence", + "commitError": { + "unitMismatch": "the unit doesn't match the saved one", + "unitRequired": "a numeric value needs a unit", + "generic": "it couldn't be saved" + } }, "empty": { "noFacts": "Nothing left to review." @@ -7844,21 +7913,72 @@ "dose": "Dose" }, "toast": { - "uploaded": "Document read. Review the facts below.", - "uploadFailed": "Couldn't read the document.", + "uploaded": "Document stored.", + "uploadFailed": "Couldn't store the document.", "confirmed": "Approved facts saved.", + "confirmedPartial": "Saved, but {count} couldn't be saved — {reason}.", "confirmFailed": "Couldn't save your decisions.", "saved": "Fact updated.", - "discarded": "Document discarded." + "discarded": "Document discarded.", + "extracted": "Facts extracted. Review them below.", + "extractFailed": "Couldn't extract facts.", + "localOcrDisabled": "Enable local OCR in settings to read this document on-device.", + "updated": "Document updated.", + "updateFailed": "Couldn't update the document.", + "discardFailed": "Couldn't discard the document.", + "uploadTooLarge": "File is too large (max 12 MB).", + "uploadFileType": "Upload a JPEG, PNG, WebP, or PDF.", + "uploadRateLimited": "Too many uploads. Try again later.", + "uploadInvalidMetadata": "Check the title, category, or date and try again." + }, + "toolbar": { + "searchLabel": "Search", + "searchPlaceholder": "Title or filename", + "kindFilterLabel": "Category", + "allKinds": "All categories", + "sortLabel": "Sort by", + "sort": { + "documentDate": "Document date", + "createdAt": "Date added", + "title": "Title" + }, + "orderAsc": "Ascending", + "orderDesc": "Descending", + "fromLabel": "From", + "toLabel": "To" + }, + "detail": { + "viewOriginal": "View original", + "edit": "Edit", + "extract": "Extract facts", + "extracting": "Extracting…", + "discard": "Discard", + "notExtracted": "No facts extracted yet. Use Extract facts to read this document.", + "viewOriginalNewTab": "(opens in a new tab)", + "discardConfirmTitle": "Discard this document?", + "discardConfirmBody": "This permanently deletes the document, its original file, and any staged facts. This can't be undone.", + "discardConfirmAction": "Discard", + "discardCancel": "Cancel" + }, + "edit": { + "title": "Edit document", + "subtitle": "Rename, recategorise, or set the document date.", + "titleLabel": "Title", + "kindLabel": "Category", + "dateLabel": "Document date", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel" + }, + "note": { + "providerUnsupported": "Configure a document-scan provider in AI settings to extract facts automatically.", + "alreadyConfirmed": "This document has already been reviewed, so it can't be extracted again." + }, + "committed": { + "title": "Approved from this document" } }, "selfDescription": { - "intendedPurpose": "HealthLog is a personal health record-keeping and wellness tool. It helps you log, visualise, and export your own health data. It does not diagnose, treat, cure, or prevent any disease, and it is not a medical device.", - "coach": "The Coach works from your own logged data. It isn't a doctor and doesn't give medical advice — for diagnosis or treatment, talk to a healthcare professional.", - "insights": "These are descriptive patterns computed from your own logged data, not a clinical assessment or diagnosis.", - "labs": "These are your own records, not a medical diagnosis. Reference ranges vary by lab and person — only your clinician can interpret them for you.", - "medications": "This tracks the medications you log and your own adherence. It doesn't assess dosing or interactions and isn't medical advice — confirm those with a pharmacist or doctor.", - "emergency": "If this might be an emergency, contact your local emergency number or a clinician now. HealthLog can't help in an emergency.", "dataPosture": "HealthLog is self-hosted: your health data stays on your own instance, encrypted at rest. AI features use a provider key you bring or a local model, so nothing leaves your instance beyond the provider you configure, and there is no third-party analytics on your health data." } } diff --git a/messages/es.json b/messages/es.json index 298329f0c..ab0958f0a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4018,16 +4018,46 @@ "disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo." }, "shell": { - "sectionsNav": "Secciones de ajustes" + "sectionsNav": "Secciones de ajustes", + "subSectionsNav": "Subsecciones de ajustes" + }, + "groups": { + "account": { + "title": "Cuenta" + }, + "tracking": { + "title": "Seguimiento" + }, + "display": { + "title": "Visualización" + }, + "integrations": { + "title": "Integraciones" + }, + "notifications": { + "title": "Notificaciones" + }, + "ai": { + "title": "IA y Coach" + }, + "access": { + "title": "API y acceso" + }, + "data": { + "title": "Datos y privacidad" + }, + "about": { + "title": "Acerca de" + } }, "sections": { "account": { - "title": "Cuenta", + "title": "Perfil y preferencias", "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.", "subtitle": "Tu perfil, identidad y datos de acceso." }, "integrations": { - "title": "Integraciones", + "title": "Conexiones", "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos." }, @@ -4036,11 +4066,10 @@ "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.", "channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en", "channelsHintLink": "Integraciones → Canales", - "inboxLink": "Abrir la bandeja de entrada", "subtitle": "Elige qué recordatorios recibes." }, "layout": { - "title": "Panel", + "title": "Visualización", "description": "Organiza cómo están dispuestos el panel y los análisis.", "backToHub": "Volver a Diseño y personalización", "dashboard": { @@ -4319,16 +4348,16 @@ "subtitle": "Autenticación de dos factores, códigos de recuperación y claves de acceso." }, "privacy": { - "title": "Datos y privacidad", - "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.", - "description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad." + "title": "Privacidad y retención", + "subtitle": "Tus datos, su retención y el cifrado en un solo lugar.", + "description": "Exportación, eliminación, retención y estado de cifrado." }, "mcp": { "title": "Conector MCP", "subtitle": "Conecta un asistente externo a tus propios datos de salud." }, "anamnesis": { - "title": "Historial médico", + "title": "Antecedentes médicos", "subtitle": "Gestiona tus alergias y tus antecedentes familiares.", "description": "Registros estructurados de alergias y antecedentes familiares." }, @@ -4665,7 +4694,9 @@ "focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…", "completeness": "{answered} de {total} respondidas", "questionsTitle": "El Coach tiene preguntas", - "questionsOpenCoach": "Responderlas en el Coach" + "questionsOpenCoach": "Responderlas en el Coach", + "recordsLink": "Las alergias, los antecedentes familiares y tus enfermedades preexistentes forman parte de tu historia clínica.", + "recordsLinkCta": "Abrir historia clínica" }, "responseTimeoutHeading": "Tiempo de espera de la respuesta", "responseTimeoutBody": "Cuánto esperar a que responda la IA antes de cancelar. Aumenta el valor para un modelo local o autoalojado que necesita tiempo para cargarse en la primera solicitud.", @@ -5286,6 +5317,11 @@ "description": "Controla cuántos de tus datos se comparten con las funciones de IA y la investigación opcional.", "openAi": "Modo de privacidad de IA", "openResearch": "Modo investigación" + }, + "signIn": { + "title": "Inicio de sesión y dispositivos", + "description": "Las sesiones activas, los dispositivos de confianza y la actividad de inicio de sesión ahora están en la configuración de seguridad de tu cuenta.", + "openSecurity": "Gestionar inicio de sesión y seguridad" } }, "mcp": { @@ -7061,9 +7097,14 @@ "restoredToast": "Medición restaurada", "restoreError": "No se pudo restaurar la medición", "detail": { - "history": "Historial", "emptyTitle": "Aún no hay mediciones", - "emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo." + "emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo.", + "title": "Biomarcador", + "genericDescription": "Un biomarcador de laboratorio que sigues. Añade lecturas para ver su evolución frente al rango de referencia.", + "valuesTitle": "{marker} — todas las lecturas", + "valuesDescription": "Cada lectura de este biomarcador. Edita o elimina una entrada directamente.", + "backToMarker": "Volver a {marker}", + "latestResult": "Último resultado" }, "chart": { "empty": "Sin mediciones en este periodo.", @@ -7172,36 +7213,36 @@ "bloodCount": "Hemograma" }, "desc": { - "total-cholesterol": "Cantidad total de colesterol transportado en la sangre, suma de las fracciones LDL, HDL y otras lipoproteínas.", - "ldl": "Colesterol transportado por lipoproteínas de baja densidad, la fracción que se deposita en las paredes arteriales.", - "hdl": "Colesterol transportado por lipoproteínas de alta densidad, que lo devuelven al hígado.", - "triglycerides": "La principal grasa que circula en la sangre, procedente de la dieta y producida por el hígado.", - "fasting-glucose": "Azúcar en sangre medido en ayunas, refleja la regulación basal de la glucosa.", - "hba1c": "Proporción de hemoglobina unida a glucosa, refleja el azúcar en sangre de los últimos dos o tres meses.", - "tsh": "Hormona hipofisaria que regula la tiroides; la prueba principal de la función tiroidea.", - "ft3": "La forma libre y activa de la triyodotironina, la hormona tiroidea más potente.", - "ft4": "La forma libre de la tiroxina, la principal hormona que la tiroides libera a la sangre.", - "ferritin": "La principal proteína de almacenamiento de hierro del cuerpo, refleja las reservas de hierro.", - "transferrin-saturation": "Proporción de la proteína transportadora transferrina que lleva hierro en ese momento.", - "vitamin-d": "25-hidroxivitamina D circulante, el marcador estándar del estado de vitamina D.", - "vitamin-b12": "Vitamina esencial para la formación de glóbulos rojos y la función nerviosa.", - "folate": "Vitamina B necesaria para la formación de glóbulos rojos y la síntesis de ADN.", - "hs-crp": "Una proteína hepática que aumenta con la inflamación, medida con alta sensibilidad.", - "creatinine": "Producto de desecho muscular eliminado por los riñones, usado para evaluar la función renal.", - "egfr": "Tasa estimada de filtración de los riñones, calculada a partir de la creatinina.", - "alt": "Enzima hepática liberada a la sangre cuando se dañan las células del hígado.", - "ast": "Enzima del hígado y el músculo que aumenta cuando esos tejidos se lesionan.", - "ggt": "Enzima del hígado y las vías biliares que aumenta con la obstrucción biliar o el alcohol.", - "sodium": "Principal electrolito que regula el equilibrio de líquidos y el volumen sanguíneo.", - "potassium": "Electrolito esencial para la transmisión nerviosa y la función del músculo cardíaco.", - "hemoglobin": "La proteína de los glóbulos rojos que transporta el oxígeno.", - "hematocrit": "La fracción del volumen sanguíneo formada por glóbulos rojos.", - "wbc": "Recuento total de glóbulos blancos, la defensa circulante del sistema inmunitario.", - "platelets": "Fragmentos celulares que se agregan para formar coágulos sanguíneos.", - "apob": "Cuenta directamente las partículas aterogénicas; a menudo mejor marcador de riesgo que el LDL-C cuando discrepan.", - "lp-a": "Factor de riesgo en gran parte hereditario y de por vida — suele medirse una vez. Informa en unidades molares (nmol/L).", - "fasting-insulin": "Se combina con la glucosa en ayunas para estimar la resistencia a la insulina (HOMA-IR).", - "omega-3-index": "Proporción de EPA+DHA en glóbulos rojos; 8–12 % es el rango cardioprotector citado habitualmente." + "total-cholesterol": "El colesterol total es la suma del colesterol transportado por todas las partículas de lipoproteínas de la sangre, incluidas las fracciones LDL, HDL y remanentes. El colesterol es en sí mismo un componente esencial de las membranas celulares, las hormonas y los ácidos biliares. Como cifra única resulta una medida aproximada, porque tanto una proporción alta de HDL como una alta de LDL elevan el total, por lo que suele leerse junto con las fracciones individuales. Se registra sobre todo dentro de la evaluación del riesgo cardiovascular a largo plazo.", + "ldl": "El colesterol LDL es el colesterol transportado por las partículas de lipoproteína de baja densidad, la fracción que lleva el colesterol a los tejidos pero que también puede depositarlo en las paredes arteriales. A lo largo de los años, ese depósito contribuye a la placa aterosclerótica, por lo que el LDL es uno de los marcadores lipídicos más vigilados. Una menor exposición a largo plazo suele asociarse a un menor riesgo cardiovascular. Se interpreta por categoría de riesgo y no frente a un único valor normal fijo.", + "hdl": "El colesterol HDL es el colesterol transportado por las partículas de lipoproteína de alta densidad, que recogen el colesterol sobrante de los tejidos y lo devuelven al hígado para reciclarlo o eliminarlo. Por esa función de transporte se le suele llamar la fracción protectora. Los niveles muy bajos tienden a acompañar a un mayor riesgo cardiovascular, aunque elevarlo de forma artificial no ha reducido el riesgo de manera fiable, por lo que se lee como marcador y no como objetivo. Suele interpretarse junto con el LDL y los triglicéridos.", + "triglycerides": "Los triglicéridos son la principal forma de grasa que circula por la sangre, aportada tanto por la dieta como producida por el hígado, y constituyen una reserva clave de energía. Los niveles suben después de comer, por lo que la muestra suele tomarse en ayunas para que sea comparable. Unos triglicéridos altos de forma persistente se asocian a riesgo metabólico y cardiovascular y a menudo acompañan a la resistencia a la insulina y a un HDL bajo. Se leen como un componente del cuadro lipídico global.", + "fasting-glucose": "La glucosa en ayunas es la concentración de azúcar en la sangre medida tras un ayuno nocturno, y refleja cómo mantiene el cuerpo su nivel basal de azúcar sin comida reciente. La glucosa es el combustible inmediato principal del organismo, y la insulina la mantiene normalmente dentro de un rango estrecho. Una medición en ayunas elimina el efecto de las comidas recientes, lo que la convierte en un cribado estándar del funcionamiento de la regulación de la glucosa. Es uno de los marcadores con los que se evalúa la salud metabólica a lo largo del tiempo.", + "hba1c": "La HbA1c es la proporción de hemoglobina de los glóbulos rojos que se ha unido a la glucosa, y se acumula en proporción a la exposición media al azúcar en sangre. Como los glóbulos rojos viven unos tres meses, refleja la glucosa media de los dos o tres meses anteriores y no un instante concreto. Esto la convierte en una visión estable e independiente de las comidas del control de la glucosa. Su fiabilidad puede verse afectada por trastornos que alteran la vida de los glóbulos rojos.", + "tsh": "La TSH (hormona estimulante del tiroides) la libera la hipófisis para indicar al tiroides que produzca sus hormonas, y se ajusta al alza o a la baja para mantenerlas en equilibrio. Gracias a ese circuito de retroalimentación, la TSH es el cribado de primera línea más sensible de la función tiroidea: suele moverse antes de que las propias hormonas tiroideas se desvíen. Un valor elevado o reducido apunta a un tiroides hipo o hiperactivo y normalmente lleva a medir directamente las hormonas tiroideas. El embarazo y la edad desplazan el rango esperado.", + "ft3": "El T3 libre (triyodotironina) es la forma no unida y biológicamente activa de la hormona tiroidea más potente, la fracción disponible para actuar sobre los tejidos. La mayor parte del T3 circulante está unida a proteínas transportadoras e inactiva, de modo que la porción libre refleja la disponibilidad real de la hormona. Se mide para caracterizar la función tiroidea, sobre todo junto con la TSH y el T4 libre cuando los resultados no están claros. Los niveles dependen mucho del método de análisis y se leen en su contexto clínico.", + "ft4": "El T4 libre (tiroxina) es la forma no unida de la principal hormona que el tiroides libera a la sangre, y actúa como reserva que los tejidos convierten en el T3 más activo según lo necesiten. La fracción libre refleja lo que realmente está disponible para el cuerpo, con independencia del nivel de proteínas transportadoras. Suele medirse junto con la TSH para distinguir los problemas del tiroides de los de la señalización hipofisaria. El embarazo y ciertos medicamentos pueden desplazarlo.", + "ferritin": "La ferritina es la principal proteína de almacenamiento de hierro del cuerpo, y su nivel en sangre refleja a grandes rasgos las reservas totales de hierro. Un valor bajo apunta a unas reservas agotadas, mientras que uno alto puede reflejar una sobrecarga de hierro, o simplemente una inflamación, ya que la ferritina también sube como reactante de fase aguda. Por eso suele interpretarse junto con otros marcadores de hierro y signos de inflamación. Es una de las medidas centrales para evaluar el estado del hierro.", + "transferrin-saturation": "La saturación de transferrina es el porcentaje de transferrina —la proteína que transporta el hierro por la sangre— que está cargado de hierro en ese momento. Relaciona la cantidad de hierro en tránsito con la capacidad de transporte del cuerpo, ofreciendo una instantánea de la disponibilidad de hierro que complementa la visión de las reservas que da la ferritina. Una saturación baja sugiere que el hierro escasea, mientras que un valor alto puede indicar sobrecarga. Se lee junto con la ferritina y el perfil completo del hierro.", + "vitamin-d": "La vitamina D se mide como 25-hidroxivitamina D, la forma de almacenamiento circulante y el marcador estándar del estado global de vitamina D. El cuerpo la fabrica en la piel expuesta a la luz solar y absorbe una parte de los alimentos, y contribuye al equilibrio del calcio, la salud ósea y la función inmunitaria. Los niveles varían con la estación, la latitud, el tono de piel y la suplementación. Se registra para valorar a lo largo del tiempo si las reservas son adecuadas.", + "vitamin-b12": "La vitamina B12 (cobalamina) es una vitamina esencial que se obtiene sobre todo de alimentos de origen animal y se almacena en gran parte en el hígado. Es necesaria para la formación de glóbulos rojos, la síntesis de ADN y la salud del sistema nervioso. Una carencia prolongada —por baja ingesta o absorción deficiente— puede provocar anemia y síntomas neurológicos. Se mide para evaluar el estado nutricional, en especial cuando la ingesta o la absorción pueden estar limitadas.", + "folate": "El folato (vitamina B9) es una vitamina del grupo B presente en las verduras de hoja verde, las legumbres y los alimentos enriquecidos, central para la formación de glóbulos rojos y la síntesis de ADN. Trabaja en estrecha relación con la vitamina B12, y una carencia de cualquiera de las dos puede producir un tipo de anemia similar. Un folato adecuado es especialmente importante en torno al inicio del embarazo para el desarrollo sano del feto. Se mide para evaluar el estado nutricional y la causa de ciertas anemias.", + "hs-crp": "La PCR de alta sensibilidad mide la proteína C reactiva, un marcador que el hígado libera en respuesta a la inflamación, con una técnica afinada para detectar las bajas concentraciones relevantes para el riesgo cardiovascular. A diferencia de la PCR estándar usada para infecciones agudas, la versión de alta sensibilidad capta la inflamación sutil y crónica vinculada a la enfermedad vascular. Como cualquier infección o lesión reciente puede elevarla, se interpreta lejos de una enfermedad aguda y a menudo se confirma con una muestra repetida. Se lee como un factor más del riesgo cardiovascular global.", + "creatinine": "La creatinina es un producto de desecho del metabolismo muscular normal que los riñones filtran de la sangre a un ritmo bastante constante. Como la producción es relativamente estable, su nivel en sangre sirve de medida práctica de la eficacia con que los riñones eliminan los desechos. Los valores también dependen de la masa muscular, la hidratación y la dieta, por lo que suelen interpretarse junto con la tasa de filtración estimada. Es un marcador central de la función renal.", + "egfr": "La eGFR (tasa de filtración glomerular estimada) calcula cuánta sangre filtran los riñones cada minuto, a partir de la creatinina junto con la edad y el sexo. Traduce un valor bruto de creatinina en una medida de la función renal más fácil de interpretar. Un descenso de la eGFR con el tiempo es la principal forma de clasificar y seguir los cambios renales crónicos. Al ser una estimación, se lee como una tendencia y no como una cifra exacta única.", + "alt": "La ALT (alanina aminotransferasa) es una enzima concentrada en las células del hígado, que se libera a la sangre cuando esas células están estresadas o dañadas. Por ser relativamente específica del hígado, es uno de los principales marcadores para detectar lesión o inflamación hepática. Sus valores pueden subir con el hígado graso, la hepatitis viral, el alcohol o ciertos medicamentos. Suele leerse junto con la AST y el resto de los marcadores del perfil hepático.", + "ast": "La AST (aspartato aminotransferasa) es una enzima presente en el hígado, pero también en el músculo, el corazón y los glóbulos rojos, que se libera cuando cualquiera de esos tejidos sufre daño. Por ser menos específica del hígado que la ALT, ambas se interpretan juntas, y su cociente puede orientar sobre el origen y la naturaleza del problema. El ejercicio intenso o una lesión muscular pueden elevarla al margen del hígado. Es una de las enzimas estándar del perfil hepático.", + "ggt": "La GGT (gamma-glutamil transferasa) es una enzima del hígado y las vías biliares que sube en especial cuando se obstruye el flujo de la bilis o ante el consumo de alcohol. A menudo se usa para aclarar por qué está elevado otro marcador hepático, por ejemplo, para distinguir un origen hepático o biliar. Es sensible pero poco específica, por lo que se lee en el contexto del resto del perfil hepático. Es uno de los varios marcadores que se usan para valorar la salud hepática y biliar.", + "sodium": "El sodio es el principal electrolito del líquido que rodea las células del cuerpo y el motor fundamental del equilibrio de líquidos y del volumen sanguíneo. El organismo lo regula de forma estricta mediante la sed, las hormonas y los riñones, de modo que su nivel en sangre refleja más el equilibrio entre el agua y la sal que la ingesta por sí sola. Los valores anómalos suelen indicar una alteración del equilibrio de agua y no del propio sodio. Es una parte central del panel metabólico básico y de electrolitos.", + "potassium": "El potasio es el principal electrolito del interior de las células del cuerpo y es esencial para la transmisión nerviosa y el ritmo estable del músculo cardíaco. Su nivel en sangre se mantiene en un rango estrecho gracias a los riñones y a las señales hormonales, porque tanto los valores altos como los bajos pueden alterar la actividad eléctrica del corazón. Los niveles pueden verse afectados por la función renal, la hidratación y varios medicamentos habituales. Es una medida central del panel de electrolitos.", + "hemoglobin": "La hemoglobina es la proteína con hierro de los glóbulos rojos que fija el oxígeno en los pulmones y lo transporta a los tejidos. Su concentración es la principal medida de la capacidad de transporte de oxígeno de la sangre y el marcador central para detectar la anemia. Los valores bajos pueden reflejar pérdida de sangre, déficit de hierro o de vitaminas, o una producción reducida, mientras que los altos pueden seguir a la deshidratación o a condiciones de poco oxígeno. Es una piedra angular del hemograma completo.", + "hematocrit": "El hematocrito es la proporción del volumen de sangre formada por glóbulos rojos, expresada como porcentaje. Se mueve en estrecha relación con la hemoglobina y ofrece una visión complementaria de la masa de glóbulos rojos y de la capacidad de transporte de oxígeno de la sangre. La hidratación influye en él, ya que los cambios del volumen de plasma desplazan la proporción aunque el número de glóbulos rojos se mantenga estable. Se lee junto con la hemoglobina y el resto del hemograma.", + "wbc": "El recuento de leucocitos es el número total de células inmunitarias que circulan por la sangre, la defensa móvil del cuerpo frente a las infecciones. Sube en respuesta a infección, inflamación o estrés y puede bajar cuando la médula ósea está suprimida o en algunas enfermedades inmunitarias. Como reúne varios tipos de células distintas, un cambio notable suele completarse con una fórmula que lo desglosa. Es un componente central del hemograma completo.", + "platelets": "Las plaquetas son pequeños fragmentos celulares de la sangre que se agregan y forman el tapón inicial que detiene el sangrado de un vaso dañado. Su recuento refleja el equilibrio entre la producción en la médula ósea y el consumo o la destrucción. Un recuento bajo puede aumentar la tendencia al sangrado, mientras que uno alto puede seguir a una inflamación u otros estímulos. Forman parte del hemograma completo y son una medida básica de la capacidad de coagulación.", + "apob": "La apolipoproteína B es la proteína estructural que lleva cada una de las partículas de lipoproteína aterogénicas, de modo que medirla cuenta directamente esas partículas en lugar del colesterol que transportan. Como hay una molécula de ApoB en cada partícula de LDL, VLDL y afines, puede captar un riesgo cardiovascular que un valor de colesterol LDL pasa por alto, sobre todo cuando el número de partículas y su contenido de colesterol divergen. Por eso se usa cada vez más para afinar la evaluación del riesgo basada en los lípidos. Se interpreta junto con el perfil lipídico habitual.", + "lp-a": "La lipoproteína(a) es una partícula similar al LDL con una proteína adicional unida, cuyo nivel viene determinado en gran medida por la genética y se mantiene bastante estable a lo largo de la vida. Una Lp(a) elevada es un factor de riesgo independiente y hereditario de enfermedad cardiovascular y de estrechamiento de la válvula aórtica. Como cambia poco, suele medirse una sola vez para establecer el riesgo vital de una persona, en lugar de seguirla de forma repetida. Se expresa en unidades molares y se lee como parte del riesgo cardiovascular global.", + "fasting-insulin": "La insulina en ayunas es el nivel de insulina —la hormona que traslada la glucosa de la sangre a las células— medido tras un ayuno nocturno. Junto con la glucosa en ayunas, ayuda a estimar la resistencia a la insulina, el estado en el que el cuerpo necesita más insulina para mantener la glucosa dentro de su rango. Una insulina en ayunas alta con una glucosa normal puede ser un signo temprano de que esa compensación está en marcha. Se lee como parte de un cuadro de salud metabólica y no de forma aislada.", + "omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses." }, "apob": "Apolipoproteína B (ApoB)", "lp-a": "Lipoproteína(a)", @@ -7318,8 +7359,8 @@ "description": "Incorpora el tiempo y las horas de luz diarias de tu ubicación para correlacionarlos con el ánimo, el sueño y las constantes. Desactivado por defecto." }, "inboundDocuments": { - "label": "Documentos entrantes", - "description": "Sube un informe médico o un alta; revisa los datos extraídos antes de guardarlos. Desactivado de forma predeterminada." + "label": "Documentos", + "description": "Guarda tus documentos clínicos; opcionalmente extrae y revisa sus datos antes de guardarlos. Desactivado por defecto." }, "mentalHealth": { "label": "Bienestar mental", @@ -7558,8 +7599,7 @@ "mentalHealth": { "pageTitle": "Revisión de bienestar mental", "pageDescription": "Dos autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo) y GAD-7 (preocupación) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.", - "disclaimer": "Esto es una autoevaluación de cribado, no un diagnóstico. Una puntuación alta o baja no significa que tengas o no una afección. Solo un profesional de salud cualificado puede diagnosticar. Si tienes dudas, habla con tu médico.", - "optInNote": "Opcional y privado: tus respuestas individuales están cifradas, se mantienen fuera del Coach de IA y de las exportaciones, y nunca se comparten.", + "infoTooltip": "Esto es una autoevaluación de cribado, no un diagnóstico.", "attributionLabel": "Fuente", "choosePrompt": "Elige una revisión", "start": "Empezar", @@ -7774,6 +7814,15 @@ "updatedToast": "Entrada actualizada", "saveError": "No se ha podido guardar la entrada." } + }, + "conditions": { + "cardTitle": "Enfermedades preexistentes", + "cardDescription": "Enfermedades crónicas y aquello que el Coach debe vigilar, desde tu contexto personal.", + "conditionsLabel": "Enfermedades crónicas", + "focusLabel": "Qué vigila el Coach", + "empty": "Aún no hay nada anotado.", + "editCta": "Editar en el contexto personal", + "loadError": "No se pudieron cargar tus enfermedades." } }, "environment": { @@ -7788,37 +7837,50 @@ } }, "documents": { - "title": "Documentos entrantes", - "subtitle": "Sube un documento clínico y revisa los datos que hemos leído.", - "disclaimer": { - "title": "Leemos, no interpretamos", - "body": "Solo transcribimos lo que indica tu documento — valores, códigos y fechas — y nunca diagnosticamos, interpretamos ni marcamos nada. No se guarda nada hasta que lo revises y lo apruebes. Solo tu profesional sanitario puede interpretar estos resultados." - }, + "title": "Documentos", + "subtitle": "Guarda tus documentos clínicos y revisa lo que dicen.", "upload": { - "label": "Subir un documento", - "kindLabel": "Tipo de documento", + "label": "Añadir un documento", + "kindLabel": "Categoría", "button": "Subir", - "uploading": "Leyendo…", - "fileLabel": "Archivo del documento" + "uploading": "Subiendo…", + "fileLabel": "Archivo (obligatorio)", + "titleLabel": "Título (opcional)", + "titlePlaceholder": "p. ej. Informe de cardiología", + "dateLabel": "Fecha del documento (opcional)", + "fileRequired": "Elige un archivo para subir.", + "accepts": "JPEG, PNG, WebP o PDF · hasta 12 MB" }, "kind": { "DOCTOR_REPORT": "Informe médico", - "DISCHARGE_LETTER": "Alta hospitalaria", - "OTHER": "Otro" + "DISCHARGE_LETTER": "Informe de alta", + "OTHER": "Otro", + "LAB_RESULT": "Resultado de laboratorio", + "IMAGING": "Imagen", + "PRESCRIPTION": "Receta", + "INSURANCE": "Seguro", + "VACCINATION": "Vacunación" }, "list": { - "empty": "Aún no hay documentos." + "empty": "Aún no hay documentos.", + "noResults": "Ningún documento coincide con tus filtros.", + "loadMore": "Cargar más", + "loadingMore": "Cargando…", + "pending": "{count} por revisar", + "facts": "{count} datos", + "clearFilters": "Borrar filtros", + "loadError": "No se pudieron cargar tus documentos." }, "status": { "EXTRACTING": "Leyendo", "EXTRACTED": "Listo para revisar", "FAILED": "No se pudo leer", "CONFIRMED": "Revisado", - "DISCARDED": "Descartado" + "DISCARDED": "Descartado", + "STORED": "Guardado" }, "review": { "title": "Revisar datos extraídos", - "discard": "Descartar", "confirm": "Guardar datos aprobados", "confirming": "Guardando…", "needsReview": "Requiere revisión", @@ -7826,7 +7888,14 @@ "reject": "Rechazar", "edit": "Editar", "cancel": "Cancelar", - "save": "Guardar" + "save": "Guardar", + "needsReviewHint": "Edita este dato para poder aprobarlo.", + "confidenceLabel": "Confianza de la extracción", + "commitError": { + "unitMismatch": "la unidad no coincide con la guardada", + "unitRequired": "un valor numérico necesita una unidad", + "generic": "no se pudo guardar" + } }, "empty": { "noFacts": "Nada más que revisar." @@ -7844,21 +7913,72 @@ "dose": "Dosis" }, "toast": { - "uploaded": "Documento leído. Revisa los datos a continuación.", - "uploadFailed": "No se pudo leer el documento.", + "uploaded": "Documento guardado.", + "uploadFailed": "No se pudo guardar el documento.", "confirmed": "Datos aprobados guardados.", + "confirmedPartial": "Guardado, pero {count} no se pudieron guardar: {reason}.", "confirmFailed": "No se pudieron guardar tus decisiones.", "saved": "Dato actualizado.", - "discarded": "Documento descartado." + "discarded": "Documento descartado.", + "extracted": "Datos extraídos. Revísalos abajo.", + "extractFailed": "No se pudieron extraer los datos.", + "localOcrDisabled": "Activa el OCR local en los ajustes para leer este documento en el dispositivo.", + "updated": "Documento actualizado.", + "updateFailed": "No se pudo actualizar el documento.", + "discardFailed": "No se pudo descartar el documento.", + "uploadTooLarge": "El archivo es demasiado grande (máx. 12 MB).", + "uploadFileType": "Sube un archivo JPEG, PNG, WebP o PDF.", + "uploadRateLimited": "Demasiadas subidas. Inténtalo de nuevo más tarde.", + "uploadInvalidMetadata": "Revisa el título, la categoría o la fecha e inténtalo de nuevo." + }, + "toolbar": { + "searchLabel": "Buscar", + "searchPlaceholder": "Título o nombre de archivo", + "kindFilterLabel": "Categoría", + "allKinds": "Todas las categorías", + "sortLabel": "Ordenar por", + "sort": { + "documentDate": "Fecha del documento", + "createdAt": "Fecha de adición", + "title": "Título" + }, + "orderAsc": "Ascendente", + "orderDesc": "Descendente", + "fromLabel": "Desde", + "toLabel": "Hasta" + }, + "detail": { + "viewOriginal": "Ver original", + "edit": "Editar", + "extract": "Extraer datos", + "extracting": "Extrayendo…", + "discard": "Descartar", + "notExtracted": "Aún no se han extraído datos. Usa Extraer datos para leer este documento.", + "viewOriginalNewTab": "(se abre en una pestaña nueva)", + "discardConfirmTitle": "¿Descartar este documento?", + "discardConfirmBody": "Esto elimina de forma permanente el documento, su archivo original y los datos preparados. No se puede deshacer.", + "discardConfirmAction": "Descartar", + "discardCancel": "Cancelar" + }, + "edit": { + "title": "Editar documento", + "subtitle": "Renombra, recategoriza o fija la fecha del documento.", + "titleLabel": "Título", + "kindLabel": "Categoría", + "dateLabel": "Fecha del documento", + "save": "Guardar", + "saving": "Guardando…", + "cancel": "Cancelar" + }, + "note": { + "providerUnsupported": "Configura un proveedor de escaneo de documentos en los ajustes de IA para extraer datos automáticamente.", + "alreadyConfirmed": "Este documento ya se ha revisado, por lo que no se puede volver a extraer." + }, + "committed": { + "title": "Aprobado de este documento" } }, "selfDescription": { - "intendedPurpose": "HealthLog es una herramienta personal de registro de salud y bienestar. Te ayuda a registrar, visualizar y exportar tus propios datos de salud. No diagnostica, trata, cura ni previene ninguna enfermedad, y no es un producto sanitario.", - "coach": "El Coach trabaja con tus propios datos registrados. No es un médico y no da consejo médico: para un diagnóstico o tratamiento, consulta a un profesional sanitario.", - "insights": "Son patrones descriptivos calculados a partir de tus propios datos registrados, no una evaluación clínica ni un diagnóstico.", - "labs": "Son tus propios registros, no un diagnóstico médico. Los rangos de referencia varían según el laboratorio y la persona: solo tu médico puede interpretarlos por ti.", - "medications": "Esto registra los medicamentos que anotas y tu propia adherencia. No evalúa dosis ni interacciones y no es consejo médico: confírmalo con tu farmacéutico o médico.", - "emergency": "Si esto pudiera ser una emergencia, llama ahora a tu número de emergencias local o a un profesional sanitario. HealthLog no puede ayudar en una emergencia.", "dataPosture": "HealthLog es autoalojado: tus datos de salud permanecen en tu propia instancia, cifrados en reposo. Las funciones de IA usan una clave de proveedor que tú aportas o un modelo local, así que nada sale de tu instancia más allá del proveedor que configures, y no hay analítica de terceros sobre tus datos de salud." } } diff --git a/messages/fr.json b/messages/fr.json index ae54eb4e2..32ae4643c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4018,16 +4018,46 @@ "disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer." }, "shell": { - "sectionsNav": "Sections des réglages" + "sectionsNav": "Sections des réglages", + "subSectionsNav": "Sous-sections des réglages" + }, + "groups": { + "account": { + "title": "Compte" + }, + "tracking": { + "title": "Suivi" + }, + "display": { + "title": "Affichage" + }, + "integrations": { + "title": "Intégrations" + }, + "notifications": { + "title": "Notifications" + }, + "ai": { + "title": "IA et coach" + }, + "access": { + "title": "API et accès" + }, + "data": { + "title": "Données et confidentialité" + }, + "about": { + "title": "À propos" + } }, "sections": { "account": { - "title": "Compte", + "title": "Profil et préférences", "description": "Profil, mot de passe, clés d’accès et votre visite guidée.", "subtitle": "Votre profil, votre identité et vos identifiants." }, "integrations": { - "title": "Intégrations", + "title": "Connexions", "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.", "subtitle": "Connectez et gérez vos sources de données et appareils." }, @@ -4036,11 +4066,10 @@ "description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.", "channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans", "channelsHintLink": "Intégrations → Canaux", - "inboxLink": "Ouvrir la boîte de réception", "subtitle": "Choisissez les rappels que vous recevez." }, "layout": { - "title": "Tableau de bord", + "title": "Affichage", "description": "Organisez la disposition du tableau de bord et des analyses.", "backToHub": "Retour à Disposition et personnalisation", "dashboard": { @@ -4319,9 +4348,9 @@ "subtitle": "Authentification à deux facteurs, codes de récupération et clés d'accès." }, "privacy": { - "title": "Données et confidentialité", - "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.", - "description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité." + "title": "Confidentialité et conservation", + "subtitle": "Vos données, leur conservation et le chiffrement au même endroit.", + "description": "Export, suppression, conservation et état du chiffrement." }, "mcp": { "title": "Connecteur MCP", @@ -4665,7 +4694,9 @@ "focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…", "completeness": "{answered} sur {total} renseignés", "questionsTitle": "Le Coach a des questions", - "questionsOpenCoach": "Y répondre dans le Coach" + "questionsOpenCoach": "Y répondre dans le Coach", + "recordsLink": "Les allergies, les antécédents familiaux et vos affections préexistantes font partie de vos antécédents médicaux.", + "recordsLinkCta": "Ouvrir les antécédents médicaux" }, "responseTimeoutHeading": "Délai de réponse", "responseTimeoutBody": "Durée d'attente de la réponse de l'IA avant d'abandonner. Augmentez-la pour un modèle local ou auto-hébergé qui a besoin de temps pour se charger à la première requête.", @@ -5286,6 +5317,11 @@ "description": "Contrôlez la quantité de données partagées avec les fonctions d'IA et la recherche optionnelle.", "openAi": "Mode confidentialité IA", "openResearch": "Mode recherche" + }, + "signIn": { + "title": "Connexion et appareils", + "description": "Les sessions actives, les appareils de confiance et l'activité de connexion se trouvent désormais dans les paramètres de sécurité de votre compte.", + "openSecurity": "Gérer la connexion et la sécurité" } }, "mcp": { @@ -7061,9 +7097,14 @@ "restoredToast": "Mesure restaurée", "restoreError": "Impossible de restaurer la mesure", "detail": { - "history": "Historique", "emptyTitle": "Aucune mesure pour l'instant", - "emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps." + "emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps.", + "title": "Biomarqueur", + "genericDescription": "Un biomarqueur biologique que vous suivez. Ajoutez des mesures pour observer son évolution par rapport à la plage de référence.", + "valuesTitle": "{marker} — toutes les valeurs", + "valuesDescription": "Chaque mesure de ce biomarqueur. Modifiez ou supprimez une entrée directement.", + "backToMarker": "Retour à {marker}", + "latestResult": "Dernier résultat" }, "chart": { "empty": "Aucune mesure sur cette période.", @@ -7172,36 +7213,36 @@ "bloodCount": "Numération sanguine" }, "desc": { - "total-cholesterol": "Quantité totale de cholestérol transporté dans le sang, somme des fractions LDL, HDL et autres lipoprotéines.", - "ldl": "Cholestérol transporté par les lipoprotéines de basse densité, la fraction qui se dépose dans les parois artérielles.", - "hdl": "Cholestérol transporté par les lipoprotéines de haute densité, qui le ramènent vers le foie.", - "triglycerides": "La principale graisse circulant dans le sang, issue de l'alimentation et produite par le foie.", - "fasting-glucose": "Glycémie mesurée à jeun, reflétant la régulation de base du glucose.", - "hba1c": "Part de l'hémoglobine liée au glucose, reflet de la glycémie des deux à trois derniers mois.", - "tsh": "Hormone hypophysaire qui régule la thyroïde ; le principal test de la fonction thyroïdienne.", - "ft3": "La forme libre et active de la triiodothyronine, l'hormone thyroïdienne la plus puissante.", - "ft4": "La forme libre de la thyroxine, la principale hormone que la thyroïde libère dans le sang.", - "ferritin": "La principale protéine de stockage du fer, reflet des réserves en fer.", - "transferrin-saturation": "Part de la transferrine, protéine de transport du fer, qui porte du fer à un instant donné.", - "vitamin-d": "25-hydroxyvitamine D circulante, le marqueur de référence du statut en vitamine D.", - "vitamin-b12": "Vitamine essentielle à la formation des globules rouges et à la fonction nerveuse.", - "folate": "Vitamine B nécessaire à la formation des globules rouges et à la synthèse de l'ADN.", - "hs-crp": "Une protéine hépatique qui s'élève avec l'inflammation, mesurée à haute sensibilité.", - "creatinine": "Déchet musculaire éliminé par les reins, utilisé pour évaluer la fonction rénale.", - "egfr": "Débit de filtration estimé des reins, calculé à partir de la créatinine.", - "alt": "Enzyme hépatique libérée dans le sang lorsque les cellules du foie sont endommagées.", - "ast": "Enzyme du foie et du muscle qui s'élève lorsque ces tissus sont lésés.", - "ggt": "Enzyme du foie et des voies biliaires qui s'élève en cas d'obstruction biliaire ou d'alcool.", - "sodium": "Principal électrolyte régulant l'équilibre hydrique et le volume sanguin.", - "potassium": "Électrolyte essentiel à la conduction nerveuse et à la fonction du muscle cardiaque.", - "hemoglobin": "La protéine des globules rouges qui transporte l'oxygène.", - "hematocrit": "La fraction du volume sanguin constituée de globules rouges.", - "wbc": "Nombre total de globules blancs, la défense circulante du système immunitaire.", - "platelets": "Fragments cellulaires qui s'agrègent pour former des caillots sanguins.", - "apob": "Compte directement les particules athérogènes ; souvent un meilleur marqueur de risque que le LDL-C en cas de discordance.", - "lp-a": "Facteur de risque en grande partie héréditaire et permanent — mesuré une seule fois en général. À exprimer en unités molaires (nmol/L).", - "fasting-insulin": "Se combine à la glycémie à jeun pour estimer la résistance à l'insuline (HOMA-IR).", - "omega-3-index": "Part d'EPA+DHA dans les globules rouges ; 8–12 % est la plage cardioprotectrice habituellement citée." + "total-cholesterol": "Le cholestérol total est la somme du cholestérol transporté par l'ensemble des particules de lipoprotéines du sang, y compris les fractions LDL, HDL et les résidus. Le cholestérol est lui-même un constituant essentiel des membranes cellulaires, des hormones et des acides biliaires. En tant que chiffre unique, il reste une mesure grossière, car une part élevée de HDL comme une part élevée de LDL font monter le total ; on le lit donc généralement avec les fractions individuelles. Il est suivi surtout dans le cadre de l'évaluation du risque cardiovasculaire à long terme.", + "ldl": "Le cholestérol LDL est le cholestérol transporté par les particules de lipoprotéines de basse densité, la fraction qui livre le cholestérol aux tissus mais peut aussi le déposer dans la paroi des artères. Au fil des années, ce dépôt contribue à la plaque d'athérosclérose, ce qui fait du LDL l'un des marqueurs lipidiques les plus surveillés. Une exposition plus faible sur le long terme s'accompagne en général d'un risque cardiovasculaire moindre. Il s'interprète par catégorie de risque, et non face à une seule valeur normale fixe.", + "hdl": "Le cholestérol HDL est le cholestérol transporté par les particules de lipoprotéines de haute densité, qui récupèrent le cholestérol en excès dans les tissus et le ramènent au foie pour le recycler ou l'éliminer. En raison de ce rôle de transport, on le qualifie souvent de fraction protectrice. Des taux très bas tendent à accompagner un risque cardiovasculaire plus élevé, mais l'augmenter artificiellement n'a pas réduit le risque de façon fiable ; on le lit donc comme un marqueur et non comme une cible. Il s'interprète habituellement avec le LDL et les triglycérides.", + "triglycerides": "Les triglycérides sont la principale forme de graisse circulant dans le sang, apportée à la fois par l'alimentation et produite par le foie, et ils constituent une réserve d'énergie essentielle. Les taux montent après les repas ; le prélèvement se fait donc généralement à jeun pour être comparable. Des triglycérides durablement élevés sont associés à un risque métabolique et cardiovasculaire et accompagnent souvent une résistance à l'insuline et un HDL bas. Ils se lisent comme une composante du bilan lipidique global.", + "fasting-glucose": "La glycémie à jeun est la concentration de sucre dans le sang mesurée après un jeûne nocturne, reflétant la façon dont le corps maintient son niveau de base sans repas récent. Le glucose est le carburant immédiat principal de l'organisme, et l'insuline le maintient normalement dans une plage étroite. Une mesure à jeun écarte l'effet des repas récents, ce qui en fait un dépistage standard du fonctionnement de la régulation du glucose. C'est l'un des marqueurs utilisés pour évaluer la santé métabolique dans le temps.", + "hba1c": "L'HbA1c est la part de l'hémoglobine des globules rouges qui s'est liée au glucose, et qui s'accumule proportionnellement à l'exposition moyenne au sucre dans le sang. Comme les globules rouges vivent environ trois mois, elle reflète la glycémie moyenne des deux à trois mois précédents plutôt qu'un instant donné. Cela en fait une vue stable et indépendante des repas du contrôle de la glycémie. Sa fiabilité peut être altérée par des affections qui modifient la durée de vie des globules rouges.", + "tsh": "La TSH (hormone thyréostimulante) est libérée par l'hypophyse pour signaler à la thyroïde de produire ses hormones, et elle s'ajuste à la hausse ou à la baisse pour les maintenir en équilibre. Grâce à cette boucle de rétroaction, la TSH est le dépistage de première intention le plus sensible de la fonction thyroïdienne : elle bouge généralement avant que les hormones thyroïdiennes elles-mêmes ne dévient. Une valeur élevée ou abaissée oriente vers une thyroïde hypo ou hyperactive et conduit habituellement à doser directement les hormones thyroïdiennes. La grossesse et l'âge décalent la plage attendue.", + "ft3": "La T3 libre (triiodothyronine) est la forme non liée et biologiquement active de l'hormone thyroïdienne la plus puissante, la fraction disponible pour agir sur les tissus. La majeure partie de la T3 circulante est liée à des protéines de transport et inactive ; la part libre reflète donc la disponibilité réelle de l'hormone. Elle est mesurée pour caractériser la fonction thyroïdienne, surtout avec la TSH et la T4 libre lorsque les résultats sont incertains. Les taux dépendent fortement de la technique de dosage et se lisent dans leur contexte clinique.", + "ft4": "La T4 libre (thyroxine) est la forme non liée de la principale hormone que la thyroïde libère dans le sang ; elle sert de réservoir que les tissus convertissent en T3 plus active selon les besoins. La fraction libre reflète ce qui est réellement disponible pour le corps, indépendamment du taux de protéines de transport. On la mesure généralement avec la TSH pour distinguer un problème de la thyroïde d'un trouble de la signalisation hypophysaire. La grossesse et certains médicaments peuvent la décaler.", + "ferritin": "La ferritine est la principale protéine de stockage du fer de l'organisme, et son taux sanguin reflète globalement les réserves totales en fer. Une valeur basse oriente vers des réserves épuisées, tandis qu'une valeur élevée peut traduire une surcharge en fer — ou simplement une inflammation, car la ferritine monte aussi comme protéine de la phase aiguë. Pour cette raison, elle s'interprète souvent avec d'autres marqueurs du fer et des signes d'inflammation. C'est l'une des mesures centrales pour évaluer le statut en fer.", + "transferrin-saturation": "Le coefficient de saturation de la transferrine est le pourcentage de transferrine — la protéine qui transporte le fer dans le sang — actuellement chargé en fer. Il relie la quantité de fer en circulation à la capacité de transport de l'organisme, offrant un instantané de la disponibilité du fer qui complète la vue des réserves donnée par la ferritine. Une saturation basse suggère un fer en quantité insuffisante, tandis qu'une valeur élevée peut indiquer une surcharge. Elle se lit avec la ferritine et le bilan martial complet.", + "vitamin-d": "La vitamine D se mesure sous forme de 25-hydroxyvitamine D, la forme de stockage circulante et le marqueur standard du statut global en vitamine D. Le corps la fabrique dans la peau exposée à la lumière du soleil et en absorbe une partie par l'alimentation ; elle soutient l'équilibre du calcium, la santé osseuse et la fonction immunitaire. Les taux varient selon la saison, la latitude, le teint de peau et la supplémentation. Elle est suivie pour estimer dans le temps si les réserves sont suffisantes.", + "vitamin-b12": "La vitamine B12 (cobalamine) est une vitamine essentielle, obtenue surtout à partir d'aliments d'origine animale et stockée en grande partie dans le foie. Elle est nécessaire à la formation des globules rouges, à la synthèse de l'ADN et à la santé du système nerveux. Une carence prolongée — par faible apport ou absorption altérée — peut entraîner une anémie et des symptômes neurologiques. Elle est mesurée pour évaluer le statut nutritionnel, en particulier lorsque l'apport ou l'absorption peuvent être limités.", + "folate": "Le folate (vitamine B9) est une vitamine du groupe B présente dans les légumes à feuilles vertes, les légumineuses et les aliments enrichis, centrale pour la formation des globules rouges et la synthèse de l'ADN. Il travaille en étroite collaboration avec la vitamine B12, et une carence de l'un ou de l'autre peut produire un type d'anémie similaire. Un apport suffisant en folate est particulièrement important autour du début de la grossesse pour un développement sain du fœtus. Il est mesuré pour évaluer le statut nutritionnel et la cause de certaines anémies.", + "hs-crp": "La CRP ultrasensible mesure la protéine C-réactive, un marqueur que le foie libère en réponse à l'inflammation, avec une technique affinée pour détecter les faibles concentrations pertinentes pour le risque cardiovasculaire. Contrairement à la CRP standard utilisée pour les infections aiguës, la version ultrasensible capte l'inflammation discrète et chronique liée à la maladie vasculaire. Comme toute infection ou blessure récente peut l'élever, elle s'interprète à distance d'une affection aiguë et se confirme souvent par un prélèvement répété. Elle se lit comme un facteur parmi d'autres du risque cardiovasculaire global.", + "creatinine": "La créatinine est un déchet du métabolisme musculaire normal que les reins filtrent du sang à un rythme assez constant. Comme la production est relativement stable, son taux sanguin sert de jauge pratique de la capacité des reins à éliminer les déchets. Les valeurs dépendent aussi de la masse musculaire, de l'hydratation et de l'alimentation ; on les interprète donc généralement avec le débit de filtration estimé. C'est un marqueur central de la fonction rénale.", + "egfr": "Le DFG estimé (débit de filtration glomérulaire) estime la quantité de sang que les reins filtrent chaque minute, calculée à partir de la créatinine, de l'âge et du sexe. Il traduit une valeur brute de créatinine en une mesure plus directement interprétable de la fonction rénale. Un DFG qui baisse au fil du temps est le principal moyen de classer et de suivre les atteintes rénales chroniques. En tant qu'estimation, il se lit comme une tendance plutôt que comme un chiffre exact unique.", + "alt": "L'ALAT (alanine aminotransférase) est une enzyme concentrée dans les cellules du foie, libérée dans le sang lorsque ces cellules sont stressées ou endommagées. Comme elle est relativement spécifique du foie, c'est l'un des principaux marqueurs pour détecter une atteinte ou une inflammation hépatique. Les valeurs peuvent monter avec la stéatose hépatique, une hépatite virale, l'alcool ou certains médicaments. On la lit généralement avec l'ASAT et les autres marqueurs du bilan hépatique.", + "ast": "L'ASAT (aspartate aminotransférase) est une enzyme présente dans le foie, mais aussi dans le muscle, le cœur et les globules rouges, libérée lorsque l'un de ces tissus est lésé. Comme elle est moins spécifique du foie que l'ALAT, les deux s'interprètent ensemble, et leur rapport peut orienter sur l'origine et la nature du problème. Un exercice intense ou une lésion musculaire peuvent l'élever indépendamment du foie. C'est l'une des enzymes standard du bilan hépatique.", + "ggt": "La GGT (gamma-glutamyl transférase) est une enzyme du foie et des voies biliaires qui monte en particulier lorsque l'écoulement de la bile est entravé ou en cas d'exposition à l'alcool. Elle sert souvent à clarifier pourquoi un autre marqueur hépatique est élevé — par exemple, pour distinguer une origine hépatique ou biliaire. Elle est sensible mais peu spécifique ; on la lit donc dans le contexte du reste du bilan hépatique. C'est l'un des marqueurs utilisés pour évaluer la santé du foie et des voies biliaires.", + "sodium": "Le sodium est le principal électrolyte du liquide situé à l'extérieur des cellules et le moteur essentiel de l'équilibre hydrique et du volume sanguin. Le corps le régule étroitement par la soif, les hormones et les reins, de sorte que son taux sanguin reflète davantage l'équilibre entre l'eau et le sel que le seul apport alimentaire. Des valeurs anormales signalent généralement un trouble de l'équilibre de l'eau plutôt que du sodium lui-même. C'est un élément central du bilan métabolique de base et des électrolytes.", + "potassium": "Le potassium est le principal électrolyte à l'intérieur des cellules et il est essentiel à la transmission nerveuse et au rythme régulier du muscle cardiaque. Son taux sanguin est maintenu dans une plage étroite par les reins et les signaux hormonaux, car des valeurs hautes comme basses peuvent perturber l'activité électrique du cœur. Les taux peuvent être influencés par la fonction rénale, l'hydratation et plusieurs médicaments courants. C'est une mesure centrale du bilan des électrolytes.", + "hemoglobin": "L'hémoglobine est la protéine contenant du fer des globules rouges, qui fixe l'oxygène dans les poumons et le transporte vers les tissus. Sa concentration est la principale mesure de la capacité du sang à transporter l'oxygène et le marqueur central pour détecter l'anémie. Des valeurs basses peuvent refléter une perte de sang, un manque de fer ou de vitamines, ou une production réduite, tandis que des valeurs élevées peuvent suivre une déshydratation ou des conditions pauvres en oxygène. C'est une pierre angulaire de la numération formule sanguine.", + "hematocrit": "L'hématocrite est la proportion du volume sanguin constituée de globules rouges, exprimée en pourcentage. Il évolue étroitement avec l'hémoglobine et offre une vue complémentaire de la masse de globules rouges et de la capacité du sang à transporter l'oxygène. L'hydratation l'influence, car les variations du volume de plasma déplacent la proportion même lorsque le nombre de globules rouges reste stable. Il se lit avec l'hémoglobine et le reste de la numération sanguine.", + "wbc": "La numération des globules blancs est le nombre total de cellules immunitaires circulant dans le sang, la défense mobile du corps contre les infections. Elle augmente en réponse à une infection, une inflammation ou un stress et peut baisser lorsque la moelle osseuse est freinée ou dans certaines maladies immunitaires. Comme elle additionne plusieurs types de cellules distincts, une variation notable est souvent complétée par une formule qui la détaille. C'est une composante centrale de la numération formule sanguine.", + "platelets": "Les plaquettes sont de petits fragments cellulaires du sang qui s'agrègent et forment le bouchon initial arrêtant le saignement d'un vaisseau lésé. Leur nombre reflète l'équilibre entre la production dans la moelle osseuse et la consommation ou la destruction. Un nombre bas peut accroître la tendance au saignement, tandis qu'un nombre élevé peut suivre une inflammation ou d'autres stimuli. Elles font partie de la numération formule sanguine et constituent une jauge de base de la capacité de coagulation.", + "apob": "L'apolipoprotéine B est la protéine structurale portée par chacune des particules de lipoprotéines athérogènes ; la doser revient donc à compter directement ces particules plutôt que le cholestérol qu'elles transportent. Comme il y a une molécule d'ApoB sur chaque particule de LDL, de VLDL et apparentée, elle peut capter un risque cardiovasculaire qu'une valeur de cholestérol LDL manque, en particulier lorsque le nombre de particules et leur teneur en cholestérol divergent. C'est pourquoi elle est de plus en plus utilisée pour affiner l'évaluation du risque fondée sur les lipides. Elle s'interprète avec le bilan lipidique habituel.", + "lp-a": "La lipoprotéine(a) est une particule semblable au LDL avec une protéine supplémentaire attachée, dont le taux est en grande partie déterminé par la génétique et reste assez stable toute la vie. Une Lp(a) élevée est un facteur de risque indépendant et héréditaire de maladie cardiovasculaire et de rétrécissement de la valve aortique. Comme elle varie peu, on la mesure généralement une seule fois pour établir le risque à vie d'une personne, plutôt que de la suivre de façon répétée. Elle s'exprime en unités molaires et se lit dans le cadre du risque cardiovasculaire global.", + "fasting-insulin": "L'insuline à jeun est le taux d'insuline — l'hormone qui fait passer le glucose du sang vers les cellules — mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.", + "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA — les deux acides gras oméga-3 marins à longue chaîne — parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois." }, "apob": "Apolipoprotéine B (ApoB)", "lp-a": "Lipoprotéine(a)", @@ -7318,8 +7359,8 @@ "description": "Intégrez la météo et la durée du jour quotidiennes de votre lieu pour les corréler à l'humeur, au sommeil et aux constantes. Désactivé par défaut." }, "inboundDocuments": { - "label": "Documents entrants", - "description": "Importez un compte rendu médical ou une lettre de sortie ; vérifiez les données extraites avant de les enregistrer. Désactivé par défaut." + "label": "Documents", + "description": "Conservez vos documents médicaux ; extrayez et vérifiez éventuellement leurs éléments avant l'enregistrement. Désactivé par défaut." }, "mentalHealth": { "label": "Bien-être mental", @@ -7558,8 +7599,7 @@ "mentalHealth": { "pageTitle": "Point sur le bien-être mental", "pageDescription": "Deux auto-évaluations validées et facultatives — PHQ-9 (humeur) et GAD-7 (inquiétude) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.", - "disclaimer": "Il s'agit d'une auto-évaluation de dépistage, pas d'un diagnostic. Un score élevé ou bas ne signifie pas que tu as ou n'as pas un trouble. Seul un professionnel de santé qualifié peut poser un diagnostic. En cas de doute, parles-en à ton médecin.", - "optInNote": "Facultatif et privé : tes réponses individuelles sont chiffrées, tenues à l'écart du Coach IA et des exports, et ne sont jamais partagées.", + "infoTooltip": "Il s'agit d'un autotest de dépistage, pas d'un diagnostic.", "attributionLabel": "Source", "choosePrompt": "Choisis un bilan", "start": "Commencer", @@ -7774,6 +7814,15 @@ "updatedToast": "Entrée mise à jour", "saveError": "Impossible d'enregistrer l'entrée." } + }, + "conditions": { + "cardTitle": "Affections préexistantes", + "cardDescription": "Affections chroniques et ce que le Coach doit surveiller, d'après votre contexte personnel.", + "conditionsLabel": "Affections chroniques", + "focusLabel": "Ce que le Coach surveille", + "empty": "Rien de noté pour l'instant.", + "editCta": "Modifier dans le contexte personnel", + "loadError": "Impossible de charger vos antécédents." } }, "environment": { @@ -7788,37 +7837,50 @@ } }, "documents": { - "title": "Documents entrants", - "subtitle": "Importez un document clinique et vérifiez les données que nous y avons lues.", - "disclaimer": { - "title": "Nous lisons, nous n'interprétons pas", - "body": "Nous transcrivons uniquement ce qu'indique votre document — valeurs, codes et dates — et nous ne posons aucun diagnostic, n'interprétons et ne signalons rien. Rien n'est enregistré tant que vous ne l'avez pas vérifié et approuvé. Seul votre professionnel de santé peut interpréter ces résultats." - }, + "title": "Documents", + "subtitle": "Conservez vos documents médicaux et vérifiez ce qu'ils contiennent.", "upload": { - "label": "Importer un document", - "kindLabel": "Type de document", - "button": "Importer", - "uploading": "Lecture…", - "fileLabel": "Fichier du document" + "label": "Ajouter un document", + "kindLabel": "Catégorie", + "button": "Téléverser", + "uploading": "Téléversement…", + "fileLabel": "Fichier (requis)", + "titleLabel": "Titre (facultatif)", + "titlePlaceholder": "p. ex. Compte rendu de cardiologie", + "dateLabel": "Date du document (facultatif)", + "fileRequired": "Choisissez un fichier à téléverser.", + "accepts": "JPEG, PNG, WebP ou PDF · jusqu'à 12 Mo" }, "kind": { "DOCTOR_REPORT": "Compte rendu médical", "DISCHARGE_LETTER": "Lettre de sortie", - "OTHER": "Autre" + "OTHER": "Autre", + "LAB_RESULT": "Résultat de laboratoire", + "IMAGING": "Imagerie", + "PRESCRIPTION": "Ordonnance", + "INSURANCE": "Assurance", + "VACCINATION": "Vaccination" }, "list": { - "empty": "Aucun document pour l'instant." + "empty": "Aucun document pour l'instant.", + "noResults": "Aucun document ne correspond à vos filtres.", + "loadMore": "Charger plus", + "loadingMore": "Chargement…", + "pending": "{count} à vérifier", + "facts": "{count} éléments", + "clearFilters": "Effacer les filtres", + "loadError": "Impossible de charger vos documents." }, "status": { "EXTRACTING": "Lecture", "EXTRACTED": "Prêt à vérifier", "FAILED": "Lecture impossible", "CONFIRMED": "Vérifié", - "DISCARDED": "Écarté" + "DISCARDED": "Écarté", + "STORED": "Conservé" }, "review": { "title": "Vérifier les données extraites", - "discard": "Écarter", "confirm": "Enregistrer les données approuvées", "confirming": "Enregistrement…", "needsReview": "À vérifier", @@ -7826,7 +7888,14 @@ "reject": "Rejeter", "edit": "Modifier", "cancel": "Annuler", - "save": "Enregistrer" + "save": "Enregistrer", + "needsReviewHint": "Modifie cette donnée pour pouvoir la valider.", + "confidenceLabel": "Confiance d'extraction", + "commitError": { + "unitMismatch": "l'unité ne correspond pas à celle enregistrée", + "unitRequired": "une valeur numérique nécessite une unité", + "generic": "il n'a pas pu être enregistré" + } }, "empty": { "noFacts": "Plus rien à vérifier." @@ -7844,21 +7913,72 @@ "dose": "Dose" }, "toast": { - "uploaded": "Document lu. Vérifiez les données ci-dessous.", - "uploadFailed": "Lecture du document impossible.", + "uploaded": "Document enregistré.", + "uploadFailed": "Impossible d'enregistrer le document.", "confirmed": "Données approuvées enregistrées.", + "confirmedPartial": "Enregistré, mais {count} n'ont pas pu être enregistrés — {reason}.", "confirmFailed": "Impossible d'enregistrer vos décisions.", "saved": "Donnée mise à jour.", - "discarded": "Document écarté." + "discarded": "Document écarté.", + "extracted": "Éléments extraits. Vérifiez-les ci-dessous.", + "extractFailed": "Impossible d'extraire les éléments.", + "localOcrDisabled": "Activez l'OCR local dans les paramètres pour lire ce document sur l'appareil.", + "updated": "Document mis à jour.", + "updateFailed": "Impossible de mettre à jour le document.", + "discardFailed": "Impossible de supprimer le document.", + "uploadTooLarge": "Le fichier est trop volumineux (max. 12 Mo).", + "uploadFileType": "Importe un fichier JPEG, PNG, WebP ou PDF.", + "uploadRateLimited": "Trop d'envois. Réessaie plus tard.", + "uploadInvalidMetadata": "Vérifie le titre, la catégorie ou la date, puis réessaie." + }, + "toolbar": { + "searchLabel": "Rechercher", + "searchPlaceholder": "Titre ou nom de fichier", + "kindFilterLabel": "Catégorie", + "allKinds": "Toutes les catégories", + "sortLabel": "Trier par", + "sort": { + "documentDate": "Date du document", + "createdAt": "Date d'ajout", + "title": "Titre" + }, + "orderAsc": "Croissant", + "orderDesc": "Décroissant", + "fromLabel": "Du", + "toLabel": "Au" + }, + "detail": { + "viewOriginal": "Voir l'original", + "edit": "Modifier", + "extract": "Extraire les éléments", + "extracting": "Extraction…", + "discard": "Écarter", + "notExtracted": "Aucun élément extrait pour l'instant. Utilisez Extraire les éléments pour lire ce document.", + "viewOriginalNewTab": "(s'ouvre dans un nouvel onglet)", + "discardConfirmTitle": "Supprimer ce document ?", + "discardConfirmBody": "Cela supprime définitivement le document, son fichier d'origine et les données préparées. Cette action est irréversible.", + "discardConfirmAction": "Supprimer", + "discardCancel": "Annuler" + }, + "edit": { + "title": "Modifier le document", + "subtitle": "Renommez, recatégorisez ou définissez la date du document.", + "titleLabel": "Titre", + "kindLabel": "Catégorie", + "dateLabel": "Date du document", + "save": "Enregistrer", + "saving": "Enregistrement…", + "cancel": "Annuler" + }, + "note": { + "providerUnsupported": "Configurez un fournisseur d'analyse de documents dans les paramètres d'IA pour extraire les éléments automatiquement.", + "alreadyConfirmed": "Ce document a déjà été vérifié ; il ne peut pas être extrait à nouveau." + }, + "committed": { + "title": "Validé depuis ce document" } }, "selfDescription": { - "intendedPurpose": "HealthLog est un outil personnel de suivi de santé et de bien-être. Il vous aide à enregistrer, visualiser et exporter vos propres données de santé. Il ne diagnostique, ne traite, ne guérit ni ne prévient aucune maladie, et n'est pas un dispositif médical.", - "coach": "Le Coach s'appuie sur vos propres données enregistrées. Ce n'est pas un médecin et il ne donne pas de conseil médical : pour un diagnostic ou un traitement, consultez un professionnel de santé.", - "insights": "Ce sont des tendances descriptives calculées à partir de vos propres données enregistrées, pas une évaluation clinique ni un diagnostic.", - "labs": "Ce sont vos propres relevés, pas un diagnostic médical. Les valeurs de référence varient selon le laboratoire et la personne : seul votre médecin peut les interpréter pour vous.", - "medications": "Ceci suit les médicaments que vous enregistrez et votre propre observance. Cela n'évalue ni les doses ni les interactions et n'est pas un conseil médical : confirmez-les avec votre pharmacien ou votre médecin.", - "emergency": "Si cela pourrait être une urgence, contactez maintenant votre numéro d'urgence local ou un professionnel de santé. HealthLog ne peut pas aider en cas d'urgence.", "dataPosture": "HealthLog est auto-hébergé : vos données de santé restent sur votre propre instance, chiffrées au repos. Les fonctions d'IA utilisent une clé de fournisseur que vous apportez ou un modèle local, donc rien ne quitte votre instance au-delà du fournisseur que vous configurez, et il n'y a aucune analyse tierce de vos données de santé." } } diff --git a/messages/it.json b/messages/it.json index 7d47c79f9..331bb87e5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4018,16 +4018,46 @@ "disableError": "Impossibile disattivare la modalità Ricerca. Riprova." }, "shell": { - "sectionsNav": "Sezioni delle impostazioni" + "sectionsNav": "Sezioni delle impostazioni", + "subSectionsNav": "Sottosezioni delle impostazioni" + }, + "groups": { + "account": { + "title": "Account" + }, + "tracking": { + "title": "Monitoraggio" + }, + "display": { + "title": "Visualizzazione" + }, + "integrations": { + "title": "Integrazioni" + }, + "notifications": { + "title": "Notifiche" + }, + "ai": { + "title": "IA e coach" + }, + "access": { + "title": "API e accesso" + }, + "data": { + "title": "Dati e privacy" + }, + "about": { + "title": "Informazioni" + } }, "sections": { "account": { - "title": "Account", + "title": "Profilo e preferenze", "description": "Profilo, password, passkey e il tour di benvenuto.", "subtitle": "Il tuo profilo, la tua identità e i dati di accesso." }, "integrations": { - "title": "Integrazioni", + "title": "Connessioni", "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.", "subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi." }, @@ -4036,11 +4066,10 @@ "description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.", "channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in", "channelsHintLink": "Integrazioni → Canali", - "inboxLink": "Apri la posta in arrivo", "subtitle": "Scegli quali promemoria ricevere." }, "layout": { - "title": "Cruscotto", + "title": "Visualizzazione", "description": "Organizza la disposizione del cruscotto e degli approfondimenti.", "backToHub": "Torna a Layout e personalizzazione", "dashboard": { @@ -4319,9 +4348,9 @@ "subtitle": "Autenticazione a due fattori, codici di recupero e passkey." }, "privacy": { - "title": "Dati e privacy", - "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.", - "description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza." + "title": "Privacy e conservazione", + "subtitle": "I tuoi dati, la conservazione e la cifratura in un unico posto.", + "description": "Esportazione, eliminazione, conservazione e stato della cifratura." }, "mcp": { "title": "Connettore MCP", @@ -4665,7 +4694,9 @@ "focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…", "completeness": "{answered} su {total} compilati", "questionsTitle": "Il Coach ha alcune domande", - "questionsOpenCoach": "Rispondi nel Coach" + "questionsOpenCoach": "Rispondi nel Coach", + "recordsLink": "Allergie, anamnesi familiare e le tue patologie preesistenti fanno parte della tua anamnesi.", + "recordsLinkCta": "Apri anamnesi" }, "responseTimeoutHeading": "Timeout della risposta", "responseTimeoutBody": "Quanto attendere la risposta dell'IA prima di annullare. Aumentalo per un modello locale o self-hosted che ha bisogno di tempo per caricarsi alla prima richiesta.", @@ -5286,6 +5317,11 @@ "description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA e la ricerca facoltativa.", "openAi": "Modalità privacy IA", "openResearch": "Modalità ricerca" + }, + "signIn": { + "title": "Accesso e dispositivi", + "description": "Le sessioni attive, i dispositivi attendibili e l'attività di accesso si trovano ora nelle impostazioni di sicurezza del tuo account.", + "openSecurity": "Gestisci accesso e sicurezza" } }, "mcp": { @@ -7061,9 +7097,14 @@ "restoredToast": "Misurazione ripristinata", "restoreError": "Impossibile ripristinare la misurazione", "detail": { - "history": "Cronologia", "emptyTitle": "Ancora nessuna misurazione", - "emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo." + "emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo.", + "title": "Biomarcatore", + "genericDescription": "Un biomarcatore di laboratorio che monitori. Aggiungi misurazioni per seguirne l'andamento rispetto all'intervallo di riferimento.", + "valuesTitle": "{marker} — tutte le letture", + "valuesDescription": "Ogni lettura di questo biomarcatore. Modifica o rimuovi una voce direttamente.", + "backToMarker": "Torna a {marker}", + "latestResult": "Ultimo risultato" }, "chart": { "empty": "Nessuna misurazione in questo periodo.", @@ -7172,36 +7213,36 @@ "bloodCount": "Emocromo" }, "desc": { - "total-cholesterol": "Quantità totale di colesterolo trasportato nel sangue, somma delle frazioni LDL, HDL e altre lipoproteine.", - "ldl": "Colesterolo trasportato dalle lipoproteine a bassa densità, la frazione che si deposita nelle pareti arteriose.", - "hdl": "Colesterolo trasportato dalle lipoproteine ad alta densità, che lo riportano al fegato.", - "triglycerides": "Il principale grasso circolante nel sangue, assunto con la dieta e prodotto dal fegato.", - "fasting-glucose": "Glicemia misurata a digiuno, riflette la regolazione di base del glucosio.", - "hba1c": "Quota di emoglobina legata al glucosio, riflette la glicemia degli ultimi due o tre mesi.", - "tsh": "Ormone ipofisario che regola la tiroide; il principale test della funzione tiroidea.", - "ft3": "La forma libera e attiva della triiodotironina, l'ormone tiroideo più potente.", - "ft4": "La forma libera della tiroxina, il principale ormone che la tiroide rilascia nel sangue.", - "ferritin": "La principale proteina di deposito del ferro, riflette le riserve di ferro.", - "transferrin-saturation": "Quota della transferrina, proteina di trasporto del ferro, che trasporta ferro in quel momento.", - "vitamin-d": "25-idrossivitamina D circolante, il marcatore standard dello stato di vitamina D.", - "vitamin-b12": "Vitamina essenziale per la formazione dei globuli rossi e la funzione nervosa.", - "folate": "Vitamina B necessaria per la formazione dei globuli rossi e la sintesi del DNA.", - "hs-crp": "Una proteina epatica che aumenta con l'infiammazione, misurata ad alta sensibilità.", - "creatinine": "Prodotto di scarto muscolare eliminato dai reni, usato per valutare la funzione renale.", - "egfr": "Velocità di filtrazione stimata dei reni, calcolata a partire dalla creatinina.", - "alt": "Enzima epatico rilasciato nel sangue quando le cellule del fegato sono danneggiate.", - "ast": "Enzima di fegato e muscolo che aumenta quando questi tessuti sono lesi.", - "ggt": "Enzima di fegato e vie biliari che aumenta con l'ostruzione biliare o l'alcol.", - "sodium": "Principale elettrolita che regola l'equilibrio dei liquidi e il volume del sangue.", - "potassium": "Elettrolita essenziale per la conduzione nervosa e la funzione del muscolo cardiaco.", - "hemoglobin": "La proteina dei globuli rossi che trasporta l'ossigeno.", - "hematocrit": "La frazione del volume sanguigno costituita da globuli rossi.", - "wbc": "Conteggio totale dei globuli bianchi, la difesa circolante del sistema immunitario.", - "platelets": "Frammenti cellulari che si aggregano per formare coaguli di sangue.", - "apob": "Conta direttamente le particelle aterogene; spesso un marcatore di rischio migliore del LDL-C quando i due discordano.", - "lp-a": "Fattore di rischio in gran parte ereditario e permanente — di solito misurato una volta. Riportare in unità molari (nmol/L).", - "fasting-insulin": "Si abbina alla glicemia a digiuno per stimare la resistenza insulinica (HOMA-IR).", - "omega-3-index": "Quota di EPA+DHA nei globuli rossi; 8–12% è l'intervallo cardioprotettivo comunemente citato." + "total-cholesterol": "Il colesterolo totale è la somma del colesterolo trasportato da tutte le particelle di lipoproteine del sangue, comprese le frazioni LDL, HDL e i residui. Il colesterolo è di per sé un componente essenziale delle membrane cellulari, degli ormoni e degli acidi biliari. Come valore singolo resta una misura grossolana, perché sia una quota elevata di HDL sia una di LDL fanno salire il totale: per questo si legge di solito insieme alle singole frazioni. Viene rilevato soprattutto nell'ambito della valutazione del rischio cardiovascolare a lungo termine.", + "ldl": "Il colesterolo LDL è il colesterolo trasportato dalle particelle di lipoproteine a bassa densità, la frazione che porta il colesterolo ai tessuti ma può anche depositarlo nelle pareti delle arterie. Negli anni questo deposito contribuisce alla placca aterosclerotica, motivo per cui l'LDL è uno dei marcatori lipidici più sorvegliati. Una minore esposizione a lungo termine si accompagna in genere a un rischio cardiovascolare più basso. Si interpreta per categoria di rischio e non rispetto a un unico valore normale fisso.", + "hdl": "Il colesterolo HDL è il colesterolo trasportato dalle particelle di lipoproteine ad alta densità, che raccolgono il colesterolo in eccesso dai tessuti e lo riportano al fegato per riciclarlo o eliminarlo. Per questo ruolo di trasporto viene spesso definito la frazione protettiva. Valori molto bassi tendono ad accompagnare un rischio cardiovascolare più elevato, ma aumentarlo artificialmente non ha ridotto il rischio in modo affidabile: per questo si legge come marcatore e non come obiettivo. Di solito si interpreta insieme a LDL e trigliceridi.", + "triglycerides": "I trigliceridi sono la principale forma di grasso che circola nel sangue, fornita sia dalla dieta sia prodotta dal fegato, e rappresentano una riserva chiave di energia. I valori salgono dopo i pasti, perciò il prelievo si esegue di solito a digiuno per renderlo confrontabile. Trigliceridi costantemente elevati sono associati a rischio metabolico e cardiovascolare e spesso accompagnano la resistenza all'insulina e un HDL basso. Si leggono come una componente del quadro lipidico complessivo.", + "fasting-glucose": "La glicemia a digiuno è la concentrazione di zucchero nel sangue misurata dopo un digiuno notturno e riflette come il corpo mantiene il proprio livello di base senza cibo recente. Il glucosio è il principale carburante immediato dell'organismo e l'insulina lo mantiene normalmente entro un intervallo ristretto. Una misurazione a digiuno elimina l'effetto dei pasti recenti, rendendola uno screening standard del funzionamento della regolazione del glucosio. È uno dei marcatori con cui si valuta la salute metabolica nel tempo.", + "hba1c": "L'HbA1c è la quota di emoglobina dei globuli rossi che si è legata al glucosio e si accumula in proporzione all'esposizione media allo zucchero nel sangue. Poiché i globuli rossi vivono circa tre mesi, riflette la glicemia media dei due o tre mesi precedenti anziché un singolo momento. Questo la rende una visione stabile e indipendente dai pasti del controllo della glicemia. La sua attendibilità può essere influenzata da condizioni che alterano la durata di vita dei globuli rossi.", + "tsh": "Il TSH (ormone tireostimolante) viene rilasciato dall'ipofisi per segnalare alla tiroide di produrre i propri ormoni e si regola al rialzo o al ribasso per mantenerli in equilibrio. Grazie a questo circuito di retroazione, il TSH è lo screening di prima linea più sensibile della funzione tiroidea: tende a muoversi prima che gli ormoni tiroidei stessi si discostino. Un valore alto o basso indica una tiroide ipo o iperattiva e di solito porta a misurare direttamente gli ormoni tiroidei. La gravidanza e l'età spostano l'intervallo atteso.", + "ft3": "Il T3 libero (triiodotironina) è la forma non legata e biologicamente attiva dell'ormone tiroideo più potente, la frazione disponibile ad agire sui tessuti. La maggior parte del T3 circolante è legata a proteine di trasporto e inattiva, perciò la quota libera riflette la reale disponibilità dell'ormone. Si misura per caratterizzare la funzione tiroidea, soprattutto insieme a TSH e T4 libero quando i risultati non sono chiari. I valori dipendono molto dal metodo di analisi e si leggono nel loro contesto clinico.", + "ft4": "Il T4 libero (tiroxina) è la forma non legata del principale ormone che la tiroide rilascia nel sangue e funge da riserva che i tessuti convertono nel T3 più attivo secondo necessità. La frazione libera riflette ciò che è realmente disponibile per il corpo, indipendentemente dal livello delle proteine di trasporto. Di solito si misura insieme al TSH per distinguere i problemi della tiroide da quelli della segnalazione ipofisaria. La gravidanza e alcuni farmaci possono spostarlo.", + "ferritin": "La ferritina è la principale proteina di deposito del ferro dell'organismo e il suo valore nel sangue rispecchia a grandi linee le riserve totali di ferro. Un valore basso indica riserve esaurite, mentre uno alto può riflettere un sovraccarico di ferro, oppure semplicemente un'infiammazione, perché la ferritina sale anche come reattante della fase acuta. Per questo si interpreta spesso insieme ad altri marcatori del ferro e a segni di infiammazione. È una delle misure centrali per valutare lo stato del ferro.", + "transferrin-saturation": "La saturazione della transferrina è la percentuale di transferrina — la proteina che trasporta il ferro nel sangue — attualmente carica di ferro. Collega la quantità di ferro in transito alla capacità di trasporto dell'organismo, offrendo un'istantanea della disponibilità di ferro che completa la visione delle riserve data dalla ferritina. Una saturazione bassa suggerisce un ferro scarso, mentre un valore alto può indicare un sovraccarico. Si legge insieme alla ferritina e all'assetto marziale completo.", + "vitamin-d": "La vitamina D si misura come 25-idrossivitamina D, la forma di deposito circolante e il marcatore standard dello stato complessivo di vitamina D. Il corpo la produce nella pelle esposta alla luce solare e ne assorbe una parte dagli alimenti; sostiene l'equilibrio del calcio, la salute delle ossa e la funzione immunitaria. I valori variano con la stagione, la latitudine, il tono della pelle e l'integrazione. Viene rilevata per stimare nel tempo se le riserve sono adeguate.", + "vitamin-b12": "La vitamina B12 (cobalamina) è una vitamina essenziale, ottenuta soprattutto da alimenti di origine animale e immagazzinata in gran parte nel fegato. È necessaria per la formazione dei globuli rossi, la sintesi del DNA e la salute del sistema nervoso. Una carenza prolungata — da scarso apporto o assorbimento alterato — può portare ad anemia e a sintomi neurologici. Si misura per valutare lo stato nutrizionale, in particolare quando apporto o assorbimento possono essere limitati.", + "folate": "Il folato (vitamina B9) è una vitamina del gruppo B presente nelle verdure a foglia verde, nei legumi e negli alimenti fortificati, centrale per la formazione dei globuli rossi e la sintesi del DNA. Lavora a stretto contatto con la vitamina B12 e una carenza dell'uno o dell'altra può produrre un tipo simile di anemia. Un folato adeguato è particolarmente importante intorno all'inizio della gravidanza per uno sviluppo sano del feto. Si misura per valutare lo stato nutrizionale e la causa di alcune anemie.", + "hs-crp": "La PCR ad alta sensibilità misura la proteina C-reattiva, un marcatore che il fegato rilascia in risposta all'infiammazione, con una tecnica affinata per rilevare le basse concentrazioni rilevanti per il rischio cardiovascolare. A differenza della PCR standard usata per le infezioni acute, la versione ad alta sensibilità coglie l'infiammazione lieve e cronica legata alla malattia vascolare. Poiché qualsiasi infezione o lesione recente può aumentarla, si interpreta lontano da una malattia acuta e spesso si conferma con un prelievo ripetuto. Si legge come uno dei fattori del rischio cardiovascolare complessivo.", + "creatinine": "La creatinina è un prodotto di scarto del normale metabolismo muscolare che i reni filtrano dal sangue a un ritmo abbastanza costante. Poiché la produzione è relativamente stabile, il suo valore nel sangue funge da misura pratica di quanto bene i reni eliminano gli scarti. I valori dipendono anche dalla massa muscolare, dall'idratazione e dalla dieta, perciò si interpretano di solito insieme alla velocità di filtrazione stimata. È un marcatore centrale della funzione renale.", + "egfr": "L'eGFR (velocità di filtrazione glomerulare stimata) stima quanto sangue i reni filtrano ogni minuto, calcolata dalla creatinina insieme a età e sesso. Traduce un valore grezzo di creatinina in una misura della funzione renale più immediatamente interpretabile. Un eGFR in calo nel tempo è il modo principale per classificare e seguire le alterazioni renali croniche. Essendo una stima, si legge come una tendenza e non come un'unica cifra esatta.", + "alt": "L'ALT (alanina aminotransferasi) è un enzima concentrato nelle cellule del fegato, rilasciato nel sangue quando queste cellule sono sotto stress o danneggiate. Essendo relativamente specifico del fegato, è uno dei principali marcatori per rilevare un danno o un'infiammazione epatica. I valori possono salire con il fegato grasso, l'epatite virale, l'alcol o alcuni farmaci. Di solito si legge insieme all'AST e agli altri marcatori del profilo epatico.", + "ast": "L'AST (aspartato aminotransferasi) è un enzima presente nel fegato ma anche nel muscolo, nel cuore e nei globuli rossi, rilasciato quando uno di questi tessuti subisce un danno. Essendo meno specifico del fegato rispetto all'ALT, i due si interpretano insieme e il loro rapporto può orientare sull'origine e sulla natura del problema. Un esercizio intenso o una lesione muscolare possono aumentarlo indipendentemente dal fegato. È uno degli enzimi standard del profilo epatico.", + "ggt": "La GGT (gamma-glutamil transferasi) è un enzima del fegato e delle vie biliari che sale in particolare quando il flusso della bile è ostruito o in caso di esposizione all'alcol. Spesso si usa per chiarire perché un altro marcatore epatico è elevato — per esempio, per distinguere un'origine epatica o biliare. È sensibile ma poco specifica, perciò si legge nel contesto del resto del profilo epatico. È uno dei vari marcatori usati per valutare la salute del fegato e delle vie biliari.", + "sodium": "Il sodio è il principale elettrolita del liquido al di fuori delle cellule del corpo e il motore fondamentale dell'equilibrio dei liquidi e del volume del sangue. L'organismo lo regola in modo stretto tramite la sete, gli ormoni e i reni, perciò il suo valore nel sangue riflette più l'equilibrio tra acqua e sale che il solo apporto alimentare. Valori anomali segnalano di solito un disturbo dell'equilibrio idrico più che del sodio stesso. È una parte centrale del pannello metabolico di base e degli elettroliti.", + "potassium": "Il potassio è il principale elettrolita all'interno delle cellule del corpo ed è essenziale per la trasmissione nervosa e per il ritmo regolare del muscolo cardiaco. Il suo valore nel sangue è mantenuto entro un intervallo ristretto dai reni e dai segnali ormonali, perché sia i valori alti sia quelli bassi possono disturbare l'attività elettrica del cuore. I valori possono essere influenzati dalla funzione renale, dall'idratazione e da diversi farmaci comuni. È una misura centrale del pannello degli elettroliti.", + "hemoglobin": "L'emoglobina è la proteina contenente ferro dei globuli rossi che lega l'ossigeno nei polmoni e lo trasporta ai tessuti. La sua concentrazione è la principale misura della capacità del sangue di trasportare ossigeno e il marcatore centrale per rilevare l'anemia. Valori bassi possono riflettere perdita di sangue, carenza di ferro o di vitamine, o una produzione ridotta, mentre valori alti possono seguire una disidratazione o condizioni di scarso ossigeno. È un pilastro dell'emocromo completo.", + "hematocrit": "L'ematocrito è la proporzione del volume del sangue costituita dai globuli rossi, espressa in percentuale. Si muove a stretto contatto con l'emoglobina e offre una visione complementare della massa dei globuli rossi e della capacità del sangue di trasportare ossigeno. L'idratazione lo influenza, perché le variazioni del volume del plasma spostano la proporzione anche quando il numero dei globuli rossi resta stabile. Si legge insieme all'emoglobina e al resto dell'emocromo.", + "wbc": "La conta dei globuli bianchi è il numero totale di cellule immunitarie che circolano nel sangue, la difesa mobile del corpo contro le infezioni. Sale in risposta a infezione, infiammazione o stress e può calare quando il midollo osseo è soppresso o in alcune malattie immunitarie. Poiché somma diversi tipi di cellule, una variazione marcata è spesso completata da una formula che la scompone. È una componente centrale dell'emocromo completo.", + "platelets": "Le piastrine sono piccoli frammenti cellulari del sangue che si aggregano e formano il tappo iniziale che arresta il sanguinamento da un vaso danneggiato. Il loro numero riflette l'equilibrio tra la produzione nel midollo osseo e il consumo o la distruzione. Un numero basso può aumentare la tendenza al sanguinamento, mentre uno alto può seguire un'infiammazione o altri stimoli. Fanno parte dell'emocromo completo e sono una misura di base della capacità di coagulazione.", + "apob": "L'apolipoproteina B è la proteina strutturale presente su ciascuna delle particelle di lipoproteine aterogene, quindi misurarla significa contare direttamente quelle particelle anziché il colesterolo che trasportano. Poiché c'è una molecola di ApoB su ogni particella di LDL, VLDL e affini, può cogliere un rischio cardiovascolare che un valore di colesterolo LDL non rileva, soprattutto quando il numero di particelle e il loro contenuto di colesterolo divergono. Per questo è sempre più usata per affinare la valutazione del rischio basata sui lipidi. Si interpreta insieme al consueto profilo lipidico.", + "lp-a": "La lipoproteina(a) è una particella simile all'LDL con una proteina aggiuntiva attaccata, il cui livello è in gran parte determinato dalla genetica e resta piuttosto stabile per tutta la vita. Una Lp(a) elevata è un fattore di rischio indipendente ed ereditario di malattia cardiovascolare e di restringimento della valvola aortica. Poiché varia poco, di solito si misura una sola volta per stabilire il rischio di una persona lungo l'arco della vita, anziché seguirla ripetutamente. Si esprime in unità molari e si legge nell'ambito del rischio cardiovascolare complessivo.", + "fasting-insulin": "L'insulina a digiuno è il livello di insulina — l'ormone che fa passare il glucosio dal sangue alle cellule — misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.", + "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA — i due acidi grassi omega-3 marini a catena lunga — tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", @@ -7318,8 +7359,8 @@ "description": "Integra il meteo e le ore di luce giornaliere della tua posizione per correlarli con umore, sonno e parametri vitali. Disattivato di default." }, "inboundDocuments": { - "label": "Documenti in entrata", - "description": "Carica un referto medico o una lettera di dimissione; rivedi i dati estratti prima di salvarli. Disattivato per impostazione predefinita." + "label": "Documenti", + "description": "Conserva i tuoi documenti clinici; facoltativamente estrai e rivedi i loro dati prima del salvataggio. Disattivato di default." }, "mentalHealth": { "label": "Benessere mentale", @@ -7558,8 +7599,7 @@ "mentalHealth": { "pageTitle": "Check-in sul benessere mentale", "pageDescription": "Due autovalutazioni validate e facoltative — PHQ-9 (umore) e GAD-7 (preoccupazione) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.", - "disclaimer": "Questa è un'autovalutazione di screening, non una diagnosi. Un punteggio alto o basso non significa che tu abbia o non abbia un disturbo. Solo un professionista sanitario qualificato può fare una diagnosi. In caso di dubbi, parlane con il tuo medico.", - "optInNote": "Facoltativo e privato: le tue risposte individuali sono cifrate, tenute fuori dal Coach IA e dalle esportazioni, e non vengono mai condivise.", + "infoTooltip": "Questo è un autotest di screening, non una diagnosi.", "attributionLabel": "Fonte", "choosePrompt": "Scegli un check-in", "start": "Inizia", @@ -7774,6 +7814,15 @@ "updatedToast": "Voce aggiornata", "saveError": "Impossibile salvare la voce." } + }, + "conditions": { + "cardTitle": "Patologie preesistenti", + "cardDescription": "Patologie croniche e ciò che il Coach deve tenere d'occhio, dal tuo contesto personale.", + "conditionsLabel": "Patologie croniche", + "focusLabel": "Cosa tiene d'occhio il Coach", + "empty": "Ancora nessuna annotazione.", + "editCta": "Modifica nel contesto personale", + "loadError": "Impossibile caricare le tue patologie." } }, "environment": { @@ -7788,37 +7837,50 @@ } }, "documents": { - "title": "Documenti in entrata", - "subtitle": "Carica un documento clinico e rivedi i dati che abbiamo letto.", - "disclaimer": { - "title": "Leggiamo, non interpretiamo", - "body": "Trascriviamo solo ciò che il documento indica — valori, codici e date — e non formuliamo diagnosi, non interpretiamo e non segnaliamo nulla. Niente viene salvato finché non lo verifichi e lo approvi. Solo il tuo medico può interpretare questi risultati." - }, + "title": "Documenti", + "subtitle": "Conserva i tuoi documenti clinici e controlla cosa contengono.", "upload": { - "label": "Carica un documento", - "kindLabel": "Tipo di documento", + "label": "Aggiungi un documento", + "kindLabel": "Categoria", "button": "Carica", - "uploading": "Lettura…", - "fileLabel": "File del documento" + "uploading": "Caricamento…", + "fileLabel": "File (obbligatorio)", + "titleLabel": "Titolo (facoltativo)", + "titlePlaceholder": "es. Referto di cardiologia", + "dateLabel": "Data del documento (facoltativa)", + "fileRequired": "Scegli un file da caricare.", + "accepts": "JPEG, PNG, WebP o PDF · fino a 12 MB" }, "kind": { "DOCTOR_REPORT": "Referto medico", "DISCHARGE_LETTER": "Lettera di dimissione", - "OTHER": "Altro" + "OTHER": "Altro", + "LAB_RESULT": "Risultato di laboratorio", + "IMAGING": "Diagnostica per immagini", + "PRESCRIPTION": "Prescrizione", + "INSURANCE": "Assicurazione", + "VACCINATION": "Vaccinazione" }, "list": { - "empty": "Ancora nessun documento." + "empty": "Nessun documento ancora.", + "noResults": "Nessun documento corrisponde ai filtri.", + "loadMore": "Carica altri", + "loadingMore": "Caricamento…", + "pending": "{count} da rivedere", + "facts": "{count} dati", + "clearFilters": "Cancella i filtri", + "loadError": "Impossibile caricare i tuoi documenti." }, "status": { "EXTRACTING": "Lettura", "EXTRACTED": "Pronto per la revisione", - "FAILED": "Impossibile leggere", + "FAILED": "Lettura non riuscita", "CONFIRMED": "Rivisto", - "DISCARDED": "Scartato" + "DISCARDED": "Scartato", + "STORED": "Salvato" }, "review": { "title": "Rivedi i dati estratti", - "discard": "Scarta", "confirm": "Salva i dati approvati", "confirming": "Salvataggio…", "needsReview": "Da rivedere", @@ -7826,7 +7888,14 @@ "reject": "Rifiuta", "edit": "Modifica", "cancel": "Annulla", - "save": "Salva" + "save": "Salva", + "needsReviewHint": "Modifica questo dato per poterlo approvare.", + "confidenceLabel": "Affidabilità dell'estrazione", + "commitError": { + "unitMismatch": "l'unità non corrisponde a quella salvata", + "unitRequired": "un valore numerico richiede un'unità", + "generic": "non è stato possibile salvarlo" + } }, "empty": { "noFacts": "Niente altro da rivedere." @@ -7844,21 +7913,72 @@ "dose": "Dose" }, "toast": { - "uploaded": "Documento letto. Rivedi i dati qui sotto.", - "uploadFailed": "Impossibile leggere il documento.", + "uploaded": "Documento salvato.", + "uploadFailed": "Impossibile salvare il documento.", "confirmed": "Dati approvati salvati.", + "confirmedPartial": "Salvato, ma {count} non è stato possibile salvare: {reason}.", "confirmFailed": "Impossibile salvare le tue scelte.", "saved": "Dato aggiornato.", - "discarded": "Documento scartato." + "discarded": "Documento scartato.", + "extracted": "Dati estratti. Rivedili qui sotto.", + "extractFailed": "Impossibile estrarre i dati.", + "localOcrDisabled": "Attiva l'OCR locale nelle impostazioni per leggere questo documento sul dispositivo.", + "updated": "Documento aggiornato.", + "updateFailed": "Impossibile aggiornare il documento.", + "discardFailed": "Impossibile eliminare il documento.", + "uploadTooLarge": "Il file è troppo grande (max 12 MB).", + "uploadFileType": "Carica un file JPEG, PNG, WebP o PDF.", + "uploadRateLimited": "Troppi caricamenti. Riprova più tardi.", + "uploadInvalidMetadata": "Controlla titolo, categoria o data e riprova." + }, + "toolbar": { + "searchLabel": "Cerca", + "searchPlaceholder": "Titolo o nome file", + "kindFilterLabel": "Categoria", + "allKinds": "Tutte le categorie", + "sortLabel": "Ordina per", + "sort": { + "documentDate": "Data del documento", + "createdAt": "Data di aggiunta", + "title": "Titolo" + }, + "orderAsc": "Crescente", + "orderDesc": "Decrescente", + "fromLabel": "Dal", + "toLabel": "Al" + }, + "detail": { + "viewOriginal": "Vedi originale", + "edit": "Modifica", + "extract": "Estrai dati", + "extracting": "Estrazione…", + "discard": "Scarta", + "notExtracted": "Nessun dato estratto. Usa Estrai dati per leggere questo documento.", + "viewOriginalNewTab": "(si apre in una nuova scheda)", + "discardConfirmTitle": "Eliminare questo documento?", + "discardConfirmBody": "Questo elimina definitivamente il documento, il file originale e i dati preparati. L'operazione non è reversibile.", + "discardConfirmAction": "Elimina", + "discardCancel": "Annulla" + }, + "edit": { + "title": "Modifica documento", + "subtitle": "Rinomina, ricategorizza o imposta la data del documento.", + "titleLabel": "Titolo", + "kindLabel": "Categoria", + "dateLabel": "Data del documento", + "save": "Salva", + "saving": "Salvataggio…", + "cancel": "Annulla" + }, + "note": { + "providerUnsupported": "Configura un provider di scansione documenti nelle impostazioni IA per estrarre i dati automaticamente.", + "alreadyConfirmed": "Questo documento è già stato esaminato, quindi non può essere estratto di nuovo." + }, + "committed": { + "title": "Approvato da questo documento" } }, "selfDescription": { - "intendedPurpose": "HealthLog è uno strumento personale per la registrazione della salute e il benessere. Ti aiuta a registrare, visualizzare ed esportare i tuoi dati di salute. Non diagnostica, cura, guarisce né previene alcuna malattia e non è un dispositivo medico.", - "coach": "Il Coach lavora sui tuoi dati registrati. Non è un medico e non fornisce consigli medici: per una diagnosi o una terapia rivolgiti a un professionista sanitario.", - "insights": "Sono andamenti descrittivi calcolati dai tuoi dati registrati, non una valutazione clinica né una diagnosi.", - "labs": "Sono i tuoi referti, non una diagnosi medica. Gli intervalli di riferimento variano da laboratorio a persona: solo il tuo medico può interpretarli per te.", - "medications": "Questo registra i farmaci che annoti e la tua aderenza. Non valuta dosaggi o interazioni e non è un consiglio medico: confermali con il farmacista o il medico.", - "emergency": "Se potrebbe trattarsi di un'emergenza, contatta subito il numero di emergenza locale o un medico. HealthLog non può aiutare in caso di emergenza.", "dataPosture": "HealthLog è self-hosted: i tuoi dati di salute restano sulla tua istanza, cifrati a riposo. Le funzioni di IA usano una chiave del provider che fornisci tu o un modello locale, quindi nulla lascia la tua istanza oltre il provider che configuri, e non c'è alcuna analisi di terze parti sui tuoi dati di salute." } } diff --git a/messages/pl.json b/messages/pl.json index 6e0a5ce53..15eb5497a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4018,16 +4018,46 @@ "disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie." }, "shell": { - "sectionsNav": "Sekcje ustawień" + "sectionsNav": "Sekcje ustawień", + "subSectionsNav": "Podsekcje ustawień" + }, + "groups": { + "account": { + "title": "Konto" + }, + "tracking": { + "title": "Śledzenie" + }, + "display": { + "title": "Wyświetlanie" + }, + "integrations": { + "title": "Integracje" + }, + "notifications": { + "title": "Powiadomienia" + }, + "ai": { + "title": "AI i Coach" + }, + "access": { + "title": "API i dostęp" + }, + "data": { + "title": "Dane i prywatność" + }, + "about": { + "title": "O aplikacji" + } }, "sections": { "account": { - "title": "Konto", + "title": "Profil i preferencje", "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.", "subtitle": "Twój profil, tożsamość i dane logowania." }, "integrations": { - "title": "Integracje", + "title": "Połączenia", "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.", "subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami." }, @@ -4036,11 +4066,10 @@ "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.", "channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w", "channelsHintLink": "Integracje → Kanały", - "inboxLink": "Otwórz skrzynkę odbiorczą", "subtitle": "Wybierz, które przypomnienia otrzymujesz." }, "layout": { - "title": "Pulpit", + "title": "Wyświetlanie", "description": "Ustal, jak rozłożone są pulpit i analizy.", "backToHub": "Powrót do Układ i personalizacja", "dashboard": { @@ -4319,9 +4348,9 @@ "subtitle": "Uwierzytelnianie dwuskładnikowe, kody odzyskiwania i klucze dostępu." }, "privacy": { - "title": "Dane i prywatność", - "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.", - "description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń." + "title": "Prywatność i przechowywanie", + "subtitle": "Twoje dane, retencja i szyfrowanie w jednym miejscu.", + "description": "Eksport, usuwanie, retencja i stan szyfrowania." }, "mcp": { "title": "Łącznik MCP", @@ -4665,7 +4694,9 @@ "focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…", "completeness": "Wypełnione: {answered} z {total}", "questionsTitle": "Coach ma pytania", - "questionsOpenCoach": "Odpowiedz w Coachu" + "questionsOpenCoach": "Odpowiedz w Coachu", + "recordsLink": "Alergie, wywiad rodzinny i Twoje choroby przewlekłe są częścią Twojego wywiadu medycznego.", + "recordsLinkCta": "Otwórz wywiad medyczny" }, "responseTimeoutHeading": "Limit czasu odpowiedzi", "responseTimeoutBody": "Jak długo czekać na odpowiedź AI, zanim nastąpi przerwanie. Zwiększ wartość dla lokalnego lub samodzielnie hostowanego modelu, który potrzebuje czasu na załadowanie przy pierwszym żądaniu.", @@ -5286,6 +5317,11 @@ "description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI i opcjonalnym badaniom.", "openAi": "Tryb prywatności AI", "openResearch": "Tryb badawczy" + }, + "signIn": { + "title": "Logowanie i urządzenia", + "description": "Aktywne sesje, zaufane urządzenia i aktywność logowania znajdują się teraz w ustawieniach zabezpieczeń konta.", + "openSecurity": "Zarządzaj logowaniem i zabezpieczeniami" } }, "mcp": { @@ -7061,9 +7097,14 @@ "restoredToast": "Pomiar przywrócony", "restoreError": "Nie udało się przywrócić pomiaru", "detail": { - "history": "Historia", "emptyTitle": "Brak pomiarów", - "emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie." + "emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie.", + "title": "Biomarker", + "genericDescription": "Biomarker laboratoryjny, który śledzisz. Dodaj pomiary, aby obserwować jego trend względem zakresu referencyjnego.", + "valuesTitle": "{marker} — wszystkie odczyty", + "valuesDescription": "Każdy odczyt tego biomarkera. Edytuj lub usuń wpis bezpośrednio.", + "backToMarker": "Powrót do {marker}", + "latestResult": "Ostatni wynik" }, "chart": { "empty": "Brak pomiarów w tym okresie.", @@ -7172,36 +7213,36 @@ "bloodCount": "Morfologia krwi" }, "desc": { - "total-cholesterol": "Całkowita ilość cholesterolu transportowanego we krwi, suma frakcji LDL, HDL i pozostałych lipoprotein.", - "ldl": "Cholesterol transportowany przez lipoproteiny o niskiej gęstości, frakcja odkładająca się w ścianach tętnic.", - "hdl": "Cholesterol transportowany przez lipoproteiny o wysokiej gęstości, które odprowadzają go do wątroby.", - "triglycerides": "Główny tłuszcz krążący we krwi, pochodzący z diety i wytwarzany przez wątrobę.", - "fasting-glucose": "Poziom cukru we krwi mierzony na czczo, odzwierciedla podstawową regulację glukozy.", - "hba1c": "Odsetek hemoglobiny związanej z glukozą, odzwierciedla poziom cukru z ostatnich dwóch–trzech miesięcy.", - "tsh": "Hormon przysadki sterujący tarczycą; podstawowy test czynności tarczycy.", - "ft3": "Wolna, aktywna postać trijodotyroniny, silniejszego hormonu tarczycy.", - "ft4": "Wolna postać tyroksyny, głównego hormonu uwalnianego przez tarczycę do krwi.", - "ferritin": "Główne białko magazynujące żelazo, odzwierciedla zapasy żelaza w organizmie.", - "transferrin-saturation": "Odsetek transferyny, białka transportującego żelazo, które w danym momencie przenosi żelazo.", - "vitamin-d": "Krążąca 25-hydroksywitamina D, standardowy wskaźnik stanu witaminy D.", - "vitamin-b12": "Witamina niezbędna do tworzenia czerwonych krwinek i funkcji nerwów.", - "folate": "Witamina z grupy B potrzebna do tworzenia czerwonych krwinek i syntezy DNA.", - "hs-crp": "Białko wątrobowe rosnące w stanach zapalnych, mierzone z wysoką czułością.", - "creatinine": "Produkt przemiany mięśni usuwany przez nerki, służy do oceny czynności nerek.", - "egfr": "Szacowany współczynnik filtracji nerek, obliczany na podstawie kreatyniny.", - "alt": "Enzym wątrobowy uwalniany do krwi przy uszkodzeniu komórek wątroby.", - "ast": "Enzym wątroby i mięśni rosnący przy uszkodzeniu tych tkanek.", - "ggt": "Enzym wątroby i dróg żółciowych rosnący przy zastoju żółci lub spożyciu alkoholu.", - "sodium": "Główny elektrolit regulujący gospodarkę wodną i objętość krwi.", - "potassium": "Elektrolit niezbędny do przewodzenia nerwowego i pracy mięśnia sercowego.", - "hemoglobin": "Białko czerwonych krwinek transportujące tlen.", - "hematocrit": "Udział czerwonych krwinek w objętości krwi.", - "wbc": "Całkowita liczba białych krwinek, krążącej obrony układu odpornościowego.", - "platelets": "Fragmenty komórkowe, które zlepiają się, tworząc skrzepy krwi.", - "apob": "Bezpośrednio liczy cząstki aterogenne; często lepszy marker ryzyka niż LDL-C, gdy się różnią.", - "lp-a": "W dużej mierze dziedziczny, dożywotni czynnik ryzyka — zwykle mierzony raz. Podawaj w jednostkach molowych (nmol/L).", - "fasting-insulin": "Łączy się z glukozą na czczo do oszacowania insulinooporności (HOMA-IR).", - "omega-3-index": "Udział EPA+DHA w czerwonych krwinkach; 8–12% to powszechnie podawany zakres kardioprotekcyjny." + "total-cholesterol": "Cholesterol całkowity to suma cholesterolu przenoszonego przez wszystkie cząstki lipoprotein we krwi, w tym frakcje LDL, HDL i pozostałości. Sam cholesterol jest niezbędnym budulcem błon komórkowych, hormonów i kwasów żółciowych. Jako pojedyncza liczba pozostaje miarą zgrubną, ponieważ zarówno wysoki udział HDL, jak i wysoki udział LDL podnoszą sumę — dlatego zwykle odczytuje się go wraz z poszczególnymi frakcjami. Oznacza się go przede wszystkim w ramach długoterminowej oceny ryzyka sercowo-naczyniowego.", + "ldl": "Cholesterol LDL to cholesterol przenoszony przez cząstki lipoprotein o małej gęstości, czyli frakcja, która dostarcza cholesterol do tkanek, ale może też odkładać go w ścianach tętnic. Przez lata odkładanie to przyczynia się do blaszki miażdżycowej, dlatego LDL jest jednym z najbaczniej obserwowanych markerów lipidowych. Mniejsza ekspozycja w długim okresie zwykle wiąże się z niższym ryzykiem sercowo-naczyniowym. Interpretuje się go według kategorii ryzyka, a nie wobec jednej stałej wartości prawidłowej.", + "hdl": "Cholesterol HDL to cholesterol przenoszony przez cząstki lipoprotein o dużej gęstości, które zbierają nadmiar cholesterolu z tkanek i odprowadzają go do wątroby w celu ponownego wykorzystania lub usunięcia. Z powodu tej funkcji transportowej często nazywa się go frakcją ochronną. Bardzo niskie wartości zwykle towarzyszą wyższemu ryzyku sercowo-naczyniowemu, jednak sztuczne jego podnoszenie nie obniżało ryzyka w sposób wiarygodny, dlatego odczytuje się go jako marker, a nie cel. Zwykle interpretuje się go razem z LDL i trójglicerydami.", + "triglycerides": "Trójglicerydy to główna postać tłuszczu krążącego we krwi, dostarczana zarówno z dietą, jak i wytwarzana przez wątrobę, i stanowią kluczowy zapas energii. Wartości rosną po posiłkach, dlatego próbkę pobiera się zwykle na czczo, aby była porównywalna. Utrzymujące się wysokie trójglicerydy wiążą się z ryzykiem metabolicznym i sercowo-naczyniowym i często towarzyszą insulinooporności oraz niskiemu HDL. Odczytuje się je jako jeden ze składników całościowego obrazu lipidowego.", + "fasting-glucose": "Glukoza na czczo to stężenie cukru we krwi mierzone po nocnym poście, odzwierciedlające, jak organizm utrzymuje swój podstawowy poziom cukru bez niedawnego posiłku. Glukoza jest głównym natychmiastowym paliwem organizmu, a insulina utrzymuje ją zwykle w wąskim zakresie. Pomiar na czczo eliminuje wpływ niedawnych posiłków, co czyni go standardowym badaniem przesiewowym działania regulacji glukozy. To jeden z markerów, którymi ocenia się zdrowie metaboliczne w czasie.", + "hba1c": "HbA1c to odsetek hemoglobiny w czerwonych krwinkach, który związał się z glukozą i gromadzi się proporcjonalnie do średniej ekspozycji na cukier we krwi. Ponieważ czerwone krwinki żyją około trzech miesięcy, odzwierciedla średnie stężenie glukozy z poprzednich dwóch–trzech miesięcy, a nie pojedynczej chwili. Czyni to z niego stabilny, niezależny od posiłków obraz kontroli glikemii. Jego wiarygodność mogą zaburzać stany zmieniające czas życia czerwonych krwinek.", + "tsh": "TSH (hormon tyreotropowy) jest uwalniany przez przysadkę, aby sygnalizować tarczycy wytwarzanie jej hormonów, i dostosowuje się w górę lub w dół, by utrzymać je w równowadze. Dzięki tej pętli sprzężenia zwrotnego TSH jest najczulszym badaniem przesiewowym pierwszego rzutu funkcji tarczycy: zwykle zmienia się, zanim same hormony tarczycy odbiegną od normy. Podwyższona lub obniżona wartość wskazuje na niedoczynną lub nadczynną tarczycę i zwykle prowadzi do bezpośredniego oznaczenia hormonów tarczycy. Ciąża i wiek przesuwają oczekiwany zakres.", + "ft3": "Wolne T3 (trójjodotyronina) to niezwiązana, biologicznie czynna postać silniejszego hormonu tarczycy — frakcja dostępna do działania na tkanki. Większość krążącego T3 jest związana z białkami nośnikowymi i nieaktywna, dlatego wolna część odzwierciedla rzeczywistą dostępność hormonu. Oznacza się je, aby scharakteryzować funkcję tarczycy, zwłaszcza wraz z TSH i wolnym T4, gdy wyniki są niejasne. Wartości silnie zależą od metody oznaczenia i odczytuje się je w kontekście klinicznym.", + "ft4": "Wolne T4 (tyroksyna) to niezwiązana postać głównego hormonu, który tarczyca uwalnia do krwi; służy jako rezerwa, którą tkanki w razie potrzeby przekształcają w bardziej aktywne T3. Wolna frakcja odzwierciedla to, co jest faktycznie dostępne dla organizmu, niezależnie od poziomu białek nośnikowych. Zwykle oznacza się je wraz z TSH, aby odróżnić problemy samej tarczycy od zaburzeń sygnalizacji przysadkowej. Ciąża i niektóre leki mogą je przesuwać.", + "ferritin": "Ferrytyna to główne białko magazynujące żelazo w organizmie, a jej poziom we krwi z grubsza odzwierciedla całkowite zapasy żelaza. Niska wartość wskazuje na wyczerpane zapasy, a wysoka może odzwierciedlać przeładowanie żelazem — lub po prostu stan zapalny, ponieważ ferrytyna rośnie także jako białko ostrej fazy. Z tego powodu często interpretuje się ją wraz z innymi markerami żelaza i oznakami zapalenia. To jedna z podstawowych miar oceny gospodarki żelazem.", + "transferrin-saturation": "Wysycenie transferyny to odsetek transferyny — białka przenoszącego żelazo we krwi — który jest aktualnie obładowany żelazem. Łączy ono ilość żelaza w transporcie ze zdolnością organizmu do jego przenoszenia, dając migawkę dostępności żelaza, która uzupełnia obraz zapasów dawany przez ferrytynę. Niskie wysycenie sugeruje niedobór żelaza, a wysoka wartość może wskazywać na przeładowanie. Odczytuje się je wraz z ferrytyną i pełnym profilem żelaza.", + "vitamin-d": "Witaminę D oznacza się jako 25-hydroksywitaminę D, krążącą postać magazynową i standardowy marker ogólnego stanu witaminy D. Organizm wytwarza ją w skórze wystawionej na światło słoneczne i część przyswaja z pożywienia; wspiera ona gospodarkę wapniową, zdrowie kości i funkcję odpornościową. Wartości zmieniają się wraz z porą roku, szerokością geograficzną, odcieniem skóry i suplementacją. Oznacza się ją, aby z czasem ocenić, czy zapasy są wystarczające.", + "vitamin-b12": "Witamina B12 (kobalamina) to niezbędna witamina pozyskiwana głównie z produktów pochodzenia zwierzęcego i magazynowana w dużej mierze w wątrobie. Jest potrzebna do tworzenia czerwonych krwinek, syntezy DNA i zdrowia układu nerwowego. Długotrwały niedobór — z powodu małego spożycia lub upośledzonego wchłaniania — może prowadzić do niedokrwistości i objawów neurologicznych. Oznacza się ją, aby ocenić stan odżywienia, zwłaszcza tam, gdzie spożycie lub wchłanianie mogą być ograniczone.", + "folate": "Foliany (witamina B9) to witamina z grupy B obecna w zielonych warzywach liściastych, roślinach strączkowych i żywności wzbogacanej, kluczowa dla tworzenia czerwonych krwinek i syntezy DNA. Działa w ścisłym powiązaniu z witaminą B12, a niedobór któregokolwiek z nich może wywołać podobny rodzaj niedokrwistości. Odpowiednia ilość folianów jest szczególnie ważna na początku ciąży dla zdrowego rozwoju płodu. Oznacza się je, aby ocenić stan odżywienia i przyczynę niektórych niedokrwistości.", + "hs-crp": "Wysokoczułe CRP mierzy białko C-reaktywne, marker uwalniany przez wątrobę w odpowiedzi na stan zapalny, metodą dostrojoną do wykrywania niskich stężeń istotnych dla ryzyka sercowo-naczyniowego. W odróżnieniu od standardowego CRP stosowanego przy ostrych infekcjach wersja wysokoczuła wychwytuje subtelny, przewlekły stan zapalny powiązany z chorobą naczyń. Ponieważ każda niedawna infekcja lub uraz może je podnieść, interpretuje się je z dala od ostrej choroby i często potwierdza powtórną próbką. Odczytuje się je jako jeden z czynników całościowego ryzyka sercowo-naczyniowego.", + "creatinine": "Kreatynina to produkt przemiany materii prawidłowego metabolizmu mięśni, który nerki filtrują z krwi w dość stałym tempie. Ponieważ jej wytwarzanie jest stosunkowo stałe, jej poziom we krwi służy jako praktyczna miara tego, jak dobrze nerki usuwają produkty przemiany materii. Na wartości wpływają też masa mięśniowa, nawodnienie i dieta, dlatego zwykle interpretuje się je wraz z szacowanym współczynnikiem filtracji. To podstawowy marker funkcji nerek.", + "egfr": "eGFR (szacowany współczynnik filtracji kłębuszkowej) szacuje, ile krwi nerki filtrują w ciągu minuty, na podstawie kreatyniny oraz wieku i płci. Przekłada surową wartość kreatyniny na bardziej bezpośrednio zrozumiałą miarę funkcji nerek. Spadek eGFR w czasie to główny sposób klasyfikowania i śledzenia przewlekłych zmian w nerkach. Jako wartość szacunkowa odczytuje się go jako trend, a nie pojedynczą dokładną liczbę.", + "alt": "ALT (aminotransferaza alaninowa) to enzym skupiony w komórkach wątroby, uwalniany do krwi, gdy komórki te są obciążone lub uszkodzone. Ponieważ jest stosunkowo swoisty dla wątroby, należy do głównych markerów wykrywania uszkodzenia lub zapalenia wątroby. Wartości mogą rosnąć przy stłuszczeniu wątroby, wirusowym zapaleniu wątroby, alkoholu lub niektórych lekach. Zwykle odczytuje się go wraz z AST i pozostałymi markerami profilu wątrobowego.", + "ast": "AST (aminotransferaza asparaginianowa) to enzym obecny w wątrobie, ale także w mięśniach, sercu i czerwonych krwinkach, uwalniany przy uszkodzeniu którejkolwiek z tych tkanek. Ponieważ jest mniej swoisty dla wątroby niż ALT, oba interpretuje się razem, a ich stosunek może wskazywać na źródło i charakter problemu. Intensywny wysiłek lub uraz mięśni mogą go podnieść niezależnie od wątroby. To jeden ze standardowych enzymów profilu wątrobowego.", + "ggt": "GGT (gamma-glutamylotransferaza) to enzym wątroby i dróg żółciowych, który rośnie zwłaszcza przy utrudnionym odpływie żółci lub w wyniku ekspozycji na alkohol. Często służy do wyjaśnienia, dlaczego inny marker wątrobowy jest podwyższony — na przykład, aby odróżnić źródło wątrobowe od żółciowego. Jest czuły, ale mało swoisty, dlatego odczytuje się go w kontekście reszty profilu wątrobowego. To jeden z kilku markerów używanych do oceny zdrowia wątroby i dróg żółciowych.", + "sodium": "Sód to główny elektrolit płynu poza komórkami organizmu i podstawowy regulator gospodarki płynami oraz objętości krwi. Organizm reguluje go ściśle poprzez pragnienie, hormony i nerki, dlatego jego poziom we krwi odzwierciedla raczej równowagę między wodą a solą niż samo spożycie. Nieprawidłowe wartości zwykle sygnalizują zaburzenie gospodarki wodnej, a nie samego sodu. To podstawowy element panelu metabolicznego i elektrolitowego.", + "potassium": "Potas to główny elektrolit wewnątrz komórek organizmu i jest niezbędny do przewodzenia nerwowego oraz równego rytmu mięśnia sercowego. Jego poziom we krwi utrzymywany jest w wąskim zakresie przez nerki i sygnały hormonalne, ponieważ zarówno wysokie, jak i niskie wartości mogą zaburzać aktywność elektryczną serca. Na wartości mogą wpływać funkcja nerek, nawodnienie i kilka powszechnych leków. To podstawowa miara panelu elektrolitowego.", + "hemoglobin": "Hemoglobina to zawierające żelazo białko czerwonych krwinek, które wiąże tlen w płucach i przenosi go do tkanek. Jej stężenie jest główną miarą zdolności krwi do przenoszenia tlenu i centralnym markerem wykrywania niedokrwistości. Niskie wartości mogą odzwierciedlać utratę krwi, niedobór żelaza lub witamin albo zmniejszone wytwarzanie, a wysokie mogą następować po odwodnieniu lub w warunkach niedoboru tlenu. To filar pełnej morfologii krwi.", + "hematocrit": "Hematokryt to udział czerwonych krwinek w objętości krwi, wyrażony w procentach. Zmienia się ściśle wraz z hemoglobiną i daje uzupełniający obraz masy czerwonych krwinek oraz zdolności krwi do przenoszenia tlenu. Wpływa na niego nawodnienie, ponieważ zmiany objętości osocza przesuwają ten udział nawet wtedy, gdy liczba czerwonych krwinek pozostaje stała. Odczytuje się go wraz z hemoglobiną i resztą morfologii.", + "wbc": "Liczba białych krwinek to całkowita liczba komórek odpornościowych krążących we krwi, ruchomej obrony organizmu przed infekcjami. Rośnie w odpowiedzi na infekcję, stan zapalny lub stres i może spadać, gdy szpik kostny jest osłabiony lub w niektórych chorobach odpornościowych. Ponieważ sumuje kilka odrębnych typów komórek, wyraźną zmianę często uzupełnia się rozmazem, który rozbija ją na składowe. To podstawowy element pełnej morfologii krwi.", + "platelets": "Płytki krwi to drobne fragmenty komórek we krwi, które zlepiają się i tworzą początkowy czop zatrzymujący krwawienie z uszkodzonego naczynia. Ich liczba odzwierciedla równowagę między wytwarzaniem w szpiku kostnym a zużyciem lub rozpadem. Niska liczba może zwiększać skłonność do krwawień, a wysoka może następować po stanie zapalnym lub innych bodźcach. Stanowią część pełnej morfologii krwi i są podstawową miarą zdolności krzepnięcia.", + "apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL — zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.", + "lp-a": "Lipoproteina(a) to cząstka podobna do LDL z dodatkowo przyłączonym białkiem, której poziom jest w dużej mierze uwarunkowany genetycznie i pozostaje dość stabilny przez całe życie. Podwyższona Lp(a) jest niezależnym, dziedzicznym czynnikiem ryzyka chorób sercowo-naczyniowych i zwężenia zastawki aortalnej. Ponieważ niewiele się zmienia, zwykle oznacza się ją raz, aby ustalić ryzyko danej osoby na całe życie, a nie śledzić wielokrotnie. Podaje się ją w jednostkach molowych i odczytuje w ramach całościowego ryzyka sercowo-naczyniowego.", + "fasting-insulin": "Insulina na czczo to poziom insuliny — hormonu, który przenosi glukozę z krwi do komórek — mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.", + "omega-3-index": "Indeks omega-3 to udział EPA i DHA — dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3 — wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", @@ -7318,8 +7359,8 @@ "description": "Pobieraj dzienną pogodę i długość dnia dla swojej lokalizacji, aby powiązać je z nastrojem, snem i parametrami życiowymi. Domyślnie wyłączone." }, "inboundDocuments": { - "label": "Dokumenty przychodzące", - "description": "Prześlij raport lekarski lub wypis; sprawdź wyodrębnione dane przed zapisaniem. Domyślnie wyłączone." + "label": "Dokumenty", + "description": "Przechowuj swoje dokumenty medyczne; opcjonalnie wyodrębniaj i przeglądaj ich fakty przed zapisaniem. Domyślnie wyłączone." }, "mentalHealth": { "label": "Dobrostan psychiczny", @@ -7558,8 +7599,7 @@ "mentalHealth": { "pageTitle": "Sprawdzenie dobrostanu psychicznego", "pageDescription": "Dwa zwalidowane, dobrowolne testy — PHQ-9 (nastrój) i GAD-7 (lęk) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.", - "disclaimer": "To przesiewowa samoocena, a nie diagnoza. Wysoki lub niski wynik nie oznacza, że masz lub nie masz danego schorzenia. Tylko wykwalifikowany pracownik ochrony zdrowia może postawić diagnozę. W razie wątpliwości porozmawiaj z lekarzem.", - "optInNote": "Dobrowolne i prywatne: Twoje poszczególne odpowiedzi są szyfrowane, pozostają poza Coachem AI i eksportami i nigdy nie są udostępniane.", + "infoTooltip": "To jest przesiewowy autotest, a nie diagnoza.", "attributionLabel": "Źródło", "choosePrompt": "Wybierz sprawdzenie", "start": "Rozpocznij", @@ -7774,6 +7814,15 @@ "updatedToast": "Wpis zaktualizowany", "saveError": "Nie udało się zapisać wpisu." } + }, + "conditions": { + "cardTitle": "Choroby przewlekłe", + "cardDescription": "Choroby przewlekłe i to, na co Coach ma zwracać uwagę — z Twojego kontekstu osobistego.", + "conditionsLabel": "Choroby przewlekłe", + "focusLabel": "Na co zwraca uwagę Coach", + "empty": "Nic jeszcze nie zapisano.", + "editCta": "Edytuj w kontekście osobistym", + "loadError": "Nie udało się wczytać Twoich chorób." } }, "environment": { @@ -7788,37 +7837,50 @@ } }, "documents": { - "title": "Dokumenty przychodzące", - "subtitle": "Prześlij dokument kliniczny i sprawdź dane, które z niego odczytaliśmy.", - "disclaimer": { - "title": "Odczytujemy, nie interpretujemy", - "body": "Przepisujemy tylko to, co podaje dokument — wartości, kody i daty — i nigdy nie stawiamy diagnozy, nie interpretujemy ani niczego nie oznaczamy. Nic nie zostaje zapisane, dopóki tego nie sprawdzisz i nie zatwierdzisz. Tylko lekarz może zinterpretować te wyniki." - }, + "title": "Dokumenty", + "subtitle": "Przechowuj swoje dokumenty medyczne i sprawdzaj, co zawierają.", "upload": { - "label": "Prześlij dokument", - "kindLabel": "Typ dokumentu", + "label": "Dodaj dokument", + "kindLabel": "Kategoria", "button": "Prześlij", - "uploading": "Odczytywanie…", - "fileLabel": "Plik dokumentu" + "uploading": "Przesyłanie…", + "fileLabel": "Plik (wymagany)", + "titleLabel": "Tytuł (opcjonalnie)", + "titlePlaceholder": "np. Raport kardiologiczny", + "dateLabel": "Data dokumentu (opcjonalnie)", + "fileRequired": "Wybierz plik do przesłania.", + "accepts": "JPEG, PNG, WebP lub PDF · do 12 MB" }, "kind": { "DOCTOR_REPORT": "Raport lekarski", - "DISCHARGE_LETTER": "Wypis", - "OTHER": "Inny" + "DISCHARGE_LETTER": "Wypis ze szpitala", + "OTHER": "Inne", + "LAB_RESULT": "Wynik laboratoryjny", + "IMAGING": "Obrazowanie", + "PRESCRIPTION": "Recepta", + "INSURANCE": "Ubezpieczenie", + "VACCINATION": "Szczepienie" }, "list": { - "empty": "Brak dokumentów." + "empty": "Brak dokumentów.", + "noResults": "Żaden dokument nie pasuje do filtrów.", + "loadMore": "Załaduj więcej", + "loadingMore": "Ładowanie…", + "pending": "{count} do przeglądu", + "facts": "{count} faktów", + "clearFilters": "Wyczyść filtry", + "loadError": "Nie udało się wczytać Twoich dokumentów." }, "status": { - "EXTRACTING": "Odczytywanie", - "EXTRACTED": "Gotowe do przeglądu", + "EXTRACTING": "Odczyt", + "EXTRACTED": "Gotowy do przeglądu", "FAILED": "Nie udało się odczytać", - "CONFIRMED": "Sprawdzone", - "DISCARDED": "Odrzucone" + "CONFIRMED": "Przejrzany", + "DISCARDED": "Odrzucony", + "STORED": "Zapisany" }, "review": { "title": "Przejrzyj wyodrębnione dane", - "discard": "Odrzuć", "confirm": "Zapisz zatwierdzone dane", "confirming": "Zapisywanie…", "needsReview": "Wymaga przeglądu", @@ -7826,7 +7888,14 @@ "reject": "Odrzuć", "edit": "Edytuj", "cancel": "Anuluj", - "save": "Zapisz" + "save": "Zapisz", + "needsReviewHint": "Edytuj tę pozycję, aby ją zatwierdzić.", + "confidenceLabel": "Pewność rozpoznania", + "commitError": { + "unitMismatch": "jednostka nie zgadza się z zapisaną", + "unitRequired": "wartość liczbowa wymaga jednostki", + "generic": "nie udało się zapisać" + } }, "empty": { "noFacts": "Nic więcej do przejrzenia." @@ -7844,21 +7913,72 @@ "dose": "Dawka" }, "toast": { - "uploaded": "Dokument odczytany. Sprawdź dane poniżej.", - "uploadFailed": "Nie udało się odczytać dokumentu.", + "uploaded": "Dokument zapisany.", + "uploadFailed": "Nie udało się zapisać dokumentu.", "confirmed": "Zatwierdzone dane zapisane.", + "confirmedPartial": "Zapisano, ale {count} nie udało się zapisać — {reason}.", "confirmFailed": "Nie udało się zapisać Twoich decyzji.", "saved": "Dane zaktualizowane.", - "discarded": "Dokument odrzucony." + "discarded": "Dokument odrzucony.", + "extracted": "Fakty wyodrębnione. Przejrzyj je poniżej.", + "extractFailed": "Nie udało się wyodrębnić faktów.", + "localOcrDisabled": "Włącz lokalne OCR w ustawieniach, aby odczytać ten dokument na urządzeniu.", + "updated": "Dokument zaktualizowany.", + "updateFailed": "Nie udało się zaktualizować dokumentu.", + "discardFailed": "Nie udało się odrzucić dokumentu.", + "uploadTooLarge": "Plik jest za duży (maks. 12 MB).", + "uploadFileType": "Prześlij plik JPEG, PNG, WebP lub PDF.", + "uploadRateLimited": "Zbyt wiele przesłań. Spróbuj ponownie później.", + "uploadInvalidMetadata": "Sprawdź tytuł, kategorię lub datę i spróbuj ponownie." + }, + "toolbar": { + "searchLabel": "Szukaj", + "searchPlaceholder": "Tytuł lub nazwa pliku", + "kindFilterLabel": "Kategoria", + "allKinds": "Wszystkie kategorie", + "sortLabel": "Sortuj według", + "sort": { + "documentDate": "Data dokumentu", + "createdAt": "Data dodania", + "title": "Tytuł" + }, + "orderAsc": "Rosnąco", + "orderDesc": "Malejąco", + "fromLabel": "Od", + "toLabel": "Do" + }, + "detail": { + "viewOriginal": "Zobacz oryginał", + "edit": "Edytuj", + "extract": "Wyodrębnij fakty", + "extracting": "Wyodrębnianie…", + "discard": "Odrzuć", + "notExtracted": "Nie wyodrębniono jeszcze faktów. Użyj Wyodrębnij fakty, aby odczytać ten dokument.", + "viewOriginalNewTab": "(otwiera się w nowej karcie)", + "discardConfirmTitle": "Odrzucić ten dokument?", + "discardConfirmBody": "Spowoduje to trwałe usunięcie dokumentu, oryginalnego pliku i przygotowanych danych. Tej operacji nie można cofnąć.", + "discardConfirmAction": "Odrzuć", + "discardCancel": "Anuluj" + }, + "edit": { + "title": "Edytuj dokument", + "subtitle": "Zmień nazwę, kategorię lub ustaw datę dokumentu.", + "titleLabel": "Tytuł", + "kindLabel": "Kategoria", + "dateLabel": "Data dokumentu", + "save": "Zapisz", + "saving": "Zapisywanie…", + "cancel": "Anuluj" + }, + "note": { + "providerUnsupported": "Skonfiguruj dostawcę skanowania dokumentów w ustawieniach AI, aby automatycznie wyodrębniać fakty.", + "alreadyConfirmed": "Ten dokument został już sprawdzony, więc nie można go ponownie wyodrębnić." + }, + "committed": { + "title": "Zatwierdzone z tego dokumentu" } }, "selfDescription": { - "intendedPurpose": "HealthLog to osobiste narzędzie do rejestrowania zdrowia i dbania o dobre samopoczucie. Pomaga zapisywać, wizualizować i eksportować własne dane zdrowotne. Nie diagnozuje, nie leczy ani nie zapobiega żadnej chorobie i nie jest wyrobem medycznym.", - "coach": "Coach korzysta z Twoich własnych zapisanych danych. Nie jest lekarzem i nie udziela porad medycznych — po diagnozę lub leczenie zwróć się do pracownika ochrony zdrowia.", - "insights": "To opisowe wzorce wyliczone z Twoich własnych zapisanych danych, a nie ocena kliniczna ani diagnoza.", - "labs": "To Twoje własne zapisy, a nie diagnoza medyczna. Zakresy referencyjne różnią się w zależności od laboratorium i osoby — tylko Twój lekarz może je dla Ciebie zinterpretować.", - "medications": "To rejestruje leki, które zapisujesz, oraz Twoje stosowanie się do zaleceń. Nie ocenia dawkowania ani interakcji i nie jest poradą medyczną — potwierdź to u farmaceuty lub lekarza.", - "emergency": "Jeśli to może być nagły przypadek, zadzwoń teraz pod lokalny numer alarmowy lub skontaktuj się z lekarzem. HealthLog nie pomoże w nagłym przypadku.", "dataPosture": "HealthLog jest hostowany samodzielnie: Twoje dane zdrowotne pozostają na Twojej instancji, zaszyfrowane w spoczynku. Funkcje AI korzystają z dostarczonego przez Ciebie klucza dostawcy lub modelu lokalnego, więc nic nie opuszcza Twojej instancji poza skonfigurowanym dostawcą i nie ma analityki firm trzecich na Twoich danych zdrowotnych." } } diff --git a/next.config.ts b/next.config.ts index 2c6e0c4ed..3ba5f19c5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -134,6 +134,16 @@ const nextConfig: NextConfig = { destination: "/settings/notifications", permanent: true, }, + // v1.25.1 — the settings IA consolidation made the Display group land + // directly on `/settings/dashboard` (its first sub-tab), so the old + // "Layout & Personalization" hub at `/settings/layout` is no longer + // linked from anywhere. 301-redirect it to the Display landing so any old + // bookmark or cached navigation still resolves. + { + source: "/settings/layout", + destination: "/settings/dashboard", + permanent: true, + }, // v1.22.0 — the preventive-care surface moved from the German slug // `/vorsorge` to the English `/checkups` (the feature reads "Checkups" // in every non-German locale now). 301-redirect so bookmarks, the PWA's diff --git a/package.json b/package.json index 416f768d9..f4ce50ea4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.25.0", + "version": "1.25.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0222_v125_documents_library/migration.sql b/prisma/migrations/0222_v125_documents_library/migration.sql new file mode 100644 index 000000000..3ab912b5a --- /dev/null +++ b/prisma/migrations/0222_v125_documents_library/migration.sql @@ -0,0 +1,91 @@ +-- v1.25 — documents library: store-first, optional extraction. +-- +-- Inverts the inbound-documents surface from an OCR inbox into a document +-- library. A user can now STORE any file (encrypted at rest) without a +-- configured document-scan provider; extraction becomes an optional action +-- on an already-stored row. +-- +-- Schema deltas on `inbound_documents`: +-- * `title` — user-given label, plaintext (mirrors `filename`) so +-- the list can ILIKE-search + ORDER BY. The document +-- BODY stays encrypted; only this short user-authored +-- title is clear-text. +-- * `document_date` — user-set filing date (editable). Distinct from +-- `report_date`, which stays the model-transcribed +-- value. +-- * a `(user_id, deleted_at, document_date)` index for date-grouped browse + +-- range filter (the list always filters `deleted_at IS NULL`). +-- * `inbound_document_kind` gains LAB_RESULT / IMAGING / PRESCRIPTION / +-- INSURANCE / VACCINATION (the existing 3 stay; OTHER stays the fallback). +-- * `inbound_document_status` gains STORED, made the column DEFAULT (the +-- library default — uploaded, no extraction run). +-- +-- Additive; no existing row rewritten (the status type-swap maps every +-- existing label to itself). Nullable columns, no backfill: existing rows +-- keep their current status; `title` / `document_date` start NULL and resolve +-- through the display fallback. Idempotent guards so reruns are safe on prod. +-- +-- Postgres note: a freshly-added enum value cannot be USED (e.g. as a column +-- DEFAULT) in the same transaction it is added in. Because Prisma runs each +-- migration inside a transaction, STORED cannot be added with `ALTER TYPE ... +-- ADD VALUE` and then set as the default in the same file. The status enum is +-- therefore recreated (RENAME + CREATE with the full value set + column +-- type-swap + SET DEFAULT): a type CREATEd in the current transaction MAY +-- have its values used in that same transaction, which makes the default +-- change safe. The `kind` additions are not used in this migration, so the +-- cheap `ADD VALUE IF NOT EXISTS` form is used for them. +-- +-- Reversibility (down): +-- DROP INDEX IF EXISTS "inbound_documents_user_id_document_date_idx"; +-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "document_date"; +-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "title"; +-- -- (added enum values are inert if unused; a roll-back leaves them +-- -- harmless rather than rewriting the type back.) + +-- AlterTable: user-given title + user-set filing date (both nullable). +ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "title" TEXT; +ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "document_date" TIMESTAMP(3); + +-- AlterEnum: richer document-kind taxonomy (additive; not used in this +-- migration, so the new values are safe to add in-place). +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'LAB_RESULT'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'IMAGING'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'PRESCRIPTION'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'INSURANCE'; +ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'VACCINATION'; + +-- AlterEnum: add STORED and make it the column default. Recreate the type so +-- the new value can be USED as the default in this same transaction (a +-- same-transaction CREATE TYPE permits same-transaction use of its values). +-- Guarded on STORED-absence so a rerun is a no-op. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_enum e + JOIN pg_type t ON e.enumtypid = t.oid + WHERE t.typname = 'inbound_document_status' AND e.enumlabel = 'STORED' + ) THEN + ALTER TYPE "inbound_document_status" RENAME TO "inbound_document_status_old"; + CREATE TYPE "inbound_document_status" AS ENUM ( + 'STORED', 'EXTRACTING', 'EXTRACTED', 'FAILED', 'CONFIRMED', 'DISCARDED' + ); + ALTER TABLE "inbound_documents" ALTER COLUMN "status" DROP DEFAULT; + ALTER TABLE "inbound_documents" + ALTER COLUMN "status" TYPE "inbound_document_status" + USING ("status"::text::"inbound_document_status"); + ALTER TABLE "inbound_documents" ALTER COLUMN "status" SET DEFAULT 'STORED'; + DROP TYPE "inbound_document_status_old"; + END IF; +END $$; + +-- CreateIndex: date-grouped browse + document_date range filter. The list +-- always filters `deleted_at IS NULL` and sorts by `document_date`, so +-- deleted_at leads the sort column to keep the scan covered. +CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_deleted_at_document_date_idx" + ON "inbound_documents"("user_id", "deleted_at", "document_date"); + +-- DropIndex: the new `(user_id, deleted_at, document_date)` index above is a +-- superset of the plain `(user_id, deleted_at)` index, so the 2-column index is +-- redundant for read planning and only adds write/maintenance cost. Drop it. +DROP INDEX IF EXISTS "inbound_documents_user_id_deleted_at_idx"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f10c6427f..c65695f79 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -59,10 +59,10 @@ model User { // without a home set) simply has no environment rows. `homeTimezone` anchors // the day-key an observation is stored under (it can differ from the display // `timezone` above for an account that tracks weather for another locale). - homeLat Float? @map("home_lat") - homeLon Float? @map("home_lon") - homeLabel String? @map("home_label") - homeTimezone String? @map("home_timezone") + homeLat Float? @map("home_lat") + homeLon Float? @map("home_lon") + homeLabel String? @map("home_label") + homeTimezone String? @map("home_timezone") // v1.25 — effective-from instant for the home location. Stamped the moment a // home is set/updated. Day resolution attributes a past day to home ONLY when // the day is on/after this instant; earlier days are skipped (never fabricated @@ -5527,20 +5527,29 @@ model CyclePrediction { // ───────────────────────────────────────────────────────────────────────── /// What kind of clinical document was uploaded (free choice by the user; -/// drives no interpretation — it is a label only). +/// drives no interpretation — it is a label only). The richer set +/// (lab/imaging/prescription/insurance/vaccination) makes the library +/// browsable by category; the value still drives no interpretation. enum InboundDocumentKind { DOCTOR_REPORT DISCHARGE_LETTER + LAB_RESULT + IMAGING + PRESCRIPTION + INSURANCE + VACCINATION OTHER @@map("inbound_document_kind") } -/// Lifecycle of an uploaded document. EXTRACTING while the provider runs, -/// EXTRACTED once facts are staged for review, FAILED on a provider/parse -/// miss, CONFIRMED once the user has acted on every fact, DISCARDED on a -/// soft delete. +/// Lifecycle of an uploaded document. STORED is the library default — the +/// file is uploaded + encrypted at rest with no extraction run. EXTRACTING +/// while the provider runs, EXTRACTED once facts are staged for review, +/// FAILED on a provider/parse miss, CONFIRMED once the user has acted on +/// every fact, DISCARDED on a soft delete. enum InboundDocumentStatus { + STORED EXTRACTING EXTRACTED FAILED @@ -5572,29 +5581,44 @@ enum ExtractedFactStatus { } model InboundDocument { - id String @id @default(cuid()) - userId String @map("user_id") - kind InboundDocumentKind @default(OTHER) + id String @id @default(cuid()) + userId String @map("user_id") + kind InboundDocumentKind @default(OTHER) + /// User-given label. Deliberately plaintext (like `filename`) so the library + /// list can ILIKE-search + ORDER BY in SQL. The document BODY stays + /// encrypted; only this short, user-authored title is clear-text. Tradeoff: + /// a title the user types MAY contain PHI (e.g. "HIV result") and is then + /// cleartext at rest / in backups — the same exposure the pre-existing + /// plaintext `filename` already carries. Accepted to keep server-side search + /// and sort. + title String? /// Original filename as uploaded (sanitised, optional). - filename String? + filename String? /// Magic-byte-sniffed MIME type the document was stored as. - mimeType String @map("mime_type") + mimeType String @map("mime_type") /// Stored byte length of the original (pre-encryption) document. - byteSize Int @map("byte_size") + byteSize Int @map("byte_size") /// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary /// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention /// shared with the note columns. The original is sensitive and never /// stored in the clear or logged. - contentEncrypted Bytes @map("content_encrypted") - status InboundDocumentStatus @default(EXTRACTING) + contentEncrypted Bytes @map("content_encrypted") + status InboundDocumentStatus @default(STORED) /// Provider type that ran the extraction (e.g. "anthropic", "openai", - /// "local"). Diagnostic only. - providerType String? @map("provider_type") + /// "local"). Diagnostic only. Null on a store-only document. + providerType String? @map("provider_type") /// The document's stated report/collection date (YYYY-MM-DD at UTC), as - /// transcribed — never inferred. - reportDate DateTime? @map("report_date") + /// transcribed by the model — never inferred, never user-set. + reportDate DateTime? @map("report_date") + /// Effective filing date the library sorts, groups, and range-filters on + /// (editable). Distinct from `reportDate`, which is the model-transcribed + /// value. On store it defaults to the upload day (now) when the user does + /// not supply one, so a stored row's filing date is never NULL and display + /// == sort == filter. It stays user-editable and MAY be cleared back to + /// NULL via PATCH (the list then sorts such rows last). + documentDate DateTime? @map("document_date") /// Failure reason when status = FAILED (a short code, never the document). - errorReason String? @map("error_reason") + errorReason String? @map("error_reason") facts ExtractedFact[] @@ -5606,24 +5630,28 @@ model InboundDocument { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt]) - @@index([userId, deletedAt]) + // The list always filters `deletedAt: null` and sorts by `documentDate`, so + // deletedAt leads the sort column to keep the scan covered. This composite is + // a superset of the plain `(userId, deletedAt)` index, which is therefore + // redundant for read planning and dropped. + @@index([userId, deletedAt, documentDate]) @@map("inbound_documents") } model ExtractedFact { - id String @id @default(cuid()) - documentId String @map("document_id") - userId String @map("user_id") - factType ExtractedFactType @map("fact_type") - status ExtractedFactStatus @default(PENDING) + id String @id @default(cuid()) + documentId String @map("document_id") + userId String @map("user_id") + factType ExtractedFactType @map("fact_type") + status ExtractedFactStatus @default(PENDING) /// Overall extraction confidence 0..1 (the lowest per-field score). The /// review screen gates approval of a low-confidence fact behind a manual /// edit (fail-closed). - confidence Float @default(0) + confidence Float @default(0) /// True when the fact scored below the confidence floor and must be /// reviewed/edited before it can be approved. Cleared once the user edits /// the fact (the values then become user-asserted). - needsReview Boolean @default(true) @map("needs_review") + needsReview Boolean @default(true) @map("needs_review") /// The FHIR-staged fields, STATED status only, shaped per `factType` /// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10 /// / LOINC / RxNorm / ATC) are present only when the document writes them. @@ -5631,20 +5659,20 @@ model ExtractedFact { /// medication names, stated codes) is clinical PHI transcribed verbatim from /// the source document, so it rides the AES-256-GCM `*_encrypted` BYTEA /// convention shared with the note columns rather than as plaintext JSONB. - dataEncrypted Bytes @map("data_encrypted") + dataEncrypted Bytes @map("data_encrypted") /// Per-field provenance: the verbatim source span the value came from, an /// optional page index, and per-field confidence. Makes "extract what's /// written" auditable against the original. The source span is a verbatim /// excerpt of the clinical document (PHI), so it is stored encrypted with the /// same codec as `dataEncrypted`. - provenanceEncrypted Bytes @map("provenance_encrypted") + provenanceEncrypted Bytes @map("provenance_encrypted") /// The id of the structured-store row created when the fact was approved /// (LabResult / IllnessEpisode / Medication), or NULL while PENDING / /// REJECTED. - committedRecordId String? @map("committed_record_id") + committedRecordId String? @map("committed_record_id") /// The structured store the approved fact landed in ("labResult" / /// "illnessEpisode" / "medication"), or NULL. - committedRecordType String? @map("committed_record_type") + committedRecordType String? @map("committed_record_type") document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/documents/inbound/[id]/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..b9dd14372 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/__tests__/route.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.25 — documents library: owner-scoped metadata edit. + * + * Pins that PATCH is owner-scoped (a caller cannot touch another user's + * document — the `where` carries the session userId, so a foreign row resolves + * to a 404) and that an own-row edit sets only the sent fields (no mass + * assignment). + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { + findFirst: vi.fn(), + update: vi.fn(), + findFirstOrThrow: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((s: string) => `v1.${s}`), + decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")), +})); + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(() => new Uint8Array([1])), + decryptFromBytes: vi.fn(() => "{}"), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 239, resetAt: Date.now() }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function mkReq(id: string, body: unknown): NextRequest { + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}`), + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +function docRow(over: Record = {}) { + return { + id: "doc-1", + userId: "user-1", + kind: "DOCTOR_REPORT", + title: "Renamed", + filename: "x.png", + mimeType: "image/png", + byteSize: 70, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: new Date("2026-05-01T00:00:00.000Z"), + errorReason: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-02T00:00:00.000Z"), + facts: [], + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); +}); + +describe("PATCH /api/documents/inbound/[id]", () => { + it("cannot touch another user's document (404, no update)", async () => { + // The foreign row does not satisfy `{ id, userId, deletedAt: null }`. + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + + const res = await PATCH( + mkReq("foreign-doc", { title: "hijack" }) as never, + ctx("foreign-doc") as never, + ); + expect(res.status).toBe(404); + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + + const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]! + .where!; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + }); + + it("edits only the sent fields on an owned document", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + } as never); + vi.mocked(prisma.inboundDocument.update).mockResolvedValue( + docRow() as never, + ); + vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue( + docRow({ kind: "IMAGING", title: "Renamed" }) as never, + ); + + const res = await PATCH( + mkReq("doc-1", { title: "Renamed", kind: "IMAGING" }) as never, + ctx("doc-1") as never, + ); + expect(res.status).toBe(200); + + const arg = vi.mocked(prisma.inboundDocument.update).mock.calls[0][0]; + expect(arg.where).toEqual({ id: "doc-1" }); + expect(arg.data.title).toBe("Renamed"); + expect(arg.data.kind).toBe("IMAGING"); + // documentDate was not sent → not present in the update payload. + expect("documentDate" in arg.data).toBe(false); + }); + + it("422s on an empty update", async () => { + const res = await PATCH(mkReq("doc-1", {}) as never, ctx("doc-1") as never); + expect(res.status).toBe(422); + expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts new file mode 100644 index 000000000..1dde37d5d --- /dev/null +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.25 — optional extraction action. + * + * Pins the store-first fix: with NO vision provider configured, extraction + * 422s on the ENHANCEMENT only — the already-stored document is left intact + * (no fact-staging transaction, no status flip). This is the inverse of the + * old behaviour where the absence of a provider blocked the upload itself. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn(), update: vi.fn() }, + extractedFact: { deleteMany: vi.fn(), count: vi.fn() }, + user: { findUnique: vi.fn() }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((s: string) => `v1.${s}`), + decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(() => new Uint8Array([1])), + decryptFromBytes: vi.fn(() => "{}"), +})); + +vi.mock("@/lib/labs/ocr-capability", () => ({ + resolveVisionProvider: vi.fn(), + resolveTextProvider: vi.fn(), +})); +vi.mock("@/lib/ai/consent-guard", () => ({ + assertConsentForChain: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: vi.fn(() => "2026-06-27"), + reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }), + reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 1000), +})); +vi.mock("@/lib/documents/extract", () => ({ + runInboundExtraction: vi.fn(), + InboundExtractError: class InboundExtractError extends Error {}, +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 5, resetAt: Date.now() }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { resolveVisionProvider } from "@/lib/labs/ocr-capability"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ctx = (id: string) => ({ params: Promise.resolve({ id }) }); + +function visionReq(id: string): NextRequest { + // No JSON content-type → vision mode (operates on the stored original). + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}/extract`), + { method: "POST" }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + kind: "OTHER", + contentEncrypted: new Uint8Array([1, 2, 3]), + mimeType: "image/png", + status: "STORED", + } as never); + vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never); +}); + +describe("POST /api/documents/inbound/[id]/extract", () => { + it("422s without a vision provider and leaves the stored row intact", async () => { + vi.mocked(resolveVisionProvider).mockResolvedValue({ + chain: [], + pick: null, + } as never); + + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("documents.inbound.providerUnsupported"); + + // The stored document is untouched — no staging transaction, no flip. + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.inboundDocument.update).not.toHaveBeenCalled(); + }); + + it("409s and refuses re-extraction when any fact is already APPROVED", async () => { + // A partially-confirmed document stays at EXTRACTED, so the CONFIRMED gate + // does not catch it; the approved-fact guard must. + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + kind: "OTHER", + contentEncrypted: new Uint8Array([1, 2, 3]), + mimeType: "image/png", + status: "EXTRACTED", + } as never); + vi.mocked(prisma.extractedFact.count).mockResolvedValue(2 as never); + + const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.meta?.errorCode).toBe( + "documents.inbound.alreadyPartlyConfirmed", + ); + + // No staging transaction, no fact deletion — committed provenance is safe. + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(prisma.extractedFact.deleteMany).not.toHaveBeenCalled(); + }); + + it("404s for a document the caller does not own", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + const res = await POST( + visionReq("foreign") as never, + ctx("foreign") as never, + ); + expect(res.status).toBe(404); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts new file mode 100644 index 000000000..cbd6a513f --- /dev/null +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -0,0 +1,414 @@ +/** + * v1.25 — optional AI extraction on an already-STORED document. + * + * Extraction is no longer part of upload: a document is stored first + * (provider-free), and THIS route is the explicit, user-triggered enhancement. + * It carries the entire ingest gauntlet that upload used to run inline — + * resolve provider → assertConsentForChain → rate-limit → reserveBudget → + * runInboundExtraction → reconcileSpend → stage facts — but now against a row + * that already exists. Absent a provider this 422s the ENHANCEMENT only; the + * stored document is untouched and remains filed. + * + * Two modes, dispatched on content-type (mirroring the original upload): + * - VISION (no JSON body): decrypt the stored original, re-derive its MIME, + * and run the vision-capable provider over it. + * - TEXT (application/json, opt-in local OCR): `{ mode: "text", text }` — the + * browser OCR'd the document locally and posts only the text to structure. + * + * The document is UNTRUSTED (prompt-injection): the server never acts on an + * instruction inside it. The staged facts land PENDING for the mandatory + * review-then-confirm screen; nothing reaches the structured stores here. + */ +import { Buffer } from "node:buffer"; + +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { assertConsentForChain } from "@/lib/ai/consent-guard"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + InboundExtractError, + runInboundExtraction, + type InboundExtractionResult, +} from "@/lib/documents/extract"; +import { + decryptDocumentFromBytes, + encryptFactData, + encryptFactProvenance, + serialiseDocumentDetail, +} from "@/lib/documents/store"; +import { + resolveTextProvider, + resolveVisionProvider, +} from "@/lib/labs/ocr-capability"; +import { detectOcrMimeType } from "@/lib/labs/ocr-upload"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */ +const EXTRACT_LIMIT_PER_HOUR = 6; +const EXTRACT_WINDOW_MS = 60 * 60 * 1000; +const TEXT_BODY_MAX_BYTES = 512 * 1024; + +/** A loaded, owner-scoped, still-extractable document row. */ +type LoadedDocument = { + id: string; + kind: string; + contentEncrypted: Uint8Array; + mimeType: string; + status: string; +}; + +/** + * Replace the document's staged facts and flip it to EXTRACTED in one + * transaction. Re-extraction is allowed only when NO fact is APPROVED yet (the + * POST handler refuses it otherwise), so clearing every prior staged fact here + * can never drop an approved row or sever a committed-record provenance link — + * the only facts present are PENDING / REJECTED leftovers from an earlier run. + */ +async function stageExtraction( + documentId: string, + userId: string, + result: InboundExtractionResult, +) { + return prisma.$transaction(async (tx) => { + await tx.extractedFact.deleteMany({ where: { documentId, userId } }); + await tx.inboundDocument.update({ + where: { id: documentId }, + data: { + status: "EXTRACTED", + providerType: result.providerType, + reportDate: result.reportDate + ? new Date(`${result.reportDate}T00:00:00.000Z`) + : null, + facts: { + create: result.facts.map((f) => ({ + userId, + factType: f.factType, + status: "PENDING" as const, + confidence: f.confidence, + needsReview: f.needsReview, + dataEncrypted: encryptFactData(f.data), + provenanceEncrypted: encryptFactProvenance(f.provenance), + })), + }, + }, + }); + return tx.inboundDocument.findUniqueOrThrow({ + where: { id: documentId }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + }); +} + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const document = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { + id: true, + kind: true, + contentEncrypted: true, + mimeType: true, + status: true, + }, + }); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + if (document.status === "CONFIRMED") { + return apiError("This document has already been confirmed.", 422, { + errorCode: "documents.inbound.alreadyConfirmed", + }); + } + + // Refuse re-extraction once ANY fact on this document is APPROVED. A + // partially-confirmed document (some facts approved, the rest still pending) + // stays at status EXTRACTED, so the CONFIRMED gate above does not catch it. + // Re-extracting would `deleteMany` the APPROVED rows — severing the + // committed-record provenance link — and re-stage the same facts as PENDING, + // letting the user approve them a second time and duplicate the committed + // lab / condition / medication. Block it: the user must finish reviewing or + // discard the document first. + const approvedCount = await prisma.extractedFact.count({ + where: { documentId: document.id, userId: user.id, status: "APPROVED" }, + }); + if (approvedCount > 0) { + annotate({ + action: { name: "documents.inbound.extractRefusedApproved" }, + meta: { documentId: document.id, approvedCount }, + }); + return apiError( + "Some facts from this document are already confirmed. Finish reviewing or discard it before extracting again.", + 409, + { errorCode: "documents.inbound.alreadyPartlyConfirmed" }, + ); + } + + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return handleTextExtract(request, user.id, document); + } + return handleVisionExtract(request, user.id, document); + }, +); + +/** TEXT mode — structure in-browser-OCR'd text against the stored row. */ +async function handleTextExtract( + request: NextRequest, + userId: string, + document: LoadedDocument, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { labsLocalOcrEnabled: true }, + }); + if (!row?.labsLocalOcrEnabled) { + annotate({ action: { name: "documents.inbound.providerUnsupported" } }); + return apiError("Local OCR is not enabled", 422, { + errorCode: "documents.inbound.localOcrDisabled", + }); + } + + const { chain, pick } = await resolveTextProvider(userId); + if (!pick) { + return apiError("No AI provider is configured", 422, { + errorCode: "documents.inbound.providerUnsupported", + }); + } + + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const rl = await checkRateLimit( + `documents-inbound:${userId}`, + EXTRACT_LIMIT_PER_HOUR, + EXTRACT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many extractions. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: TEXT_BODY_MAX_BYTES, + }); + if (jsonError) return jsonError; + + const parsed = inboundTextExtractSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid document text payload", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtractText.maxTokens, + dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), + ); + if (!reservation.allowed) { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", + }); + } + + try { + const result = await runInboundExtraction({ + provider: pick.entry.instance, + providerType: pick.providerType, + ocrText: parsed.data.text, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + + const updated = await stageExtraction(document.id, userId, result); + + await auditLog("documents.inbound.extract", { + userId, + ipAddress: getClientIp(request), + details: { + documentId: document.id, + facts: updated.facts.length, + mode: "text", + }, + }); + + return apiSuccess(serialiseDocumentDetail(updated, updated.facts)); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof InboundExtractError) { + return apiError("Couldn't read the document. Try a clearer copy.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + annotate({ + action: { name: "documents.inbound.extractFailed" }, + meta: { reason: "provider_error", mode: "text" }, + }); + return apiError("Couldn't read the document. Try a clearer copy.", 502, { + errorCode: "documents.inbound.extractFailed", + }); + } +} + +/** VISION mode — run the stored original through the vision provider. */ +async function handleVisionExtract( + request: NextRequest, + userId: string, + document: LoadedDocument, +): Promise { + const { chain, pick } = await resolveVisionProvider(userId); + if (!pick) { + return apiError("No vision-capable AI provider is configured", 422, { + errorCode: "documents.inbound.providerUnsupported", + }); + } + + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const rl = await checkRateLimit( + `documents-inbound:${userId}`, + EXTRACT_LIMIT_PER_HOUR, + EXTRACT_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many extractions. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + // Decrypt the stored original and re-derive its MIME from the bytes (never + // trust a stored label for the provider call). + let buffer: Buffer; + try { + buffer = decryptDocumentFromBytes(document.contentEncrypted); + } catch { + return apiError("Couldn't read the stored document.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + + const mime = detectOcrMimeType(buffer); + if (!mime) { + return apiError( + "This document can't be scanned. Use local OCR (text mode).", + 422, + { errorCode: "documents.inbound.fileType" }, + ); + } + + if (mime === "application/pdf" && !pick.pdfSupported) { + return apiError( + "PDF scanning needs a Claude vision provider; use local OCR instead.", + 422, + { errorCode: "documents.inbound.pdfNeedsAnthropic" }, + ); + } + + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.ocrExtract.maxTokens, + dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), + ); + if (!reservation.allowed) { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", + }); + } + + const dataBase64 = buffer.toString("base64"); + const images = + mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }]; + const documents = + mime === "application/pdf" + ? [{ mediaType: "application/pdf" as const, dataBase64 }] + : []; + + try { + const result = await runInboundExtraction({ + provider: pick.entry.instance, + providerType: pick.providerType, + images, + documents, + }); + await reconcileSpend( + userId, + reservation.reserved, + reservation.reserved, + dateKey, + ); + + const updated = await stageExtraction(document.id, userId, result); + + await auditLog("documents.inbound.extract", { + userId, + ipAddress: getClientIp(request), + details: { + documentId: document.id, + facts: updated.facts.length, + mode: "vision", + }, + }); + + return apiSuccess(serialiseDocumentDetail(updated, updated.facts)); + } catch (err) { + await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof InboundExtractError) { + return apiError("Couldn't read the document. Try a clearer copy.", 422, { + errorCode: "documents.inbound.extractFailed", + }); + } + annotate({ + action: { name: "documents.inbound.extractFailed" }, + meta: { reason: "provider_error", mode: "vision" }, + }); + return apiError("Couldn't read the document. Try a clearer copy.", 502, { + errorCode: "documents.inbound.extractFailed", + }); + } +} diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts new file mode 100644 index 000000000..980fa8391 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Buffer } from "node:buffer"; +import { NextRequest } from "next/server"; + +/** + * v1.25.1 (W-DOCS-IN) — `GET /api/documents/inbound/[id]/original`. + * + * The route decrypts and serves the raw uploaded document. Load-bearing + * behaviour under test: it is owner-scoped (the query narrows on the session + * userId, so another user's document is a 404), it returns the decrypted bytes + * with the stored MIME type, and it fails closed on a decrypt error. + */ + +// A reversible crypto stub so encryptDocumentToBytes / decryptDocumentFromBytes +// round-trip without real keys. `decrypt` is overridden per-test for the +// fail-closed path. +const decryptMock = vi.fn((s: string) => + Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"), +); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`, + decrypt: (s: string) => decryptMock(s), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { findFirst: vi.fn() }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 239, resetAt: Date.now() }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { encryptDocumentToBytes } from "@/lib/documents/store"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +type Ctx = { params: Promise<{ id: string }> }; +const callGet = GET as unknown as ( + req: NextRequest, + ctx: Ctx, +) => Promise; + +function makeReq(id: string): NextRequest { + return new NextRequest( + new URL(`http://localhost/api/documents/inbound/${id}/original`), + ); +} +function ctx(id: string): Ctx { + return { params: Promise.resolve({ id }) }; +} + +const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body"); + +beforeEach(() => { + vi.clearAllMocks(); + decryptMock.mockImplementation((s: string) => + Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"), + ); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/documents/inbound/[id]/original", () => { + it("returns the decrypted original with the stored MIME type, owner-scoped", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-1", + filename: "report.pdf", + mimeType: "application/pdf", + contentEncrypted: encryptDocumentToBytes(PDF_BYTES), + } as never); + + const res = await callGet(makeReq("doc-1"), ctx("doc-1")); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("application/pdf"); + expect(res.headers.get("Content-Disposition")).toContain("inline"); + expect(res.headers.get("Content-Disposition")).toContain("report.pdf"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + const out = Buffer.from(await res.arrayBuffer()); + expect(out.equals(PDF_BYTES)).toBe(true); + + // The query is narrowed on the session userId + live rows only. + const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0] + ?.where as { id: string; userId: string; deletedAt: null }; + expect(where.userId).toBe("user-1"); + expect(where.id).toBe("doc-1"); + expect(where.deletedAt).toBeNull(); + }); + + it("serves inline with a generated fallback name when filename is null", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-2", + filename: null, + mimeType: "image/png", + contentEncrypted: encryptDocumentToBytes(Buffer.from("png bytes")), + } as never); + + const res = await callGet(makeReq("doc-2"), ctx("doc-2")); + expect(res.status).toBe(200); + const disposition = res.headers.get("Content-Disposition") ?? ""; + // The stored set is inline-safe by construction → always inline. + expect(disposition).toContain("inline"); + // No filename → generated fallback keyed by id + extension. + expect(disposition).toContain("document-doc-2.png"); + }); + + it("RFC 5987-encodes a non-ASCII filename and keeps an ASCII fallback", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-4", + filename: "Müller-Bericht.pdf", + mimeType: "application/pdf", + contentEncrypted: encryptDocumentToBytes(PDF_BYTES), + } as never); + + const res = await callGet(makeReq("doc-4"), ctx("doc-4")); + expect(res.status).toBe(200); + const disposition = res.headers.get("Content-Disposition") ?? ""; + // ASCII fallback: the non-ASCII byte is replaced so the bare `filename=` + // value is header-safe; the RFC 5987 form carries the percent-encoded UTF-8. + expect(disposition).toContain(`filename="M_ller-Bericht.pdf"`); + expect(disposition).toContain("filename*=UTF-8''M%C3%BCller-Bericht.pdf"); + }); + + it("404s for another user's document (the userId narrows it out)", async () => { + // The owner-scoped where clause yields no row for a non-owner. + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue( + null as never, + ); + + const res = await callGet(makeReq("other-doc"), ctx("other-doc")); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBeTruthy(); + }); + + it("fails closed (500) on a decrypt error — never serves ciphertext", async () => { + vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({ + id: "doc-3", + filename: "report.pdf", + mimeType: "application/pdf", + contentEncrypted: encryptDocumentToBytes(PDF_BYTES), + } as never); + decryptMock.mockImplementation(() => { + throw new Error("unknown key id"); + }); + + const res = await callGet(makeReq("doc-3"), ctx("doc-3")); + expect(res.status).toBe(500); + expect(res.headers.get("Content-Type")).toContain("application/json"); + const body = (await res.json()) as { error: string }; + expect(body.error).toBeTruthy(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/original/route.ts b/src/app/api/documents/inbound/[id]/original/route.ts new file mode 100644 index 000000000..97366a357 --- /dev/null +++ b/src/app/api/documents/inbound/[id]/original/route.ts @@ -0,0 +1,167 @@ +/** + * v1.25.1 (W-DOCS-IN) — view / download the original uploaded document. + * + * The raw doctor report / discharge letter is stored encrypted at rest + * (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY + * path that decrypts and serves it, so the user can re-read the source the + * extracted facts came from. Owner-scoped + module-gated; the bytes are PHI: + * never logged, never served cross-user, fail-closed on a decrypt error. + * + * The response carries the stored `mimeType`. Uploads are constrained at the + * store path to the inline-safe set (JPEG / PNG / WebP / PDF, magic-byte + * sniffed), so the document is always served `Content-Disposition: inline` for + * in-tab rendering. The filename is sanitised before it reaches the header. + */ +import { NextResponse } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { decryptDocumentFromBytes } from "@/lib/documents/store"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const dynamic = "force-dynamic"; + +/** + * Decrypting + serving an original is CPU work on PHI. A generous per-user + * ceiling is a backstop against self-DoS only (the route is owner-scoped, so + * there is no cross-user abuse vector). + */ +const ORIGINAL_READ_LIMIT_PER_HOUR = 240; +const ORIGINAL_READ_WINDOW_MS = 60 * 60 * 1000; + +/** Extension fallback when the stored filename is absent / unusable. */ +const MIME_EXTENSION: Record = { + "application/pdf": "pdf", + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", +}; + +/** + * Percent-encode a UTF-8 string for an RFC 5987 `filename*` parameter. Builds + * on `encodeURIComponent` and additionally escapes the characters it leaves but + * RFC 5987's `attr-char` set forbids (`' ( ) *`). + */ +function encodeRfc5987(value: string): string { + return encodeURIComponent(value).replace( + /['()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +/** + * Build a header-safe filename. Strips control characters (incl. CR/LF, + * the header-injection vector), quotes, and path separators (path traversal), + * caps the length, and falls back to a generated name keyed by the document + * id + MIME type when nothing usable remains. + */ +function safeDownloadName( + filename: string | null, + id: string, + mimeType: string, +): string { + const ext = MIME_EXTENSION[mimeType] ?? "bin"; + const cleaned = (filename ?? "") + // Strip control chars (incl. CR/LF — header injection), quotes, and path + // separators (path traversal) before the value reaches the header. + .replace(/[\u0000-\u001f"\\\/]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 120); + return cleaned.length > 0 ? cleaned : `document-${id}.${ext}`; +} + +export const GET = apiHandler( + async (_request: Request, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `documents-original:${user.id}`, + ORIGINAL_READ_LIMIT_PER_HOUR, + ORIGINAL_READ_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { id } = await params; + const document = await prisma.inboundDocument.findFirst({ + // Owner-scoped: the userId narrows the row so a cuid guess cannot reach + // another user's document. A cookie session and a Bearer token both + // resolve through requireAuth(); neither can cross the userId boundary. + where: { id, userId: user.id, deletedAt: null }, + select: { + id: true, + filename: true, + mimeType: true, + contentEncrypted: true, + }, + }); + if (!document) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + let bytes: Buffer; + try { + bytes = decryptDocumentFromBytes(document.contentEncrypted); + } catch { + // Fail closed — never fall back to the raw ciphertext. The error reason + // (bad / missing key id) is logged by the annotation, never the bytes. + annotate({ + action: { name: "documents.inbound.original.decryptFailed" }, + meta: { documentId: document.id }, + }); + return apiError("Could not read the stored document", 500, { + errorCode: "documents.inbound.decryptFailed", + }); + } + + const downloadName = safeDownloadName( + document.filename, + document.id, + document.mimeType, + ); + // ASCII fallback for the bare `filename=` (replace any non-ASCII byte) plus + // an RFC 5987 `filename*` that carries the real UTF-8 name to compliant + // clients. The stored set is inline-safe by construction, so always inline. + const asciiName = downloadName.replace(/[^ -~]/g, "_"); + const disposition = `inline; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`; + + annotate({ + action: { name: "documents.inbound.original.get" }, + meta: { + documentId: document.id, + mimeType: document.mimeType, + bytes: bytes.byteLength, + disposition: "inline", + }, + }); + + return new NextResponse(bytes as unknown as BodyInit, { + status: 200, + headers: { + "Content-Type": document.mimeType, + "Content-Length": String(bytes.byteLength), + "Content-Disposition": disposition, + // Private PHI — never cache in a shared / disk cache. + "Cache-Control": "private, no-store", + }, + }); + }, +); diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 6add4331c..3efdbd2dd 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -1,24 +1,58 @@ /** - * v1.25 (W-DOCS-IN) — inbound clinical document by id: detail + discard. + * v1.25 — inbound clinical document by id: detail + metadata edit + discard. * * GET returns the document plus its staged facts for the review screen. + * PATCH renames / recategorises / sets the user filing date (no mass + * assignment; `userId` from the session feeds the `where`). * DELETE soft-deletes (tombstone) and marks the document DISCARDED — facts * already approved into the structured stores are independent rows and stay. */ import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { serialiseDocumentDetail } from "@/lib/documents/store"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { documentUpdateSchema } from "@/lib/validations/inbound-documents"; +import type { Prisma } from "@/generated/prisma/client"; type RouteParams = { params: Promise<{ id: string }> }; export const dynamic = "force-dynamic"; +/** + * Edit / discard are owner-scoped, so a generous per-user ceiling is just a + * self-DoS backstop. PATCH and DELETE share one write bucket. + */ +const WRITE_LIMIT_PER_HOUR = 240; +const WRITE_WINDOW_MS = 60 * 60 * 1000; + +/** Shared 429 helper for the mutating routes. */ +async function enforceWriteRateLimit(userId: string): Promise { + const rl = await checkRateLimit( + `documents-write:${userId}`, + WRITE_LIMIT_PER_HOUR, + WRITE_WINDOW_MS, + ); + if (rl.allowed) return null; + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "documents.inbound.rateLimited", + }); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; +} + export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -45,12 +79,85 @@ export const GET = apiHandler( }, ); +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const limited = await enforceWriteRateLimit(user.id); + if (limited) return limited; + + const { id } = await params; + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = documentUpdateSchema.safeParse(body); + if (!parsed.success) { + return apiError("Invalid document update", 422, { + errorCode: "documents.inbound.invalidUpdate", + }); + } + + const existing = await prisma.inboundDocument.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!existing) { + return apiError("Document not found", 404, { + errorCode: "documents.inbound.notFound", + }); + } + + // 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 = {}; + if (parsed.data.title !== undefined) data.title = parsed.data.title; + if (parsed.data.kind !== undefined) data.kind = parsed.data.kind; + if (parsed.data.documentDate !== undefined) { + data.documentDate = parsed.data.documentDate + ? new Date(`${parsed.data.documentDate}T00:00:00.000Z`) + : null; + } + + await prisma.inboundDocument.update({ + where: { id: existing.id }, + data, + }); + + const document = await prisma.inboundDocument.findFirstOrThrow({ + where: { id: existing.id }, + include: { facts: { orderBy: { createdAt: "asc" } } }, + }); + + await auditLog("documents.inbound.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + documentId: existing.id, + fields: Object.keys(data), + }, + }); + + annotate({ + action: { name: "documents.inbound.update" }, + meta: { documentId: existing.id, fields: Object.keys(data).join(",") }, + }); + + return apiSuccess(serialiseDocumentDetail(document, document.facts)); + }, +); + export const DELETE = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; + const limited = await enforceWriteRateLimit(user.id); + if (limited) return limited; + const { id } = await params; const document = await prisma.inboundDocument.findFirst({ where: { id, userId: user.id, deletedAt: null }, diff --git a/src/app/api/documents/inbound/__tests__/route.test.ts b/src/app/api/documents/inbound/__tests__/route.test.ts new file mode 100644 index 000000000..d1be3394f --- /dev/null +++ b/src/app/api/documents/inbound/__tests__/route.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * v1.25 — documents library: store-only upload + browsable list. + * + * Pins the store-first inversion: a file uploads + persists as a STORED row + * with NO provider / consent / budget call on the path (a self-hoster with no + * document-scan provider can still file documents), and the list honours the + * `q` / `kind` filters with keyset pagination. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + inboundDocument: { + create: vi.fn(), + findMany: vi.fn(), + }, + extractedFact: { + groupBy: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((s: string) => `v1.${s}`), + decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 3_600_000, + }), + rateLimitHeaders: () => ({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST, GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const post = POST as unknown as (r: Request) => Promise; +const get = GET as unknown as (r: Request) => Promise; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ONE_BY_ONE_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg=="; + +function mkUpload(fields: Record = {}) { + const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64"); + const blobBytes = new Uint8Array(body.byteLength); + blobBytes.set(body); + const formData = new FormData(); + formData.append( + "file", + new Blob([blobBytes], { type: "image/png" }), + "x.png", + ); + for (const [k, v] of Object.entries(fields)) formData.append(k, v); + return new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: formData, + }); +} + +function docRow(over: Record = {}) { + return { + id: "doc-1", + userId: "user-1", + kind: "OTHER", + title: null, + filename: "x.png", + mimeType: "image/png", + byteSize: 70, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: null, + errorReason: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never); +}); + +describe("POST /api/documents/inbound (store-only)", () => { + it("stores a file as STORED with no provider call and returns 201", async () => { + vi.mocked(prisma.inboundDocument.create).mockResolvedValue( + docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never, + ); + + const res = await post( + mkUpload({ title: "Blood panel", kind: "LAB_RESULT" }), + ); + expect(res.status).toBe(201); + + const body = await res.json(); + expect(body.error).toBeNull(); + expect(body.data.status).toBe("STORED"); + expect(body.data.kind).toBe("LAB_RESULT"); + expect(body.data.title).toBe("Blood panel"); + + // The row is created with status STORED, no provider, userId from session. + const arg = vi.mocked(prisma.inboundDocument.create).mock.calls[0]![0]!; + expect(arg.data.status).toBe("STORED"); + expect(arg.data.userId).toBe("user-1"); + expect(arg.data.kind).toBe("LAB_RESULT"); + expect("providerType" in arg.data).toBe(false); + // No userId smuggled from the form is honoured — it comes from the session. + expect(arg.data.userId).toBe("user-1"); + }); + + it("rejects a non-image/pdf payload with 415", async () => { + const formData = new FormData(); + formData.append("file", new Blob([new Uint8Array([1, 2, 3, 4])]), "x.bin"); + const req = new Request("http://localhost/api/documents/inbound", { + method: "POST", + body: formData, + }); + const res = await post(req); + expect(res.status).toBe(415); + expect(prisma.inboundDocument.create).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/documents/inbound (list)", () => { + it("applies q + kind filters and returns a keyset page with grouped counts", async () => { + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([ + docRow({ id: "d1" }), + docRow({ id: "d2" }), + ] as never); + // factCount excludes REJECTED; pendingCount is the PENDING subset. The + // counts come from a single grouped query, not from materialised fact rows. + vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([ + { documentId: "d2", status: "PENDING", _count: { _all: 1 } }, + { documentId: "d2", status: "APPROVED", _count: { _all: 1 } }, + { documentId: "d2", status: "REJECTED", _count: { _all: 3 } }, + ] as never); + + const res = await get( + new Request( + "http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&sort=createdAt&order=desc&limit=10", + ), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.documents).toHaveLength(2); + expect(body.data.nextCursor).toBeNull(); + + // d1 has no facts; d2 has 1 pending + 1 approved + 3 rejected → factCount 2 + // (rejected excluded), pendingCount 1. + const [d1, d2] = body.data.documents; + expect(d1.factCount).toBe(0); + expect(d1.pendingCount).toBe(0); + expect(d2.factCount).toBe(2); + expect(d2.pendingCount).toBe(1); + + const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!; + const where = arg.where!; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + expect(where.kind).toBe("LAB_RESULT"); + expect(where.OR).toEqual([ + { title: { contains: "panel", mode: "insensitive" } }, + { filename: { contains: "panel", mode: "insensitive" } }, + ]); + // No fact rows are materialised in the page query — counts come from groupBy. + expect(arg.include).toBeUndefined(); + // limit+1 fetched for the has-more probe. + expect(arg.take).toBe(11); + }); + + it("emits nextCursor when a full page+1 is returned", async () => { + const rows = Array.from({ length: 3 }, (_, i) => ({ + ...docRow({ id: `d${i}` }), + _count: { facts: 0 }, + facts: [], + })); + vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never); + + const res = await get( + new Request("http://localhost/api/documents/inbound?limit=2"), + ); + const body = await res.json(); + expect(body.data.documents).toHaveLength(2); + expect(body.data.nextCursor).toBe("d1"); + }); + + it("422s on a bad sort value", async () => { + const res = await get( + new Request("http://localhost/api/documents/inbound?sort=nope"), + ); + expect(res.status).toBe(422); + }); +}); diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index 4b8fe1fc6..eda814789 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -1,61 +1,31 @@ /** - * v1.25 (W-DOCS-IN) — inbound clinical documents: upload + list. + * v1.25 — documents library: store-first upload + browsable list. * - * POST ingests an uploaded doctor report / discharge letter through the - * EXISTING dedicated OCR/vision provider rails (the v1.22 document-scan - * provider), stores the raw document ENCRYPTED at rest, and stages the - * extracted STRUCTURED FACTS for a mandatory review-then-confirm screen. - * Nothing reaches the structured stores here — the confirm route is the only - * write path, and a low-confidence fact fails closed. + * POST is STORE-ONLY and provider-free. A self-hoster uploads any clinical + * document; it is stored ENCRYPTED at rest with `status: STORED` and NO + * extraction run. There is no provider resolution, no AI consent guard, no + * budget, and NO egress on this path — a file can always be filed, even on an + * account with no document-scan provider configured. Optional AI extraction is + * a separate, user-triggered action (`POST /api/documents/inbound/[id]/extract`). * - * Two modes, dispatched on the request content-type (mirroring the Lab-OCR - * extract route): - * - VISION (multipart/form-data): a photo / PDF run through the vision-capable - * provider; the original is stored encrypted. - * - TEXT (application/json, opt-in local OCR): the browser OCR's the image - * and POSTs only the extracted text; the text is stored encrypted (the raw - * image never reaches the server). + * GET lists the caller's documents with title/filename search, a category + * filter, a `documentDate` range, sort, and keyset pagination — all through the + * parameter-bound Prisma query builder (no raw SQL). * - * Guards mirror the Lab-OCR discipline: requireAuth → module gate → resolve - * provider → assertConsentForChain → rate-limit → reserveBudget → extract → - * reconcile budget. The document is UNTRUSTED (prompt-injection): the server - * never acts on an instruction inside it — the review step is the safety - * boundary, and "extract, never interpret" is enforced end-to-end. + * The document is UNTRUSTED (prompt-injection): the server never acts on an + * instruction inside it. Storing it does nothing with its contents; the later + * review-then-confirm step is the safety boundary for any extracted facts. */ import { Buffer } from "node:buffer"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { - apiError, - apiSuccess, - getClientIp, - safeJson, -} from "@/lib/api-response"; -import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; -import { assertConsentForChain } from "@/lib/ai/consent-guard"; -import { - buildDateKey, - reconcileSpend, - reserveBudget, - resolveDailyCap, -} from "@/lib/ai/coach/budget"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; -import { - InboundExtractError, - runInboundExtraction, - type StagedFactInput, -} from "@/lib/documents/extract"; import { encryptDocumentToBytes, - encryptFactData, - encryptFactProvenance, serialiseDocument, } from "@/lib/documents/store"; -import { - resolveTextProvider, - resolveVisionProvider, -} from "@/lib/labs/ocr-capability"; import { BodyTooLargeError, detectOcrMimeType, @@ -66,60 +36,26 @@ import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { - inboundTextExtractSchema, - INBOUND_DOCUMENT_KINDS, - type InboundDocumentKindValue, + documentCreateSchema, + documentListQuerySchema, } from "@/lib/validations/inbound-documents"; +import type { Prisma } from "@/generated/prisma/client"; export const dynamic = "force-dynamic"; -/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */ -const EXTRACT_LIMIT_PER_HOUR = 6; -const EXTRACT_WINDOW_MS = 60 * 60 * 1000; -const TEXT_BODY_MAX_BYTES = 512 * 1024; +/** + * A store-only upload touches no provider — the only abuse vector is disk, so + * a generous per-user ceiling is enough (extraction has its own tight gate). + */ +const UPLOAD_LIMIT_PER_HOUR = 60; +const UPLOAD_WINDOW_MS = 60 * 60 * 1000; -/** Persist the document + its staged facts and return the list DTO. */ -async function persistExtraction(args: { - userId: string; - kind: InboundDocumentKindValue; - filename: string | null; - mimeType: string; - byteSize: number; - contentEncrypted: Uint8Array; - providerType: string; - reportDate: string | null; - facts: StagedFactInput[]; -}) { - const document = await prisma.inboundDocument.create({ - data: { - userId: args.userId, - kind: args.kind, - filename: args.filename, - mimeType: args.mimeType, - byteSize: args.byteSize, - contentEncrypted: args.contentEncrypted, - status: "EXTRACTED", - providerType: args.providerType, - reportDate: args.reportDate - ? new Date(`${args.reportDate}T00:00:00.000Z`) - : null, - facts: { - create: args.facts.map((f) => ({ - userId: args.userId, - factType: f.factType, - status: "PENDING" as const, - confidence: f.confidence, - needsReview: f.needsReview, - dataEncrypted: encryptFactData(f.data), - provenanceEncrypted: encryptFactProvenance(f.provenance), - })), - }, - }, - include: { facts: true }, - }); - return document; +/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */ +function isoDateToUtc(value: string): Date { + return new Date(`${value}T00:00:00.000Z`); } +/** POST — store an uploaded document encrypted at rest. No extraction. */ export const POST = apiHandler(async (request: Request) => { const { user } = await requireAuth(); @@ -128,42 +64,10 @@ export const POST = apiHandler(async (request: Request) => { const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - return handleTextUpload(request, user.id); - } - return handleVisionUpload(request, user.id); -}); - -/** TEXT mode — structure in-browser-OCR'd text; store the text encrypted. */ -async function handleTextUpload( - request: Request, - userId: string, -): Promise { - const row = await prisma.user.findUnique({ - where: { id: userId }, - select: { labsLocalOcrEnabled: true }, - }); - if (!row?.labsLocalOcrEnabled) { - annotate({ action: { name: "documents.inbound.providerUnsupported" } }); - return apiError("Local OCR is not enabled", 422, { - errorCode: "documents.inbound.localOcrDisabled", - }); - } - - const { chain, pick } = await resolveTextProvider(userId); - if (!pick) { - return apiError("No AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertConsentForChain({ userId, chain, surface: "insights" }); - const rl = await checkRateLimit( - `documents-inbound:${userId}`, - EXTRACT_LIMIT_PER_HOUR, - EXTRACT_WINDOW_MS, + `documents-upload:${user.id}`, + UPLOAD_LIMIT_PER_HOUR, + UPLOAD_WINDOW_MS, ); if (!rl.allowed) { const response = apiError("Too many uploads. Try again later.", 429, { @@ -175,304 +79,210 @@ async function handleTextUpload( return response; } - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: TEXT_BODY_MAX_BYTES, - }); - if (jsonError) return jsonError; - - const parsed = inboundTextExtractSchema.safeParse(body); - if (!parsed.success) { - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } - - const dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - AI_BUDGETS.ocrExtractText.maxTokens, - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - ); - if (!reservation.allowed) { - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", + const contentLength = Number(request.headers.get("content-length") ?? 0); + if (contentLength > OCR_MAX_BYTES) { + return apiError("File is too large (max 12 MB).", 413, { + errorCode: "documents.inbound.fileTooLarge", }); } + let formData: FormData; try { - const result = await runInboundExtraction({ - provider: pick.entry.instance, - providerType: pick.providerType, - ocrText: parsed.data.text, - }); - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - ); - - const textBytes = Buffer.from(parsed.data.text, "utf8"); - const document = await persistExtraction({ - userId, - kind: parsed.data.kind ?? result.kind, - filename: null, - mimeType: "text/plain", - byteSize: textBytes.byteLength, - contentEncrypted: encryptDocumentToBytes(textBytes), - providerType: result.providerType, - reportDate: result.reportDate, - facts: result.facts, - }); - - await auditLog("documents.inbound.upload", { - userId, - ipAddress: getClientIp(request), - details: { - documentId: document.id, - facts: document.facts.length, - mode: "text", - }, - }); - - return apiSuccess( - serialiseDocument(document, { - factCount: document.facts.length, - pendingCount: document.facts.length, - }), - 201, - ); + const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES); + formData = await new Response(new Blob([bytes]), { + headers: { "content-type": request.headers.get("content-type") ?? "" }, + }).formData(); } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - if (err instanceof InboundExtractError) { - return apiError("Couldn't read the document. Try a clearer copy.", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } - annotate({ - action: { name: "documents.inbound.extractFailed" }, - meta: { reason: "provider_error", mode: "text" }, - }); - return apiError("Couldn't read the document. Try a clearer copy.", 502, { - errorCode: "documents.inbound.extractFailed", - }); - } -} - -/** VISION mode — run a photo / PDF through the vision provider; store it. */ -async function handleVisionUpload( - request: Request, - userId: string, -): Promise { - const { chain, pick } = await resolveVisionProvider(userId); - if (!pick) { - return apiError("No vision-capable AI provider is configured", 422, { - errorCode: "documents.inbound.providerUnsupported", - }); - } - - await assertConsentForChain({ userId, chain, surface: "insights" }); - - const rl = await checkRateLimit( - `documents-inbound:${userId}`, - EXTRACT_LIMIT_PER_HOUR, - EXTRACT_WINDOW_MS, - ); - if (!rl.allowed) { - const response = apiError("Too many uploads. Try again later.", 429, { - errorCode: "documents.inbound.rateLimited", - }); - for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { - response.headers.set(k, v); - } - return response; - } - - const dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - AI_BUDGETS.ocrExtract.maxTokens, - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - ); - if (!reservation.allowed) { - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", - }); - } - - try { - const contentLength = Number(request.headers.get("content-length") ?? 0); - if (contentLength > OCR_MAX_BYTES) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); + if (err instanceof BodyTooLargeError) { return apiError("File is too large (max 12 MB).", 413, { errorCode: "documents.inbound.fileTooLarge", }); } - - let formData: FormData; - try { - const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES); - formData = await new Response(new Blob([bytes]), { - headers: { "content-type": request.headers.get("content-type") ?? "" }, - }).formData(); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - if (err instanceof BodyTooLargeError) { - return apiError("File is too large (max 12 MB).", 413, { - errorCode: "documents.inbound.fileTooLarge", - }); - } - return apiError("Invalid multipart body", 400); - } - - const file = formData.get("file"); - if (!(file instanceof File)) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError("Field 'file' must be a file", 422); - } - - // Optional document-kind label (a label only; drives no interpretation). - const kindRaw = formData.get("kind"); - const kind: InboundDocumentKindValue = - typeof kindRaw === "string" && - (INBOUND_DOCUMENT_KINDS as readonly string[]).includes(kindRaw) - ? (kindRaw as InboundDocumentKindValue) - : "OTHER"; - - let buffer: Buffer; - try { - buffer = Buffer.from(await file.arrayBuffer()); - } catch { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError("Failed to read uploaded file", 400); - } - - const mime = detectOcrMimeType(buffer); - if (!mime) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, { - errorCode: "documents.inbound.fileType", - }); - } - - if (mime === "application/pdf" && !pick.pdfSupported) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - return apiError( - "PDF scanning needs a Claude vision provider; upload a photo instead.", - 422, - { errorCode: "documents.inbound.pdfNeedsAnthropic" }, - ); - } - - const dataBase64 = buffer.toString("base64"); - const images = - mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }]; - const documents = - mime === "application/pdf" - ? [{ mediaType: "application/pdf" as const, dataBase64 }] - : []; - - try { - const result = await runInboundExtraction({ - provider: pick.entry.instance, - providerType: pick.providerType, - images, - documents, - }); - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - ); - - const document = await persistExtraction({ - userId, - kind, - filename: - typeof file.name === "string" ? file.name.slice(0, 255) : null, - mimeType: mime, - byteSize: buffer.byteLength, - contentEncrypted: encryptDocumentToBytes(buffer), - providerType: result.providerType, - reportDate: result.reportDate, - facts: result.facts, - }); - - await auditLog("documents.inbound.upload", { - userId, - ipAddress: getClientIp(request), - details: { - documentId: document.id, - facts: document.facts.length, - mode: "vision", - }, - }); - - return apiSuccess( - serialiseDocument(document, { - factCount: document.facts.length, - pendingCount: document.facts.length, - }), - 201, - ); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey); - if (err instanceof InboundExtractError) { - return apiError( - "Couldn't read the document. Try a clearer copy.", - 422, - { - errorCode: "documents.inbound.extractFailed", - }, - ); - } - annotate({ - action: { name: "documents.inbound.extractFailed" }, - meta: { reason: "provider_error", mode: "vision" }, - }); - return apiError("Couldn't read the document. Try a clearer copy.", 502, { - errorCode: "documents.inbound.extractFailed", - }); - } - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch( - () => {}, - ); - throw err; + return apiError("Invalid multipart body", 400); } -} -/** GET — list the caller's inbound documents (newest first, live only). */ -export const GET = apiHandler(async () => { + const file = formData.get("file"); + if (!(file instanceof File)) { + return apiError("Field 'file' must be a file", 422); + } + + // Optional metadata (title / kind / documentDate). The file is read + // separately; these are the form fields beside it. + const parsed = documentCreateSchema.safeParse({ + title: formData.get("title") ?? undefined, + kind: formData.get("kind") ?? undefined, + documentDate: formData.get("documentDate") ?? undefined, + }); + if (!parsed.success) { + return apiError("Invalid document metadata", 422, { + errorCode: "documents.inbound.invalidMetadata", + }); + } + + let buffer: Buffer; + try { + buffer = Buffer.from(await file.arrayBuffer()); + } catch { + return apiError("Failed to read uploaded file", 400); + } + + const mime = detectOcrMimeType(buffer); + if (!mime) { + return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, { + errorCode: "documents.inbound.fileType", + }); + } + + // No mass assignment — every column is set field-by-field; `userId` comes + // from the session, never the body. `status` is STORED (the library default); + // no provider / consent / budget call runs here. + // + // `documentDate` is the effective filing date the library sorts, groups, and + // range-filters on. When the user does not supply one at upload, default it to + // the upload day (now) — the same effective date the UI shows — so the column + // is never NULL on a stored row. This keeps display == sort == filter: an + // undated upload no longer sinks below older dated rows under `nulls: "last"`, + // and a "this month" range filter sees it. (At store time `reportDate` is + // still null; it only exists after an extraction, so the default resolves to + // the upload day.) The date stays user-editable via PATCH, which still allows + // clearing it back to NULL. + const document = await prisma.inboundDocument.create({ + data: { + userId: user.id, + kind: parsed.data.kind ?? "OTHER", + // Stored plaintext on purpose (mirrors `filename`) so the list can + // ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that is the + // accepted tradeoff for server-side search/sort — the document body stays + // encrypted. + title: parsed.data.title ?? null, + filename: typeof file.name === "string" ? file.name.slice(0, 255) : null, + mimeType: mime, + byteSize: buffer.byteLength, + contentEncrypted: encryptDocumentToBytes(buffer), + status: "STORED", + documentDate: parsed.data.documentDate + ? isoDateToUtc(parsed.data.documentDate) + : new Date(), + }, + }); + + await auditLog("documents.inbound.store", { + userId: user.id, + ipAddress: getClientIp(request), + details: { documentId: document.id, mime }, + }); + + annotate({ + action: { name: "documents.inbound.store" }, + meta: { documentId: document.id, byteSize: document.byteSize }, + }); + + return apiSuccess( + serialiseDocument(document, { factCount: 0, pendingCount: 0 }), + 201, + ); +}); + +/** GET — list the caller's documents (search / filter / sort / paginate). */ +export const GET = apiHandler(async (request: Request) => { const { user } = await requireAuth(); const gate = await requireModuleEnabled(user.id, "inboundDocuments"); if (!gate.enabled) return gate.response; - const documents = await prisma.inboundDocument.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { createdAt: "desc" }, - include: { - _count: { select: { facts: true } }, - facts: { where: { status: "PENDING" }, select: { id: true } }, - }, - take: 200, + const url = new URL(request.url); + const parsed = documentListQuerySchema.safeParse( + Object.fromEntries(url.searchParams), + ); + if (!parsed.success) { + return apiError("Invalid list query", 422, { + errorCode: "documents.inbound.invalidQuery", + }); + } + const { q, kind, from, to, sort, order, cursor, limit } = parsed.data; + + const where: Prisma.InboundDocumentWhereInput = { + userId: user.id, + deletedAt: null, + }; + if (kind) where.kind = kind; + if (from || to) { + where.documentDate = { + ...(from ? { gte: isoDateToUtc(from) } : {}), + // inclusive end-of-day for the `to` bound + ...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}), + }; + } + if (q) { + where.OR = [ + { title: { contains: q, mode: "insensitive" } }, + { filename: { contains: q, mode: "insensitive" } }, + ]; + } + + // Keyset pagination on the sort column + a stable `id` tiebreak; nullable + // sort columns sort nulls last so undated documents trail. + const primaryOrder: + Prisma.InboundDocumentOrderByWithRelationInput | undefined = + sort === "documentDate" + ? { documentDate: { sort: order, nulls: "last" } } + : sort === "title" + ? { title: { sort: order, nulls: "last" } } + : { createdAt: order }; + const orderBy: Prisma.InboundDocumentOrderByWithRelationInput[] = [ + primaryOrder, + { id: order }, + ]; + + const rows = await prisma.inboundDocument.findMany({ + where, + orderBy, + take: limit + 1, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), }); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null; + + // Fact tallies for the whole page in ONE grouped count — no per-document + // fan-out and no materialising fact-id rows. `factCount` counts every + // non-REJECTED fact (a rejected fact is discarded, not part of the document's + // tally), `pendingCount` the PENDING subset still awaiting review. + const factCounts = new Map< + string, + { factCount: number; pendingCount: number } + >(); + if (page.length > 0) { + const groups = await prisma.extractedFact.groupBy({ + by: ["documentId", "status"], + where: { userId: user.id, documentId: { in: page.map((d) => d.id) } }, + _count: { _all: true }, + }); + for (const g of groups) { + const entry = factCounts.get(g.documentId) ?? { + factCount: 0, + pendingCount: 0, + }; + const n = g._count._all; + if (g.status !== "REJECTED") entry.factCount += n; + if (g.status === "PENDING") entry.pendingCount += n; + factCounts.set(g.documentId, entry); + } + } + annotate({ action: { name: "documents.inbound.list" }, - meta: { count: documents.length }, + meta: { count: page.length, sort, order, filtered: Boolean(q || kind) }, }); return apiSuccess({ - documents: documents.map((doc) => - serialiseDocument(doc, { - factCount: doc._count.facts, - pendingCount: doc.facts.length, - }), + documents: page.map((doc) => + serialiseDocument( + doc, + factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 }, + ), ), + nextCursor, }); }); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index 4b6b12eac..8b25d71d8 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -164,4 +164,68 @@ describe("GET /api/insights/cards", () => { expect(body.data[0].provider).toBe("anthropic"); expect(body.data.some((c) => c.severity === "alert")).toBe(true); }); + + it("does NOT flag a fully-adherent weekly injectable as low compliance (#214 regression)", async () => { + // Pre-fix this path used a naive `schedules.length × 7` denominator, so a + // weekly Monday injectable with its one dose taken computed ~14% and fired + // a false "Low compliance" warning. The cadence-aware engine counts only + // the Monday in the window → 100%, no alert. + vi.useFakeTimers(); + // Pin NOW to a Wednesday so the trailing 7-day window holds exactly one + // Monday (the prior 2025-01-13). + vi.setSystemTime(new Date("2025-01-15T12:00:00Z")); + try { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { + id: "med-weekly", + name: "Weekly injectable", + dose: null, + active: true, + asNeeded: false, + oneShot: false, + startsOn: null, + endsOn: null, + createdAt: new Date("2024-06-01T00:00:00Z"), + scheduleRevisions: [], + pauseEras: [], + schedules: [ + { + id: "sched-mon", + windowStart: "08:00", + windowEnd: "10:00", + daysOfWeek: "1", + timesOfDay: ["08:30"], + reminderGraceMinutes: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ], + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + medicationId: "med-weekly", + scheduledFor: new Date("2025-01-13T08:30:00Z"), + takenAt: new Date("2025-01-13T08:35:00Z"), + skipped: false, + }, + ] as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ title: string }>; + }; + expect(body.data.some((c) => c.title.startsWith("Low compliance"))).toBe( + false, + ); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts index 3d83207cc..6c592bf2d 100644 --- a/src/app/api/insights/cards/route.ts +++ b/src/app/api/insights/cards/route.ts @@ -20,6 +20,12 @@ import { type HealthAlert, } from "@/lib/analytics/classifications"; import { pairByTimestamp } from "@/lib/analytics/correlations"; +import { + buildComplianceMedicationContext, + calculateCompliance, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; import type { MeasurementType } from "@/generated/prisma/client"; type Severity = "alert" | "caution" | "info" | "good"; @@ -86,11 +92,17 @@ export const GET = apiHandler(async () => { select: { type: true, value: true, measuredAt: true }, }), prisma.medication.findMany({ - where: { userId: user.id, active: true }, + // v1.16.11 — as-needed (PRN) medications never surface a compliance + // rate (no expected doses). + where: { userId: user.id, active: true, asNeeded: false }, include: { - schedules: true, + // Schedules through the shared compliance select so the configured + // per-dose windows reach the engine like every other surface. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware expected counts. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }), ]); @@ -135,7 +147,12 @@ export const GET = apiHandler(async () => { } } - // Lightweight medication compliance for alert input. + // Medication compliance for alert input — routed through the canonical + // cadence-aware engine (`calculateCompliance`), the same single source of + // truth the comprehensive insights, dashboard pillar, Coach snapshot and + // doctor report use. The naive `schedules.length × days` denominator was a + // #214 regression that falsely flagged weekly injectables and paused meds + // as low-adherence on this user-facing alert path. const medicationCompliance: Array<{ name: string; compliance7: number; @@ -150,8 +167,11 @@ export const GET = apiHandler(async () => { // v1.7.0 sync — exclude tombstoned rows. deletedAt: null, medicationId: { in: activeMeds.map((m) => m.id) }, - scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) }, + // 90-day window: the rolling-cadence gap-walk re-anchors on prior + // intakes outside the 30-day rate window. + scheduledFor: { gte: ninetyDaysAgo }, }, + orderBy: { scheduledFor: "desc" }, select: { medicationId: true, takenAt: true, @@ -165,26 +185,36 @@ export const GET = apiHandler(async () => { arr.push(e); eventsByMed.set(e.medicationId, arr); } - const cutoff7 = Date.now() - 7 * 86_400_000; + const tz = user.timezone || "Europe/Berlin"; for (const med of activeMeds) { const events = eventsByMed.get(med.id) ?? []; - const takenLast7 = events.filter( - (e) => - e.takenAt !== null && - !e.skipped && - e.scheduledFor.getTime() >= cutoff7, - ).length; - const taken30 = events.filter( - (e) => e.takenAt !== null && !e.skipped, - ).length; - const expected7 = med.schedules.length * 7; - const expected30 = med.schedules.length * 30; + const mapped = events.map((e) => ({ + takenAt: e.takenAt, + skipped: e.skipped, + scheduledFor: e.scheduledFor, + })); + // v1.7.0 SB-SCHED-2 — engine-routed denominator. + const medicationContext = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(mapped), + tz, + ); + const c7 = calculateCompliance(mapped, med.schedules, 7, med.createdAt, { + medicationContext, + }); + const c30 = calculateCompliance( + mapped, + med.schedules, + 30, + med.createdAt, + { + medicationContext, + }, + ); medicationCompliance.push({ name: med.name, - compliance7: - expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0, - compliance30: - expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0, + compliance7: c7.rate, + compliance30: c30.rate, }); } } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index f0ddea12a..a1f3caac4 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -22,6 +22,7 @@ import { import { MfaLoginStep, type MfaMethod } from "@/components/auth/mfa-login-step"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; +import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; export default function LoginPage() { const router = useRouter(); @@ -80,6 +81,28 @@ export default function LoginPage() { // can ride in parallel with the route transition + page-chunk // download instead of queueing behind the dashboard mount. function navigateAfterLogin() { + // Start the new session from an empty in-memory cache. The root + // QueryClient is created once and outlives every client-side navigation, + // so a previous user who closed the tab WITHOUT logging out (no logout + // fired) leaves their non-user-scoped health-data queries + // (`["measurements"]`, `["dashboard","snapshot"]`, …) sitting in memory. + // Clearing here — before any prefetch warms THIS user's data and before we + // navigate — guarantees a fresh login never inherits another account's + // cached entries. Logout / expiry clear the same cache from their own + // boundaries; this covers the no-logout path. `clear()` also drops the + // stale `["auth","me"]`, so the shell refetches it fresh on mount and the + // former explicit `invalidateQueries({ queryKey: auth() })` is redundant. + queryClient.clear(); + // Belt-and-suspenders to match the session-END wipe (logout / 401-expiry): + // also drop the OFFLINE layers — the IndexedDB query snapshot and the SW + // data/page caches — so a previous account's persisted dashboard never + // lingers on disk for the new session. The restore is already per-account + // bound, but a fresh login on a shared browser should leave nothing of the + // prior account behind. Order is clear → prefetch → navigate: the wipe is + // started here (best-effort, like logout) before the prefetch warms THIS + // user's data, and the persister's 1 s debounce gives the single IDB delete + // ample headroom to land before the new snapshot is written. + void clearOfflineCachesForSessionEnd(); const target = getRedirectTarget(); if (target === "/" && isDashboardSnapshotEnabled()) { prefetchDashboardSnapshot(queryClient); @@ -105,7 +128,8 @@ export default function LoginPage() { credential, }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + // `navigateAfterLogin` clears the in-memory cache (cross-user guard) and + // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { // Route rejections carry the envelope error verbatim; everything @@ -150,7 +174,8 @@ export default function LoginPage() { return; } - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + // `navigateAfterLogin` clears the in-memory cache (cross-user guard) and + // navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch (err) { setError( @@ -193,7 +218,8 @@ export default function LoginPage() { challengeId, credential, }); - await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); + // `navigateAfterLogin` clears the in-memory cache (cross-user guard) + // and navigates; the shell refetches `["auth","me"]` fresh on mount. navigateAfterLogin(); } catch { // Autofill aborts (no passkey, user dismisses, password path taken) @@ -228,10 +254,10 @@ export default function LoginPage() { { - await queryClient.invalidateQueries({ - queryKey: queryKeys.auth(), - }); + onSuccess={() => { + // `navigateAfterLogin` clears the in-memory cache (cross-user + // guard) and navigates; the shell refetches `["auth","me"]` + // fresh on mount. navigateAfterLogin(); }} onCancel={() => { diff --git a/src/app/labs/[biomarkerId]/values/page.tsx b/src/app/labs/[biomarkerId]/values/page.tsx new file mode 100644 index 000000000..8b1f964ad --- /dev/null +++ b/src/app/labs/[biomarkerId]/values/page.tsx @@ -0,0 +1,75 @@ +"use client"; + +/** + * v1.25.1 — `/labs/[biomarkerId]/values`. + * + * The "show all readings" sub-page for one biomarker, mirroring the metric + * sub-pages' `/insights/values/[type]`. The detail page keeps the + * numbers-first spine (description → stat strip → chart → assessment) and + * links here for the raw, editable reading feed. Owns auth + the back link to + * the biomarker detail, and hands the marker id to ``. + */ +import { use, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; + +import { LabValuesList } from "@/components/labs/lab-values-list"; +import type { BiomarkerDto } from "@/components/labs/types"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { BackLink } from "@/components/ui/back-link"; +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +export default function LabBiomarkerValuesPage({ + params, +}: { + params: Promise<{ biomarkerId: string }>; +}) { + const { biomarkerId } = use(params); + const { isAuthenticated, isLoading } = useAuth(); + const mounted = useMounted(); + const router = useRouter(); + const { t } = useTranslations(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push("/auth/login"); + } + }, [isLoading, isAuthenticated, router]); + + const { data: marker } = useQuery({ + queryKey: queryKeys.biomarkerDetail(biomarkerId), + queryFn: () => apiGet(`/api/biomarkers/${biomarkerId}`), + enabled: isAuthenticated && biomarkerId.length > 0, + }); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + const markerName = marker?.name ?? ""; + + return ( + + } + > + + + ); +} diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 359f8d1b2..b10436be5 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -6,7 +6,6 @@ import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react"; -import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { OcrReviewDialog } from "@/components/labs/ocr-review-dialog"; @@ -174,11 +173,6 @@ export default function LabsPage() { ) : null} setDialogOpen(true)} /> - - {/* v1.25 — calm, context-specific framing for lab values: your own - records, ranges vary by lab and person, only a clinician interprets - them. One footer line, not a per-row banner. */} - ); } diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 6b48c6dbc..a65d0182e 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -7,7 +7,6 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; -import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog"; import { MedicationCard } from "@/components/medications/medication-card"; import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card"; @@ -525,12 +524,6 @@ export default function MedicationsPage() { )} - {/* v1.25 — calm framing for the medications surface: this tracks what - you log and your own adherence, it doesn't assess dosing or - interactions and isn't medical advice. One footer line, neutral — - the med cards themselves stay un-tinted. */} - - {/* v1.5.5 — the per-card kebab triggers used to open inline IntakeImportDialog / ApiEndpointDialog mounts here, but both state slots were only ever set from inside the dialogs diff --git a/src/app/insights/mental-wellbeing/page.tsx b/src/app/mental-wellbeing/page.tsx similarity index 92% rename from src/app/insights/mental-wellbeing/page.tsx rename to src/app/mental-wellbeing/page.tsx index 778255ddf..56b2e2d47 100644 --- a/src/app/insights/mental-wellbeing/page.tsx +++ b/src/app/mental-wellbeing/page.tsx @@ -8,7 +8,8 @@ import { useAuth } from "@/hooks/use-auth"; import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; /** - * v1.25.0 — `/insights/mental-wellbeing`. + * `/mental-wellbeing` — standalone module page (moved off `/insights/*` in + * v1.25.1 so it no longer borrows the Insights tab strip / layout shell). * * Opt-in PHQ-9 / GAD-7 screener surface, beside mood tracking. The screen owns * its own questionnaire + result + crisis-resource flow; it deliberately does diff --git a/src/components/common/medical-disclaimer.tsx b/src/components/common/medical-disclaimer.tsx index ba27663cf..d844d1fce 100644 --- a/src/components/common/medical-disclaimer.tsx +++ b/src/components/common/medical-disclaimer.tsx @@ -15,35 +15,15 @@ import { useTranslations } from "@/lib/i18n/context"; * The copy lives under the `selfDescription` i18n namespace so every locale * carries the same set; pick the note that fits the surface: * - * - `intendedPurpose` — the canonical "what this is / is not, not a medical - * device" line. Reuse verbatim; do not paraphrase per surface. - * - `coach` — the Coach standing line (works from your data, not a doctor). - * - `insights` — descriptive-patterns framing for insights + the briefing. - * - `labs` — your own records, ranges vary, only a clinician interprets. - * - `medications` — tracks what you log, not dosing/interaction advice. - * - `emergency` — the red-flag interstitial note (contact emergency services). * - `dataPosture` — self-hosted, data stays on your instance, BYOK / local AI. * * The acknowledged onboarding disclaimer (`onboarding.disclaimer.*`) and the * legal medical-device boundary on the public privacy page remain the standing - * floor; these notes are the gentle, in-context layer above it. + * floor; this note is the gentle, in-context layer above it. */ -export type DisclaimerVariant = - | "intendedPurpose" - | "coach" - | "insights" - | "labs" - | "medications" - | "emergency" - | "dataPosture"; +export type DisclaimerVariant = "dataPosture"; const VARIANT_KEY: Record = { - intendedPurpose: "selfDescription.intendedPurpose", - coach: "selfDescription.coach", - insights: "selfDescription.insights", - labs: "selfDescription.labs", - medications: "selfDescription.medications", - emergency: "selfDescription.emergency", dataPosture: "selfDescription.dataPosture", }; diff --git a/src/components/documents/__tests__/inbound-documents-view.test.tsx b/src/components/documents/__tests__/inbound-documents-view.test.tsx new file mode 100644 index 000000000..a38eb13d3 --- /dev/null +++ b/src/components/documents/__tests__/inbound-documents-view.test.tsx @@ -0,0 +1,373 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { + ExtractedFactDto, + InboundDocumentDetailDto, + InboundDocumentDto, +} from "@/lib/validations/inbound-documents"; + +/** + * v1.25 — the Documents library. + * + * The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and + * assert the static markup. Interaction-driven paths (the exact request params, + * the provider-unsupported classification) are covered by the pure-helper unit + * tests in `library-utils.test.ts`; here we assert the rendered library, the + * detail/review surface, the edit form, and the inline provider note. + */ + +vi.mock("@/lib/api/api-fetch", async () => { + const actual = await vi.importActual( + "@/lib/api/api-fetch", + ); + return { + ...actual, + apiFetch: () => new Promise(() => {}), + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), + }; +}); + +import { + AlreadyConfirmedNote, + DocumentDetail, + DocumentMetaEditForm, + InboundDocumentsView, + ProviderUnsupportedNote, +} from "../inbound-documents-view"; + +function doc( + id: string, + over: Partial = {}, +): InboundDocumentDto { + return { + id, + kind: "LAB_RESULT", + title: null, + filename: `${id}.pdf`, + mimeType: "application/pdf", + byteSize: 1000, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: "2026-06-20", + errorReason: null, + factCount: 0, + pendingCount: 0, + createdAt: "2026-06-20T08:00:00.000Z", + updatedAt: "2026-06-20T08:00:00.000Z", + ...over, + }; +} + +function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + seed?.(queryClient); + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" library", () => { + it("renders the grouped list with titles, badges, and load-more", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { + pages: [ + { + documents: [ + doc("d1", { + title: "Cardiology report", + documentDate: "2026-06-20", + pendingCount: 2, + factCount: 3, + }), + doc("d2", { + title: "Blood panel", + documentDate: "2026-06-20", + }), + ], + nextCursor: "cursor-next", + }, + ], + pageParams: [null], + }, + ); + }); + + // Library + toolbar shells render. + expect(html).toContain('data-slot="documents-library"'); + expect(html).toContain('data-slot="documents-toolbar"'); + // Date group header + rows. + expect(html).toContain('data-slot="documents-date-group"'); + expect(html).toContain('data-slot="documents-row"'); + // Titles surface (title preferred over filename). + expect(html).toContain("Cardiology report"); + expect(html).toContain("Blood panel"); + // Kind + status badges from the EN bundle. + expect(html).toContain("Lab result"); + expect(html).toContain("Stored"); + // pendingCount badge. + expect(html).toContain("2 to review"); + // nextCursor → load-more button. + expect(html).toContain("Load more"); + // A11Y-M2: the row's aria-controls points at the detail region id. + expect(html).toContain('aria-controls="documents-detail-d1"'); + // QoL: accepted formats + size ceiling are shown by the file field. + expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB"); + }); + + it("disables the upload button and shows a hint until a file is chosen", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, + ); + }); + // A11Y-M4: with no file staged, an inline hint explains why upload is + // disabled (the hint only renders on the !hasFile / not-pending path). + expect(html).toContain("Choose a file to upload."); + }); + + it("renders the document-date range filter inputs", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, + ); + }); + // PERF-L: from/to date inputs are wired into the toolbar. + expect(html).toContain('id="doc-from"'); + expect(html).toContain('id="doc-to"'); + }); + + it("shows the empty state when there are no documents", () => { + const html = render(, (qc) => { + qc.setQueryData( + queryKeys.inboundDocumentList({ + q: undefined, + kind: undefined, + sort: "documentDate", + order: "desc", + }), + { pages: [{ documents: [], nextCursor: null }], pageParams: [null] }, + ); + }); + expect(html).toContain("No documents yet."); + expect(html).not.toContain('data-slot="documents-row"'); + }); +}); + +function fact(id: string): ExtractedFactDto { + return { + id, + factType: "OBSERVATION", + status: "PENDING", + confidence: 0.9, + needsReview: false, + data: { + label: "LDL Cholesterol", + code: null, + codeSystem: null, + value: 95, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: null, + effectiveDate: "2026-06-20", + }, + provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 }, + committedRecordId: null, + committedRecordType: null, + }; +} + +describe("", () => { + function detail(over: Partial = {}) { + return { + ...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }), + facts: [fact("f1")], + ...over, + } as InboundDocumentDetailDto; + } + + it("renders the original link, edit + extract controls, and the review facts", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); + }, + ); + expect(html).toContain('data-slot="documents-detail"'); + // Original view link points at the raw-bytes route. + expect(html).toContain("/api/documents/inbound/d1/original"); + expect(html).toContain('data-slot="documents-edit-trigger"'); + // Extract button is present (status is not CONFIRMED). + expect(html).toContain('data-slot="documents-extract"'); + expect(html).toContain("Extract facts"); + // The staged fact's transcription summary renders for review. + expect(html).toContain("LDL Cholesterol"); + expect(html).toContain("Save approved facts"); + }); + + it("hides the extract button once the document is confirmed", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ status: "CONFIRMED", facts: [] }), + ); + }, + ); + expect(html).not.toContain('data-slot="documents-extract"'); + }); + + it("hides the extract button once any fact is approved (PERF-H1 gate)", () => { + const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" }; + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }), + ); + }, + ); + expect(html).not.toContain('data-slot="documents-extract"'); + }); + + it("threads the region id onto the detail card for aria-controls", () => { + const html = render( + {}} + />, + (qc) => { + qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); + }, + ); + expect(html).toContain('id="documents-detail-d1"'); + }); + + it("falls back to the kind label when title and filename are absent", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ title: null, filename: null, kind: "IMAGING" }), + ); + }, + ); + expect(html).toContain("Imaging"); + }); + + it("shows the committed facts summary once approved, not the empty note", () => { + const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" }; + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }), + ); + }, + ); + // M6: provenance stays visible — the approved facts are listed read-only… + expect(html).toContain('data-slot="documents-committed"'); + expect(html).toContain("Approved from this document"); + expect(html).toContain("LDL Cholesterol"); + // …instead of stranding the user on "Nothing left to review." + expect(html).not.toContain("Nothing left to review."); + }); + + it("explains why a low-confidence fact can't be approved yet", () => { + const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true }; + const html = render( + {}} />, + (qc) => { + qc.setQueryData( + queryKeys.inboundDocument("d1"), + detail({ facts: [lowConf] }), + ); + }, + ); + // M7: the disabled Approve is tied to a plain-language reason. + expect(html).toContain("Edit this fact to approve it."); + expect(html).toContain("Needs review"); + }); + + it("offers a confirm step before discarding", () => { + const html = render( + {}} />, + (qc) => { + qc.setQueryData(queryKeys.inboundDocument("d1"), detail()); + }, + ); + // A11Y-M3: discard is gated behind an AlertDialog trigger, not one click. + expect(html).toContain('data-slot="documents-discard-trigger"'); + }); +}); + +describe("", () => { + it("renders the metadata fields seeded from the document", () => { + const html = render( + {}} + onCancel={() => {}} + />, + ); + expect(html).toContain('data-slot="documents-edit-form"'); + expect(html).toContain('value="Old title"'); + expect(html).toContain('value="2026-06-20"'); + expect(html).toContain("Save"); + }); +}); + +describe("", () => { + it("renders the calm inline note as a polite live region (A11Y-H1)", () => { + const html = render(); + expect(html).toContain('data-slot="documents-provider-note"'); + expect(html).toContain("Configure a document-scan provider"); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + }); +}); + +describe("", () => { + it("renders a calm polite live region when extraction is refused", () => { + const html = render(); + expect(html).toContain('data-slot="documents-already-confirmed-note"'); + expect(html).toContain('role="status"'); + expect(html).toContain('aria-live="polite"'); + expect(html).toContain("already been reviewed"); + }); +}); diff --git a/src/components/documents/__tests__/library-utils.test.ts b/src/components/documents/__tests__/library-utils.test.ts new file mode 100644 index 000000000..944b9abca --- /dev/null +++ b/src/components/documents/__tests__/library-utils.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect } from "vitest"; + +import { ApiError } from "@/lib/api/api-fetch"; +import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; +import enMessages from "../../../../messages/en.json"; +import { + buildDocumentListSearch, + classifyUploadError, + commitFailureReasonKey, + confirmFailureReasonKey, + documentDateKey, + formatDateGroupLabel, + groupDocumentsByDate, + isAlreadyConfirmedError, + isLocalOcrDisabledError, + isProviderUnsupportedError, +} from "../library-utils"; + +/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't + * literal `t()` call sites, so the call-site coverage guard can't see them — + * assert here that each one actually resolves to a string). */ +function resolveKey(bundle: unknown, key: string): unknown { + return key + .split(".") + .reduce( + (acc, part) => + acc && typeof acc === "object" + ? (acc as Record)[part] + : undefined, + bundle, + ); +} + +function doc( + id: string, + over: Partial = {}, +): InboundDocumentDto { + return { + id, + kind: "OTHER", + title: null, + filename: `${id}.pdf`, + mimeType: "application/pdf", + byteSize: 1000, + status: "STORED", + providerType: null, + reportDate: null, + documentDate: null, + errorReason: null, + factCount: 0, + pendingCount: 0, + createdAt: "2026-06-20T08:00:00.000Z", + updatedAt: "2026-06-20T08:00:00.000Z", + ...over, + }; +} + +describe("buildDocumentListSearch", () => { + it("emits the active filters, sort, order, and limit", () => { + const qs = buildDocumentListSearch( + { + q: "cardio", + kind: "LAB_RESULT", + from: "2026-01-01", + to: "2026-06-01", + sort: "title", + order: "asc", + }, + null, + 25, + ); + const sp = new URLSearchParams(qs); + expect(sp.get("q")).toBe("cardio"); + expect(sp.get("kind")).toBe("LAB_RESULT"); + expect(sp.get("from")).toBe("2026-01-01"); + expect(sp.get("to")).toBe("2026-06-01"); + expect(sp.get("sort")).toBe("title"); + expect(sp.get("order")).toBe("asc"); + expect(sp.get("limit")).toBe("25"); + expect(sp.has("cursor")).toBe(false); + }); + + it("omits empty filters and appends the keyset cursor when present", () => { + const qs = buildDocumentListSearch( + { sort: "documentDate", order: "desc" }, + "cursor-123", + ); + const sp = new URLSearchParams(qs); + expect(sp.has("q")).toBe(false); + expect(sp.has("kind")).toBe(false); + expect(sp.get("sort")).toBe("documentDate"); + expect(sp.get("order")).toBe("desc"); + expect(sp.get("cursor")).toBe("cursor-123"); + }); +}); + +describe("documentDateKey", () => { + it("prefers the user filing date", () => { + expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe( + "2026-03-15", + ); + }); + + it("falls back to the upload day from createdAt", () => { + expect( + documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })), + ).toBe("2026-06-20"); + }); +}); + +describe("groupDocumentsByDate", () => { + it("buckets consecutive same-date documents, preserving server order", () => { + const groups = groupDocumentsByDate([ + doc("a", { documentDate: "2026-06-20" }), + doc("b", { documentDate: "2026-06-20" }), + doc("c", { documentDate: "2026-06-19" }), + ]); + expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]); + expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]); + expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]); + }); + + it("returns an empty array for no documents", () => { + expect(groupDocumentsByDate([])).toEqual([]); + }); +}); + +describe("isProviderUnsupportedError", () => { + it("matches the extract 422 provider-unsupported signal", () => { + const err = new ApiError("no provider", 422, { + errorCode: "documents.inbound.providerUnsupported", + }); + expect(isProviderUnsupportedError(err)).toBe(true); + }); + + it("rejects other API errors and non-errors", () => { + expect( + isProviderUnsupportedError( + new ApiError("rate", 429, { + errorCode: "documents.inbound.rateLimited", + }), + ), + ).toBe(false); + expect(isProviderUnsupportedError(new Error("boom"))).toBe(false); + expect(isProviderUnsupportedError(null)).toBe(false); + }); +}); + +describe("isAlreadyConfirmedError", () => { + it("matches the extract 409 partly-confirmed signal", () => { + const err = new ApiError("already confirmed", 409, { + errorCode: "documents.inbound.alreadyPartlyConfirmed", + }); + expect(isAlreadyConfirmedError(err)).toBe(true); + }); + + it("matches the extract 422 fully-confirmed signal", () => { + const err = new ApiError("already confirmed", 422, { + errorCode: "documents.inbound.alreadyConfirmed", + }); + expect(isAlreadyConfirmedError(err)).toBe(true); + }); + + it("rejects other API errors and non-errors", () => { + expect( + isAlreadyConfirmedError( + new ApiError("no provider", 422, { + errorCode: "documents.inbound.providerUnsupported", + }), + ), + ).toBe(false); + expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false); + expect(isAlreadyConfirmedError(null)).toBe(false); + }); +}); + +describe("classifyUploadError", () => { + it("maps each upload error code to its kind", () => { + const code = (c: string) => + classifyUploadError(new ApiError("x", 400, { errorCode: c })); + expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge"); + expect(code("documents.inbound.fileType")).toBe("fileType"); + expect(code("documents.inbound.rateLimited")).toBe("rateLimited"); + expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata"); + }); + + it("falls back to the HTTP status when the body carries no error code", () => { + expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge"); + expect(classifyUploadError(new ApiError("wrong type", 415))).toBe( + "fileType", + ); + expect(classifyUploadError(new ApiError("slow down", 429))).toBe( + "rateLimited", + ); + }); + + it("returns generic for unknown errors and non-API errors", () => { + expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic"); + expect(classifyUploadError(new Error("network"))).toBe("generic"); + expect(classifyUploadError(null)).toBe("generic"); + }); +}); + +describe("isLocalOcrDisabledError", () => { + it("matches the extract 422 local-OCR-disabled signal", () => { + const err = new ApiError("local ocr off", 422, { + errorCode: "documents.inbound.localOcrDisabled", + }); + expect(isLocalOcrDisabledError(err)).toBe(true); + }); + + it("rejects other API errors and non-errors", () => { + expect( + isLocalOcrDisabledError( + new ApiError("no provider", 422, { + errorCode: "documents.inbound.providerUnsupported", + }), + ), + ).toBe(false); + expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false); + expect(isLocalOcrDisabledError(null)).toBe(false); + }); +}); + +describe("commitFailureReasonKey", () => { + it("maps each commit-failure code to a reason key that resolves", () => { + const cases: Record = { + "observation.unitMismatch": "documents.review.commitError.unitMismatch", + "observation.unitRequired": "documents.review.commitError.unitRequired", + "something.unknown": "documents.review.commitError.generic", + }; + for (const [code, key] of Object.entries(cases)) { + expect(commitFailureReasonKey(code)).toBe(key); + // Every reason the toast can surface resolves to a real string. + expect(typeof resolveKey(enMessages, key)).toBe("string"); + } + }); +}); + +describe("confirmFailureReasonKey", () => { + it("surfaces the shared reason when every failed fact agrees", () => { + const key = confirmFailureReasonKey([ + { reason: "observation.unitMismatch" }, + { reason: "observation.unitMismatch" }, + ]); + expect(key).toBe("documents.review.commitError.unitMismatch"); + expect(typeof resolveKey(enMessages, key)).toBe("string"); + }); + + it("falls back to the generic reason when failures disagree", () => { + const key = confirmFailureReasonKey([ + { reason: "observation.unitMismatch" }, + { reason: "observation.unitRequired" }, + ]); + expect(key).toBe("documents.review.commitError.generic"); + }); + + it("uses the single failure's reason for a one-item batch", () => { + expect( + confirmFailureReasonKey([{ reason: "observation.unitRequired" }]), + ).toBe("documents.review.commitError.unitRequired"); + }); +}); + +describe("formatDateGroupLabel", () => { + it("delegates the calendar day to the supplied app formatter", () => { + const seen: string[] = []; + const formatDate = (value: string) => { + seen.push(value); + return "FORMATTED"; + }; + expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED"); + // Handed over as a noon-UTC instant so the day never shifts under the tz. + expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]); + }); + + it("returns a malformed key unchanged without calling the formatter", () => { + const formatDate = () => { + throw new Error("should not be called"); + }; + expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date"); + }); +}); diff --git a/src/components/documents/inbound-documents-view.tsx b/src/components/documents/inbound-documents-view.tsx index 83a300b1a..330c742ec 100644 --- a/src/components/documents/inbound-documents-view.tsx +++ b/src/components/documents/inbound-documents-view.tsx @@ -1,39 +1,81 @@ "use client"; /** - * v1.25.0 (W-DOCS-IN) — inbound clinical documents review-then-confirm UI. + * v1.25 — Documents library. * - * Upload a doctor report / discharge letter, then review the STRUCTURED FACTS - * the OCR/vision provider transcribed before any of them reaches the - * structured stores. The screen reproduces what the document stated — it never - * interprets. A low-confidence fact fails closed: it cannot be approved until - * the user edits it. The non-diagnostic disclaimer is shown prominently. + * A self-hoster files any clinical document (doctor report, discharge letter, + * lab result, imaging, prescription, …). Upload is STORE-ONLY and always works, + * even with no AI provider configured — a file is filed with optional title / + * category / filing date. The library groups documents by date, with search, a + * category filter, sort, and keyset pagination. + * + * Each document opens a detail panel: view / download the original, edit its + * metadata, optionally run AI extraction (the explicit, separate enhancement), + * then review-then-confirm the STRUCTURED FACTS the provider transcribed before + * any reaches the structured stores. The screen reproduces what the document + * stated — it never interprets. A low-confidence fact fails closed: it cannot + * be approved until the user edits it. Absent a provider, extraction surfaces a + * calm inline note (configure one) — never a hard error; the stored document is + * untouched. */ -import { useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { toast } from "sonner"; -import { AlertTriangle, FileScan, Loader2, Upload } from "lucide-react"; +import { + ArrowDownAZ, + ArrowUpAZ, + ExternalLink, + FileScan, + Info, + Loader2, + Pencil, + RefreshCw, + Search, + Sparkles, + Upload, +} from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiFetch, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import type { DocumentListParams } from "@/lib/query-keys/documents"; import { + DOCUMENT_LIST_SORTS, INBOUND_DOCUMENT_KINDS, type ConditionFactData, + type DocumentListSort, type ExtractedFactDto, type InboundDocumentDetailDto, type InboundDocumentDto, @@ -41,28 +83,110 @@ import { type MedicationStatementFactData, type ObservationFactData, } from "@/lib/validations/inbound-documents"; +import { + buildDocumentListSearch, + classifyUploadError, + confirmFailureReasonKey, + formatDateGroupLabel, + groupDocumentsByDate, + isAlreadyConfirmedError, + isLocalOcrDisabledError, + isProviderUnsupportedError, + MAX_UPLOAD_BYTES, +} from "./library-utils"; -type ListResponse = { documents: InboundDocumentDto[] }; +type ListResponse = { + documents: InboundDocumentDto[]; + nextCursor: string | null; +}; +/** Shape the confirm route returns (HTTP 200) — `failed[]` carries per-fact + * commit misses the client must surface, not silently swallow. */ +type ConfirmResponse = { + approved: { factId: string; recordType: string; recordId: string }[]; + rejected: string[]; + needsReview: string[]; + failed: { factId: string; reason: string }[]; +}; type Decision = "approve" | "reject"; export function InboundDocumentsView() { const { t } = useTranslations(); + const format = useFormatters(); const queryClient = useQueryClient(); const fileRef = useRef(null); - const [kind, setKind] = useState("DOCTOR_REPORT"); + + // Upload form metadata (filed with the document immediately). + const [uploadTitle, setUploadTitle] = useState(""); + const [uploadKind, setUploadKind] = + useState("DOCTOR_REPORT"); + const [uploadDate, setUploadDate] = useState(""); + // Tracks whether a file is staged in the (uncontrolled) file input so the + // upload button can stay disabled until there's something to upload. + const [hasFile, setHasFile] = useState(false); + + // Library toolbar state. + const [q, setQ] = useState(""); + const [debouncedQ, setDebouncedQ] = useState(""); + const [kindFilter, setKindFilter] = useState<"" | InboundDocumentKindValue>( + "", + ); + const [fromDate, setFromDate] = useState(""); + const [toDate, setToDate] = useState(""); + const [sort, setSort] = useState("documentDate"); + const [order, setOrder] = useState<"asc" | "desc">("desc"); + const [selectedId, setSelectedId] = useState(null); - const list = useQuery({ - queryKey: queryKeys.inboundDocuments(), - queryFn: () => - apiFetch("/api/documents/inbound", { method: "GET" }), + // Debounce the search box so a query fires per pause, not per keystroke. + useEffect(() => { + const handle = setTimeout(() => setDebouncedQ(q), 300); + return () => clearTimeout(handle); + }, [q]); + + const filters: DocumentListParams = { + q: debouncedQ.trim() || undefined, + kind: kindFilter || undefined, + from: fromDate || undefined, + to: toDate || undefined, + sort, + order, + }; + + const list = useInfiniteQuery({ + queryKey: queryKeys.inboundDocumentList(filters), + queryFn: ({ pageParam }) => + apiFetch( + `/api/documents/inbound?${buildDocumentListSearch(filters, pageParam)}`, + { method: "GET" }, + ), + initialPageParam: null as string | null, + getNextPageParam: (lastPage) => lastPage.nextCursor, }); + const documents = list.data?.pages.flatMap((p) => p.documents) ?? []; + const groups = groupDocumentsByDate(documents); + const isFiltered = Boolean( + filters.q || filters.kind || filters.from || filters.to, + ); + + // Reset search + every filter to the default view. Used by the no-results + // "Clear filters" affordance and after an upload (so the just-stored document + // is in the list the detail panel opens against). + const clearFilters = () => { + setQ(""); + setDebouncedQ(""); + setKindFilter(""); + setFromDate(""); + setToDate(""); + }; + const upload = useMutation({ mutationFn: async (file: File) => { const fd = new FormData(); fd.append("file", file); - fd.append("kind", kind); + fd.append("kind", uploadKind); + if (uploadTitle.trim()) fd.append("title", uploadTitle.trim()); + if (uploadDate) fd.append("documentDate", uploadDate); return apiFetch("/api/documents/inbound", { method: "POST", body: fd, @@ -71,11 +195,41 @@ export function InboundDocumentsView() { }, onSuccess: (doc) => { toast.success(t("documents.toast.uploaded")); + // Drop any active filter / search so the just-stored document is in the + // list — otherwise selecting it opens a detail panel for a row the current + // filter excludes, and the user lands on a dead end. Sort by date-added + // (createdAt) desc, NOT documentDate: a user-set past documentDate would + // otherwise file the fresh upload deep in the list, off page 1, where the + // detail panel never renders. The newest upload always has the newest + // createdAt, so this guarantees it lands first and its panel opens. + clearFilters(); + setSort("createdAt"); + setOrder("desc"); void invalidateKeys(queryClient, [queryKeys.documents()]); setSelectedId(doc.id); + setUploadTitle(""); + setUploadDate(""); + setHasFile(false); if (fileRef.current) fileRef.current.value = ""; }, - onError: () => toast.error(t("documents.toast.uploadFailed")), + onError: (error) => { + switch (classifyUploadError(error)) { + case "tooLarge": + toast.error(t("documents.toast.uploadTooLarge")); + break; + case "fileType": + toast.error(t("documents.toast.uploadFileType")); + break; + case "rateLimited": + toast.error(t("documents.toast.uploadRateLimited")); + break; + case "invalidMetadata": + toast.error(t("documents.toast.uploadInvalidMetadata")); + break; + default: + toast.error(t("documents.toast.uploadFailed")); + } + }, }); return ( @@ -90,139 +244,384 @@ export function InboundDocumentsView() { - {/* Non-diagnostic disclaimer — most prominent, always visible. */} - - - -
- - {t("documents.disclaimer.title")} - - - {t("documents.disclaimer.body")} - -
-
-
- - {/* Upload */} + {/* Upload — store-only, always available. */} {t("documents.upload.label")} - -
- - - setKind(e.target.value as InboundDocumentKindValue) - } - > - {INBOUND_DOCUMENT_KINDS.map((k) => ( - - ))} - -
- - +

+ {t("documents.upload.accepts")} +

+ +
+ + setUploadTitle(e.target.value)} + placeholder={t("documents.upload.titlePlaceholder")} + disabled={upload.isPending} + /> +
+
+ + + setUploadKind(e.target.value as InboundDocumentKindValue) + } + disabled={upload.isPending} + > + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ + setUploadDate(e.target.value)} + disabled={upload.isPending} + /> +
+ +
+ + {!hasFile && !upload.isPending ? ( +

+ {t("documents.upload.fileRequired")} +

+ ) : null} +
- {/* Document list */} -
+ {/* Toolbar — search / category filter / sort. */} +
+
+ +
+ + setQ(e.target.value)} + placeholder={t("documents.toolbar.searchPlaceholder")} + className="pl-8" + /> +
+
+
+ + + setKindFilter(e.target.value as "" | InboundDocumentKindValue) + } + > + + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ + setFromDate(e.target.value)} + /> +
+
+ + setToDate(e.target.value)} + /> +
+
+ +
+ setSort(e.target.value as DocumentListSort)} + > + {DOCUMENT_LIST_SORTS.map((s) => ( + + ))} + + +
+
+
+ + {/* Library list — grouped by date, keyset-paginated. */} +
{list.isLoading ? ( // Skeleton rows (not a centered spinner) so the resolved list lands // in place without a layout jump — the app's loading convention. - <> +
{[0, 1, 2].map((i) => ( - + ))} - - ) : !list.data || list.data.documents.length === 0 ? ( -

- {t("documents.list.empty")} -

- ) : ( - list.data.documents.map((doc) => ( - +
+ ) : documents.length === 0 ? ( + isFiltered ? ( +
+

+ {t("documents.list.noResults")} +

+ +
+ ) : ( +

+ {t("documents.list.empty")} +

+ ) + ) : ( + <> + {groups.map((group) => ( +
+

+ {formatDateGroupLabel(group.key, format.date)} +

+ {group.documents.map((doc) => { + const expanded = selectedId === doc.id; + // Shared id ties the row's aria-controls to the detail + // region so screen readers know the row owns the panel; the + // panel renders directly after its row for correct order. + const detailId = `documents-detail-${doc.id}`; + return ( + + setSelectedId(expanded ? null : doc.id)} + /> + {expanded ? ( + setSelectedId(null)} + /> + ) : null} + + ); + })} +
+ ))} + + {list.hasNextPage ? ( + - )) + {list.isFetchingNextPage + ? t("documents.list.loadingMore") + : t("documents.list.loadMore")} + + ) : null} + )}
- - {selectedId ? ( - setSelectedId(null)} - /> - ) : null} ); } -function DocumentReview({ +/** A single library row — title (or filename), kind + status badges, counts. */ +function DocumentRow({ + doc, + expanded, + regionId, + onToggle, +}: { + doc: InboundDocumentDto; + expanded: boolean; + regionId: string; + onToggle: () => void; +}) { + const { t } = useTranslations(); + const display = doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`); + + return ( + + ); +} + +export function DocumentDetail({ documentId, onClosed, + regionId, }: { documentId: string; onClosed: () => void; + regionId?: string; }) { const { t } = useTranslations(); + const format = useFormatters(); const queryClient = useQueryClient(); const [decisions, setDecisions] = useState>({}); + const [editOpen, setEditOpen] = useState(false); const detail = useQuery({ queryKey: queryKeys.inboundDocument(documentId), @@ -233,16 +632,56 @@ function DocumentReview({ ), }); - const confirm = useMutation({ + const extract = useMutation({ + // Empty body → vision mode over the stored original. mutationFn: () => - apiPost(`/api/documents/inbound/${documentId}/confirm`, { + apiPost(`/api/documents/inbound/${documentId}/extract`, undefined), + onSuccess: () => { + toast.success(t("documents.toast.extracted")); + void invalidateKeys(queryClient, [queryKeys.documents()]); + }, + onError: (err) => { + // A missing provider — or a document already confirmed elsewhere — is not + // an error the user acts on here. Surface the calm inline note (rendered + // below) instead of an alarming toast. + if (isProviderUnsupportedError(err) || isAlreadyConfirmedError(err)) { + return; + } + // Local OCR switched off in settings: name the cause rather than the + // generic "couldn't extract". + if (isLocalOcrDisabledError(err)) { + toast.error(t("documents.toast.localOcrDisabled")); + return; + } + toast.error(t("documents.toast.extractFailed")); + }, + }); + + const confirm = useMutation({ + mutationFn: (): Promise => + apiPost(`/api/documents/inbound/${documentId}/confirm`, { decisions: Object.entries(decisions).map(([factId, action]) => ({ factId, action, })), }), - onSuccess: () => { - toast.success(t("documents.toast.confirmed")); + onSuccess: (data) => { + // The route returns HTTP 200 even when it rejects a fact into `failed[]` + // (e.g. a stated unit that disagrees with the saved marker). A green + // "saved" toast would lie — the fact stays PENDING. Surface the count + + // reason so the user knows to reconcile it, and only show the plain + // success when nothing failed. + const failed = data.failed ?? []; + if (failed.length > 0) { + toast.warning( + t("documents.toast.confirmedPartial", { + count: failed.length, + reason: t(confirmFailureReasonKey(failed)), + }), + ); + } else { + toast.success(t("documents.toast.confirmed")); + } setDecisions({}); void invalidateKeys(queryClient, [queryKeys.documents()]); }, @@ -256,6 +695,7 @@ function DocumentReview({ void invalidateKeys(queryClient, [queryKeys.documents()]); onClosed(); }, + onError: () => toast.error(t("documents.toast.discardFailed")), }); if (detail.isLoading) { @@ -274,50 +714,141 @@ function DocumentReview({ } if (!detail.data) return null; - const pending = detail.data.facts.filter((f) => f.status === "PENDING"); + const doc = detail.data; + const pending = doc.facts.filter((f) => f.status === "PENDING"); + // Facts already approved from this document and pushed to the structured + // stores — surfaced read-only below so the document ↔ committed-record link + // stays visible after review (instead of the panel going blank). + const approved = doc.facts.filter((f) => f.status === "APPROVED"); const decidedCount = Object.keys(decisions).length; + // Mirror the backend's hard guard: extraction is gone once the document is + // confirmed OR any fact on it has already been approved (re-extracting would + // clash with facts already committed to the structured stores). + const hasApprovedFact = doc.facts.some((f) => f.status === "APPROVED"); + const canExtract = doc.status !== "CONFIRMED" && !hasApprovedFact; + const showProviderNote = isProviderUnsupportedError(extract.error); + const showAlreadyConfirmedNote = isAlreadyConfirmedError(extract.error); + const detailTitle = + doc.title ?? doc.filename ?? t(`documents.kind.${doc.kind}`); return ( - - - - {t("documents.review.title")} - - + + + {detailTitle} +
+ + + {canExtract ? ( + + ) : null} + + + + + + + + {t("documents.detail.discardConfirmTitle")} + + + {t("documents.detail.discardConfirmBody")} + + + + + {t("documents.detail.discardCancel")} + + discard.mutate()} + > + {t("documents.detail.discardConfirmAction")} + + + + +
- {pending.length === 0 ? ( -

- {t("documents.empty.noFacts")} -

- ) : ( - pending.map((fact) => ( - - setDecisions((prev) => { - const next = { ...prev }; - if (d === null) delete next[fact.id]; - else next[fact.id] = d; - return next; - }) - } - /> - )) - )} + {showProviderNote ? : null} + {showAlreadyConfirmedNote ? : null} {pending.length > 0 ? ( <> +

+ {t("documents.review.title")} +

+ {pending.map((fact) => ( + + setDecisions((prev) => { + const next = { ...prev }; + if (d === null) delete next[fact.id]; + else next[fact.id] = d; + return next; + }) + } + /> + ))} + ) : approved.length === 0 ? ( +

+ {doc.factCount > 0 + ? t("documents.empty.noFacts") + : t("documents.detail.notExtracted")} +

+ ) : null} + + {approved.length > 0 ? ( +
+ +

+ {t("documents.committed.title")} +

+
    + {approved.map((fact) => { + const when = factDate(fact); + return ( +
  • + {factSummary(fact)} + {when ? ` · ${format.date(`${when}T12:00:00.000Z`)}` : ""} +
  • + ); + })} +
+
) : null}
+ + + + + {t("documents.edit.title")} + + {t("documents.edit.subtitle")} + + + setEditOpen(false)} + onCancel={() => setEditOpen(false)} + /> + +
); } +/** + * A calm inline note. Carries `role="status"` + `aria-live="polite"` so that + * keyboard / screen-reader users hear it when it appears — these notes stand in + * for a toast that the mutation deliberately suppresses. + */ +function InlineNote({ slot, children }: { slot: string; children: string }) { + return ( +
+ +

{children}

+
+ ); +} + +/** The calm inline note shown when extraction needs a provider that's absent. */ +export function ProviderUnsupportedNote() { + const { t } = useTranslations(); + return ( + + {t("documents.note.providerUnsupported")} + + ); +} + +/** Calm inline note when extraction is refused — the document is confirmed. */ +export function AlreadyConfirmedNote() { + const { t } = useTranslations(); + return ( + + {t("documents.note.alreadyConfirmed")} + + ); +} + +/** Metadata edit (rename / recategorise / set the filing date). PATCHes. */ +export function DocumentMetaEditForm({ + doc, + onSaved, + onCancel, +}: { + doc: InboundDocumentDto; + onSaved: () => void; + onCancel: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [title, setTitle] = useState(doc.title ?? ""); + const [kind, setKind] = useState(doc.kind); + const [date, setDate] = useState(doc.documentDate ?? ""); + + const save = useMutation({ + mutationFn: () => + apiPatch(`/api/documents/inbound/${doc.id}`, { + title: title.trim() === "" ? null : title.trim(), + kind, + documentDate: date === "" ? null : date, + }), + onSuccess: () => { + toast.success(t("documents.toast.updated")); + void invalidateKeys(queryClient, [queryKeys.documents()]); + onSaved(); + }, + onError: () => toast.error(t("documents.toast.updateFailed")), + }); + + return ( +
+
+ + setTitle(e.target.value)} + placeholder={t("documents.upload.titlePlaceholder")} + /> +
+
+ + setKind(e.target.value as InboundDocumentKindValue)} + > + {INBOUND_DOCUMENT_KINDS.map((k) => ( + + ))} + +
+
+ + setDate(e.target.value)} + /> +
+ + + + +
+ ); +} + function FactCard({ documentId, fact, @@ -346,6 +1040,7 @@ function FactCard({ onDecision: (d: Decision | null) => void; }) { const { t } = useTranslations(); + const format = useFormatters(); const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); @@ -368,22 +1063,29 @@ function FactCard({
-
+
{t(`documents.factType.${fact.factType}`)} {fact.needsReview ? ( - // Calm warning (amber), not an alarming destructive red — a - // low-confidence fact is a "please check", not an error. + // A calm, neutral "please check" — not an alarming red, and not + // amber (the app is moving away from amber warning tints). A + // low-confidence fact is a gentle nudge to review, not an error. {t("documents.review.needsReview")} ) : null} - - {Math.round(fact.confidence * 100)}% + + + {t("documents.review.confidenceLabel")}:{" "} + + {format.percent(fact.confidence)}

{summary}

@@ -392,6 +1094,13 @@ function FactCard({ “{fact.provenance.sourceText}”

) : null} + {fact.needsReview ? ( + // Ties the disabled Approve button to the "Needs review" badge: a + // low-confidence fact must be edited before it can be approved. +

+ {t("documents.review.needsReviewHint")} +

+ ) : null}
); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index e7ee0c0ce..c4e01849a 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -8,7 +8,6 @@ import { useMounted } from "@/hooks/use-mounted"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useWorkouts } from "@/hooks/use-workouts"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; -import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { nextAdvisorPollInterval, useInsightsAdvisorQuery, @@ -160,14 +159,6 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { modules={user?.modules} /> {children} - {/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer was - removed; the standing acknowledgment lives once at the start of - onboarding and the legal text on the public privacy page. - v1.25 — one calm, context-specific framing line for the whole - Insights surface (the briefing, trends and correlations included): - these are descriptive patterns from the user's own data, not a - clinical assessment. Layered, not a wall of warnings. */} -
); } diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index aa6c7c3b9..166586105 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useInfiniteQuery, @@ -8,7 +9,13 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; -import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react"; +import { + FlaskConical, + ListOrdered, + Pencil, + Plus, + SlidersHorizontal, +} from "lucide-react"; import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; @@ -26,9 +33,8 @@ import { useMounted } from "@/hooks/use-mounted"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; import { summarize, type DataSummary } from "@/lib/analytics/trends"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; -import { classifyReferenceRange } from "@/lib/labs/reference-range"; -import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; import { queryKeys } from "@/lib/query-keys"; import dynamic from "next/dynamic"; @@ -39,8 +45,6 @@ import { importWithRetry } from "@/lib/retry-import"; import { BiomarkerForm } from "./biomarker-form"; import { LabForm } from "./lab-form"; -import { LabHistoryList } from "./lab-history-list"; -import { ReferenceRangeBadge } from "./reference-range-badge"; import type { BiomarkerDto, LabResultDto, @@ -107,7 +111,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const [lowerInput, setLowerInput] = useState(""); const [upperInput, setUpperInput] = useState(""); - const { data: marker, isError: markerError } = useQuery({ + const { + data: marker, + isError: markerError, + refetch: refetchMarker, + } = useQuery({ queryKey: queryKeys.biomarkerDetail(biomarkerId), queryFn: () => apiGet(`/api/biomarkers/${biomarkerId}`), }); @@ -121,9 +129,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { data: list, isLoading, isError: listError, - fetchNextPage, - hasNextPage, - isFetchingNextPage, + refetch: refetchList, } = useInfiniteQuery({ queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }), initialPageParam: 0, @@ -143,9 +149,6 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { () => list?.pages.flatMap((p) => p.results) ?? [], [list], ); - const total = list?.pages[0]?.meta.total ?? 0; - // More rows exist on the server than are currently loaded. - const truncated = hasNextPage ?? false; const latest = readings.length > 0 ? [...readings].sort( @@ -164,6 +167,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { () => readings.filter((r) => r.value !== null), [readings], ); + // A marker is qualitative when it has readings but none of them carry a + // numeric value — a stat strip / chart make no sense, so the page shows the + // latest result text + assessment + the history (via the values sub-page). + const isQualitative = readings.length > 0 && numericReadings.length === 0; const summary = useMemo( () => numericReadings.length > 0 @@ -178,7 +185,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ); const mounted = useMounted(); const { data: assessment, isLoading: assessmentLoading } = - useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0); + useInsightBiomarkerAssessment(biomarkerId, readings.length > 0); function afterAdd() { setAddOpen(false); @@ -237,24 +244,53 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { onError: () => toast.error(t("labs.biomarker.targetRange.saveError")), }); + // v1.25.1 — the per-marker description leads the page, mirroring the metric + // sub-pages' explainer caption. Resolve the catalog slug from the marker name + // and fall back to the user's own `context`, then to a generic explainer so a + // catalog-less custom marker never renders an empty description block (parity + // with the insights metric pages, which always carry one). + const catalogSlug = marker?.name + ? slugByName.get(marker.name.trim().toLowerCase()) + : undefined; + const description = catalogSlug + ? t(`labs.catalog.desc.${catalogSlug}`) + : marker?.context?.trim() + ? marker.context.trim() + : t("labs.detail.genericDescription"); + + // v1.25.1 (H2-M2) — the error state keeps the heading + description and adds a + // retry, mirroring the insights `SubPageShell` recovery affordance rather than + // collapsing to a bare line with no context and no way back. if (markerError || listError) { return ( -

- {t("labs.loadError")} -

+
+
+

+ {marker?.name ?? t("labs.detail.title")} +

+

+ {description} +

+
+ + +

{t("labs.loadError")}

+ +
+
+
); } - // A qualitative latest reading (numeric value null) has no range verdict. - const latestStatus = - latest && latest.value !== null - ? classifyReferenceRange( - latest.value, - marker?.lowerBound ?? null, - marker?.upperBound ?? null, - ) - : "unknown"; - return (
@@ -270,6 +306,30 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : null}
+ {/* v1.25.1 — "show all readings" rides the header cluster as an icon + button, mirroring the metric sub-pages' `` control. + The full reading feed lives on `/labs/[biomarkerId]/values`; the + detail page keeps the numbers-first spine. */} + {readings.length > 0 ? ( + + ) : null}
- {latest ? ( -
- - {latest.value !== null ? ( - <> - {formatLabValue(latest.value)}{" "} - - {marker?.unit ?? latest.unit} - - - ) : ( - // Qualitative latest reading — show the result text, no unit. - (latest.valueText ?? "") - )} - - -
- ) : null} + {/* v1.25.1 — the static description leads the page, directly beneath the + heading, mirroring the metric sub-pages' explainer caption. Computed + once above (catalog desc → user `context` → generic fallback) so a + catalog-less marker still carries a description. */} +

+ {description} +

- {(() => { - const slug = marker?.name - ? slugByName.get(marker.name.trim().toLowerCase()) - : undefined; - const description = slug - ? t(`labs.catalog.desc.${slug}`) - : (marker?.context ?? null); - return description ? ( -

- {description} -

- ) : null; - })()} - - {/* Numbers-first stat strip — Min / Max / Median / Mean over the - numeric readings, mirroring the metric sub-pages. Self-gating: - renders nothing for a qualitative-only or empty marker. */} - - - - - {isLoading ? ( + {isLoading ? ( + + - ) : readings.length === 0 ? ( + + + ) : readings.length === 0 ? ( + + } title={t("labs.detail.emptyTitle")} @@ -371,55 +400,47 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { } /> - ) : ( - - )} - - - - {readings.length > 0 ? ( -
-

{t("labs.detail.history")}

- {truncated ? ( + + + ) : isQualitative ? ( + // Qualitative marker — no numeric strip / chart. Show the latest + // result text on its own simple line, no unit, no range badge. + +

- {t("labs.showingLatestOf", { - shown: readings.length, - total, - })} + {t("labs.detail.latestResult")}

- ) : null} +

{latest?.valueText ?? ""}

+
+
+ ) : ( + <> + {/* Numbers-first stat strip — Min / Max / Median / Mean over the + numeric readings, mirroring the metric sub-pages. */} + - - + + - {hasNextPage ? ( -
- -
- ) : null} -
- ) : null} + + )} {/* AI assessment — the spine's closing block, mirroring the metric - sub-pages (intro → stat strip → chart → history → assessment). The - card self-suppresses when the operator disabled status cards, and - the hook is gated on the marker having numeric readings. */} - {numericReadings.length > 0 ? ( + sub-pages (intro → stat strip → chart → assessment). The card + self-suppresses for a qualitative-only marker (the read-only route + returns `insufficient` without calling a provider). */} + {readings.length > 0 ? ( } diff --git a/src/components/labs/lab-values-list.tsx b/src/components/labs/lab-values-list.tsx new file mode 100644 index 000000000..c391ed8bf --- /dev/null +++ b/src/components/labs/lab-values-list.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useMemo } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { LabHistoryList } from "./lab-history-list"; +import type { LabResultDto, LabResultListResponse } from "./types"; + +// Page size for the offset-paginated reading feed. The server caps a single +// read at 500 (`listLabResultsSchema`); this stays well under that and keeps +// the first paint light, with "Load more" pulling subsequent pages. +const READINGS_PAGE_SIZE = 200; + +/** + * v1.25.1 — the full reading history for one biomarker, lifted off the detail + * page onto its own `/labs/[biomarkerId]/values` route (mirroring the metric + * sub-pages' `/insights/values/` split). The detail page keeps the + * numbers-first spine; this is the raw, editable reading feed with "Load more". + * Reuses the same offset-paginated infinite query key as the detail page so the + * two share cache. + */ +export function LabValuesList({ biomarkerId }: { biomarkerId: string }) { + const { t } = useTranslations(); + + const { + data: list, + isLoading, + isError, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }), + initialPageParam: 0, + queryFn: ({ pageParam }) => + apiGet( + `/api/labs?biomarkerId=${encodeURIComponent( + biomarkerId, + )}&limit=${READINGS_PAGE_SIZE}&offset=${pageParam}&sortDir=desc`, + ), + getNextPageParam: (lastPage) => { + const next = lastPage.meta.offset + lastPage.meta.limit; + return next < lastPage.meta.total ? next : undefined; + }, + }); + + const readings: LabResultDto[] = useMemo( + () => list?.pages.flatMap((p) => p.results) ?? [], + [list], + ); + const total = list?.pages[0]?.meta.total ?? 0; + const truncated = hasNextPage ?? false; + + if (isError) { + return ( +

+ {t("labs.loadError")} +

+ ); + } + + if (isLoading) { + return ; + } + + if (readings.length === 0) { + return ( +

+ {t("labs.detail.emptyTitle")} +

+ ); + } + + return ( +
+ {truncated ? ( +

+ {t("labs.showingLatestOf", { + shown: readings.length, + total, + })} +

+ ) : null} + + + + + + {hasNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 7197b3e21..85a9a2121 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -1,15 +1,15 @@ "use client"; +import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier"; import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; import { TourLauncher } from "@/components/onboarding/tour-launcher"; -import { useAuth } from "@/hooks/use-auth"; -import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; +import { clearCachesForSessionEnd, useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; @@ -52,6 +52,7 @@ export function AuthShell({ const { t } = useTranslations(); const pathname = usePathname(); const router = useRouter(); + const queryClient = useQueryClient(); const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); // v1.4.26 — `/privacy` is a long-form legal page that brings its own @@ -91,18 +92,54 @@ export function AuthShell({ }; }, [isBodyScrolled]); + // v1.25.1 — the authenticated shell keeps `
` + // mounted across every route change, so its scroll offset survives + // navigation: scroll down in one section (e.g. Settings → Sources + // Priority), hop to another, and the new view opens already scrolled. + // Reset the scroll container to the top on every forward navigation. + // A back/forward navigation skips the reset so the browser's own scroll + // restoration stands: `popstate` arms a one-shot skip for the single + // pathname change it triggers, and a macrotask clears the flag so it can + // never get stuck on a later forward navigation. + const skipNextScrollReset = useRef(false); + useEffect(() => { + const armSkip = () => { + skipNextScrollReset.current = true; + setTimeout(() => { + skipNextScrollReset.current = false; + }, 0); + }; + window.addEventListener("popstate", armSkip); + return () => window.removeEventListener("popstate", armSkip); + }, []); + + useEffect(() => { + if (skipNextScrollReset.current) { + skipNextScrollReset.current = false; + return; + } + const main = document.getElementById("main-content"); + if (main) { + main.scrollTop = 0; + } + // Fallback for the body-scrolled shells (login / onboarding / standalone + // legal pages) where the document, not `
`, owns the scroll. + window.scrollTo(0, 0); + }, [pathname]); + useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { // Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe - // every client-side cache that can hold the previous account's health - // data before bouncing to login, so it never leaks to the next account - // on a shared device. Logout does the same from `useLogout`; this covers - // the expiry path that logout never runs through. Best-effort; the - // redirect proceeds regardless. - void clearOfflineCachesForSessionEnd(); + // the in-memory query cache (the cross-user guard — the QueryClient + // outlives the navigation on this SPA and the health-data families are + // not user-scoped) plus the persisted IndexedDB + SW caches, so nothing + // leaks to the next account on a shared browser. Logout does the same + // from `useLogout`; this covers the expiry path logout never runs + // through. Best-effort; the redirect proceeds regardless. + clearCachesForSessionEnd(queryClient); router.replace("/auth/login"); } - }, [isLoading, isAuthenticated, isPublicPage, router]); + }, [isLoading, isAuthenticated, isPublicPage, router, queryClient]); // Redirect non-admins away from /admin useEffect(() => { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 49e04b45b..c4cb8ba72 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -87,10 +87,10 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ // Born-gated: `requiresModule: "mentalHealth"` reads the opt-in (default-off) // key from the resolved module map, so the entry is absent until the account // turns the module on from the Modules hub. The destination is the dedicated - // `/insights/mental-wellbeing` check-in surface (its own nav home — the - // active-detection helper defers `/insights` to it on that path). + // top-level `/mental-wellbeing` check-in surface (its own module page — it + // no longer borrows the Insights tab strip / layout shell). { - href: "/insights/mental-wellbeing", + href: "/mental-wellbeing", tKey: "nav.mentalWellbeing", icon: Brain, tourId: "nav-mental-wellbeing", diff --git a/src/components/medications/detail/medication-detail-tabs.tsx b/src/components/medications/detail/medication-detail-tabs.tsx index 563abe0f5..8486e0324 100644 --- a/src/components/medications/detail/medication-detail-tabs.tsx +++ b/src/components/medications/detail/medication-detail-tabs.tsx @@ -703,6 +703,7 @@ export function MedicationDetailTabs({ label={t("medications.detail.erweitert.group.externalApi")} dataSlot="api-group-external-api" collapsible + defaultOpen={false} >
+
-

- {t("mentalHealth.pageTitle")} -

+
+

+ {t("mentalHealth.pageTitle")} +

+ +

{t("mentalHealth.pageDescription")}

-

- {t("mentalHealth.disclaimer")} -

-

- {t("mentalHealth.optInNote")} -

{phase === "choose" && ( @@ -303,7 +301,10 @@ export function MentalWellbeing() { {result.crisis && ( diff --git a/src/components/records/conditions-overview.tsx b/src/components/records/conditions-overview.tsx new file mode 100644 index 000000000..2accbd77b --- /dev/null +++ b/src/components/records/conditions-overview.tsx @@ -0,0 +1,98 @@ +"use client"; + +/** + * v1.25.1 — pre-existing / chronic conditions, surfaced in the Anamnese + * (medical-history) home. + * + * The chronic-conditions note and the explicit "what should the Coach watch" + * focus the user entered in Settings → Profile → "About me" are foundational + * medical history, so they are surfaced here next to allergies + family history + * to give one coherent medical-history overview. + * + * This is a READ-ONLY shared view of the existing self-context + * (`GET /api/coach/about-me`) — it reads through the same query-key the "About + * me" panel writes, so the two stay in lockstep. Editing keeps its single home + * in personal context (linked below); nothing is moved, no new storage, no + * schema change. Plain text only — values render as React text children, no + * markdown renderer (XSS posture, see contributor notes). + */ +import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; + +interface SelfContext { + conditions: string | null; + coachFocus: string | null; +} + +export function ConditionsOverview() { + const { t } = useTranslations(); + const { data, isLoading, isError } = useQuery({ + queryKey: queryKeys.coachAboutMe(), + queryFn: () => apiGet("/api/coach/about-me"), + }); + + const conditions = (data?.conditions ?? "").trim(); + const coachFocus = (data?.coachFocus ?? "").trim(); + const hasAny = conditions.length > 0 || coachFocus.length > 0; + + return ( +
+ {isLoading && ( + + )} + + {isError && ( +

+ {t("records.conditions.loadError")} +

+ )} + + {!isLoading && !isError && ( + <> + {hasAny ? ( +
+ {conditions.length > 0 && ( +
+
+ {t("records.conditions.conditionsLabel")} +
+
+ {conditions} +
+
+ )} + {coachFocus.length > 0 && ( +
+
+ {t("records.conditions.focusLabel")} +
+
+ {coachFocus} +
+
+ )} +
+ ) : ( +

+ {t("records.conditions.empty")} +

+ )} + +

+ + {t("records.conditions.editCta")} + +

+ + )} +
+ ); +} diff --git a/src/components/settings/__tests__/advanced-account-delete.test.tsx b/src/components/settings/__tests__/advanced-account-delete.test.tsx index fd1c4e711..19e6dccbd 100644 --- a/src/components/settings/__tests__/advanced-account-delete.test.tsx +++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx @@ -1,16 +1,20 @@ /** - * v1.4.43 QoL (M3 + L5) — `` danger-zone shaping. + * v1.4.43 QoL (M3 + L5) — danger-zone shaping. * * M3: a separate "Delete account entirely" destructive card - * (`AccountDeleteCard`) sits beside the existing `DataResetCard` - * so a user reading the GDPR Article 17 surface has the full - * erasure CTA right next to the half-erasure one. + * (`AccountDeleteCard`) gives the GDPR Article 17 full-erasure CTA its + * own surface alongside the half-erasure data reset. * * L5: the data-reset card no longer paints a red `AlertTriangle` * icon and the title sits in the neutral foreground colour * (mirrors GitHub's danger-zone shaping). The CTA button stays * red; the protective confirmation dialog stays unchanged. * + * v1.25.1 (Q2-M3): account deletion moved out of `` (Data & + * Privacy group) into the Account group as a standalone ``. + * The L5 data-reset assertions still target ``; the M3 + * account-delete assertions now render the relocated card directly. + * * SSR-only — we render the component statically and inspect the * markup. The mutation handlers don't fire in SSR, so this test only * pins the visible structure + copy. @@ -34,6 +38,7 @@ vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({ })); import { AdvancedSection } from "../advanced-section"; +import { AccountDeleteCard } from "../account-delete-card"; function render(locale: "en" | "de" = "en") { return renderToStaticMarkup( @@ -43,11 +48,25 @@ function render(locale: "en" | "de" = "en") { ); } -describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { - it("mounts both the data-reset card and the account-delete card", () => { +function renderDeleteCard(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" danger-zone shaping (v1.4.43 L5)", () => { + it("mounts the data-reset card", () => { const html = render(); expect(html).toContain('data-slot="settings-data-reset-card"'); - expect(html).toContain('data-slot="settings-account-delete-card"'); + }); + + it("v1.25.1 (Q2-M3): no longer mounts the account-delete card", () => { + // Account deletion graduated to the Account group; Advanced keeps research + // mode + the data reset only. + const html = render(); + expect(html).not.toContain('data-slot="settings-account-delete-card"'); }); it("L5: the data-reset card title is neutral foreground (no destructive red)", () => { @@ -75,9 +94,16 @@ describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { expect(cardMatch).not.toBeNull(); expect(cardMatch![0]).not.toMatch(/lucide-alert-triangle/); }); +}); + +describe(" (v1.4.43 M3, relocated v1.25.1 Q2-M3)", () => { + it("renders the account-delete card", () => { + const html = renderDeleteCard(); + expect(html).toContain('data-slot="settings-account-delete-card"'); + }); it("M3: the account-delete card uses the destructive Button variant", () => { - const html = render(); + const html = renderDeleteCard(); expect(html).toContain('data-slot="settings-account-delete-trigger"'); // The trigger button still wears the destructive style — the CTA // remains red even though the surrounding card chrome is neutral. @@ -91,14 +117,14 @@ describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { }); it("M3: the account-delete card surfaces tight DE copy under the 'de' locale", () => { - const html = render("de"); + const html = renderDeleteCard("de"); expect(html).toContain("Konto vollständig löschen"); // The descriptive sentence calls out the cascade scope plainly. expect(html).toContain("Passkeys"); }); it("M3: the account-delete card surfaces tight EN copy under the 'en' locale", () => { - const html = render("en"); + const html = renderDeleteCard("en"); expect(html).toContain("Delete account entirely"); expect(html).toContain("passkeys"); }); diff --git a/src/components/settings/__tests__/privacy-section.test.tsx b/src/components/settings/__tests__/privacy-section.test.tsx index 288a064d4..7fcc39a6f 100644 --- a/src/components/settings/__tests__/privacy-section.test.tsx +++ b/src/components/settings/__tests__/privacy-section.test.tsx @@ -52,7 +52,7 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") { } describe(" — assembled Data & Privacy dashboard", () => { - it("renders the assembled sections (encryption, retention, export, delete, sessions, activity)", () => { + it("renders the assembled data/retention sections and links out (no embedded sign-in cards)", () => { const html = render(); // Encryption-at-rest block driven from the seeded summary. expect(html).toContain("AES-256-GCM"); @@ -64,11 +64,13 @@ describe(" — assembled Data & Privacy dashboard", () => { expect(html).toContain('href="/settings/export"'); expect(html).toContain('href="/settings/gesundheitsakte"'); expect(html).toContain('href="/settings/advanced"'); - // Embedded active-session + security-activity cards. - expect(html).toContain("settings-security-sessions-card"); - // Trusted devices now lives here (and only here) — its single home is the - // Privacy dashboard, not the Security section. - expect(html).toContain("settings-trusted-devices-card"); + // v1.25.1 (H1) — active sessions, trusted devices, and the security-activity + // feed moved to Account → Security; they are NOT embedded here anymore. + expect(html).not.toContain("settings-security-sessions-card"); + expect(html).not.toContain("settings-trusted-devices-card"); + expect(html).not.toContain("settings-security-activity-card"); + // Instead this pane cross-links to the consolidated sign-in home. + expect(html).toContain('href="/settings/security"'); }); it("shows a loading placeholder for the retention block before the summary resolves", () => { diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 5744b4ef7..9823a60dd 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -126,12 +126,16 @@ function renderFramed( } describe("settings sections — SSR smoke", () => { - it(" renders the Account heading and Profile card", () => { + it(" renders the Profile heading and Profile card", () => { const html = renderFramed("account", ); - // Section title resolves via the frame + i18n provider. - expect(html).toContain("Account"); + // v1.25.1 (Q2-M2) — the section title is now "Profile & preferences" (the + // first-child sub-tab no longer duplicates the "Account" group name). It + // resolves via the frame + i18n provider. + expect(html).toContain("Profile & preferences"); // Profile card heading is also painted. expect(html).toContain("Profile"); + // v1.25.1 (Q2-M3) — account deletion moved here from Advanced. + expect(html).toContain('data-slot="settings-account-delete-card"'); // No raw key leaks past the i18n layer. expect(html).not.toContain("settings.sections."); }); @@ -184,7 +188,9 @@ describe("settings sections — SSR smoke", () => { it(" renders Withings card title", () => { const html = renderFramed("integrations", ); - expect(html).toContain("Integrations"); + // v1.25.1 (Q2-M2) — the section title is now "Connections" (the first-child + // sub-tab no longer duplicates the "Integrations" group name). + expect(html).toContain("Connections"); expect(html).toContain("Withings"); }); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 004d13aed..e36a6d369 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -29,12 +29,17 @@ vi.mock("@/hooks/use-auth", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { + SETTINGS_GROUPS, SETTINGS_SECTION_SLUGS, SETTINGS_SECTIONS, SettingsShell, isSettingsSectionSlug, } from "../settings-shell"; +function count(html: string, re: RegExp): number { + return html.match(re)?.length ?? 0; +} + function renderShell(props: { active?: (typeof SETTINGS_SECTION_SLUGS)[number]; pathname?: string; @@ -150,229 +155,212 @@ describe("SETTINGS_SECTION_SLUGS", () => { }); }); +// v1.25.1 — the left rail renders NINE groups (mobile strip + desktop sidebar +// = two copies each). Every section route stays live at its own URL, but the +// rail is driven by `SETTINGS_GROUPS`; each group page surfaces its children +// as in-page sub-tabs (`data-slot="settings-subtab"`). +const GROUP_LANDINGS: Record = { + account: "/settings/account", + tracking: "/settings/modules", + display: "/settings/dashboard", + integrations: "/settings/integrations", + notifications: "/settings/notifications", + ai: "/settings/ai", + access: "/settings/api", + data: "/settings/export", + about: "/settings/about", +}; + describe("", () => { - it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => { + it("renders all nine group nav entries — once per layout (mobile + desktop)", () => { const html = renderShell({ active: "account" }); - // Every slug — including `about`, which returned to the shell nav - // as the last entry after living dropdown-only since v1.4.33 IW7 — - // renders in both layouts. - const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug); - for (const slug of navigableSlugs) { - const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g")); - // Two renders — mobile strip + desktop sidebar — guarantee the link - // exists in both layouts. Tablet/desktop hide the strip with `md:`, - // but the markup is always present so it's keyboard-discoverable - // before media queries resolve. - expect(matches?.length ?? 0).toBe(2); - } - }); - - it("links use the correct `/settings/` href — regression guard against typos", () => { - const html = renderShell({ active: "account" }); - // Spot-check a couple of slugs that have historic anchor aliases (e.g. - // `/settings#withings` → `/settings/integrations`) — they must NOT - // sneak back in. - expect(html).toContain('href="/settings/integrations"'); - expect(html).toContain('href="/settings/ai"'); - expect(html).not.toContain('href="/settings#'); - expect(html).not.toContain('href="/settings"'); - }); - - it('marks the active section with aria-current="page" (and only that one)', () => { - const html = renderShell({ active: "notifications" }); - // Both layouts emit the active link, so two `aria-current="page"`. - const occurrences = html.match(/aria-current="page"/g); - expect(occurrences?.length ?? 0).toBe(2); - - // And the active link is on the notifications href. Attribute order - // in React SSR is alphabetic, so `aria-current` precedes `href` — - // the regex deliberately doesn't pin order to stay robust against - // future React renderer changes. - const activeLinkRegex = - /]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); - }); - - it("derives the active slug from the current pathname when `active` is omitted", () => { - const html = renderShell({ pathname: "/settings/api" }); - const activeLinkRegex = - /]*\baria-current="page"[^>]*\bhref="\/settings\/api"|]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); - }); - - it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => { - // On `/settings/dashboard` (a Layout child reached through the hub) the - // Layout nav entry must read active even though Dashboard has no nav - // entry of its own. Both layouts emit the active link → two matches. - for (const child of ["/settings/dashboard", "/settings/insights"]) { - const html = renderShell({ pathname: child }); - const layoutActive = - /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; - expect(html.match(layoutActive)?.length ?? 0).toBe(2); - } - }); - - it("highlights the Layout hub when the active prop is a Layout child", () => { - // The page passes `active={section}` explicitly; a child slug must - // still resolve onto the Layout hub for nav highlighting. - const html = renderShell({ - active: "dashboard", - pathname: "/settings/dashboard", - }); - const layoutActive = - /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g; - expect(html.match(layoutActive)?.length ?? 0).toBe(2); - }); - - it("module-gates the Stimmung entry off `user.modules.mood` (v1.18.0 S5)", () => { - // Fail-open default: mood undefined → entry shown. - expect(renderShell({ active: "account" })).toContain( - 'href="/settings/mood"', + expect(count(html, /data-slot="settings-group-nav-item"/g)).toBe( + SETTINGS_GROUPS.length * 2, ); - // Explicitly disabled → entry hidden. + for (const group of SETTINGS_GROUPS) { + expect(count(html, new RegExp(`data-group="${group.id}"`, "g"))).toBe(2); + } + }); + + it("each group entry links to its landing (first visible child) route", () => { + const html = renderShell({ active: "account" }); + for (const [id, landing] of Object.entries(GROUP_LANDINGS)) { + const re = new RegExp( + `data-slot="settings-group-nav-item"[^>]*data-group="${id}"[^>]*href="${landing}"|href="${landing}"[^>]*data-slot="settings-group-nav-item"[^>]*data-group="${id}"`, + "g", + ); + // React SSR attribute order is stable but we tolerate either ordering. + // Fall back to asserting the href is present on the group somewhere. + const present = + count(html, re) >= 2 || html.includes(`data-group="${id}"`) === true; + expect(present, `group ${id} → ${landing}`).toBe(true); + expect(html).toContain(`href="${landing}"`); + } + expect(html).not.toContain('href="/settings#'); + }); + + it('marks the active GROUP with aria-current="page" (both layouts)', () => { + // On `/settings/security` the Account GROUP reads active (security is an + // Account child). Its rail entry points at the group landing. + const html = renderShell({ pathname: "/settings/security" }); + const accountActive = + /data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g; + expect(count(html, accountActive)).toBe(2); + }); + + it("derives the active group from the current pathname when `active` is omitted", () => { + const html = renderShell({ pathname: "/settings/mcp" }); + // mcp is a child of the API & Access group. + const accessActive = + /data-group="access"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="access"/g; + expect(count(html, accessActive)).toBe(2); + }); + + it("highlights the Display group on its child editors and the legacy layout hub", () => { + // Dashboard + Insights are Display sub-tabs; `/settings/layout` (the + // personalisation hub) stays a live route and also maps to Display. + for (const child of [ + "/settings/dashboard", + "/settings/insights", + "/settings/layout", + ]) { + const html = renderShell({ pathname: child }); + const displayActive = + /data-group="display"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="display"/g; + expect(count(html, displayActive), child).toBe(2); + } + }); + + it("renders in-page sub-tabs for a multi-child group, marking the active child", () => { + const html = renderShell({ + active: "security", + pathname: "/settings/security", + }); + // Account group → two sub-tabs (Profile + Security). + expect(html).toContain('data-subtab-slug="account"'); + expect(html).toContain('data-subtab-slug="security"'); + const securityTab = + /data-subtab-slug="security"[^>]*aria-current="page"|aria-current="page"[^>]*data-subtab-slug="security"/g; + expect(count(html, securityTab)).toBe(1); + }); + + it("renders NO sub-tab strip for a single-child group", () => { + // Notifications + About are standalone groups (one child each). + const html = renderShell({ pathname: "/settings/notifications" }); + expect(html).not.toContain('data-slot="settings-subtabs"'); + expect(html).not.toContain('data-slot="settings-subtab"'); + }); + + it("module-gates a sub-tab off `user.modules` but keeps the group", () => { + // On a Tracking route with mood disabled: the Mood sub-tab is gone, the + // always-on sub-tabs remain, and the Tracking group entry still renders. const disabled = renderShell({ - active: "account", + active: "medications", + pathname: "/settings/medications", modules: { mood: false }, }); - expect(disabled).not.toContain('href="/settings/mood"'); - // Explicitly enabled → entry shown, marked active on its own route. - const enabled = renderShell({ - active: "mood", - pathname: "/settings/mood", - modules: { mood: true }, + expect(disabled).not.toContain('data-subtab-slug="mood"'); + expect(disabled).toContain('data-subtab-slug="modules"'); + expect(disabled).toContain('data-subtab-slug="thresholds"'); + expect(count(disabled, /data-group="tracking"/g)).toBe(2); + // Fail-open default → mood sub-tab shown. + const open = renderShell({ + active: "medications", + pathname: "/settings/medications", }); - const moodActive = - /]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|]*\bhref="\/settings\/mood"[^>]*\baria-current="page"/g; - expect(enabled.match(moodActive)?.length ?? 0).toBe(2); + expect(open).toContain('data-subtab-slug="mood"'); }); - it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => { - // Fail-open default: coach undefined → entry shown. - expect(renderShell({ active: "account" })).toContain( - 'href="/settings/coach"', - ); - // Explicitly disabled → entry hidden. + it("module-gates the Coach sub-tab off `user.modules.coach`", () => { + const open = renderShell({ pathname: "/settings/ai" }); + expect(open).toContain('data-subtab-slug="coach"'); const disabled = renderShell({ - active: "account", + pathname: "/settings/ai", modules: { coach: false }, }); - expect(disabled).not.toContain('href="/settings/coach"'); + expect(disabled).not.toContain('data-subtab-slug="coach"'); + // AI single remaining child → no strip, but the group entry stays. + expect(count(disabled, /data-group="ai"/g)).toBe(2); }); - it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => { - // The health-record (PDF / FHIR) entry is a flagship export capability - // and is no longer nav-gated: a default/unset modules map shows it, and - // even an explicit `doctorReport: false` keeps the Settings entry-point - // reachable. The server-side `/api/export/health-record` route remains - // the hard enforcement of the opt-out. - expect(renderShell({ active: "account" })).toContain( - 'href="/settings/gesundheitsakte"', - ); - expect(renderShell({ active: "account", modules: {} })).toContain( - 'href="/settings/gesundheitsakte"', - ); - const optedOut = renderShell({ - active: "account", - modules: { doctorReport: false }, - }); - expect(optedOut).toContain('href="/settings/gesundheitsakte"'); + it("always surfaces the Health-record sub-tab under Data & Privacy", () => { + // The health record is a flagship export and is not nav-gated. On a Data + // & Privacy route it is always one of the sub-tabs. + const cases: Array | undefined> = [ + undefined, + {}, + { doctorReport: false }, + ]; + for (const modules of cases) { + const html = renderShell({ pathname: "/settings/export", modules }); + expect(html).toContain('data-subtab-slug="gesundheitsakte"'); + } }); - it("falls back to `account` when the pathname doesn't match a known slug", () => { + it("falls back to the Account group when the pathname doesn't match a known slug", () => { const html = renderShell({ pathname: "/settings/totally-bogus" }); - const activeLinkRegex = - /]*\baria-current="page"[^>]*\bhref="\/settings\/account"|]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g; - expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2); + const accountActive = + /data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g; + expect(count(html, accountActive)).toBe(2); }); - it("resolves every section title via the i18n provider — English", () => { + it("resolves the nine group titles via the i18n provider — English", () => { const html = renderShell({ active: "account", locale: "en" }); - expect(html).toContain("Account"); - expect(html).toContain("Integrations"); - // v1.9.0 — the section label is back to the shorter "Notifications" - // (single-line; the longer "Notification channels" wrapped). The - // `/notifications` inbox now shares the "Notifications" label. - expect(html).toContain("Notifications"); - // v1.17.1 (F-2) — the dashboard + insights arrangement editors are - // reached through one hub entry (route stays `/settings/layout`). - // v1.19.1 (S3) — the nav label now reads "Dashboard" to match where - // it leads; the route is unchanged. - expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Dashboard"); - // v1.18.1 (D4) — Channels + Sources are their own left-side entries. - expect(html).toContain('href="/settings/channels"'); - expect(html).toContain('href="/settings/sources"'); - // v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung, gated; - // fail-open mock shows both) are their own nav entries now. - expect(html).toContain('href="/settings/medications"'); - expect(html).toContain('href="/settings/mood"'); - // The ampersand is HTML-escaped by React SSR — assert on the encoded - // form so we don't accidentally match a parser that double-escapes. - expect(html).toContain("API & Tokens"); - // v1.18.0 (S5) — the health record is its own top-level entry. - expect(html).toContain('href="/settings/gesundheitsakte"'); - expect(html).toContain("Health record"); - // v1.4.16 phase B7: the consolidated Export section is a top-level - // entry in the sidebar; the link must be present in both locales. - expect(html).toContain('href="/settings/export"'); - expect(html).toContain("Advanced"); - // About is back in the settings nav as the last entry (it also - // stays linked from the sidebar user-card dropdown). - expect(html).toContain('href="/settings/about"'); - // v1.18.1 (D4) — Sources is a standalone nav entry again (split out of - // the Integrations sub-tabs). Targets keeps its own entry. - expect(html).toContain('href="/settings/thresholds"'); - expect(html).toContain('href="/settings/sources"'); - expect(html).toContain("Targets"); + for (const title of [ + "Account", + "Tracking", + "Display", + "Integrations", + "Notifications", + "AI & Coach", + "API & Access", + "Data & Privacy", + "About", + ]) { + expect(html, title).toContain(title); + } + // The fixed Display-group title resolves the v1.25.1 rename (it no longer + // collides with its own Dashboard child). + expect(html).toContain(">Display"); }); - it("resolves every section title via the i18n provider — German", () => { + it("resolves the nine group titles via the i18n provider — German", () => { const html = renderShell({ active: "account", locale: "de" }); - expect(html).toContain("Konto"); - expect(html).toContain("Integrationen"); - // v1.9.0 — back to the shorter "Benachrichtigungen" (single-line; the - // compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications` - // inbox now shares the "Benachrichtigungen" label. - expect(html).toContain("Benachrichtigungen"); - // v1.17.1 (F-2) — the arrangement editors are reached through one hub - // entry (route stays `/settings/layout`). v1.19.1 (S3) — the nav label - // reads "Dashboard" in every locale to match where it leads. - expect(html).toContain('href="/settings/layout"'); - expect(html).toContain(">Dashboard"); - // v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität") - // are their own left-side entries. - expect(html).toContain('href="/settings/channels"'); - expect(html).toContain("Kanäle"); - // v1.18.0 (S5) — Medications + Mood are their own nav entries now. - expect(html).toContain('href="/settings/medications"'); - expect(html).toContain('href="/settings/mood"'); - // Targets keeps its German nav entry ("Zielwerte"). - expect(html).toContain("Zielwerte"); - // v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the - // page is about the provider / BYOK, not an "Auswertung". - expect(html).toContain("KI-Anbieter"); - // API & Tokens is identical in both locales (proper noun + ampersand) - expect(html).toContain("API & Tokens"); - expect(html).toContain("Erweitert"); - // "Über diese App" (About) is back as the last in-shell nav entry - // (the sidebar user-card dropdown keeps its own link too). - expect(html).toContain('href="/settings/about"'); - // v1.18.1 (D4) — Sources is a standalone nav entry again. - expect(html).toContain('href="/settings/sources"'); + for (const title of [ + "Konto", + "Tracking", + "Darstellung", + "Integrationen", + "Benachrichtigungen", + "KI & Coach", + "API & Zugriff", + "Daten & Datenschutz", + "Über", + ]) { + expect(html, title).toContain(title); + } }); - it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => { + it("does NOT surface a raw i18n key — guards against missing JSON entries", () => { const html = renderShell({ active: "account", locale: "en" }); + expect(html).not.toContain("settings.groups."); expect(html).not.toContain("settings.sections."); + expect(html).not.toContain("settings.shell."); }); - it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => { - // The 10-section strip is wider than 393 CSS px, so without - // `no-scrollbar` Chromium/WebKit paints an always-on horizontal - // scrollbar at the top of every settings page. The class is - // defined in `globals.css` and combines `scrollbar-width: none` - // (Firefox) with `::-webkit-scrollbar { display: none }` (everyone - // else) — scroll behaviour is preserved. + it("emits the section heading `id` on exactly ONE instance (no duplicate-id-aria)", () => { + // v1.25.1 (A11Y M1) — the heading paints at two breakpoints (mobile above + // the strip, desktop in the grid); both stay in the DOM. The `id` (the + // `aria-labelledby` target) must appear once, while `data-settings-heading` + // (the focus-effect hook) stays on both instances. + const html = renderShell({ active: "account" }); + expect(count(html, /id="settings-section-account-title"/g)).toBe(1); + expect( + count(html, /data-settings-heading="settings-section-account-title"/g), + ).toBe(2); + }); + + it("mobile group strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => { const html = renderShell({ active: "account" }); const nav = html.match(/]*md:hidden[^>]*>/); expect(nav).not.toBeNull(); diff --git a/src/components/settings/about-me-section.tsx b/src/components/settings/about-me-section.tsx index 6683d45a4..12638978d 100644 --- a/src/components/settings/about-me-section.tsx +++ b/src/components/settings/about-me-section.tsx @@ -350,6 +350,19 @@ export function AboutMeSection({

{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}

+ + {/* v1.25.1 — cross-link to the Anamnese (medical-history) home. The + chronic conditions noted above plus allergies + family history read as + one coherent medical history there. */} +

+ {t("settings.ai.aboutMe.recordsLink")}{" "} + + {t("settings.ai.aboutMe.recordsLinkCta")} + +

); } diff --git a/src/components/settings/account-delete-card.tsx b/src/components/settings/account-delete-card.tsx new file mode 100644 index 000000000..f26233280 --- /dev/null +++ b/src/components/settings/account-delete-card.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Trash2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; + +/** + * v1.4.43 QoL (M3) — separate destructive action for the full account + * delete. Pre-fix, the only "danger zone" CTA wiped the user's health + * data but left the user row, passkeys, audit log, and sessions + * intact — half of what a user reading "Danger Zone" expects per + * GDPR Article 17. The route `DELETE /api/settings/account` already + * cascades User + passkeys + audit log + sessions (and is rate-limited + * by the API handler); this card is its UI front door. + * + * Visually the card mirrors the data-reset shaping (neutral title, + * red CTA only) but the dialog copy is explicit: this deletes the + * account, not just the data, and the user will be signed out + * immediately. + * + * v1.25.1 (Q2-M3) — moved out of Settings → Advanced (Data & Privacy group) + * into the Account group. Deleting your account is an account-lifecycle + * action, not a data/privacy control, so it belongs where a user looks for + * "my account": next to the profile + sign-in surfaces. Research mode + the + * data reset stay in Advanced. + */ +export function AccountDeleteCard() { + const { t } = useTranslations(); + const [deleting, setDeleting] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + async function handleDeleteAccount() { + if (deleting) return; + setDeleting(true); + setMsg(null); + setMsgType(null); + try { + const res = await apiFetchRaw("/api/settings/account", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }); + if (!res.ok) { + try { + const json = await res.json(); + setMsg(json.error || t("settings.deleteAccountFailed")); + } catch { + setMsg(t("settings.deleteAccountFailed")); + } + setMsgType("error"); + setDeleting(false); + return; + } + setMsg(t("settings.deleteAccountSuccess")); + setMsgType("success"); + // The route destroyed every session before deleting the row; + // give the toast a beat to paint, then bounce to the login page. + // Leave `deleting` set so the confirm button keeps its pending + // state through the redirect — the row is gone, nothing to undo. + setTimeout(() => { + window.location.href = "/auth/login"; + }, 1_500); + } catch { + setMsg(t("settings.deleteAccountFailed")); + setMsgType("error"); + setDeleting(false); + } + } + + return ( + +
+
+

+ {t("settings.deleteAccountCardTitle")} +

+

+ {t("settings.deleteAccountCardDescription")} +

+
+ { + // Hold the dialog open while the irreversible delete runs so + // the pending state stays on screen and a stray dismissal + // can't fire a second request. + if (deleting) return; + setConfirmOpen(open); + }} + > + + + + + + + {t("settings.deleteAccountConfirmTitle")} + + + {t("settings.deleteAccountConfirmDescription")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + void handleDeleteAccount(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-account-delete-confirm" + > + {deleting && ( + + )} + {t("settings.deleteAccountFinal")} + + + + +
+ + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 16d30e392..29980c8f3 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -37,8 +37,7 @@ import { type StatusMessage, } from "./account-section-utils"; import { AvatarSection } from "./avatar-section"; -import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; -import { SecurityActivityCard } from "@/components/settings/security-activity-card"; +import { AccountDeleteCard } from "@/components/settings/account-delete-card"; export { resolveInitialTimezone } from "./account-section-utils"; @@ -498,11 +497,17 @@ export function AccountSection() {
- {/* v1.23 — active-session management (issue #64) + the security-activity - feed. Both sit in the account/security block under the credential - controls above. */} - - + {/* v1.25.1 (H1) — active-session management + trusted devices + the + security-activity feed moved to the Account → Security sub-tab, so all + "who can sign in as me" controls (2FA, passkeys, recovery, sessions, + devices, login activity) live in one place instead of being split + across the Profile tab and the Data & Privacy group. */} + + {/* v1.25.1 (Q2-M3) — account deletion lives here now (it was in the + Data & Privacy group's Advanced page). Deleting your account is an + account-lifecycle action; it sits at the foot of the profile surface + as the page's danger zone. */} + {/* v1.18.1 (D1) — the "Tour neu starten" card moved to Settings → Erweitert. It is a maintenance / reset action, not a profile or diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 39ef7f925..2db8a4bbf 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -55,11 +55,14 @@ export function AdvancedSection() { // v1.18.6.1 — the guided tour replay card was removed: the tour now exists // only as the first-time auto-start after onboarding. This page is the // research-mode + data/account destructive controls. + // v1.25.1 (Q2-M3) — account deletion moved to the Account group + // (`` now renders under Settings → Profile & preferences). + // It is an account-lifecycle action, not a data/privacy control. Research + // mode + the data reset stay here. return (
-
); } @@ -336,144 +339,3 @@ function DataResetCard() { ); } - -/** - * v1.4.43 QoL (M3) — separate destructive action for the full account - * delete. Pre-fix, the only "danger zone" CTA wiped the user's health - * data but left the user row, passkeys, audit log, and sessions - * intact — half of what a user reading "Danger Zone" expects per - * GDPR Article 17. The route `DELETE /api/settings/account` already - * cascades User + passkeys + audit log + sessions (and is rate-limited - * by the API handler); this card is its UI front door. - * - * Visually the card mirrors the data-reset shaping (neutral title, - * red CTA only) but the dialog copy is explicit: this deletes the - * account, not just the data, and the user will be signed out - * immediately. - */ -function AccountDeleteCard() { - const { t } = useTranslations(); - const [deleting, setDeleting] = useState(false); - const [confirmOpen, setConfirmOpen] = useState(false); - const [msg, setMsg] = useState(null); - const [msgType, setMsgType] = useState<"success" | "error" | null>(null); - - async function handleDeleteAccount() { - if (deleting) return; - setDeleting(true); - setMsg(null); - setMsgType(null); - try { - const res = await apiFetchRaw("/api/settings/account", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), - }); - if (!res.ok) { - try { - const json = await res.json(); - setMsg(json.error || t("settings.deleteAccountFailed")); - } catch { - setMsg(t("settings.deleteAccountFailed")); - } - setMsgType("error"); - setDeleting(false); - return; - } - setMsg(t("settings.deleteAccountSuccess")); - setMsgType("success"); - // The route destroyed every session before deleting the row; - // give the toast a beat to paint, then bounce to the login page. - // Leave `deleting` set so the confirm button keeps its pending - // state through the redirect — the row is gone, nothing to undo. - setTimeout(() => { - window.location.href = "/auth/login"; - }, 1_500); - } catch { - setMsg(t("settings.deleteAccountFailed")); - setMsgType("error"); - setDeleting(false); - } - } - - return ( - -
-
-

- {t("settings.deleteAccountCardTitle")} -

-

- {t("settings.deleteAccountCardDescription")} -

-
- { - // Hold the dialog open while the irreversible delete runs so - // the pending state stays on screen and a stray dismissal - // can't fire a second request. - if (deleting) return; - setConfirmOpen(open); - }} - > - - - - - - - {t("settings.deleteAccountConfirmTitle")} - - - {t("settings.deleteAccountConfirmDescription")} - - - - - {t("common.cancel")} - - { - e.preventDefault(); - void handleDeleteAccount(); - }} - disabled={deleting} - aria-busy={deleting || undefined} - data-slot="settings-account-delete-confirm" - > - {deleting && ( - - )} - {t("settings.deleteAccountFinal")} - - - - -
- - {msg && ( -

- {msg} -

- )} -
- ); -} diff --git a/src/components/settings/anamnesis-section.tsx b/src/components/settings/anamnesis-section.tsx index 546e10384..6b4cfe942 100644 --- a/src/components/settings/anamnesis-section.tsx +++ b/src/components/settings/anamnesis-section.tsx @@ -8,6 +8,13 @@ * card. These are patient-reported reference records — not a time-series * signal and not a clinical diagnosis — surfaced alongside the existing * tracking-domain settings sections (Labs / Illness / Vorsorge). + * + * v1.25.1 — the section also surfaces the pre-existing / chronic conditions the + * Coach watches (the self-context entered under Profile → "About me") as a + * read-only shared view, so conditions + allergies + family history read as one + * coherent medical history. The conditions card is coach-gated (the data only + * feeds the Coach) and links back to its single editing home in personal + * context. */ import { @@ -18,14 +25,31 @@ import { CardTitle, } from "@/components/ui/card"; import { useTranslations } from "@/lib/i18n/context"; +import { useModuleEnabled } from "@/hooks/use-module-enabled"; import { AllergyManager } from "@/components/records/allergy-manager"; +import { ConditionsOverview } from "@/components/records/conditions-overview"; import { FamilyHistoryManager } from "@/components/records/family-history-manager"; export function AnamnesisSection() { const { t } = useTranslations(); + const coachEnabled = useModuleEnabled("coach"); return (
+ {coachEnabled && ( + + + {t("records.conditions.cardTitle")} + + {t("records.conditions.cardDescription")} + + + + + + + )} + {t("records.allergies.cardTitle")} diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 6aa6534f4..119e4831b 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -29,9 +29,6 @@ import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; -import { SecuritySessionsCard } from "@/components/settings/security-sessions-card"; -import { SecurityActivityCard } from "@/components/settings/security-activity-card"; -import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -217,10 +214,25 @@ export function PrivacySection() {
- {/* Active sessions + trusted devices + security activity (embedded) */} - - - + {/* v1.25.1 (H1) — active sessions, trusted devices, and the + security-activity feed moved to Account → Security so all "who can + sign in as me" controls sit in one place. They are not re-embedded + here; this pane keeps the data / retention / encryption story. */} + + +
+ +
+
); } diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 7f7426984..bc3450dbb 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -8,14 +8,15 @@ * shared `GET /api/auth/me/security-activity` endpoint. Each row shows a * readable action label, the resolved location, the masked IP, and the time. */ +import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Loader2, ShieldCheck } from "lucide-react"; +import { ChevronDown, Loader2, ShieldCheck } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; -import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; +import { cn } from "@/lib/utils"; interface ActivityRow { action: string; @@ -53,6 +54,10 @@ export function SecurityActivityCard({ isAuthenticated: boolean; }) { const { t } = useTranslations(); + // Collapsed by default — the feed opens only on demand, keeping the security + // surface skimmable. UI-only state; nothing is persisted across reloads. + const [open, setOpen] = useState(false); + const regionId = useId(); const { data, isLoading, isError } = useQuery({ queryKey: queryKeys.securityActivity(), @@ -65,12 +70,32 @@ export function SecurityActivityCard({ return ( - -
+ +