diff --git a/.gitleaks.toml b/.gitleaks.toml index ca4bddc63..6905bb139 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -22,6 +22,9 @@ regexes = [ '''idempotency-key''', # Allow the placeholder prefix used in OpenAPI examples + docs. '''hlk_example''', + # i18n caption keys for the briefing-driven trend charts are dotted message + # keys, not secrets — one trips the generic-api-key entropy heuristic. + '''insights\.trendsRow\.caption''', ] # Specific commit fingerprints from prior false-positive findings (kept for reproducibility). diff --git a/CHANGELOG.md b/CHANGELOG.md index af8ac9363..a090d8be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [1.8.6] — 2026-06-01 — Compliance two-row return, insights polish, targets deprecation + +### Added + +- **Mood narrative takeaways above the charts** — a ranked feed of plain-language findings (the weekday your mood dips, the overall trend, the tags that lift or lower it, your in-target share, your logging streak, the weekend effect), now drawing on the structured tags as well as free-text ones. +- **A GLP-1 dose-strength curve** that plots the titration history (2.5 → 5 → 7.5 mg …) in place of the previous empty therapy-course block. +- **A FHIR `Coverage` resource in the health-record export**, with an optional insurer institution number (IKNR) captured on the profile — making the insurer machine-resolvable for German systems. +- **An optional `source` on the measurements batch endpoint**, so a client backfilling manually-entered history can tag it correctly instead of defaulting to Apple Health. + +### Changed + +- **Medication compliance returns to two rows for every medication.** The cadence-aware timeline is gone; the two windows now scale to how often a medication is due — a daily medication keeps 7-day and 30-day, a rarely-taken one steps up to longer windows (up to twelve months) so both rows stay meaningful and the layout stays steady. +- **The medication advanced settings are a single, calmer column** with a symmetric danger zone (matched delete-history / delete-medication actions), replacing the cramped two-column layout. +- **The medication wizard uses a jumpable dot stepper** with a "next step" hint; in edit mode every step is reachable in one tap. +- **Insights category headers are quieter.** The question-mark circle is gone (the metric definition stays inline beneath the heading), the measurement-diversity hint moved to a heading lightbulb with the explanation on hover, the assessment border is de-emphasised, and "Coach fragen" moved to the header as an icon. +- **Target ranges are now edited inline from Insights.** The Targets page is deprecated — it carries a notice, is removed from the menu, and will be retired in a future release; editing happens where you read the trend. + +### Fixed + +- **Every briefing-driven Trend card now shows its caption** — additive metrics no longer render a chart with empty space below it. +- **A long mood note no longer stretches the entries table** — it truncates with the full text on hover. +- **The mood in-target share is shown once**, not duplicated between the headline tile and the takeaway feed. +- **Medication compliance no longer counts days before a medication existed**, so a newly-added medication's expected-dose figures reflect its real age. + ## [1.8.5] — 2026-06-01 — Insights reference panels, mood depth, injection tracking ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2788fb057..15b29b6ee 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.8.5 + version: 1.8.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1738,6 +1738,46 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a2 "429": *a3 + /api/user/profile: + get: + tags: + - Auth + summary: Read the calling user's profile + description: Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The + KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext. + responses: + "200": + description: The calling user's profile. + content: + application/json: + schema: + $ref: "#/components/schemas/GetProfileResponse" + "401": *a1 + "422": *a2 + "429": *a3 + patch: + tags: + - Auth + summary: Update the calling user's profile + description: Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A + non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must + pass the mod-10 check. `userId` is never accepted from the body. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileUpdateRequest" + responses: + "200": + description: Saved profile echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchProfileResponse" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/layout: get: tags: @@ -1992,6 +2032,15 @@ components: type: string minLength: 1 maxLength: 120 + source: + description: Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user + entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, + externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / + `IMPORT` are not accepted here. + type: string + enum: + - APPLE_HEALTH + - MANUAL deviceType: description: Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server @@ -2480,6 +2529,74 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + ProfileUpdateRequest: + type: object + properties: + email: + anyOf: + - type: string + format: email + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + - type: "null" + heightCm: + anyOf: + - type: number + minimum: 50 + maximum: 300 + - type: "null" + dateOfBirth: + anyOf: + - type: string + - type: "null" + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - type: "null" + displayName: + anyOf: + - type: string + minLength: 1 + maxLength: 80 + - type: "null" + locale: + anyOf: + - type: string + enum: + - de + - en + - type: "null" + timezone: + type: string + minLength: 1 + maxLength: 64 + moodReminderEnabled: + type: boolean + fullName: + anyOf: + - type: string + maxLength: 120 + - type: "null" + insurerName: + anyOf: + - type: string + maxLength: 120 + - type: "null" + insuranceNumber: + description: German KVNR. Empty/null clears it; mod-10 check enforced. + anyOf: + - type: string + - type: "null" + insurerIkNumber: + description: German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly + 9 digits (no checksum enforced). + anyOf: + - type: string + - type: "null" + description: Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or + empty string) clears it. `userId` is never accepted — it is narrowed from the session/token. InsightsLayoutBody: type: object properties: @@ -5361,6 +5478,182 @@ components: - data - error additionalProperties: false + GetProfileResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ProfileResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ProfileResponse: + type: object + properties: + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + email: + anyOf: + - type: string + - type: "null" + dateOfBirth: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - type: "null" + heightCm: + anyOf: + - type: number + - type: "null" + locale: + anyOf: + - type: string + - type: "null" + timezone: + type: string + moodReminderEnabled: + type: boolean + fullName: + anyOf: + - type: string + - type: "null" + insurerName: + anyOf: + - type: string + - type: "null" + insurerIkNumber: + anyOf: + - type: string + - type: "null" + description: German insurer institution number (IKNR), 9 digits. + insuranceNumber: + anyOf: + - type: string + - type: "null" + description: German KVNR, decrypted for the form prefill. + required: + - username + - email + - dateOfBirth + - gender + - heightCm + - locale + - timezone + - moodReminderEnabled + - fullName + - insurerName + - insurerIkNumber + - insuranceNumber + additionalProperties: false + description: Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) + is plaintext at rest. + PatchProfileResponse: + type: object + properties: + data: + $ref: "#/components/schemas/ProfileUpdateResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ProfileUpdateResponse: + type: object + properties: + username: + type: string + displayName: + anyOf: + - type: string + - type: "null" + email: + anyOf: + - type: string + - type: "null" + dateOfBirth: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + gender: + anyOf: + - type: string + enum: + - MALE + - FEMALE + - type: "null" + heightCm: + anyOf: + - type: number + - type: "null" + locale: + anyOf: + - type: string + - type: "null" + timezone: + type: string + moodReminderEnabled: + type: boolean + fullName: + anyOf: + - type: string + - type: "null" + insurerName: + anyOf: + - type: string + - type: "null" + insurerIkNumber: + anyOf: + - type: string + - type: "null" + description: German insurer institution number (IKNR), 9 digits. + hasInsuranceNumber: + type: boolean + description: Whether a KVNR is on file (the value itself is not echoed). + required: + - username + - email + - dateOfBirth + - gender + - heightCm + - locale + - timezone + - moodReminderEnabled + - fullName + - insurerName + - insurerIkNumber + - hasInsuranceNumber + additionalProperties: false + description: Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than + re-emitting the decrypted value. InsightsLayout: type: object properties: diff --git a/e2e/medications-wizard-helpers.ts b/e2e/medications-wizard-helpers.ts index 5f4352c2e..eba4a9f2a 100644 --- a/e2e/medications-wizard-helpers.ts +++ b/e2e/medications-wizard-helpers.ts @@ -217,10 +217,16 @@ export async function expectStep( displayIndex: number, totalSteps?: number, ): Promise { - const pattern = totalSteps - ? new RegExp(`Schritt ${displayIndex} von ${totalSteps}`, "i") - : new RegExp(`Schritt ${displayIndex} von \\d+`, "i"); - await expect(page.getByText(pattern)).toBeVisible({ timeout: 5_000 }); + // The "Schritt X von Y" caption is mobile-only (`sm:hidden`) since the dot + // stepper landed, so it is hidden on the desktop e2e viewport. Assert the + // viewport-independent root data-attributes the dialog always carries. + const root = page.locator('[data-slot="medication-wizard-dialog"]'); + await expect(root).toHaveAttribute("data-display-step", String(displayIndex), { + timeout: 5_000, + }); + if (totalSteps != null) { + await expect(root).toHaveAttribute("data-total-steps", String(totalSteps)); + } } /** diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 1baae5fb9..0cacdaaa8 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -8,13 +8,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * * Two contracts under test: * - * 1. Every routed Insights sub-page mounts a ``. - * The component renders BOTH a sticky-bottom FAB - * (`data-slot="coach-launch-fab"`, visible on `=sm` it mounts as a @@ -121,28 +121,23 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => { .locator('[data-slot="coach-launch-fab"]') .first(); await expect(fab).toBeVisible({ timeout: 10_000 }); - - // The inline branch is in the DOM but hidden via `hidden lg:inline-flex`. - const inline = page.locator('[data-slot="coach-launch-inline"]').first(); - await expect(inline).toBeHidden(); }); - test(`/insights/${slug} mounts the Coach launch button on Desktop Chrome (inline branch)`, async ({ + test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({ page, }, testInfo) => { // Desktop Chrome viewport is 1280 — `lg` (1024) is matched so the - // inline branch wins; the FAB is hidden by `lg:hidden`. + // layout FAB is hidden by `lg:hidden`; the header icon the shell + // mounts is the Coach entry on the page itself. test.skip( testInfo.project.name !== "chromium-desktop", - "desktop inline branch", + "desktop header-icon branch", ); await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" }); - const inline = page - .locator('[data-slot="coach-launch-inline"]') - .first(); - await expect(inline).toBeVisible({ timeout: 10_000 }); + const icon = page.locator('[data-slot="coach-launch-icon"]').first(); + await expect(icon).toBeVisible({ timeout: 10_000 }); const fab = page.locator('[data-slot="coach-launch-fab"]').first(); await expect(fab).toBeHidden(); @@ -168,7 +163,7 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => { await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet"); }); - test("clicking the inline action opens the Coach drawer as a side-sheet on Desktop Chrome", async ({ + test("clicking the header Coach icon opens the Coach drawer as a side-sheet on Desktop Chrome", async ({ page, }, testInfo) => { test.skip( @@ -178,11 +173,9 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => { await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); - const inline = page - .locator('[data-slot="coach-launch-inline"]') - .first(); - await expect(inline).toBeVisible({ timeout: 10_000 }); - await inline.click(); + const icon = page.locator('[data-slot="coach-launch-icon"]').first(); + await expect(icon).toBeVisible({ timeout: 10_000 }); + await icon.click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible({ timeout: 10_000 }); diff --git a/messages/de.json b/messages/de.json index e880d7d36..b7043af03 100644 --- a/messages/de.json +++ b/messages/de.json @@ -586,16 +586,10 @@ "veryOverdue": "Stark überfällig", "compliance7d": "7-Tage-Compliance", "compliance30d": "30-Tage-Compliance", + "complianceWindow": "{days}-Tage-Compliance", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", "dayStreak": "Tage Serie", - "lastNDoses": "Letzte {total} Dosen: {taken} von {total}", - "doseStreak": "{count} in Folge", - "doseTimelineAriaLabel": "Jüngste Einnahmetreue", - "doseTaken": "Genommen", - "doseMissed": "Verpasst", - "doseSkipped": "Übersprungen", - "doseUpcoming": "Anstehend", "taken": "Genommen", "skipped": "Übersprungen", "intakeToastTaken": "{name} als genommen erfasst", @@ -810,7 +804,11 @@ "back": "Zurück", "next": "Weiter", "save": "Speichern", - "saveEdit": "Änderungen speichern" + "saveEdit": "Änderungen speichern", + "nextHint": "Weiter: {step}", + "reviewHint": "Weiter: Speichern", + "jumpFirst": "Zum ersten Schritt springen", + "jumpLast": "Zur Übersicht und zum Speichern springen" }, "nl": { "button": "Beschreiben" @@ -820,11 +818,13 @@ "title": "Wie heißt das Medikament?", "subline": "Genau so wie es auf der Packung steht.", "label": "Name", - "placeholder": "z. B. Ramipril" + "placeholder": "z. B. Ramipril", + "short": "Name" }, "step2": { "title": "Was für ein Medikament ist das?", - "subline": "Wir nutzen das für passende Vorlagen und Auswertungen." + "subline": "Wir nutzen das für passende Vorlagen und Auswertungen.", + "short": "Art" }, "step3": { "title": "Wie hoch ist die Dosis?", @@ -856,15 +856,18 @@ "dosesPerUnitPlaceholder": "z. B. 4", "dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.", "injectionSiteToggle": "Injektionsstellen-Rotation", - "injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst." + "injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.", + "short": "Dosis" }, "step4": { "title": "Über welchen Zeitraum nimmst du es?", - "subline": "Heute reicht ein Startdatum. Ein Ende kannst du später setzen." + "subline": "Heute reicht ein Startdatum. Ein Ende kannst du später setzen.", + "short": "Zeitraum" }, "step5": { "title": "Wie oft nimmst du es?", - "subline": "Wähle die Regelmäßigkeit — die Details kommen im nächsten Schritt." + "subline": "Wähle die Regelmäßigkeit — die Details kommen im nächsten Schritt.", + "short": "Takt" }, "step6": { "title": "Wie sieht das im Detail aus?", @@ -880,7 +883,8 @@ "rollingDays": { "label": "Alle", "suffix": "Tage ab der letzten Einnahme" - } + }, + "short": "Details" }, "step7": { "title": "Wann nimmst du es?", @@ -891,14 +895,16 @@ "noon": "Mittags", "evening": "Abends", "night": "Nachts" - } + }, + "short": "Zeiten" }, "step8": { "title": "Fertig — soll HealthLog dich erinnern?", "subline": "Du kannst alles unten nochmal prüfen, bevor du speicherst.", "remindersLabel": "Erinnerungen aktiv", "remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.", - "multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version." + "multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version.", + "short": "Fertig" } }, "classRow": { @@ -974,23 +980,13 @@ "submitFailed": "Medikament konnte nicht gespeichert werden — bitte erneut versuchen." } }, - "titration": { - "section": "Titrationsstufen", - "loading": "Lade Titrationsdaten", - "loadFailed": "Titrationsdaten konnten nicht geladen werden.", - "drugLadderHeader": "Standard-Schema (EMA-Referenz)", - "typicalWeeksOnStep": "≥ {weeks} Wo. typisch", - "youAreHere": "Hier stehst du", - "nextStepCaption": "Nächste Stufe (typisch): {dose} mg nach ~{weeks} Wochen auf der aktuellen Stufe.", - "ceilingMessage": "Du bist auf der höchsten Standardstufe.", - "escalationDueHint": "Du bist seit {weeks} Wochen auf dieser Stufe; das Schema sieht eine Steigerung typischerweise nach ~{typical} Wochen vor.", - "currentStepDwell": "Seit {weeks} Wochen auf dieser Stufe", - "doseUnitMg": "mg", - "emptyState": "Noch keine Dosis-Historie. Erfasse eine Dosis, um deine Position im Schema zu sehen.", - "nonStandardDose": "Aktuelle Dosis liegt außerhalb des Standard-Schemas.", - "disclaimer": "EMA-Referenzwerte. Sprich mit deiner Ärztin oder deinem Arzt, bevor du Dosen änderst.", - "emaSourceCta": "EMA-Quelle", - "stepLabel": "Stufe {n}" + "doseStrength": { + "title": "Dosisstärke im Zeitverlauf", + "axisLabel": "Erfasste Dosis ({unit})", + "tooltipLabel": "Dosis", + "currentDose": "Aktuelle Dosis: {dose} {unit}", + "noCurrentDose": "Noch keine Dosis erfasst.", + "empty": "Noch keine Titrations-Historie. Erfasse eine Dosisänderung, um die Stärkekurve zu sehen." }, "injectionSiteBodyOutlineAriaLabel": "Körperumriss", "injectionSitePickerAriaLabel": "Injektionsstelle auswählen", @@ -1287,8 +1283,7 @@ "group": { "data": "Daten", "reminders": "Erinnerungen", - "lifecycle": "Lebenszyklus", - "danger": "Gefahrenzone" + "lifecycle": "Lebenszyklus" }, "dataPortability": { "import": { @@ -1578,7 +1573,22 @@ }, "trendsRow": { "title": "Trends", - "subtitle": "Die letzten 30 Tage auf einen Blick" + "subtitle": "Die letzten 30 Tage auf einen Blick", + "caption": { + "bp": "Systolische und diastolische Werte der letzten 30 Tage.", + "weight": "Gewichtsverlauf der letzten 30 Tage.", + "pulse": "Ruhepuls der letzten 30 Tage.", + "mood": "Täglicher Stimmungswert der letzten 30 Tage.", + "sleep": "Nächtliche Schlafdauer der letzten 30 Tage.", + "steps": "Tägliche Schrittzahl der letzten 30 Tage.", + "hrv": "Herzratenvariabilität der letzten 30 Tage.", + "resting_hr": "Ruheherzfrequenz der letzten 30 Tage.", + "active_energy": "Aktiv verbrauchte Energie der letzten 30 Tage.", + "flights": "Erklommene Stockwerke der letzten 30 Tage.", + "distance": "Geh- und Laufstrecke der letzten 30 Tage.", + "vo2_max": "VO₂max der letzten 30 Tage.", + "body_temp": "Körpertemperatur der letzten 30 Tage." + } }, "vo2Max": { "statLatest": "Aktuell", @@ -1975,6 +1985,7 @@ "valuesPageTitle": "{metric} — alle Werte", "valuesPageDescription": "Jeder einzelne Wert hinter dieser Kennzahl. Einträge lassen sich direkt bearbeiten oder entfernen.", "diversity": { + "trigger": "Messhinweis", "weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.", "timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild." }, @@ -2392,7 +2403,21 @@ "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen." }, "structuredTagsTitle": "Tag-Auswertung", - "notesTimelineTitle": "Notizen" + "inTargetValue": "{pct} %", + "inTargetCaption": "der letzten Tage im guten Stimmungsbereich", + "narrative": { + "title": "Das fällt auf", + "weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} – {delta} unter deinem Wochenschnitt.", + "weekdayPeak": "{weekday} ist durchgehend dein bester Tag mit einem Schnitt von {value}.", + "trendUp": "Deine Stimmung steigt über die letzten {days} Tage (+{delta} gegenüber dem Vorzeitraum).", + "trendDown": "Deine Stimmung sinkt über die letzten {days} Tage (-{delta} gegenüber dem Vorzeitraum).", + "weekendUp": "Am Wochenende geht es dir besser (+{delta} gegenüber Wochentagen).", + "weekendDown": "Am Wochenende ist deine Stimmung niedriger (-{delta} gegenüber Wochentagen).", + "tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.", + "tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.", + "inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.", + "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so." + } } }, "thresholds": { @@ -3976,6 +4001,10 @@ "targets": { "title": "Zielwerte", "introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.", + "deprecation": { + "title": "Diese Seite wird eingestellt", + "body": "Zielbereiche werden jetzt direkt in den Insights bearbeitet, im Referenzbereich jeder Metrik. Diese Seite bleibt vorerst erreichbar und wird in einem künftigen Release entfernt." + }, "bmi": "BMI", "average30d": "30-Tage-Schnitt:", "consistency": { diff --git a/messages/en.json b/messages/en.json index b72e5de89..ab4fb72b3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -586,16 +586,10 @@ "veryOverdue": "Very overdue", "compliance7d": "7-day compliance", "compliance30d": "30-day compliance", + "complianceWindow": "{days}-day compliance", "lastIntake": "Last intake:", "nextIntake": "Next intake:", "dayStreak": "day streak", - "lastNDoses": "Last {total} doses: {taken} of {total}", - "doseStreak": "{count} in a row", - "doseTimelineAriaLabel": "Recent dose adherence", - "doseTaken": "Taken", - "doseMissed": "Missed", - "doseSkipped": "Skipped", - "doseUpcoming": "Upcoming", "taken": "Taken", "skipped": "Skipped", "intakeToastTaken": "{name} recorded as taken", @@ -810,7 +804,11 @@ "back": "Back", "next": "Next", "save": "Save", - "saveEdit": "Save changes" + "saveEdit": "Save changes", + "nextHint": "Next: {step}", + "reviewHint": "Next: Save", + "jumpFirst": "Jump to the first step", + "jumpLast": "Jump to review and save" }, "nl": { "button": "Describe" @@ -820,11 +818,13 @@ "title": "What's the medication called?", "subline": "Exactly as it appears on the box.", "label": "Name", - "placeholder": "e.g. Ramipril" + "placeholder": "e.g. Ramipril", + "short": "Name" }, "step2": { "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics." + "subline": "We use this for the right templates and analytics.", + "short": "Type" }, "step3": { "title": "What's the dose?", @@ -856,15 +856,18 @@ "dosesPerUnitPlaceholder": "e.g. 4", "dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.", "injectionSiteToggle": "Injection-site rotation", - "injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake." + "injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.", + "short": "Dose" }, "step4": { "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later." + "subline": "A start date is enough for today. You can add an end date later.", + "short": "When" }, "step5": { "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step." + "subline": "Pick the cadence — the details follow on the next step.", + "short": "Cadence" }, "step6": { "title": "What does that look like in detail?", @@ -880,7 +883,8 @@ "rollingDays": { "label": "Every", "suffix": "days from the last intake" - } + }, + "short": "Detail" }, "step7": { "title": "When do you take it?", @@ -891,14 +895,16 @@ "noon": "Noon", "evening": "Evening", "night": "Night" - } + }, + "short": "Times" }, "step8": { "title": "All done — should HealthLog remind you?", "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release." + "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "short": "Done" } }, "classRow": { @@ -974,23 +980,13 @@ "submitFailed": "Could not save the medication — please retry." } }, - "titration": { - "section": "Titration ladder", - "loading": "Loading titration data", - "loadFailed": "Titration data failed to load.", - "drugLadderHeader": "Standard ladder (EMA reference)", - "typicalWeeksOnStep": "≥ {weeks} wks typical", - "youAreHere": "You are here", - "nextStepCaption": "Next step (typical): {dose} mg after ~{weeks} weeks on the current step.", - "ceilingMessage": "You are at the top of the standard ladder.", - "escalationDueHint": "You've been on this step for {weeks} weeks; the ladder typically steps up around {typical} weeks.", - "currentStepDwell": "On this step for {weeks} weeks", - "doseUnitMg": "mg", - "emptyState": "No dose history yet. Log a dose to see where you are on the ladder.", - "nonStandardDose": "Current dose is outside the standard ladder buckets.", - "disclaimer": "EMA reference values. Talk to your doctor before changing doses.", - "emaSourceCta": "EMA source", - "stepLabel": "Step {n}" + "doseStrength": { + "title": "Dose strength over time", + "axisLabel": "Recorded dose ({unit})", + "tooltipLabel": "Dose", + "currentDose": "Current dose: {dose} {unit}", + "noCurrentDose": "No dose recorded yet.", + "empty": "No titration history yet. Log a dose change to see the strength curve." }, "injectionSiteBodyOutlineAriaLabel": "Body outline", "injectionSitePickerAriaLabel": "Injection site picker", @@ -1287,8 +1283,7 @@ "group": { "data": "Data", "reminders": "Reminders", - "lifecycle": "Lifecycle", - "danger": "Danger zone" + "lifecycle": "Lifecycle" }, "dataPortability": { "import": { @@ -1578,7 +1573,22 @@ }, "trendsRow": { "title": "Trends", - "subtitle": "Last 30 days at a glance" + "subtitle": "Last 30 days at a glance", + "caption": { + "bp": "Systolic and diastolic readings over the last 30 days.", + "weight": "Body-weight trend over the last 30 days.", + "pulse": "Resting pulse over the last 30 days.", + "mood": "Daily mood score over the last 30 days.", + "sleep": "Nightly sleep duration over the last 30 days.", + "steps": "Daily step count over the last 30 days.", + "hrv": "Heart-rate variability over the last 30 days.", + "resting_hr": "Resting heart rate over the last 30 days.", + "active_energy": "Active energy burned over the last 30 days.", + "flights": "Flights of stairs climbed over the last 30 days.", + "distance": "Walking and running distance over the last 30 days.", + "vo2_max": "VO₂ max over the last 30 days.", + "body_temp": "Body temperature over the last 30 days." + } }, "vo2Max": { "statLatest": "Latest", @@ -1975,6 +1985,7 @@ "valuesPageTitle": "{metric} — all readings", "valuesPageDescription": "Every reading behind this metric. Edit or remove an entry directly.", "diversity": { + "trigger": "Measurement tip", "weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.", "timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture." }, @@ -2392,7 +2403,21 @@ "emptyDescription": "Log mood and this metric on the same days to see a correlation." }, "structuredTagsTitle": "Tag breakdown", - "notesTimelineTitle": "Notes" + "inTargetValue": "{pct}%", + "inTargetCaption": "of recent days in your good-mood range", + "narrative": { + "title": "What stands out", + "weekdayDip": "Your mood dips most on {weekday} — {delta} below your week.", + "weekdayPeak": "{weekday} is consistently your best day, averaging {value}.", + "trendUp": "Your mood is trending up over the last {days} days (+{delta} vs the prior period).", + "trendDown": "Your mood is trending down over the last {days} days (-{delta} vs the prior period).", + "weekendUp": "You feel better on weekends (+{delta} vs weekdays).", + "weekendDown": "Your mood runs lower on weekends (-{delta} vs weekdays).", + "tagLift": "Days tagged \"{tag}\" average {delta} above your overall mood.", + "tagDrop": "Days tagged \"{tag}\" run {delta} below your average.", + "inTarget": "You've been in your good-mood range {pct}% of the last 30 days.", + "streak": "You've logged your mood {days} days in a row — keep it up." + } } }, "thresholds": { @@ -3976,6 +4001,10 @@ "targets": { "title": "Targets", "introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.", + "deprecation": { + "title": "This page is being retired", + "body": "Target ranges are now edited directly from the Insights pages, on each metric's reference panel. This page stays available for now and will be removed in a future release." + }, "bmi": "BMI", "average30d": "30-day average:", "consistency": { diff --git a/messages/es.json b/messages/es.json index 4a09a62ae..7ae81e64d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -586,16 +586,10 @@ "veryOverdue": "Muy vencida", "compliance7d": "Cumplimiento 7 días", "compliance30d": "Cumplimiento 30 días", + "complianceWindow": "Cumplimiento {days} días", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", "dayStreak": "días de racha", - "lastNDoses": "Últimas {total} dosis: {taken} de {total}", - "doseStreak": "{count} seguidas", - "doseTimelineAriaLabel": "Adherencia reciente a las dosis", - "doseTaken": "Tomada", - "doseMissed": "Omitida", - "doseSkipped": "Saltada", - "doseUpcoming": "Próxima", "taken": "Tomada", "skipped": "Omitida", "intakeToastTaken": "{name} registrada como tomada", @@ -810,7 +804,11 @@ "back": "Back", "next": "Next", "save": "Save", - "saveEdit": "Save changes" + "saveEdit": "Save changes", + "nextHint": "Siguiente: {step}", + "reviewHint": "Siguiente: Guardar", + "jumpFirst": "Ir al primer paso", + "jumpLast": "Ir a revisar y guardar" }, "nl": { "button": "Describe" @@ -820,11 +818,13 @@ "title": "What's the medication called?", "subline": "Exactly as it appears on the box.", "label": "Name", - "placeholder": "e.g. Ramipril" + "placeholder": "e.g. Ramipril", + "short": "Nombre" }, "step2": { "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics." + "subline": "We use this for the right templates and analytics.", + "short": "Tipo" }, "step3": { "title": "What's the dose?", @@ -856,15 +856,18 @@ "dosesPerUnitPlaceholder": "p. ej. 4", "dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.", "injectionSiteToggle": "Rotación del sitio de inyección", - "injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma." + "injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.", + "short": "Dosis" }, "step4": { "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later." + "subline": "A start date is enough for today. You can add an end date later.", + "short": "Cuándo" }, "step5": { "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step." + "subline": "Pick the cadence — the details follow on the next step.", + "short": "Frecuencia" }, "step6": { "title": "What does that look like in detail?", @@ -880,7 +883,8 @@ "rollingDays": { "label": "Every", "suffix": "days from the last intake" - } + }, + "short": "Detalle" }, "step7": { "title": "When do you take it?", @@ -891,14 +895,16 @@ "noon": "Noon", "evening": "Evening", "night": "Night" - } + }, + "short": "Horas" }, "step8": { "title": "All done — should HealthLog remind you?", "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release." + "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "short": "Listo" } }, "classRow": { @@ -974,23 +980,13 @@ "submitFailed": "Could not save the medication — please retry." } }, - "titration": { - "section": "Escalado de titulación", - "loading": "Cargando datos de titulación", - "loadFailed": "No se pudieron cargar los datos de titulación.", - "drugLadderHeader": "Escalado estándar (referencia EMA)", - "typicalWeeksOnStep": "≥ {weeks} sem. típico", - "youAreHere": "Estás aquí", - "nextStepCaption": "Siguiente escalón (típico): {dose} mg tras ~{weeks} semanas en el escalón actual.", - "ceilingMessage": "Estás en el último escalón estándar.", - "escalationDueHint": "Llevas {weeks} semanas en este escalón; el esquema suele avanzar tras ~{typical} semanas.", - "currentStepDwell": "En este escalón desde hace {weeks} semanas", - "doseUnitMg": "mg", - "emptyState": "Aún no hay historial de dosis. Registra una dosis para ver tu posición en el escalado.", - "nonStandardDose": "La dosis actual está fuera de los escalones estándar.", - "disclaimer": "Valores de referencia EMA. Consulta a tu médico antes de cambiar la dosis.", - "emaSourceCta": "Fuente EMA", - "stepLabel": "Escalón {n}" + "doseStrength": { + "title": "Dosis a lo largo del tiempo", + "axisLabel": "Dosis registrada ({unit})", + "tooltipLabel": "Dosis", + "currentDose": "Dosis actual: {dose} {unit}", + "noCurrentDose": "Aún no hay dosis registrada.", + "empty": "Aún no hay historial de titulación. Registra un cambio de dosis para ver la curva de dosis." }, "injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo", "injectionSitePickerAriaLabel": "Selector de sitio de inyección", @@ -1287,8 +1283,7 @@ "group": { "data": "Datos", "reminders": "Recordatorios", - "lifecycle": "Ciclo de vida", - "danger": "Zona de peligro" + "lifecycle": "Ciclo de vida" }, "dataPortability": { "import": { @@ -1578,7 +1573,22 @@ }, "trendsRow": { "title": "Tendencias", - "subtitle": "Los últimos 30 días de un vistazo" + "subtitle": "Los últimos 30 días de un vistazo", + "caption": { + "bp": "Valores sistólicos y diastólicos de los últimos 30 días.", + "weight": "Evolución del peso corporal de los últimos 30 días.", + "pulse": "Pulso en reposo de los últimos 30 días.", + "mood": "Puntuación diaria del estado de ánimo de los últimos 30 días.", + "sleep": "Duración del sueño nocturno de los últimos 30 días.", + "steps": "Recuento diario de pasos de los últimos 30 días.", + "hrv": "Variabilidad de la frecuencia cardíaca de los últimos 30 días.", + "resting_hr": "Frecuencia cardíaca en reposo de los últimos 30 días.", + "active_energy": "Energía activa quemada en los últimos 30 días.", + "flights": "Pisos subidos en los últimos 30 días.", + "distance": "Distancia caminando y corriendo de los últimos 30 días.", + "vo2_max": "VO₂ máx de los últimos 30 días.", + "body_temp": "Temperatura corporal de los últimos 30 días." + } }, "vo2Max": { "statLatest": "Actual", @@ -1975,6 +1985,7 @@ "valuesPageTitle": "{metric} — todas las mediciones", "valuesPageDescription": "Cada medición detrás de esta métrica. Edita o elimina una entrada directamente.", "diversity": { + "trigger": "Consejo de medición", "weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.", "timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa." }, @@ -2392,7 +2403,21 @@ "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación." }, "structuredTagsTitle": "Desglose de etiquetas", - "notesTimelineTitle": "Notas" + "inTargetValue": "{pct} %", + "inTargetCaption": "de los últimos días en tu rango de buen ánimo", + "narrative": { + "title": "Lo que destaca", + "weekdayDip": "Tu ánimo baja más el {weekday}: {delta} por debajo de tu semana.", + "weekdayPeak": "El {weekday} es siempre tu mejor día, con una media de {value}.", + "trendUp": "Tu ánimo va al alza en los últimos {days} días (+{delta} frente al periodo anterior).", + "trendDown": "Tu ánimo va a la baja en los últimos {days} días (-{delta} frente al periodo anterior).", + "weekendUp": "Te sientes mejor los fines de semana (+{delta} frente a entre semana).", + "weekendDown": "Tu ánimo es más bajo los fines de semana (-{delta} frente a entre semana).", + "tagLift": "Los días con la etiqueta «{tag}» promedian {delta} por encima de tu ánimo general.", + "tagDrop": "Los días con la etiqueta «{tag}» quedan {delta} por debajo de tu media.", + "inTarget": "Has estado en tu rango de buen ánimo el {pct} % de los últimos 30 días.", + "streak": "Has registrado tu ánimo {days} días seguidos: sigue así." + } } }, "thresholds": { @@ -3976,6 +4001,10 @@ "targets": { "title": "Valores objetivo", "introText": "Tus valores actuales frente a los rangos objetivo recomendados. Las fuentes de las guías clínicas están enlazadas en cada tarjeta.", + "deprecation": { + "title": "Esta página se va a retirar", + "body": "Los rangos objetivo ahora se editan directamente desde las páginas de Insights, en el panel de referencia de cada métrica. Esta página sigue disponible por ahora y se eliminará en una versión futura." + }, "bmi": "IMC", "average30d": "Media 30 días:", "consistency": { diff --git a/messages/fr.json b/messages/fr.json index b0536ba98..30635a52f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -586,16 +586,10 @@ "veryOverdue": "Très en retard", "compliance7d": "Observance 7 jours", "compliance30d": "Observance 30 jours", + "complianceWindow": "Observance {days} jours", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", "dayStreak": "jours de série", - "lastNDoses": "{total} dernières doses : {taken} sur {total}", - "doseStreak": "{count} d'affilée", - "doseTimelineAriaLabel": "Observance récente des doses", - "doseTaken": "Pris", - "doseMissed": "Manqué", - "doseSkipped": "Ignoré", - "doseUpcoming": "À venir", "taken": "Pris", "skipped": "Sauté", "intakeToastTaken": "{name} enregistré comme pris", @@ -810,7 +804,11 @@ "back": "Back", "next": "Next", "save": "Save", - "saveEdit": "Save changes" + "saveEdit": "Save changes", + "nextHint": "Suivant : {step}", + "reviewHint": "Suivant : Enregistrer", + "jumpFirst": "Aller à la première étape", + "jumpLast": "Aller à la vérification et à l’enregistrement" }, "nl": { "button": "Describe" @@ -820,11 +818,13 @@ "title": "What's the medication called?", "subline": "Exactly as it appears on the box.", "label": "Name", - "placeholder": "e.g. Ramipril" + "placeholder": "e.g. Ramipril", + "short": "Nom" }, "step2": { "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics." + "subline": "We use this for the right templates and analytics.", + "short": "Type" }, "step3": { "title": "What's the dose?", @@ -856,15 +856,18 @@ "dosesPerUnitPlaceholder": "p. ex. 4", "dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.", "injectionSiteToggle": "Rotation des sites d'injection", - "injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise." + "injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.", + "short": "Dose" }, "step4": { "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later." + "subline": "A start date is enough for today. You can add an end date later.", + "short": "Quand" }, "step5": { "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step." + "subline": "Pick the cadence — the details follow on the next step.", + "short": "Fréquence" }, "step6": { "title": "What does that look like in detail?", @@ -880,7 +883,8 @@ "rollingDays": { "label": "Every", "suffix": "days from the last intake" - } + }, + "short": "Détail" }, "step7": { "title": "When do you take it?", @@ -891,14 +895,16 @@ "noon": "Noon", "evening": "Evening", "night": "Night" - } + }, + "short": "Heures" }, "step8": { "title": "All done — should HealthLog remind you?", "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release." + "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "short": "Fini" } }, "classRow": { @@ -974,23 +980,13 @@ "submitFailed": "Could not save the medication — please retry." } }, - "titration": { - "section": "Paliers de titration", - "loading": "Chargement des données de titration", - "loadFailed": "Échec du chargement des données de titration.", - "drugLadderHeader": "Paliers standard (référence EMA)", - "typicalWeeksOnStep": "≥ {weeks} sem. typique", - "youAreHere": "Vous êtes ici", - "nextStepCaption": "Palier suivant (typique) : {dose} mg après ~{weeks} semaines au palier actuel.", - "ceilingMessage": "Vous êtes au sommet du palier standard.", - "escalationDueHint": "Vous êtes à ce palier depuis {weeks} semaines ; le schéma prévoit une progression typique après ~{typical} semaines.", - "currentStepDwell": "À ce palier depuis {weeks} semaines", - "doseUnitMg": "mg", - "emptyState": "Aucun historique de dose. Enregistrez une dose pour voir votre position dans le schéma.", - "nonStandardDose": "La dose actuelle est en dehors des paliers standard.", - "disclaimer": "Valeurs de référence EMA. Consultez votre médecin avant toute modification de dose.", - "emaSourceCta": "Source EMA", - "stepLabel": "Palier {n}" + "doseStrength": { + "title": "Dose au fil du temps", + "axisLabel": "Dose enregistrée ({unit})", + "tooltipLabel": "Dose", + "currentDose": "Dose actuelle : {dose} {unit}", + "noCurrentDose": "Aucune dose enregistrée pour l'instant.", + "empty": "Aucun historique de titration. Enregistrez un changement de dose pour voir la courbe de dose." }, "injectionSiteBodyOutlineAriaLabel": "Silhouette du corps", "injectionSitePickerAriaLabel": "Sélecteur de site d'injection", @@ -1287,8 +1283,7 @@ "group": { "data": "Données", "reminders": "Rappels", - "lifecycle": "Cycle de vie", - "danger": "Zone de danger" + "lifecycle": "Cycle de vie" }, "dataPortability": { "import": { @@ -1578,7 +1573,22 @@ }, "trendsRow": { "title": "Tendances", - "subtitle": "Les 30 derniers jours en un coup d’œil" + "subtitle": "Les 30 derniers jours en un coup d’œil", + "caption": { + "bp": "Valeurs systoliques et diastoliques des 30 derniers jours.", + "weight": "Évolution du poids corporel sur les 30 derniers jours.", + "pulse": "Pouls au repos des 30 derniers jours.", + "mood": "Score d'humeur quotidien des 30 derniers jours.", + "sleep": "Durée du sommeil nocturne des 30 derniers jours.", + "steps": "Nombre de pas quotidiens des 30 derniers jours.", + "hrv": "Variabilité de la fréquence cardiaque des 30 derniers jours.", + "resting_hr": "Fréquence cardiaque au repos des 30 derniers jours.", + "active_energy": "Énergie active dépensée sur les 30 derniers jours.", + "flights": "Étages gravis sur les 30 derniers jours.", + "distance": "Distance de marche et de course des 30 derniers jours.", + "vo2_max": "VO₂ max des 30 derniers jours.", + "body_temp": "Température corporelle des 30 derniers jours." + } }, "vo2Max": { "statLatest": "Actuel", @@ -1975,6 +1985,7 @@ "valuesPageTitle": "{metric} — toutes les mesures", "valuesPageDescription": "Chaque mesure derrière cette métrique. Modifiez ou supprimez une entrée directement.", "diversity": { + "trigger": "Conseil de mesure", "weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.", "timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète." }, @@ -2392,7 +2403,21 @@ "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation." }, "structuredTagsTitle": "Répartition des étiquettes", - "notesTimelineTitle": "Notes" + "inTargetValue": "{pct} %", + "inTargetCaption": "des derniers jours dans votre plage de bonne humeur", + "narrative": { + "title": "Ce qui ressort", + "weekdayDip": "Votre humeur baisse surtout le {weekday} — {delta} sous votre semaine.", + "weekdayPeak": "Le {weekday} est toujours votre meilleur jour, avec une moyenne de {value}.", + "trendUp": "Votre humeur progresse sur les {days} derniers jours (+{delta} par rapport à la période précédente).", + "trendDown": "Votre humeur recule sur les {days} derniers jours (-{delta} par rapport à la période précédente).", + "weekendUp": "Vous vous sentez mieux le week-end (+{delta} par rapport à la semaine).", + "weekendDown": "Votre humeur est plus basse le week-end (-{delta} par rapport à la semaine).", + "tagLift": "Les jours marqués « {tag} » dépassent votre humeur générale de {delta} en moyenne.", + "tagDrop": "Les jours marqués « {tag} » sont {delta} sous votre moyenne.", + "inTarget": "Vous avez été dans votre plage de bonne humeur {pct} % des 30 derniers jours.", + "streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez." + } } }, "thresholds": { @@ -3976,6 +4001,10 @@ "targets": { "title": "Valeurs cibles", "introText": "Tes valeurs actuelles face aux plages cibles recommandées. Les sources des recommandations sont liées sur chaque carte.", + "deprecation": { + "title": "Cette page va être retirée", + "body": "Les plages cibles se modifient désormais directement depuis les pages Insights, sur le panneau de référence de chaque métrique. Cette page reste accessible pour l'instant et sera supprimée dans une prochaine version." + }, "bmi": "IMC", "average30d": "Moy. 30 jours :", "consistency": { diff --git a/messages/it.json b/messages/it.json index 73de3baa4..dba26913b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -586,16 +586,10 @@ "veryOverdue": "Molto in ritardo", "compliance7d": "Aderenza 7 giorni", "compliance30d": "Aderenza 30 giorni", + "complianceWindow": "Aderenza {days} giorni", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", "dayStreak": "giorni di serie", - "lastNDoses": "Ultime {total} dosi: {taken} su {total}", - "doseStreak": "{count} di fila", - "doseTimelineAriaLabel": "Aderenza recente alle dosi", - "doseTaken": "Assunta", - "doseMissed": "Mancata", - "doseSkipped": "Saltata", - "doseUpcoming": "In arrivo", "taken": "Assunta", "skipped": "Saltata", "intakeToastTaken": "{name} registrata come assunta", @@ -810,7 +804,11 @@ "back": "Back", "next": "Next", "save": "Save", - "saveEdit": "Save changes" + "saveEdit": "Save changes", + "nextHint": "Avanti: {step}", + "reviewHint": "Avanti: Salva", + "jumpFirst": "Vai al primo passaggio", + "jumpLast": "Vai a revisione e salvataggio" }, "nl": { "button": "Describe" @@ -820,11 +818,13 @@ "title": "What's the medication called?", "subline": "Exactly as it appears on the box.", "label": "Name", - "placeholder": "e.g. Ramipril" + "placeholder": "e.g. Ramipril", + "short": "Nome" }, "step2": { "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics." + "subline": "We use this for the right templates and analytics.", + "short": "Tipo" }, "step3": { "title": "What's the dose?", @@ -856,15 +856,18 @@ "dosesPerUnitPlaceholder": "es. 4", "dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.", "injectionSiteToggle": "Rotazione del sito di iniezione", - "injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione." + "injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.", + "short": "Dose" }, "step4": { "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later." + "subline": "A start date is enough for today. You can add an end date later.", + "short": "Quando" }, "step5": { "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step." + "subline": "Pick the cadence — the details follow on the next step.", + "short": "Frequenza" }, "step6": { "title": "What does that look like in detail?", @@ -880,7 +883,8 @@ "rollingDays": { "label": "Every", "suffix": "days from the last intake" - } + }, + "short": "Dettaglio" }, "step7": { "title": "When do you take it?", @@ -891,14 +895,16 @@ "noon": "Noon", "evening": "Evening", "night": "Night" - } + }, + "short": "Orari" }, "step8": { "title": "All done — should HealthLog remind you?", "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release." + "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "short": "Fatto" } }, "classRow": { @@ -974,23 +980,13 @@ "submitFailed": "Could not save the medication — please retry." } }, - "titration": { - "section": "Scala di titolazione", - "loading": "Caricamento dati di titolazione", - "loadFailed": "Impossibile caricare i dati di titolazione.", - "drugLadderHeader": "Scala standard (riferimento EMA)", - "typicalWeeksOnStep": "≥ {weeks} sett. tipico", - "youAreHere": "Sei qui", - "nextStepCaption": "Prossimo gradino (tipico): {dose} mg dopo ~{weeks} settimane al gradino attuale.", - "ceilingMessage": "Sei al gradino più alto della scala standard.", - "escalationDueHint": "Sei su questo gradino da {weeks} settimane; lo schema prevede un avanzamento tipico dopo ~{typical} settimane.", - "currentStepDwell": "Su questo gradino da {weeks} settimane", - "doseUnitMg": "mg", - "emptyState": "Nessuna cronologia delle dosi. Registra una dose per vedere la tua posizione sulla scala.", - "nonStandardDose": "La dose attuale è fuori dai gradini standard.", - "disclaimer": "Valori di riferimento EMA. Consulta il tuo medico prima di cambiare la dose.", - "emaSourceCta": "Fonte EMA", - "stepLabel": "Gradino {n}" + "doseStrength": { + "title": "Dose nel tempo", + "axisLabel": "Dose registrata ({unit})", + "tooltipLabel": "Dose", + "currentDose": "Dose attuale: {dose} {unit}", + "noCurrentDose": "Nessuna dose ancora registrata.", + "empty": "Nessuna cronologia di titolazione. Registra un cambio di dose per vedere la curva della dose." }, "injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo", "injectionSitePickerAriaLabel": "Selettore del sito di iniezione", @@ -1287,8 +1283,7 @@ "group": { "data": "Dati", "reminders": "Promemoria", - "lifecycle": "Ciclo di vita", - "danger": "Zona pericolosa" + "lifecycle": "Ciclo di vita" }, "dataPortability": { "import": { @@ -1578,7 +1573,22 @@ }, "trendsRow": { "title": "Tendenze", - "subtitle": "Gli ultimi 30 giorni a colpo d’occhio" + "subtitle": "Gli ultimi 30 giorni a colpo d’occhio", + "caption": { + "bp": "Valori sistolici e diastolici degli ultimi 30 giorni.", + "weight": "Andamento del peso corporeo degli ultimi 30 giorni.", + "pulse": "Battito a riposo degli ultimi 30 giorni.", + "mood": "Punteggio giornaliero dell'umore degli ultimi 30 giorni.", + "sleep": "Durata del sonno notturno degli ultimi 30 giorni.", + "steps": "Conteggio giornaliero dei passi degli ultimi 30 giorni.", + "hrv": "Variabilità della frequenza cardiaca degli ultimi 30 giorni.", + "resting_hr": "Frequenza cardiaca a riposo degli ultimi 30 giorni.", + "active_energy": "Energia attiva bruciata negli ultimi 30 giorni.", + "flights": "Piani saliti negli ultimi 30 giorni.", + "distance": "Distanza a piedi e di corsa degli ultimi 30 giorni.", + "vo2_max": "VO₂ max degli ultimi 30 giorni.", + "body_temp": "Temperatura corporea degli ultimi 30 giorni." + } }, "vo2Max": { "statLatest": "Attuale", @@ -1975,6 +1985,7 @@ "valuesPageTitle": "{metric} — tutte le misurazioni", "valuesPageDescription": "Ogni misurazione dietro questa metrica. Modifica o rimuovi una voce direttamente.", "diversity": { + "trigger": "Suggerimento di misurazione", "weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.", "timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo." }, @@ -2392,7 +2403,21 @@ "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione." }, "structuredTagsTitle": "Analisi etichette", - "notesTimelineTitle": "Note" + "inTargetValue": "{pct} %", + "inTargetCaption": "degli ultimi giorni nel tuo intervallo di buon umore", + "narrative": { + "title": "Cosa risalta", + "weekdayDip": "Il tuo umore cala di più il {weekday}: {delta} sotto la tua settimana.", + "weekdayPeak": "Il {weekday} è sempre il tuo giorno migliore, con una media di {value}.", + "trendUp": "Il tuo umore è in crescita negli ultimi {days} giorni (+{delta} rispetto al periodo precedente).", + "trendDown": "Il tuo umore è in calo negli ultimi {days} giorni (-{delta} rispetto al periodo precedente).", + "weekendUp": "Ti senti meglio nel weekend (+{delta} rispetto ai giorni feriali).", + "weekendDown": "Il tuo umore è più basso nel weekend (-{delta} rispetto ai giorni feriali).", + "tagLift": "I giorni con l'etichetta «{tag}» superano in media il tuo umore generale di {delta}.", + "tagDrop": "I giorni con l'etichetta «{tag}» restano {delta} sotto la tua media.", + "inTarget": "Sei stato nel tuo intervallo di buon umore il {pct} % degli ultimi 30 giorni.", + "streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così." + } } }, "thresholds": { @@ -3976,6 +4001,10 @@ "targets": { "title": "Valori obiettivo", "introText": "I tuoi valori attuali rispetto agli intervalli obiettivo consigliati. Le fonti delle linee guida sono collegate su ogni card.", + "deprecation": { + "title": "Questa pagina verrà rimossa", + "body": "Gli intervalli obiettivo ora si modificano direttamente dalle pagine Insights, nel pannello di riferimento di ogni metrica. Questa pagina resta disponibile per ora e verrà rimossa in una versione futura." + }, "bmi": "IMC", "average30d": "Media 30 giorni:", "consistency": { diff --git a/messages/pl.json b/messages/pl.json index d781ee6f1..b5cd296ad 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -586,16 +586,10 @@ "veryOverdue": "Mocno po terminie", "compliance7d": "Przestrzeganie 7 dni", "compliance30d": "Przestrzeganie 30 dni", + "complianceWindow": "Przestrzeganie {days} dni", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", "dayStreak": "dni serii", - "lastNDoses": "Ostatnie {total} dawek: {taken} z {total}", - "doseStreak": "{count} z rzędu", - "doseTimelineAriaLabel": "Ostatnie przestrzeganie dawek", - "doseTaken": "Przyjęte", - "doseMissed": "Pominięte", - "doseSkipped": "Opuszczone", - "doseUpcoming": "Nadchodzące", "taken": "Przyjęte", "skipped": "Pominięte", "intakeToastTaken": "{name} oznaczone jako przyjęte", @@ -810,7 +804,11 @@ "back": "Back", "next": "Next", "save": "Save", - "saveEdit": "Save changes" + "saveEdit": "Save changes", + "nextHint": "Dalej: {step}", + "reviewHint": "Dalej: Zapisz", + "jumpFirst": "Przejdź do pierwszego kroku", + "jumpLast": "Przejdź do podsumowania i zapisu" }, "nl": { "button": "Describe" @@ -820,11 +818,13 @@ "title": "What's the medication called?", "subline": "Exactly as it appears on the box.", "label": "Name", - "placeholder": "e.g. Ramipril" + "placeholder": "e.g. Ramipril", + "short": "Nazwa" }, "step2": { "title": "What kind of medication is it?", - "subline": "We use this for the right templates and analytics." + "subline": "We use this for the right templates and analytics.", + "short": "Typ" }, "step3": { "title": "What's the dose?", @@ -856,15 +856,18 @@ "dosesPerUnitPlaceholder": "np. 4", "dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.", "injectionSiteToggle": "Rotacja miejsc wstrzyknięcia", - "injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia." + "injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.", + "short": "Dawka" }, "step4": { "title": "Over what period are you taking it?", - "subline": "A start date is enough for today. You can add an end date later." + "subline": "A start date is enough for today. You can add an end date later.", + "short": "Kiedy" }, "step5": { "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step." + "subline": "Pick the cadence — the details follow on the next step.", + "short": "Częstotliwość" }, "step6": { "title": "What does that look like in detail?", @@ -880,7 +883,8 @@ "rollingDays": { "label": "Every", "suffix": "days from the last intake" - } + }, + "short": "Szczegóły" }, "step7": { "title": "When do you take it?", @@ -891,14 +895,16 @@ "noon": "Noon", "evening": "Evening", "night": "Night" - } + }, + "short": "Godziny" }, "step8": { "title": "All done — should HealthLog remind you?", "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release." + "multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.", + "short": "Gotowe" } }, "classRow": { @@ -974,23 +980,13 @@ "submitFailed": "Could not save the medication — please retry." } }, - "titration": { - "section": "Schemat titracji", - "loading": "Wczytywanie danych titracji", - "loadFailed": "Nie udało się wczytać danych titracji.", - "drugLadderHeader": "Standardowy schemat (referencja EMA)", - "typicalWeeksOnStep": "≥ {weeks} tyg. typowo", - "youAreHere": "Jesteś tutaj", - "nextStepCaption": "Następny stopień (typowo): {dose} mg po ~{weeks} tygodniach na obecnym stopniu.", - "ceilingMessage": "Jesteś na najwyższym stopniu schematu standardowego.", - "escalationDueHint": "Jesteś na tym stopniu od {weeks} tygodni; schemat zwykle przewiduje przejście wyżej po ~{typical} tygodniach.", - "currentStepDwell": "Na tym stopniu od {weeks} tygodni", - "doseUnitMg": "mg", - "emptyState": "Brak historii dawek. Zarejestruj dawkę, aby zobaczyć swoją pozycję w schemacie.", - "nonStandardDose": "Aktualna dawka jest poza standardowymi stopniami schematu.", - "disclaimer": "Wartości referencyjne EMA. Skonsultuj się z lekarzem przed zmianą dawki.", - "emaSourceCta": "Źródło EMA", - "stepLabel": "Stopień {n}" + "doseStrength": { + "title": "Dawka w czasie", + "axisLabel": "Zapisana dawka ({unit})", + "tooltipLabel": "Dawka", + "currentDose": "Aktualna dawka: {dose} {unit}", + "noCurrentDose": "Nie zapisano jeszcze żadnej dawki.", + "empty": "Brak historii titracji. Zapisz zmianę dawki, aby zobaczyć krzywą dawki." }, "injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała", "injectionSitePickerAriaLabel": "Wybór miejsca iniekcji", @@ -1287,8 +1283,7 @@ "group": { "data": "Dane", "reminders": "Przypomnienia", - "lifecycle": "Cykl życia", - "danger": "Strefa zagrożenia" + "lifecycle": "Cykl życia" }, "dataPortability": { "import": { @@ -1578,7 +1573,22 @@ }, "trendsRow": { "title": "Trendy", - "subtitle": "Ostatnie 30 dni w skrócie" + "subtitle": "Ostatnie 30 dni w skrócie", + "caption": { + "bp": "Wartości skurczowe i rozkurczowe z ostatnich 30 dni.", + "weight": "Trend masy ciała z ostatnich 30 dni.", + "pulse": "Tętno spoczynkowe z ostatnich 30 dni.", + "mood": "Dzienna ocena nastroju z ostatnich 30 dni.", + "sleep": "Długość snu nocnego z ostatnich 30 dni.", + "steps": "Dzienna liczba kroków z ostatnich 30 dni.", + "hrv": "Zmienność rytmu serca z ostatnich 30 dni.", + "resting_hr": "Spoczynkowe tętno z ostatnich 30 dni.", + "active_energy": "Aktywnie spalona energia z ostatnich 30 dni.", + "flights": "Pokonane piętra z ostatnich 30 dni.", + "distance": "Dystans marszu i biegu z ostatnich 30 dni.", + "vo2_max": "VO₂ max z ostatnich 30 dni.", + "body_temp": "Temperatura ciała z ostatnich 30 dni." + } }, "vo2Max": { "statLatest": "Aktualne", @@ -1975,6 +1985,7 @@ "valuesPageTitle": "{metric} — wszystkie pomiary", "valuesPageDescription": "Każdy pomiar stojący za tą metryką. Edytuj lub usuń wpis bezpośrednio.", "diversity": { + "trigger": "Wskazówka pomiarowa", "weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.", "timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz." }, @@ -2392,7 +2403,21 @@ "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację." }, "structuredTagsTitle": "Podział tagów", - "notesTimelineTitle": "Notatki" + "inTargetValue": "{pct}%", + "inTargetCaption": "ostatnich dni w zakresie dobrego nastroju", + "narrative": { + "title": "Co się wyróżnia", + "weekdayDip": "Twój nastrój najbardziej spada w {weekday} — {delta} poniżej twojego tygodnia.", + "weekdayPeak": "{weekday} to zawsze twój najlepszy dzień, średnio {value}.", + "trendUp": "Twój nastrój rośnie w ciągu ostatnich {days} dni (+{delta} względem poprzedniego okresu).", + "trendDown": "Twój nastrój spada w ciągu ostatnich {days} dni (-{delta} względem poprzedniego okresu).", + "weekendUp": "W weekendy czujesz się lepiej (+{delta} względem dni roboczych).", + "weekendDown": "Twój nastrój jest niższy w weekendy (-{delta} względem dni roboczych).", + "tagLift": "Dni z tagiem „{tag}” są średnio o {delta} powyżej twojego ogólnego nastroju.", + "tagDrop": "Dni z tagiem „{tag}” są o {delta} poniżej twojej średniej.", + "inTarget": "Przez {pct}% ostatnich 30 dni byłeś w zakresie dobrego nastroju.", + "streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj." + } } }, "thresholds": { @@ -3976,6 +4001,10 @@ "targets": { "title": "Wartości docelowe", "introText": "Twoje aktualne wartości na tle zalecanych zakresów docelowych. Źródła wytycznych podlinkowane na każdej karcie.", + "deprecation": { + "title": "Ta strona zostanie wycofana", + "body": "Zakresy docelowe edytuje się teraz bezpośrednio na stronach Insights, w panelu referencyjnym każdej metryki. Ta strona pozostaje na razie dostępna i zostanie usunięta w przyszłej wersji." + }, "bmi": "BMI", "average30d": "Średnia 30-dniowa:", "consistency": { diff --git a/package.json b/package.json index f6ff459ec..cee07cdbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.8.5", + "version": "1.8.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0102_v186_user_insurer_ik_number/migration.sql b/prisma/migrations/0102_v186_user_insurer_ik_number/migration.sql new file mode 100644 index 000000000..9566841ab --- /dev/null +++ b/prisma/migrations/0102_v186_user_insurer_ik_number/migration.sql @@ -0,0 +1,32 @@ +-- v1.8.6 — optional German insurer institution number (IKNR) on the user +-- profile. +-- +-- The IKNR ("Institutionskennzeichen", 9 digits) identifies the statutory +-- or private health insurer. It sits next to the existing plaintext +-- `insurer_name` and the encrypted `insurance_number_encrypted` (KVNR). +-- Unlike the KVNR — a per-person quasi-identifier kept AES-256-GCM +-- encrypted at rest — the IKNR is a public registry number for the +-- institution, so it is stored plaintext, matching `insurer_name`. +-- +-- Surfaced on the health-record FHIR export as the contained payor +-- `Organization` identifier (system `http://fhir.de/sid/arge-ik/iknr`) +-- inside the new `Coverage` resource. +-- +-- Single additive, nullable, NULL-defaulted column on `users`. No +-- backfill — pre-v1.8.6 rows read NULL (no IKNR captured yet), which the +-- exporter treats identically to an absent value. +-- +-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only. +-- +-- Reversibility: +-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insurer_ik_number"; +-- A roll-back loses the captured IKNR; the FHIR export simply emits the +-- payor `Organization` with a name only (no identifier), and the +-- application reads tolerate a missing column / NULL value. +-- +-- No index — reads are per-user at request time (`WHERE id = $1`), +-- matching `insurer_name` and the other display-identity columns on the +-- same table. + +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "insurer_ik_number" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e03a08b5f..971c85626 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -418,6 +418,16 @@ model User { insurerName String? @map("insurer_name") insuranceNumberEncrypted String? @map("insurance_number_encrypted") + // v1.8.6 profile — optional German insurer institution number (IKNR, + // 9 digits). Surfaced on the FHIR `Coverage` resource's contained + // payor `Organization` (system `http://fhir.de/sid/arge-ik/iknr`). + // Stored plaintext, matching `insurerName` — the IKNR is a public + // registry identifier, not a quasi-identifier like the KVNR, so it + // does not warrant the encrypted column treatment. Optional; an unset + // field collapses the Coverage payor identifier exactly like an + // unset `insurerName` collapses its name. + insurerIkNumber String? @map("insurer_ik_number") + @@map("users") } diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index e19773251..49f74a96a 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -66,6 +66,7 @@ export const GET = apiHandler(async () => { // v1.7.0 — health-record export identity fields. All optional. fullName: user.fullName ?? null, insurerName: user.insurerName ?? null, + insurerIkNumber: user.insurerIkNumber ?? null, insuranceNumber, }); }); diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index ede9d4faa..c84a59771 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -33,6 +33,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { timezone: result.user.timezone, fullName: result.user.fullName, insurerName: result.user.insurerName, + insurerIkNumber: result.user.insurerIkNumber, hasInsuranceNumber: result.user.hasInsuranceNumber, }); }); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index c9e98ecef..f2c2713ba 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -72,6 +72,17 @@ const MAX_BATCH_ENTRIES = 500; const BATCH_RATE_LIMIT_MAX = 60; const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; +// v1.8.6 W6 — the set of `source` values this client-facing ingest +// route accepts. Deliberately narrower than the full `MeasurementSource` +// enum: `WITHINGS` and `IMPORT` are server-owned (the Withings webhook +// and the CSV importer mint those rows respectively), so letting an iOS +// client forge rows attributed to them would let an authenticated client +// pollute the per-source canonical picker with rows the server never saw +// from those integrations. The standalone iOS client only needs to tag +// its own adopt-on-pair backfill as `MANUAL` vs the default +// `APPLE_HEALTH` passthrough, so we accept exactly that pair. +const batchSourceEnum = z.enum(["APPLE_HEALTH", "MANUAL"]); + const batchEntrySchema = z.object({ hkIdentifier: z.string().min(1).max(120), value: z.number().finite(), @@ -81,6 +92,14 @@ const batchEntrySchema = z.object({ sleepStage: z.number().int().min(0).max(20).optional(), externalId: z.string().min(1).max(120), externalSourceVersion: z.string().min(1).max(120).optional(), + // v1.8.6 W6 — optional per-entry source tag. Defaults to + // `APPLE_HEALTH` so every pre-W6 caller (web + current iOS) stays + // byte-for-byte unchanged. The iOS standalone adopt-on-pair backfill + // sends `MANUAL` for rows the user entered by hand on-device so they + // are not mis-attributed to HealthKit. `source` is part of the + // `(userId, type, source, externalId)` dedup key, so a `MANUAL` row + // and an `APPLE_HEALTH` row sharing an externalId are distinct rows. + source: batchSourceEnum.optional(), // v1.4.25 W8c — optional device-type tag. The iOS client maps the // `HKDevice.model` of each sample to one of the canonical device // classes (watch | band | ring | phone | scale | other | unknown). @@ -228,7 +247,9 @@ async function postBatch(request: NextRequest): Promise { type: mapped.type, value: mapped.value, unit: mapped.unit, - source: "APPLE_HEALTH", + // v1.8.6 W6 — honour the per-entry source tag, defaulting to + // `APPLE_HEALTH` when absent so legacy callers are unchanged. + source: entry.source ?? "APPLE_HEALTH", measuredAt: mapped.takenAt, externalId: entry.externalId, externalSourceVersion: entry.externalSourceVersion ?? null, @@ -251,31 +272,37 @@ async function postBatch(request: NextRequest): Promise { // (createMany with skipDuplicates does the dedup but doesn't tell // us *which* rows it skipped). We look up existing rows under the // composite unique key first, then createMany the survivors. + // v1.8.6 W6 — `source` is now part of the dedup key, so the lookup + // matches on (type, source, externalId) per incoming row rather than + // a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an + // `APPLE_HEALTH` row sharing an externalId are distinct, so each must + // carry its source through the existence probe and the composite key. const incomingKeys = prepared.map((p) => ({ type: p.row.type as Prisma.MeasurementCreateManyInput["type"], + source: p.row.source as Prisma.MeasurementCreateManyInput["source"], externalId: p.row.externalId as string, })); const existing = await prisma.measurement.findMany({ where: { userId: user.id, - source: "APPLE_HEALTH", OR: incomingKeys.map((k) => ({ type: k.type, + source: k.source, externalId: k.externalId, })), }, - select: { type: true, externalId: true }, + select: { type: true, source: true, externalId: true }, }); const existingSet = new Set( - existing.map((row) => `${row.type}::${row.externalId}`), + existing.map((row) => `${row.type}::${row.source}::${row.externalId}`), ); const toInsert: Prisma.MeasurementCreateManyInput[] = []; const toOverwrite: Prepared[] = []; for (const p of prepared) { - const key = `${p.row.type}::${p.row.externalId}`; + const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`; if (existingSet.has(key)) { // v1.5.0 issue #213 — per-day cumulative `stats:*` rows are // intentionally overwritten on a re-post so today's tile reflects @@ -300,7 +327,10 @@ async function postBatch(request: NextRequest): Promise { await tx.measurement.updateMany({ where: { userId: user.id, - source: "APPLE_HEALTH", + // v1.8.6 W6 — scope the overwrite to the row's own source + // so a `stats:*` re-post only touches the matching + // (type, source, externalId) row. + source: p.row.source as Prisma.MeasurementCreateManyInput["source"], type: p.row.type as MeasurementType, externalId: p.row.externalId as string, }, diff --git a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts index a281f05e5..ae9e21ed9 100644 --- a/src/app/api/medications/[id]/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/compliance/__tests__/route.test.ts @@ -247,13 +247,12 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => { }); }); -// v1.8.5 — the additive `complianceDisplay` block. The server decides the -// render mode from expected-dose density: a daily med keeps the percentage -// bars (`"percent"`), a sparse rolling/long-interval med swaps to the -// per-dose timeline (`"timeline"`). The existing `compliance7` / -// `compliance30` fields stay untouched. +// v1.8.6 — the `complianceDisplay` block is always two percentage rows; the +// server scales the two windows to the dosing cadence. A daily med keeps +// 7 / 30 days; a sparse rolling med steps both windows up to the widest rung. +// The existing `compliance7` / `compliance30` fields stay untouched. describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { - it("daily med → mode: percent", async () => { + it("daily med → 7 / 30-day windows", async () => { vi.mocked(prisma.medication.findUnique).mockResolvedValue( medication([ { @@ -277,24 +276,32 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { const data = await callRouteData(); const display = data.complianceDisplay as { - mode: string; - expected30: number; + shortDays: number; + longDays: number; + expectedLong: number; minStableDoses: number; + short: { rate: number; streak: number }; + long: { rate: number }; }; - expect(display.mode).toBe("percent"); - expect(display.expected30).toBeGreaterThanOrEqual(display.minStableDoses); + expect(display.shortDays).toBe(7); + expect(display.longDays).toBe(30); + expect(display.expectedLong).toBeGreaterThanOrEqual(display.minStableDoses); + expect(display.short).toBeDefined(); + expect(display.long).toBeDefined(); + // No timeline field — the display is two rows only. + expect(display).not.toHaveProperty("mode"); // The legacy fields stay on the wire. expect(data.compliance7).toBeDefined(); expect(data.compliance30).toBeDefined(); }); - it("35-day-interval med → mode: timeline", async () => { + it("35-day-interval med → steps the windows up past 7 / 30 days", async () => { vi.mocked(prisma.medication.findUnique).mockResolvedValue( { id: "med-1", userId: "user-1", - createdAt: new Date(Date.now() - 200 * 24 * 60 * 60 * 1000), - startsOn: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000), + createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000), + startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000), endsOn: null, oneShot: false, schedules: [ @@ -314,8 +321,6 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { ], } as never, ); - // One past intake re-anchors the rolling cadence ~25 days ago, so the - // next slot lands beyond the 30-day window → <4 expected → timeline. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ { takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000), @@ -326,11 +331,12 @@ describe("GET /api/medications/[id]/compliance — complianceDisplay", () => { const data = await callRouteData(); const display = data.complianceDisplay as { - mode: string; - expected30: number; - minStableDoses: number; + shortDays: number; + longDays: number; }; - expect(display.mode).toBe("timeline"); - expect(display.expected30).toBeLessThan(display.minStableDoses); + // A 35-day cadence can't clear four expected doses in a 30-day window, so + // the ladder steps the short window beyond 7 days and the long beyond 30. + expect(display.shortDays).toBeGreaterThan(7); + expect(display.longDays).toBeGreaterThan(30); }); }); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 7dce9c68c..779506b34 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -142,12 +142,12 @@ export const GET = apiHandler( { medicationContext }, ); - // v1.8.5 — the additive display block. `mode` is server-decided from - // expected-dose density: dense cadences keep the 7-/30-day percentage - // bars (`"percent"`), sparse cadences (weekly, bi-/tri-weekly, rolling - // 35-day injections) swap to the per-dose uptime strip (`"timeline"`). - // `compliance7` / `compliance30` above are untouched — iOS + the Health - // Score read them verbatim. + // v1.8.6 — the two-row compliance display. The card always shows two + // percentage rows; the server scales the two windows to the dosing + // cadence (dense meds keep 7 / 30 days, sparse meds step both windows + // up) and computes each row's rate over the chosen span. `compliance7` + // / `compliance30` above are untouched — iOS + the Health Score read + // them verbatim. const complianceDisplay = buildComplianceDisplay( mapped, medication.schedules, @@ -289,7 +289,8 @@ export const GET = apiHandler( meta: { compliance7: compliance7.rate, compliance30: compliance30.rate, - displayMode: complianceDisplay.mode, + complianceShortDays: complianceDisplay.shortDays, + complianceLongDays: complianceDisplay.longDays, }, }); diff --git a/src/app/api/medications/[id]/titration/__tests__/route.test.ts b/src/app/api/medications/[id]/titration/__tests__/route.test.ts deleted file mode 100644 index f505fb912..000000000 --- a/src/app/api/medications/[id]/titration/__tests__/route.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * v1.4.25 W19f — titration-ladder route tests. - * - * Mirrors the W19e cadence-route test fixture: external dependencies - * are mocked at module boundaries (prisma, session, logging - * transport) so each test pins one contract case. The pure ladder - * helpers are covered separately in - * `src/lib/medications/titration/__tests__/ladder.test.ts`. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - medication: { findUnique: vi.fn() }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -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 { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "marc", role: "USER" as const }, -}; - -function makeMed( - overrides: Partial<{ - name: string; - dose: string; - treatmentClass: "GLP1" | "GENERIC"; - userId: string; - doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>; - }> = {}, -) { - return { - id: "med-1", - name: overrides.name ?? "Mounjaro", - dose: overrides.dose ?? "5 mg", - treatmentClass: overrides.treatmentClass ?? ("GLP1" as const), - userId: overrides.userId ?? "user-1", - createdAt: new Date(), - doseChanges: overrides.doseChanges ?? [], - }; -} - -beforeEach(() => { - vi.resetAllMocks(); -}); - -describe("GET /api/medications/[id]/titration", () => { - it("returns 401 when unauthenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(401); - }); - - it("returns 404 when the medication is not owned by the caller", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.medication.findUnique).mockResolvedValue( - makeMed({ userId: "OTHER" }) as never, - ); - - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(404); - }); - - it("returns 404 when the medication is not GLP-1", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.medication.findUnique).mockResolvedValue( - makeMed({ treatmentClass: "GENERIC" }) as never, - ); - - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(404); - }); - - it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.medication.findUnique).mockResolvedValue( - makeMed({ name: "MysteryDrug" }) as never, - ); - - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(404); - }); - - it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.medication.findUnique).mockResolvedValue( - makeMed({ - name: "Mounjaro", - dose: "5 mg", - doseChanges: [ - { - effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000), - doseValue: 2.5, - }, - { - effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000), - doseValue: 5, - }, - ], - }) as never, - ); - - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json.data.drugId).toBe("tirzepatide"); - expect(json.data.ladder.length).toBe(6); - expect(json.data.currentStep.doseMg).toBe(5); - expect(json.data.currentStepIndex).toBe(1); - expect(json.data.weeksOnCurrentStep).toBe(2); - expect(json.data.nextStep.doseMg).toBe(7.5); - expect(json.data.escalationDue).toBe(false); - }); - - it("returns a null current step when the user dose is outside any ladder bucket", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.medication.findUnique).mockResolvedValue( - makeMed({ - name: "Mounjaro", - dose: "6 mg", // outside the ±10 % window of 5 and 7.5 - doseChanges: [], - }) as never, - ); - - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json.data.currentStep).toBeNull(); - expect(json.data.currentStepIndex).toBeNull(); - expect(json.data.nextStep).toBeNull(); - expect(json.data.escalationDue).toBe(false); - }); - - it("returns a null next-step at the ladder ceiling", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.medication.findUnique).mockResolvedValue( - makeMed({ - name: "Mounjaro", - dose: "15 mg", - doseChanges: [ - { - effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000), - doseValue: 15, - }, - ], - }) as never, - ); - - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json.data.currentStep.doseMg).toBe(15); - expect(json.data.nextStep).toBeNull(); - expect(json.data.escalationDue).toBe(false); - }); - - it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.medication.findUnique).mockResolvedValue( - makeMed({ - name: "Mounjaro", - dose: "2.5 mg", - doseChanges: [ - { - // Six weeks ago, past tirzepatide's 4-week interval. - effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000), - doseValue: 2.5, - }, - ], - }) as never, - ); - - const res = await GET( - new NextRequest("http://localhost/api/medications/med-1/titration"), - { params: Promise.resolve({ id: "med-1" }) }, - ); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json.data.currentStep.doseMg).toBe(2.5); - expect(json.data.weeksOnCurrentStep).toBe(6); - expect(json.data.escalationDue).toBe(true); - }); -}); diff --git a/src/app/api/medications/[id]/titration/route.ts b/src/app/api/medications/[id]/titration/route.ts deleted file mode 100644 index 55ee026cb..000000000 --- a/src/app/api/medications/[id]/titration/route.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * v1.4.25 W19f — GLP-1 titration-ladder read endpoint. - * - * GET /api/medications/[id]/titration - * - returns the EMA-reference ladder for the medication's drug, - * the user's current step (matched within ±10 % tolerance to - * their latest dose), how long they've been on that step, the - * immediate-next step (if any), and the observational - * escalation-due flag. - * - * Pure computation — no writes. Delegates math to the - * `src/lib/medications/titration/ladder` module so the route, the - * Coach snapshot (future wave), and the detail-page section all read - * the same numbers. - * - * Auth: cookie-session via requireAuth(); medication is verified to - * belong to the caller before any read. Defence-in-depth: returns - * 404 for non-GLP-1 medications (the UI already gates on - * `treatmentClass === "GLP1"`). - */ - -import { NextRequest } from "next/server"; - -import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; -import { - findDrugByBrand, - findDrugIdByBrand, -} from "@/lib/medications/glp1-knowledge"; -import { parseDoseMgOrNull } from "@/lib/medications/dose-string"; -import { assertMedicationOwnership } from "@/lib/medications/route-guards"; -import { - escalationDue, - findCurrentStep, - ladderFromRecord, - nextStep, - weeksOnCurrentStep, -} from "@/lib/medications/titration/ladder"; - -type RouteParams = { params: Promise<{ id: string }> }; - -export const GET = apiHandler( - async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); - const { id } = await params; - - // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so - // the 404 leak shape stays consistent across every medication - // sub-route. - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; - - const med = await prisma.medication.findUnique({ - where: { id }, - include: { - doseChanges: { orderBy: { effectiveFrom: "asc" } }, - }, - }); - if (!med) { - return apiError("Medication not found", 404); - } - if (med.treatmentClass !== "GLP1") { - // Defence-in-depth: the UI already gates the section on this - // class, but the route refuses to surface the ladder for a - // non-GLP-1 row so we never leak a misleading "you are here" - // on a generic medication. - return apiError("Medication not found", 404); - } - - // Resolve the GLP-1 drug record from the medication name. Same - // path the DrugLevelChart uses; null when the medication name - // doesn't map to a catalog brand (e.g. user typed a generic - // INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go - // through the shared helpers in glp1-knowledge. - const record = findDrugByBrand(med.name); - const drugId = findDrugIdByBrand(med.name); - if (!record || !drugId) { - return apiError("Medication not found", 404); - } - - const ladder = ladderFromRecord(record); - - // Resolve the user's most-recent dose. Prefer the - // MedicationDoseChange stream (the history of record); fall back - // to the legacy free-text `medication.dose` if the stream is empty. - const latestChange = - med.doseChanges.length > 0 - ? med.doseChanges[med.doseChanges.length - 1] - : null; - const latestDoseMg = latestChange - ? latestChange.doseValue - : parseDoseMgOrNull(med.dose); - - const currentStep = findCurrentStep(drugId, latestDoseMg); - const next = nextStep(drugId, currentStep); - const asOf = new Date(); - const weeks = weeksOnCurrentStep( - drugId, - currentStep, - med.doseChanges.map((dc) => ({ - effectiveFrom: dc.effectiveFrom, - doseValue: dc.doseValue, - })), - asOf, - ); - const due = escalationDue(drugId, currentStep, weeks); - - annotate({ - action: { - name: "medication.titration", - entity_type: "medication", - entity_id: id, - }, - meta: { - drug_id: drugId, - step_index: currentStep?.stepIndex ?? null, - weeks_on_step: weeks, - }, - }); - - return apiSuccess({ - drugId, - drugInn: record.inn, - ladder, - currentStep, - currentStepIndex: currentStep?.stepIndex ?? null, - weeksOnCurrentStep: weeks, - nextStep: next, - escalationDue: due, - sourceEMA: record.sourceEMA, - }); - }, -); - diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index 5c68f57f8..82b9c1b4b 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -81,6 +81,33 @@ describe("GET /api/user/profile", () => { expect(body.data.heightCm).toBe(180); expect(body.data.locale).toBe("de"); }); + + it("echoes the insurer IK number on GET", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "marc", + displayName: null, + email: null, + dateOfBirth: null, + gender: null, + heightCm: null, + locale: null, + timezone: "Europe/Berlin", + moodReminderEnabled: false, + fullName: null, + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + insuranceNumberEncrypted: null, + } as never); + + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { insurerIkNumber: string | null; insurerName: string | null }; + }; + expect(body.data.insurerIkNumber).toBe("101234567"); + expect(body.data.insurerName).toBe("Example Insurer"); + }); }); describe("PATCH /api/user/profile", () => { @@ -132,4 +159,45 @@ describe("PATCH /api/user/profile", () => { }), ); }); + + it("accepts a valid IKNR and echoes it back", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "marc", + displayName: null, + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + locale: null, + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: "101234567", + insuranceNumberEncrypted: null, + } as never); + + const res = await PATCH(req({ insurerIkNumber: "101234567" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ insurerIkNumber: "101234567" }), + }), + ); + const body = (await res.json()) as { + data: { insurerIkNumber: string | null }; + }; + expect(body.data.insurerIkNumber).toBe("101234567"); + }); + + it("returns 422 for a malformed IKNR", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PATCH(req({ insurerIkNumber: "abc" })); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 615285297..6dd2ed943 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -39,6 +39,7 @@ export const GET = apiHandler(async () => { moodReminderEnabled: true, fullName: true, insurerName: true, + insurerIkNumber: true, insuranceNumberEncrypted: true, }, }); @@ -66,6 +67,7 @@ export const GET = apiHandler(async () => { moodReminderEnabled: dbUser?.moodReminderEnabled ?? false, fullName: dbUser?.fullName ?? null, insurerName: dbUser?.insurerName ?? null, + insurerIkNumber: dbUser?.insurerIkNumber ?? null, insuranceNumber, }); }); @@ -95,6 +97,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => { moodReminderEnabled: result.user.moodReminderEnabled, fullName: result.user.fullName, insurerName: result.user.insurerName, + insurerIkNumber: result.user.insurerIkNumber, hasInsuranceNumber: result.user.hasInsuranceNumber, }); }); diff --git a/src/app/insights/__tests__/explainer-metric-keys.test.ts b/src/app/insights/__tests__/explainer-metric-keys.test.ts index 18618a09d..dec10a84b 100644 --- a/src/app/insights/__tests__/explainer-metric-keys.test.ts +++ b/src/app/insights/__tests__/explainer-metric-keys.test.ts @@ -5,14 +5,20 @@ import { join } from "node:path"; /** * v1.8.0 — explainer-metric key guard. * - * `` builds its i18n lookups from a template literal: + * The sub-page shell builds its i18n lookup from a template literal off + * the `explainerMetric` prop: * - * t(`insights.subPage.explainer.${metric}Title`) - * t(`insights.subPage.explainer.${metric}Body`) + * t(`insights.subPage.explainer.${metric}Body`) // inline definition + * + * v1.8.6 — the round `?` popover that also read the `Title` leaf + * is gone; the shell now renders only the inline `Body` caption. The + * `Title` leaf is still pinned here so the bundle keeps a stable, fully + * populated key pair per metric (the locale-integrity guard propagates + * it across the other five locales). * * The static `i18n-call-site-coverage.test.ts` only sees literal * `t("…")` calls, so a typo in an `explainerMetric=` prop would resolve - * to a missing key and paint the raw key string into the popover — with + * to a missing key and paint the raw key string under the heading — with * no CI signal. This test closes that gap the same way the * Health-Score provenance key tests pin their dynamic keys: it walks * every `explainerMetric="…"` value used under `src/app/insights/**` diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 391a9f83d..cd3e71194 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; @@ -101,6 +100,7 @@ export default function InsightsBlutdruckPage() { title={t("insights.bloodPressureSectionTitle")} description={t("insights.subPage.blutdruckDescription")} explainerMetric="bloodPressure" + coachLaunch > - - ); } diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 46f5abdf4..d539f6a21 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -110,6 +110,7 @@ export default function InsightsBmiPage() { title={t("insights.bmiSectionTitle")} description={t("insights.subPage.bmiDescription")} explainerMetric="bmi" + coachLaunch > - - ); } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 0341e5aa4..57cb7cadd 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -187,6 +187,7 @@ export default function InsightsMedikamentePage() { title={t("insights.medicationCompliance")} description={t("insights.subPage.medikamenteDescription")} explainerMetric="medications" + coachLaunch >
- - ); } diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index ce89442ef..6e42bea95 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -12,7 +12,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; @@ -99,6 +98,7 @@ export default function InsightsStimmungPage() { title={t("insights.moodSectionTitle")} description={t("insights.subPage.stimmungDescription")} explainerMetric="mood" + coachLaunch > - - ); } diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index a36cb2a64..7eeb88f4c 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; @@ -129,6 +128,7 @@ export default function InsightsPulsPage() { timeZone={user?.timezone ?? undefined} /> } + coachLaunch showAllValuesType="PULSE" > - - ); } diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 5777e8a67..b4ea865b6 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -6,7 +6,6 @@ import { Moon } from "lucide-react"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SleepOverview } from "@/components/insights/sleep-overview"; @@ -62,6 +61,7 @@ export default function InsightsSchlafPage() { title={t("insights.sleep.title")} description={t("insights.sleep.description")} explainerMetric="sleep" + coachLaunch > @@ -78,8 +78,6 @@ export default function InsightsSchlafPage() { structural slot waits for v1.5 where the route + hook key will wire in. */} - - ); } diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 209622e39..c2d431799 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; @@ -86,6 +85,7 @@ export default function InsightsGewichtPage() { timeZone={user?.timezone ?? undefined} /> } + coachLaunch showAllValuesType="WEIGHT" > - - ); } diff --git a/src/app/insights/workouts/page.tsx b/src/app/insights/workouts/page.tsx index bb89f4867..014f2738a 100644 --- a/src/app/insights/workouts/page.tsx +++ b/src/app/insights/workouts/page.tsx @@ -4,7 +4,6 @@ import { Activity } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useWorkouts } from "@/hooks/use-workouts"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { WorkoutList } from "@/components/insights/workout-list"; @@ -36,6 +35,7 @@ export default function InsightsWorkoutsPage() { title={t("insights.workouts.title")} description={t("insights.workouts.description")} explainerMetric="workouts" + coachLaunch > {isLoading ? (
@@ -52,10 +52,7 @@ export default function InsightsWorkoutsPage() { coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?" /> ) : data ? ( - <> - - - + ) : null} ); diff --git a/src/app/medications/[id]/history/page.tsx b/src/app/medications/[id]/history/page.tsx index 5de206a80..e33edcab7 100644 --- a/src/app/medications/[id]/history/page.tsx +++ b/src/app/medications/[id]/history/page.tsx @@ -29,7 +29,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; -import { TitrationSection } from "@/components/medications/TitrationSection"; +import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable"; import { IntakeImportDialog } from "@/components/medications/intake-import-dialog"; import { useTranslations } from "@/lib/i18n/context"; @@ -129,10 +129,12 @@ export default function IntakeHistoryPage({ /> )} - {/* O-2 — estimated active-ingredient curve + titration ladder as a - default-CLOSED disclosure for GLP-1 medications. Genuine - context, but opt-in so the intake table stays the dominant - surface. The chart itself gates further on Research Mode. */} + {/* O-2 — dose-strength (titration) curve + estimated + active-ingredient curve as a default-CLOSED disclosure for + GLP-1 medications. Genuine context, but opt-in so the intake + table stays the dominant surface. The dose-strength curve plots + the user's own logged dose-change history; the drug-level chart + gates further on Research Mode. */} {medication && isGlp1 && (
+ -
)} diff --git a/src/app/medications/[id]/page.tsx b/src/app/medications/[id]/page.tsx index 4f5a5aa93..32de81d9e 100644 --- a/src/app/medications/[id]/page.tsx +++ b/src/app/medications/[id]/page.tsx @@ -37,7 +37,7 @@ import { parseScheduleRecurrence } from "@/lib/medication-schedule"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { DrugLevelChart } from "@/components/medications/DrugLevelChart"; -import { TitrationSection } from "@/components/medications/TitrationSection"; +import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve"; import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary"; import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview"; import { SideEffectsSection } from "@/components/medications/SideEffectsSection"; @@ -235,10 +235,11 @@ export default function MedicationDetailPage({ (genuine history context). */} {isGlp1 && } - {/* GLP-1 drug-level curve + titration ladder as a default-CLOSED - disclosure — genuine history context, opt-in so the intake - table stays the dominant surface. The chart gates further on - Research Mode. */} + {/* GLP-1 dose-strength (titration) curve + estimated drug-level + chart as a default-CLOSED disclosure — genuine history context, + opt-in so the intake table stays the dominant surface. The + dose-strength curve plots the user's own logged dose-change + history; the drug-level chart gates further on Research Mode. */} {isGlp1 && (
+ -
)} diff --git a/src/app/targets/__tests__/page.test.tsx b/src/app/targets/__tests__/page.test.tsx new file mode 100644 index 000000000..1fcbe607e --- /dev/null +++ b/src/app/targets/__tests__/page.test.tsx @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.8.6 — `/targets` deprecation banner. + * + * The Targets (Zielwerte) page is deprecated: target-range editing moved + * inline into the Insights category pages. The page stays routable but + * carries a top banner telling the user where editing lives now and that + * the page will be removed in a future release. This SSR test pins the + * banner copy + structure. + * + * The page leans on `useAuth`, two `useQuery` reads, `useFeatureFlags` + * and `useCoachHandoff`; all are stubbed so the render reaches the + * banner without standing up an App-Router runtime or a QueryClient. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { id: "u1", disableCoach: false }, + }), +})); + +// The page issues two `useQuery` reads (targets payload + provider +// chain). Drive both off the same stub: the first call resolves the +// targets payload, the rest return a null provider chain. +let queryCall = 0; +const TARGETS_PAYLOAD = { + targets: [], + bpDiastolic: { current: null, average30: null, range: null }, + profile: { heightCm: 175, age: 40, gender: "male", glucoseUnit: "mg/dL" }, +}; +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => { + queryCall += 1; + if (queryCall === 1) { + return { data: TARGETS_PAYLOAD, isLoading: false }; + } + return { data: null, isLoading: false }; + }, +})); + +// Turn Coach off so the page never mounts the SSE drawer machinery. +vi.mock("@/hooks/use-feature-flags", () => ({ + useFeatureFlags: () => ({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + correlations: false, + healthScoreExplainer: false, + }), +})); + +vi.mock("@/hooks/use-coach-handoff", () => ({ + useCoachHandoff: () => ({ + coachOpen: false, + setCoachOpen: vi.fn(), + coachPrefill: null, + coachScope: null, + askCoach: vi.fn(), + }), +})); + +const { default: TargetsPage } = await import("../page"); + +function render(locale: "en" | "de" = "en") { + queryCall = 0; + return renderToStaticMarkup( + + + , + ); +} + +describe(" deprecation banner", () => { + it("renders the deprecation banner with title + body", () => { + const html = render(); + expect(html).toContain('data-slot="targets-deprecation"'); + expect(html).toContain("This page is being retired"); + expect(html).toContain("Insights"); + // Reuses the warning-card visual pattern. + expect(html).toContain("border-warning"); + }); + + it("localises the banner copy", () => { + const html = render("de"); + expect(html).toContain("Diese Seite wird eingestellt"); + }); +}); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index 9d6a9e49a..20f8a80c2 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -1,5 +1,21 @@ "use client"; +// DEPRECATED since v1.8.6. Target-range editing moved inline into the +// Insights category pages (the per-metric reference panel now mounts the +// same ), so this standalone page no longer appears in +// the navigation. It stays routable behind the `/zielwerte`→`/targets` +// proxy redirect and is a remove candidate after ~10 releases. +// +// What stays in place because Insights consumes the same API: +// • GET /api/insights/targets — the read powering this page is the +// same payload the Insights panel reads (`queryKeys.insightsTargets()`). +// • PUT/DELETE /api/user/thresholds — the threshold override write the +// inline calls. +// • src/components/targets/* — TargetCard, TargetEditSheet, RangeBar, +// ConsistencyStrip, TargetStatusPill, source-link helper are all +// reused by the inline Insights panel. +// Removing the page later is a UI-only change; no data or API moves. + import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { AlertCircle, Loader2 } from "lucide-react"; @@ -200,26 +216,45 @@ export default function TargetsPage() {

{t("targets.introText")}

- {/* Profile incomplete hint */} - {profileIncomplete && ( - - - + {/* v1.8.6 — single warning banner. Target editing moved inline into + the Insights category pages; this page stays reachable but will + be removed in a future release. + + Design Low fix: the deprecation notice and the profile-incomplete + hint used to stack as two near-identical warning cards. They are + folded into one card now — the deprecation message stays the + headline, and the profile-incomplete detail (height / age missing) + renders as a secondary line beneath it only when relevant, so the + page never shows two warning banners. */} + + + +

- {t("targets.profileIncomplete")} + {t("targets.deprecation.title")}

- {!data.profile.heightCm && !data.profile.age - ? t("targets.profileIncompleteHeightAge") - : !data.profile.heightCm - ? t("targets.profileIncompleteHeight") - : t("targets.profileIncompleteAge")} + {t("targets.deprecation.body")}

- - - )} + {profileIncomplete && ( +
+

+ {t("targets.profileIncomplete")} +

+

+ {!data.profile.heightCm && !data.profile.age + ? t("targets.profileIncompleteHeightAge") + : !data.profile.heightCm + ? t("targets.profileIncompleteHeight") + : t("targets.profileIncompleteAge")} +

+
+ )} +
+
+
{/* v1.4.25 W3e — page-level summary line. Renders nothing when the API hasn't shipped pageSummary yet (older clients during diff --git a/src/components/insights/__tests__/metric-explainer.test.tsx b/src/components/insights/__tests__/metric-explainer.test.tsx deleted file mode 100644 index 6a93fd9c5..000000000 --- a/src/components/insights/__tests__/metric-explainer.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider, useTranslations } from "@/lib/i18n/context"; -import { MetricExplainer } from "../metric-explainer"; - -/** - * v1.8.0 — `` unit tests. - * - * The explainer is the `?` glyph next to a metric sub-page heading that - * opens a static "What is X?" read on tap / Enter / Space. Tests run - * through SSR so they don't depend on a browser surface: the trigger - * button is always present, the popover / sheet body mounts only after - * the user activates it. The load-bearing constraints pinned here are - * the a11y contract (real button, labelled, disclosure semantics, 44 px - * hit surface) and that every locale ships the title + body copy. - */ - -function ssr(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -/** - * Probe that resolves the explainer keys through the same `t()` calls - * the component uses, so a dropped locale key fails here at the call - * site instead of silently rendering the literal key path on the page. - */ -function KeyProbe({ metric }: { metric: string }) { - const { t } = useTranslations(); - return ( -
- - {t(`insights.subPage.explainer.${metric}Title`)} - - - {t(`insights.subPage.explainer.${metric}Body`)} - -
- ); -} - -// v1.8.0 — English key segments (ADR-0001). Every routed insights -// category page now passes one of these; the explainer copy ships in -// all six locales. -const METRICS = [ - "bloodPressure", - "pulse", - "weight", - "bmi", - "sleep", - "mood", - "medications", - "restingHr", - "hrv", - "oxygenSaturation", - "bodyTemperature", - "activeEnergy", - "workouts", - "respiratoryRate", - "bodyWater", - "boneMass", - "fatFreeMass", - "fatMass", - "muscleMass", - "visceralFat", - "leanBodyMass", - "flightsClimbed", - "walkingDistance", - "walkingSteadiness", - "walkingHeartRate", - "walkingAsymmetry", - "doubleSupportTime", - "stepLength", - "walkingSpeed", - "pulseWaveVelocity", - "vascularAge", - "environmentalAudio", - "headphoneAudio", - "audioEvents", - "daylight", - "bloodGlucose", - "skinTemperature", -] as const; - -describe("", () => { - it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => { - const html = ssr(); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger).not.toBeNull(); - expect(trigger?.[0]).toContain('aria-label="What is Blood pressure?"'); - }); - - it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => { - // The glyph stays 14 px but the click surface inflates to 44 px via - // `min-h-11 min-w-11`; the surrounding heading row keeps its stride - // because `-my-3 -mx-2` swallows the extra reach. Pinned so a future - // refactor can't collapse the hit surface back to the visual chip. - const html = ssr(); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger?.[0]).toContain("min-h-11"); - expect(trigger?.[0]).toContain("min-w-11"); - }); - - it("threads disclosure semantics onto the trigger button", () => { - const html = ssr(); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger?.[0]).toContain("aria-expanded"); - expect(trigger?.[0]).toContain("aria-controls"); - }); - - it("does not paint the popover/sheet body on the initial SSR snapshot", () => { - // Closed-by-default: the body lives in a portal that mounts after - // the user activates the trigger. Pinning the negative keeps a - // future "open on render" refactor from leaking copy into the - // static markup. - const html = ssr(); - expect(html).not.toContain('data-slot="metric-explainer-body"'); - expect(html).not.toContain('data-slot="metric-explainer-title"'); - }); - - it("localises the trigger label in German", () => { - const html = ssr(, "de"); - const trigger = html.match( - /]*data-slot="metric-explainer-trigger"[^>]*>/, - ); - expect(trigger?.[0]).toContain('aria-label="Was ist Schlaf?"'); - }); - - it("ships the title + body copy for every category metric in every locale", () => { - for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) { - for (const metric of METRICS) { - const html = renderToStaticMarkup( - - - , - ); - // A missing key surfaces as the literal `insights.subPage…` - // path, which never contains a space. - expect(html).not.toContain(`insights.subPage.explainer.${metric}`); - } - } - }); -}); diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 0ff5bbaf9..9b9aaa980 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -23,8 +23,13 @@ vi.mock("@/hooks/use-auth", () => ({ })); const useQueryMock = vi.fn(); +// The inline (mounted by the panel once the +// adjust-target button is clicked) also pulls `useMutation` + +// `useQueryClient`, so stub those alongside `useQuery`. vi.mock("@tanstack/react-query", () => ({ useQuery: (opts: unknown) => useQueryMock(opts), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); const { MetricTargetSummary } = await import("../metric-target-summary"); @@ -188,10 +193,41 @@ describe("", () => { expect(html).toContain("30-day average: 122/76 mmHg"); }); - it("renders the adjust-target link to /targets", () => { + it("renders the inline adjust-target button instead of a /targets link", () => { const html = renderWith("weight", WEIGHT_DATA); - expect(html).toContain('href="/targets"'); + // v1.8.6 — the /targets page is deprecated; the editor moved inline, + // so the footer is a button that opens the sheet, not a route link. + expect(html).toContain('data-slot="metric-target-adjust"'); expect(html).toContain("Adjust target range"); + expect(html).not.toContain('href="/targets"'); + }); + + it("keeps the inline editor lazy-mounted while the panel is closed", () => { + // The panel mounts alongside itself; + // the sheet body (and its TanStack Query hooks) only instantiate once + // the button flips the open flag, so a closed panel paints no editor. + const html = renderWith("weight", WEIGHT_DATA); + expect(html).not.toContain('data-slot="target-edit-sheet"'); + }); + + it("renders the adjust-target affordance as a button, not a navigation link", () => { + // The migration's load-bearing change: the affordance is a local + // button (opens the inline sheet) rather than an / routing to + // the deprecated /targets page. + const html = renderWith("weight", WEIGHT_DATA); + const button = html.match(/]*data-slot="metric-target-adjust"[^>]*/)?.[0] ?? ""; + expect(button).toContain('type="button"'); + expect(html).not.toContain('href="/targets"'); + }); + + it("threads the diastolic range into the editor for blood pressure", () => { + // The panel passes `bpDiastolic.range` as the sheet's + // `initialDiastolicRange`. Render the panel for BP and confirm the + // adjust button is present so the BP editor is reachable inline (the + // sheet body itself portals, so it isn't part of the static markup). + const html = renderWith("blood-pressure", BP_DATA); + expect(html).toContain('data-slot="metric-target-adjust"'); + expect(html).not.toContain('href="/targets"'); }); it("suppresses the share + strip when the route flags insufficient data", () => { diff --git a/src/components/insights/__tests__/sub-page-shell.test.tsx b/src/components/insights/__tests__/sub-page-shell.test.tsx index e586b2329..46394c46d 100644 --- a/src/components/insights/__tests__/sub-page-shell.test.tsx +++ b/src/components/insights/__tests__/sub-page-shell.test.tsx @@ -64,13 +64,42 @@ describe("", () => { ); }); - it("keeps the explainer trigger glyph when explainerMetric is set", () => { + it("no longer paints the round `?` explainer trigger next to the heading", () => { const html = render( , ); - // The `?` glyph stays available alongside the inline caption. - expect(html).toMatch(/data-slot="metric-explainer-trigger"/); + // v1.8.6 — the `?` popover affordance was dropped; only the inline + // definition caption remains. + expect(html).not.toMatch(/data-slot="metric-explainer-trigger"/); + }); + + it("renders the diversity nudge node inside the heading row", () => { + const html = render( + } + > + + , + ); + // v1.8.6 — the nudge moved up to the heading row (a `Lightbulb` + // glyph), no longer an inline block beneath the stat strip. + const headerEnd = html.indexOf(""); + const probeIdx = html.indexOf('data-slot="diversity-probe"'); + expect(probeIdx).toBeGreaterThan(-1); + expect(probeIdx).toBeLessThan(headerEnd); + }); + + it("mounts no coach launch surface when coachLaunch is omitted", () => { + const html = render( + + + , + ); + // The coach icon only renders under the launch provider + flags; the + // shell renders nothing for it on the default path. + expect(html).not.toContain('data-slot="coach-launch-icon"'); }); }); diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index 490189708..215e5703b 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -213,11 +213,40 @@ describe("", () => { expect(html).toMatch(/data-metric="mood"/); }); - it("renders additive metrics chart-only (no annotation slot)", () => { + it("captions an additive metric with its standard description (no advisor annotation slot)", () => { + // v1.8.6 W8 — additive metrics carry no advisor annotation, so the + // tile must fall back to the metric's standard one-line caption + // rather than painting empty space below the chart. The + // `` slot is reserved for the legacy triple. const html = render(); expect(html).toMatch(/data-metric="pulse"/); - // No TrendAnnotation empty/pending slot for a chart-only tile. expect(html).not.toMatch(/data-slot="trend-annotation-empty"/); + // The standard caption renders in its place. + expect(html).toMatch(/data-slot="trends-row-caption"[^>]*data-metric="pulse"/); + expect(html).toContain("Resting pulse over the last 30 days."); + }); + + it("never renders a caption-less card — every briefing-driven tile carries a caption", () => { + // Regression guard for the v1.8.5 caption-drop: a briefing that + // flags additive metrics with no advisor annotation used to paint + // the chart with empty space underneath. Every tile must surface + // either a `` slot (legacy triple) or the + // standard `trends-row-caption` description (additive metrics). + const html = render( + , + ); + const cards = html.match(/data-slot="trends-row-card"/g) ?? []; + expect(cards.length).toBe(3); + // None of these additive metrics carries an advisor annotation, so + // all three captions come through the standard description slot. + const captions = + html.match(/data-slot="trends-row-caption"/g) ?? []; + expect(captions.length).toBe(3); + expect(html).toContain("Heart-rate variability over the last 30 days."); + expect(html).toContain("Daily step count over the last 30 days."); + expect(html).toContain( + "Walking and running distance over the last 30 days.", + ); }); it("propagates per-metric confidence chips", () => { diff --git a/src/components/insights/coach-launch-button.tsx b/src/components/insights/coach-launch-button.tsx index d6e1dd7d4..bf9e1c3e5 100644 --- a/src/components/insights/coach-launch-button.tsx +++ b/src/components/insights/coach-launch-button.tsx @@ -33,12 +33,24 @@ export interface CoachLaunchButtonProps { prefill?: string; /** Optional className passthrough for inline overrides. */ className?: string; + /** + * v1.8.6 — render the launch as an icon-only affordance instead of the + * labelled pill. The sub-page shell mounts this variant at heading + * height in the page header so "Coach fragen" sits top-right, aligned + * with the title, rather than at the foot of the page. The accessible + * label still resolves the shared CTA copy via `aria-label`. The icon + * variant stays visible across breakpoints — it is the header's own + * Coach entry, not the `lg+`-only inline pill the old foot placement + * used (the mobile FAB covers ` launch.askCoach(prefill ?? null)} + className={cn("text-muted-foreground hover:text-foreground", className)} + > + ); } diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 24f0f261f..66d9561c6 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -163,7 +163,12 @@ export function InsightStatusCard({ // shell padding to `py-4 md:py-5` so the assessment block reads tight // in the denser sub-page rhythm. The same override lands on every // status variant below so the gap is consistent across states. - className="animate-insight-in border-l-dracula-purple gap-2 border-l-2 py-4 md:gap-3 md:py-5" + // + // v1.8.6 — drop the purple left accent (`border-l-dracula-purple + // border-l-2`). The coloured rule made the assessment card read as + // restless against the calmer surrounding cards; the plain card + // border carries enough separation on its own. + className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5" >
diff --git a/src/components/insights/measurement-diversity-nudge.tsx b/src/components/insights/measurement-diversity-nudge.tsx index 23b81d76b..74eb1bf63 100644 --- a/src/components/insights/measurement-diversity-nudge.tsx +++ b/src/components/insights/measurement-diversity-nudge.tsx @@ -3,6 +3,12 @@ import { useQuery } from "@tanstack/react-query"; import { Lightbulb } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -18,6 +24,14 @@ import { detectMeasurementDiversity } from "@/lib/insights/measurement-diversity * when the spread is genuinely lopsided — surfaces a gentle hint to * measure on other days / times. * + * v1.8.6 — the hint moved off the page body and onto the heading. The + * inline `role="note"` block confused on the category pages (it read + * like a data finding rather than a tip), so the nudge now renders as a + * small `Lightbulb` glyph beside the page title. The hint text the block + * used to show inline lives in a hover / focus Tooltip; the glyph mounts + * only when the spread is actually lopsided, so a healthy metric shows + * no extra affordance at all. + * * v1.8.3 anti-freeze posture: the read is a bounded `limit`-capped query * with a 5-minute `staleTime`, fires only after the page has mounted * (no SSR blocking), and renders nothing while in flight or when the @@ -75,18 +89,29 @@ export function MeasurementDiversityNudge({ const message = t(`insights.subPage.diversity.${signal.kind}`, { metric: metricLabel, }); + const triggerLabel = t("insights.subPage.diversity.trigger"); return ( -
-
+ + + + + + + {message} + + + ); } diff --git a/src/components/insights/metric-explainer.tsx b/src/components/insights/metric-explainer.tsx deleted file mode 100644 index 6f9ba4630..000000000 --- a/src/components/insights/metric-explainer.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { useId, useState } from "react"; -import { HelpCircle } from "lucide-react"; - -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; -import { useIsMobile } from "@/hooks/use-is-mobile"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -/** - * v1.8.0 — per-category "What is X?" explainer. - * - * A question-mark glyph that sits next to a metric sub-page heading and, - * on tap / Enter / Space, surfaces one to two layperson sentences that - * define the metric. The copy is static — there is no network call — so - * the explainer paints instantly and works offline. Each metric resolves - * its title + body off a stable i18n key pair: - * - * insights.subPage.explainer.Title - * insights.subPage.explainer.Body - * - * The surface choice mirrors ``: a popover on - * `md+`, a bottom-sheet on phone-class viewports. Both branches share the - * same body copy so the read is identical regardless of viewport. - * - * a11y: the trigger is a real ` - ); - - if (isMobile) { - return ( - <> - {trigger} - -

- {body} -

-
- - ); - } - - return ( - - {trigger} - -

- {title} -

-

- {body} -

-
-
- ); -} diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index c444dc25c..b875518c1 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -1,6 +1,6 @@ "use client"; -import Link from "next/link"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { ExternalLink, Target } from "lucide-react"; @@ -11,6 +11,7 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { RangeBar } from "@/components/targets/range-bar"; import { ConsistencyStrip } from "@/components/targets/consistency-strip"; import { TargetStatusPill } from "@/components/targets/target-status-pill"; +import { TargetEditSheet } from "@/components/targets/target-edit-sheet"; import { getTargetSourceLink } from "@/lib/targets/source-link"; /** @@ -36,7 +37,9 @@ import { getTargetSourceLink } from "@/lib/targets/source-link"; * (the spatial answer). BP renders the second diastolic bar. * • Row 3: in-target share % + the 30-day average. * • Row 4: `` — the last seven days at a glance. - * • Footer: the "Adjust target range" link to `/targets`. + * • Footer: an "Adjust target range" button that opens the + * `` inline (v1.8.6 — the `/targets` page is + * deprecated, so the editor mounts here rather than routing). * * Each insights slug maps to one target `type`, except blood glucose * which maps to up to four per-context cards (fasting / postprandial / @@ -169,9 +172,9 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { if (panels.length === 0) return null; - // Glucose fans out to up to four per-context panels. The - // "Adjust target range" link is identical on every one, so render - // it once for the whole group rather than repeating it per panel. + // Glucose fans out to up to four per-context panels. Each context + // maps to its own editable threshold, so every panel carries its own + // inline edit button rather than a single shared one. return (
{panels.map((panel) => ( @@ -179,12 +182,8 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { key={panel.type} target={panel} heading={panel.label} - hideAdjustLink /> ))} -
- -
); } @@ -207,25 +206,6 @@ interface TargetReferencePanelProps { bpDiastolic?: TargetsResponse["bpDiastolic"]; /** Optional sub-heading shown above the panel (used for glucose contexts). */ heading?: string; - /** - * Suppress the per-panel "Adjust target range" link. Glucose stacks - * up to four panels and lifts a single link to the group wrapper, so - * each panel omits its own to avoid repeating the same link. - */ - hideAdjustLink?: boolean; -} - -/** The shared "Adjust target range" link to the `/targets` editing surface. */ -function AdjustTargetLink() { - const { t } = useTranslations(); - return ( - - {t("insights.subPage.target.adjustLink")} - - ); } /** @@ -239,10 +219,17 @@ function TargetReferencePanel({ target, bpDiastolic, heading, - hideAdjustLink, }: TargetReferencePanelProps) { const { t } = useTranslations(); + // v1.8.6 — the `/targets` page is deprecated; the per-metric editor + // moved here. The button below opens the same self-contained + // `` the targets card mounts, seeded with this + // metric's type / unit / range (and the diastolic range for BP). The + // sheet writes `PUT /api/user/thresholds` and invalidates the + // `insightsTargets()` cache on save, so the panel repaints in place. + const [editOpen, setEditOpen] = useState(false); + const { range, unit } = target; if (!range) return null; @@ -378,7 +365,7 @@ function TargetReferencePanel({ /> ) : null} - {/* Footer: source link + adjust-target link. */} + {/* Footer: source link + inline adjust-target button. */}
+ + {/* v1.8.6 — inline target editor, mounted alongside the panel and + portalled by the sheet primitive. The body only instantiates its + TanStack Query hooks once `editOpen` is true (lazy mount inside + the sheet), so closed panels stay cheap. */} +
); } diff --git a/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx new file mode 100644 index 000000000..807bed63e --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-insights-sections-in-target.test.tsx @@ -0,0 +1,113 @@ +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"; + +/** + * v1.8.6 QA — the in-target share must appear exactly once on the mood + * page. The canonical surface is ``; when it renders + * (inTargetPct present) the same-number `in-target` takeaway is dropped + * from the narrative feed so the percentage is not duplicated one row + * below the headline tile. + */ + +// `useAuth` runs its own /me query; stub it to a signed-in user so the +// section's `enabled: isAuthenticated` gate opens without a fetch. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1" }, + isLoading: false, + isAuthenticated: true, + error: null, + refetch: () => {}, + }), +})); + +import { MoodInsightsSections } from "../mood-insights-sections"; + +type Response = { + summary: { totalEntries: number; inTargetPct: number | null }; + heatmap: { windowDays: number; cells: [] }; + distribution: []; + weekday: []; + tags: []; + structuredTags: []; + narratives: Array<{ + kind: string; + messageKey: string; + vars: Record; + }>; + correlations: { + sleep: EmptyCorrelation; + steps: EmptyCorrelation; + pulse: EmptyCorrelation; + }; +}; + +type EmptyCorrelation = { result: null; points: []; n: number }; +const emptyCorrelation: EmptyCorrelation = { result: null, points: [], n: 0 }; + +function renderWithData(data: Response) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + // Seed the cache so the synchronous SSR render sees resolved data. + queryClient.setQueryData(queryKeys.moodInsights(), data); + return renderToStaticMarkup( + + + + + , + ); +} + +function baseData(inTargetPct: number | null): Response { + return { + summary: { totalEntries: 12, inTargetPct }, + heatmap: { windowDays: 30, cells: [] }, + distribution: [], + weekday: [], + tags: [], + structuredTags: [], + narratives: [ + { + kind: "in-target", + messageKey: "insights.mood.narrative.inTarget", + vars: { pct: "72" }, + }, + { + kind: "streak", + messageKey: "insights.mood.narrative.streak", + vars: { days: "5" }, + }, + ], + correlations: { + sleep: emptyCorrelation, + steps: emptyCorrelation, + pulse: emptyCorrelation, + }, + }; +} + +describe(" — in-target dedup", () => { + it("renders the tile and drops the duplicate in-target narrative", () => { + const html = renderWithData(baseData(72)); + // The tile carries the percentage exactly once. + expect((html.match(/72%/g) ?? []).length).toBe(1); + // The feed's in-target sentence is suppressed. + expect(html).not.toContain("of the last 30 days"); + // The tile is still present. + expect(html).toContain("of recent days in your good-mood range"); + // Other narratives survive. + expect(html).toContain("5 days in a row"); + }); + + it("keeps the in-target narrative when no tile renders", () => { + const html = renderWithData(baseData(null)); + // No tile (inTargetPct null) → the narrative is the only surface. + expect(html).toContain("72% of the last 30 days"); + }); +}); diff --git a/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx b/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx new file mode 100644 index 000000000..f6bc4db9a --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-narrative-feed.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + MoodNarrativeFeed, + type MoodNarrativeItem, +} from "../mood-narrative-feed"; +import { MoodInTargetTile } from "../mood-in-target-tile"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders nothing for an empty feed (no platitude placeholders)", () => { + expect(render()).toBe(""); + }); + + it("resolves the weekday key inside the sentence", () => { + const items: MoodNarrativeItem[] = [ + { + kind: "weekday-dip", + messageKey: "insights.mood.narrative.weekdayDip", + vars: { weekdayKey: "charts.weekdaysFull.mon", delta: "0.8" }, + }, + ]; + const html = render(); + expect(html).toContain("Monday"); + expect(html).toContain("0.8"); + expect(html).toContain("dips most"); + expect(html).toContain('data-slot="mood-narrative-feed"'); + }); + + it("resolves a structured tag key into the sentence", () => { + const items: MoodNarrativeItem[] = [ + { + kind: "tag-lift", + messageKey: "insights.mood.narrative.tagLift", + vars: { tagKey: "charts.weekdaysFull.fri", delta: "0.6" }, + }, + ]; + // `charts.weekdaysFull.fri` stands in for a catalog label key — the + // renderer must resolve `tagKey` the same way it resolves `weekdayKey`. + const html = render(); + expect(html).toContain("Friday"); + expect(html).not.toContain("charts.weekdaysFull.fri"); + expect(html).toContain("0.6"); + }); + + it("renders one row per takeaway in order", () => { + const items: MoodNarrativeItem[] = [ + { + kind: "in-target", + messageKey: "insights.mood.narrative.inTarget", + vars: { pct: "72" }, + }, + { + kind: "streak", + messageKey: "insights.mood.narrative.streak", + vars: { days: "5" }, + }, + ]; + const html = render(); + expect((html.match(/
  • ", () => { + it("renders nothing when the percentage is unavailable", () => { + expect(render()).toBe(""); + }); + + it("shows the rounded in-target percentage", () => { + const html = render(); + expect(html).toContain("72%"); + expect(html).toContain("good-mood range"); + }); +}); diff --git a/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts new file mode 100644 index 000000000..ca89bd9e3 --- /dev/null +++ b/src/components/insights/mood/__tests__/mood-notes-timeline-removed.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +const COMPONENT_DIR = join(__dirname, ".."); +const MOOD_LIST = join( + __dirname, + "../../../mood/mood-list.tsx", +); + +/** + * v1.8.6 — the notes timeline display is removed from the mood insights + * surface (note capture on the form stays). These structural guards keep + * the surface from regressing back to a notes feed and keep long notes + * truncated in the entries table. + */ +describe("mood notes timeline removal", () => { + it("deletes the notes-timeline component file", () => { + expect(existsSync(join(COMPONENT_DIR, "mood-notes-timeline.tsx"))).toBe( + false, + ); + }); + + it("no longer renders the notes timeline from the insights sections", () => { + const src = readFileSync( + join(COMPONENT_DIR, "mood-insights-sections.tsx"), + "utf8", + ); + expect(src).not.toContain("MoodNotesTimeline"); + expect(src).not.toContain("notesTimeline"); + expect(src).not.toContain("notesTimelineTitle"); + }); + + it("mounts the narrative feed in the insights sections", () => { + const src = readFileSync( + join(COMPONENT_DIR, "mood-insights-sections.tsx"), + "utf8", + ); + expect(src).toContain("MoodNarrativeFeed"); + expect(src).toContain("MoodInTargetTile"); + }); +}); + +describe("mood entries table — long-note truncation", () => { + const src = readFileSync(MOOD_LIST, "utf8"); + + it("caps the note column width to prevent horizontal scroll", () => { + expect(src).toContain("max-w-[18rem]"); + }); + + it("clamps the note and exposes the full text via a tooltip", () => { + expect(src).toContain("line-clamp-2"); + expect(src).toContain("TooltipContent"); + }); +}); diff --git a/src/components/insights/mood/mood-in-target-tile.tsx b/src/components/insights/mood/mood-in-target-tile.tsx new file mode 100644 index 000000000..6e3241e90 --- /dev/null +++ b/src/components/insights/mood/mood-in-target-tile.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Target } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.8.6 — in-target headline tile. + * + * Promotes the already-computed `inTargetPct` (previously only fed to + * the LLM snapshot) to a visible stat: the share of recent days that + * landed in the good-mood band. Renders nothing when the number is + * unavailable (no recent data) so the page degrades gracefully. + */ +export function MoodInTargetTile({ pct }: { pct: number | null }) { + const { t } = useTranslations(); + + if (pct == null) return null; + + const rounded = Math.round(pct); + + return ( + + +
    + +
    +
    +

    + {t("insights.mood.inTargetValue", { pct: String(rounded) })} +

    +

    + {t("insights.mood.inTargetCaption")} +

    +
    +
    +
    + ); +} diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index db1b9bc6e..e65225593 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -29,9 +29,10 @@ import { type MoodStructuredTagRow, } from "./mood-structured-tag-breakdown"; import { - MoodNotesTimeline, - type MoodNoteEntry, -} from "./mood-notes-timeline"; + MoodNarrativeFeed, + type MoodNarrativeItem, +} from "./mood-narrative-feed"; +import { MoodInTargetTile } from "./mood-in-target-tile"; /** * v1.8.5 — additional Mood Insights sections. @@ -46,6 +47,7 @@ import { interface MoodInsightsResponse { summary: { totalEntries: number; + inTargetPct: number | null; }; heatmap: { windowDays: number; @@ -55,7 +57,7 @@ interface MoodInsightsResponse { weekday: MoodWeekdayRow[]; tags: MoodTagRow[]; structuredTags: MoodStructuredTagRow[]; - notesTimeline: MoodNoteEntry[]; + narratives: MoodNarrativeItem[]; correlations: { sleep: MoodMetricCorrelationData; steps: MoodMetricCorrelationData; @@ -110,10 +112,22 @@ export function MoodInsightsSections() { const hasTags = data.tags.length > 0; const hasStructuredTags = data.structuredTags.length > 0; - const hasNotes = data.notesTimeline.length > 0; + + // The in-target tile is the canonical surface for the in-target share. + // When it renders (inTargetPct present) drop the same-number `in-target` + // takeaway from the feed so the percentage appears exactly once on the + // page. The narrative still rides the API/LLM payload unchanged. + const inTargetShown = data.summary.inTargetPct != null; + const narratives = inTargetShown + ? data.narratives.filter((item) => item.kind !== "in-target") + : data.narratives; return (
    + + + +
    - - {hasNotes && ( - - - - )}
  • ); } diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx new file mode 100644 index 000000000..a528cd69f --- /dev/null +++ b/src/components/insights/mood/mood-narrative-feed.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { + ArrowDownRight, + ArrowUpRight, + CalendarDays, + Flame, + Tag, + Target, + TrendingDown, + TrendingUp, +} from "lucide-react"; +import type { ComponentType } from "react"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * v1.8.6 — narrative takeaway feed. + * + * The "read this first" layer above the mood charts: a ranked, compact + * list of plain-language takeaways the server already gated on its + * thresholds. The component is a pure renderer — every gating decision + * lives in `computeMoodNarratives`, so an empty array renders nothing + * (no platitude placeholders). Each row resolves its i18n template with + * the server-supplied vars; the weekday key is itself an i18n key so it + * localises with the rest of the bundle. Notes render as plain React + * text children (no markdown library — XSS posture). + */ + +export interface MoodNarrativeItem { + kind: string; + messageKey: string; + vars: Record; +} + +const ICONS: Record> = { + "weekday-dip": TrendingDown, + "weekday-peak": CalendarDays, + trend: TrendingUp, + weekend: CalendarDays, + "tag-lift": ArrowUpRight, + "tag-drop": ArrowDownRight, + "in-target": Target, + streak: Flame, +}; + +function pickIcon(item: MoodNarrativeItem): ComponentType<{ + className?: string; +}> { + if (item.kind === "trend") { + return item.vars.direction === "down" ? TrendingDown : TrendingUp; + } + if (item.kind === "weekend") { + return item.vars.direction === "down" ? ArrowDownRight : ArrowUpRight; + } + return ICONS[item.kind] ?? Tag; +} + +export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) { + const { t } = useTranslations(); + + if (items.length === 0) return null; + + return ( +
      + {items.map((item, index) => { + const Icon = pickIcon(item); + // The weekday key is itself an i18n key — resolve it before + // interpolating so the sentence reads in the active locale. A + // structured tag→mood takeaway carries its catalog `tagKey` + // (an i18n key) the same way; flat tags supply `tag` verbatim. + const vars: Record = { ...item.vars }; + if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey); + if (vars.tagKey) vars.tag = t(vars.tagKey); + return ( +
    • + +

      + {t(item.messageKey, vars)} +

      +
    • + ); + })} +
    + ); +} diff --git a/src/components/insights/mood/mood-notes-timeline.tsx b/src/components/insights/mood/mood-notes-timeline.tsx deleted file mode 100644 index 136cdbcfd..000000000 --- a/src/components/insights/mood/mood-notes-timeline.tsx +++ /dev/null @@ -1,78 +0,0 @@ -"use client"; - -import { formatDateTime } from "@/lib/format"; -import { useTranslations } from "@/lib/i18n/context"; -import { MOOD_LABEL_KEYS } from "@/lib/mood/labels"; - -/** - * v1.8.5 (C1) — notes timeline. - * - * Chronological feed of recent mood entries that carry a free-text note, - * each with its score, mood label, note text, and tag chips. Renders the - * note as a plain React text child (no markdown library — the project - * forbids one for XSS reasons). - */ - -export interface MoodNoteEntry { - date: string; - loggedAt: string; - score: number; - mood: string | null; - note: string; - tags: string[]; - structuredTagLabelKeys: string[]; -} - -export function MoodNotesTimeline({ notes }: { notes: MoodNoteEntry[] }) { - const { t } = useTranslations(); - - return ( -
      - {notes.map((entry) => { - const moodLabel = - entry.mood && MOOD_LABEL_KEYS[entry.mood] - ? t(MOOD_LABEL_KEYS[entry.mood]) - : entry.mood; - return ( -
    • -
      - - {entry.score} - - {moodLabel && ({moodLabel})} - · - {formatDateTime(entry.loggedAt)} -
      -

      - {entry.note} -

      - {(entry.structuredTagLabelKeys.length > 0 || - entry.tags.length > 0) && ( -
      - {entry.structuredTagLabelKeys.map((labelKey) => ( - - {t(labelKey)} - - ))} - {entry.tags.map((tag) => ( - - {tag} - - ))} -
      - )} -
    • - ); - })} -
    - ); -} diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 1bb8454d6..e12d34a60 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -6,7 +6,7 @@ import { ListOrdered } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { MetricExplainer } from "@/components/insights/metric-explainer"; +import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -40,18 +40,16 @@ export interface SubPageShellProps { */ description?: string; /** - * v1.8.0 — when set, a `?` glyph next to the heading opens a static - * "What is X?" explainer (popover on desktop, bottom-sheet on phones). - * The value is the metric key feeding - * `insights.subPage.explainer.{Title,Body}`. Omitted on the - * mother page; every routed metric sub-page passes its key. + * v1.8.0 — when set, the metric's static "What is X?" definition + * renders as a muted caption directly beneath the heading. The value + * is the metric key feeding `insights.subPage.explainer.Body`. + * Omitted on the mother page; every routed metric sub-page passes its + * key. * - * v1.8.4 — the same explainer body also renders inline as a muted - * caption directly beneath the heading, so the one- to two-sentence - * definition is visible without opening the popover. The `?` glyph - * stays as the always-available pointer for the same copy. Both the - * inline caption and the popover read the identical - * `insights.subPage.explainer.Body` string — no duplicated copy. + * v1.8.6 — the round `?` popover affordance next to the heading is + * gone (it read as restless next to the title). The inline definition + * caption stays — it is the single always-visible source of the + * one- to two-sentence definition, no tap required. */ explainerMetric?: string; /** @@ -77,11 +75,27 @@ export interface SubPageShellProps { */ statStrip?: ReactNode; /** - * v1.8.5 W4b — measurement-diversity nudge mounted just under the stat - * strip. A gentle hint when readings cluster on one weekday / time. - * Self-gating: renders nothing when the spread is healthy. + * v1.8.5 W4b — measurement-diversity nudge. + * + * v1.8.6 — relocated from an inline block under the stat strip to a + * `Lightbulb` glyph beside the heading (a hover / focus Tooltip carries + * the hint copy). The node is ``, which is + * self-gating: it renders the glyph only when readings cluster on one + * weekday / time, and nothing when the spread is healthy. */ diversityNudge?: ReactNode; + /** + * v1.8.6 — opt-in Coach launch in the page header. + * + * When true, the shell mounts an icon-only `` at + * the top-right of the header, vertically aligned with the heading, so + * "Coach fragen" reads as a header action rather than a foot-of-page + * button. Every routed category page passes it; the mother page and + * empty-state branches omit it. The button self-gates on the Coach + * feature flag + per-user opt-out, so a disabled-Coach tenant shows + * nothing. + */ + coachLaunch?: boolean; /** * v1.8.5 W4b — a "show all readings" entry rendered at the foot of the * page, after the chart + cards. Sub-pages pass the metric's @@ -100,6 +114,7 @@ export function SubPageShell({ focusOnMount = false, statStrip, diversityNudge, + coachLaunch = false, showAllValuesType, children, }: SubPageShellProps) { @@ -128,34 +143,60 @@ export function SubPageShell({ return (
    -
    -

    - {title} -

    - {explainerMetric ? ( - - ) : null} - {badge ? ( - - {badge} - + {/* v1.8.6 — the heading row pins the Coach icon top-right while the + title / nudge / badge wrap together on the left. + + Design M2 fix: the Coach icon is a `shrink-0` sibling of the + left group (not an `ml-auto` member of a single flex-wrap row), + so a long or wrapped title can never isolate it or crowd it on a + 375 px screen — it stays cleanly top-right and the title text + reflows in the space that remains. + + Design M1 fix: the row reserves a constant `min-h-10` so the + baseline holds whether or not the self-gating diversity-nudge + glyph (a 44 px-tall touch target) is present — category pages + with and without a nudge now align. `items-start` keeps the + Coach icon at the top of the row when the title wraps to two + lines. */} +
    +
    +

    + {title} +

    + {/* v1.8.6 — the diversity nudge rides the heading as a + `Lightbulb` glyph (the node self-gates to nothing when the + spread is healthy), replacing the old inline block. */} + {diversityNudge} + {badge ? ( + + {badge} + + ) : null} +
    + {/* v1.8.6 — Coach launch sits top-right at heading height as an + icon, pinned by the row's `justify-between` so it aligns with + the title regardless of badge / glyph presence or a wrapped + title. The button self-gates on the Coach flag + per-user + opt-out. */} + {coachLaunch ? ( + ) : null}
    {explainerMetric ? ( - // v1.8.4 — surface the explainer definition inline, reusing the - // exact body string the `?` popover reads. + // v1.8.4 — surface the metric's static definition inline, + // directly beneath the heading. // // v1.8.5 W4a — render the explainer body as the *single* caption // under the heading. Pre-v1.8.5 both this paragraph and the @@ -165,6 +206,10 @@ export function SubPageShell({ // explainer body is the definition, so it wins; the description // only renders when no explainer is set (the mother page and any // future explainer-less sub-page). + // + // v1.8.6 — this caption is now the only surface for the + // definition; the round `?` popover that used to read the same + // string was removed.

    {description}

    ) : null}
    - {/* v1.8.5 W4b — numbers-first stat strip + diversity nudge sit - between the header and the chart so the page leads with data, - mirroring the Apple Health / Withings detail layout. Both are - self-gating (render nothing without data), so the spacing - rhythm holds on the empty-state and brand-new-metric paths. */} + {/* v1.8.5 W4b — numbers-first stat strip sits between the header + and the chart so the page leads with data, mirroring the Apple + Health / Withings detail layout. Self-gating (renders nothing + without data), so the spacing rhythm holds on the empty-state + and brand-new-metric paths. + v1.8.6 — the diversity nudge moved up to the heading row. */} {statStrip} - {diversityNudge} {children} {/* v1.8.5 W4b — "show all readings" entry at the foot, linking to - the dedicated per-metric values subpage. */} + the dedicated per-metric values subpage. + v1.8.6 — normalised to the `h-10` secondary-button height so it + reads as a consistent control now that the foot-of-page Coach + button (which set the old visual baseline) has moved to the + header. */} {showAllValuesType ? (
    ); })} diff --git a/src/components/layout/__tests__/bottom-nav.test.tsx b/src/components/layout/__tests__/bottom-nav.test.tsx index 4a7985167..b161d41c3 100644 --- a/src/components/layout/__tests__/bottom-nav.test.tsx +++ b/src/components/layout/__tests__/bottom-nav.test.tsx @@ -5,9 +5,12 @@ * - exactly five primary links + one "More" button render in the strip * - the More button is keyboard-reachable as a `
    - - -
    + {/* Tier 3b — Medikament löschen */} +

    {t("medications.detail.zone.delete.title")} diff --git a/src/components/medications/settings-group.tsx b/src/components/medications/settings-group.tsx index 769682184..f304b2d58 100644 --- a/src/components/medications/settings-group.tsx +++ b/src/components/medications/settings-group.tsx @@ -2,11 +2,13 @@ /** * v1.7.0 — advanced-settings group header. - * - * Lightweight presentational grouping for the redesigned - * ``: an uppercase micro-label over a hairline - * rule, then the group body. Reads as "one settings document, four - * parts" rather than three bordered section cards. + * v1.8.6 — rebuilt as a bordered section card. The sheet now stacks + * every group in a single column (Reminders → Lifecycle → Data → + * Danger), so each group needs its own frame to read as a distinct + * block rather than a hairline-separated run. The card owns one spacing + * rule: an uppercase micro-label header, then a `divide-y` body where + * each row carries `py-3`. Callers pass one `

    ` per row; the card + * supplies the dividers so no row interleaves its own ``. * * The label is a plain `

    ` (not a heading with an id) so it stays out * of the `aria-labelledby` graph — the sheet already owns the dialog @@ -17,26 +19,30 @@ import { type ReactNode } from "react"; +import { cn } from "@/lib/utils"; + export interface SettingsGroupProps { label: ReactNode; children: ReactNode; dataSlot?: string; + className?: string; } export function SettingsGroup({ label, children, dataSlot, + className, }: SettingsGroupProps) { return (

    -

    +

    {label}

    - {children} +
    {children}
    ); } diff --git a/src/components/medications/wizard/MedicationWizardDialog.tsx b/src/components/medications/wizard/MedicationWizardDialog.tsx index 0d0f7625b..236d18b28 100644 --- a/src/components/medications/wizard/MedicationWizardDialog.tsx +++ b/src/components/medications/wizard/MedicationWizardDialog.tsx @@ -40,6 +40,7 @@ import { toast } from "sonner"; import { Bell, CalendarRange, + ChevronRight, Clock, FlaskConical, Loader2, @@ -50,7 +51,6 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; @@ -62,6 +62,7 @@ import { buildCreateBody, commitActiveDraft, emptyWizardPayload, + firstInvalidIndex, hydrateWizardPayload, landingStepForEdit, type MedicationPayload, @@ -70,6 +71,7 @@ import { setActiveSchedule, type WizardPayload, } from "./wizard-payload"; +import { WizardStepper } from "./WizardStepper"; import { Step1Name } from "./steps/Step1Name"; import { Step2Class } from "./steps/Step2Class"; import { Step3Dose } from "./steps/Step3Dose"; @@ -176,7 +178,26 @@ function WizardDialogShell({ const displayStep = fallbackIndex + 1; const isLastStep = displayStep === totalSteps; const isFirstStep = displayStep === 1; - const progress = (displayStep / totalSteps) * 100; + + // v1.8.6 W4b — forward-reachability ceiling for the dot stepper. The + // dot at path-index `j` is reachable iff every gate from the active + // slot up to (but not including) `j` passes. `firstInvalidIndex` + // returns the first failing slot from `fallbackIndex`; every slot up + // to and including that index is reachable (the user can always step + // onto the gate that's currently blocking them). Slots already at or + // behind the active slot are always reachable. On edit the hydrated + // payload validates every gate, so the ceiling is the whole path. + const reachableUntil = useMemo( + () => + Math.max(fallbackIndex, firstInvalidIndex(payload, stepList, fallbackIndex)), + [payload, stepList, fallbackIndex], + ); + // Jump-to-last is enabled only when the WHOLE path validates — i.e. + // every step from the first slot onward passes its gate, so a single + // tap can land the user on the review step without stranding them + // behind an unmet requirement. + const lastReachable = + firstInvalidIndex(payload, stepList, 0) >= totalSteps; const canContinue = useMemo( () => validateStep(payload, step), @@ -212,6 +233,31 @@ function WizardDialogShell({ } }, [payload.mode, payload.cadence.kind, step]); + // v1.8.6 W4b — jump-to-arbitrary-step from the dot stepper. Backward + // jumps are always allowed; a forward jump is gated on EVERY + // intervening path gate validating (a multi-step lookahead over the + // single-step `validateStep`). A jump that lands on the final review + // step replicates the `goNext` Step-8 commit so the schedule-list view + // renders the just-edited cadence + times. On edit the payload is + // pre-hydrated so every gate passes → all dots reachable and + // jump-to-last is one tap. + const goToStep = useCallback( + (target: number) => { + const list = progressIndices(payload.mode, payload.cadence.kind); + const j = list.indexOf(target); + // `fallbackIndex` mirrors the active dot the stepper renders, so + // the jump gate and the visual reachability never drift. + const from = fallbackIndex; + if (j < 0 || j === from) return; + if (j > from && firstInvalidIndex(payload, list, from) < j) return; + if (target === 8) { + setPayload((prev) => commitActiveDraft(prev)); + } + setStep(target as StepNumber); + }, + [payload, fallbackIndex], + ); + // Focus the first interactive control on each step change. The // initial render skips so opening the dialog does not steal focus // from the trigger button — UNLESS the dialog opened on a non-Step-1 @@ -324,6 +370,26 @@ function WizardDialogShell({ total: totalSteps, }); + // v1.8.6 W4b — short per-slot labels for the dot stepper. Keyed by + // raw step number so the path-aware `stepList` maps straight through. + const stepperLabels = useMemo(() => { + const map: Record = {}; + for (const n of stepList) { + map[n] = t(`medications.wizard.steps.step${n}.short`); + } + return map; + }, [stepList, t]); + + // "What's next" hint under the step title. On the final slot the + // hint flips to the save target; otherwise it names the next slot's + // short label. + const nextSlot = stepList[fallbackIndex + 1]; + const nextHint = isLastStep + ? t("medications.wizard.nav.reviewHint") + : t("medications.wizard.nav.nextHint", { + step: t(`medications.wizard.steps.step${nextSlot}.short`), + }); + const primaryCtaLabel = isLastStep ? mode === "edit" ? t("medications.wizard.nav.saveEdit") @@ -396,24 +462,30 @@ function WizardDialogShell({ aria-busy={submitting || undefined} className="flex flex-col" > - {/* v1.5.5 D-3 §8 — progress strip + body share the same - outer padding so they read as one column. The right - padding leaves room for the shell's 36 px close-X - (`pr-12` mobile / `pr-14` desktop). */} + {/* v1.8.6 W4b — dot stepper replaces the v1.5.5 continuous + `` strip + "Step X of Y" caption. The dot row + + body share the same outer padding so they read as one + column; the right padding leaves room for the shell's + 36 px close-X (`pr-12` mobile / `pr-14` desktop). The NL + "Describe" affordance is re-homed beside the stepper on + the Step-1 create path. */}
    - `. The - // primitive ships `transition-all`; the strip-bar morph - // proposed in D-2 was stripped in D-3 #4. No clip-path, - // no keyframes; reduced-motion picked up via the shared - // `motion-reduce` invariant. - className="h-1.5 transition-all motion-reduce:transition-none" - aria-label={stepOf} + goToStep(stepList[0])} + onLast={() => goToStep(stepList[totalSteps - 1])} + firstEnabled={!isFirstStep} + lastEnabled={lastReachable && !isLastStep} + firstLabel={t("medications.wizard.nav.jumpFirst")} + lastLabel={t("medications.wizard.nav.jumpLast")} + srLabel={stepOf} /> -
    -

    {stepOf}

    - {step === 1 && mode === "create" && ( + {step === 1 && mode === "create" && ( +
    - )} -
    +
    + )}
    {/* Step body — v1.5.5 D-3 §8 spacing buckets: @@ -480,6 +552,19 @@ function WizardDialogShell({

    {stepSubline}

    + {/* v1.8.6 W4b — muted "what's next" hint so the user + knows what the Next tap leads to (or, on the final + slot, that Next saves). */} +

    +

    diff --git a/src/components/medications/wizard/WizardStepper.tsx b/src/components/medications/wizard/WizardStepper.tsx new file mode 100644 index 000000000..c46848754 --- /dev/null +++ b/src/components/medications/wizard/WizardStepper.tsx @@ -0,0 +1,211 @@ +"use client"; + +/** + * v1.8.6 W4b — dot/bullet stepper for the medication wizard. + * + * Replaces the continuous `` bar + "Step X of Y" caption with + * one dot per path slot (so the one-shot path shows 5 dots, the + * recurring path 8 — the dialog feeds the mode/cadence-aware `stepList` + * straight in, so the dot count auto-collapses live). Each dot is a + * button: completed + reachable dots jump on click, future dots gate on + * the dialog's multi-step `validateStep` lookahead (`reachableUntil`). + * `SkipBack` / `SkipForward` flank the row for jump-to-first / + * jump-to-last. + * + * Purely presentational — every gating decision is computed in the + * dialog (`goToStep` + `reachableUntil`) so the stepper stays dumb. + * Connectors run dashed for not-yet-reached segments and solid for + * completed ones, giving the "bullets, not one continuous line" feel. + */ + +import { SkipBack, SkipForward } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export interface WizardStepperProps { + /** Ordered raw step numbers for the active mode + cadence path. */ + steps: readonly number[]; + /** The raw step number the body currently renders. */ + current: number; + /** + * Path-index ceiling (exclusive upper bound) of forward-reachable + * slots. A slot at index `j` is reachable iff `j <= reachableUntil`. + * The dialog computes it from the `validateStep` lookahead. + */ + reachableUntil: number; + /** Short per-step label keyed by raw step number. */ + labels: Record; + /** Jump to a raw step number. */ + onJump: (step: number) => void; + onFirst: () => void; + onLast: () => void; + firstEnabled: boolean; + lastEnabled: boolean; + /** Accessible labels for the flanking jump buttons. */ + firstLabel: string; + lastLabel: string; + /** Accessible group label, e.g. "Step 4 of 8". */ + srLabel: string; +} + +export function WizardStepper({ + steps, + current, + reachableUntil, + labels, + onJump, + onFirst, + onLast, + firstEnabled, + lastEnabled, + firstLabel, + lastLabel, + srLabel, +}: WizardStepperProps) { + const currentIndex = steps.indexOf(current); + // When the step pointer is briefly off-path (e.g. Step 5 after the + // mode collapses the path), pin the active slot to the last slot at + // or before the current step so the active dot never disappears. + const activeIndex = + currentIndex >= 0 + ? currentIndex + : steps.reduce((acc, n, i) => (n <= current ? i : acc), 0); + + const activeStepNumber = steps[activeIndex]; + const activeLabel = + labels[activeStepNumber] ?? String(activeStepNumber ?? current); + + return ( +
    +
    + + +
      + {steps.map((stepNumber, index) => { + const isActive = index === activeIndex; + const isCompleted = index < activeIndex; + const isReachable = index <= reachableUntil; + const label = labels[stepNumber] ?? String(stepNumber); + // Backward + current are always navigable; forward is gated + // on the reachability ceiling. + const enabled = index <= activeIndex || isReachable; + // A connector segment leads INTO this dot from the previous + // one; it reads "completed" (solid) once we've passed it. + const connectorSolid = index <= activeIndex; + + return ( +
    1. + {index > 0 && ( +
    2. + ); + })} +
    + + +
    + + {/* Compact caption — on narrow viewports the per-dot labels collapse + to the active dot only, so this line names where the user stands + ("Step 4 of 8 · How often") instead of leaving bare dots. Hidden + from `sm+` where every dot carries its own label, and aria-hidden + because the group already announces `srLabel`. */} + +
    + ); +} diff --git a/src/components/medications/wizard/__tests__/wizard-payload.test.ts b/src/components/medications/wizard/__tests__/wizard-payload.test.ts index caa0854aa..4dcef09f1 100644 --- a/src/components/medications/wizard/__tests__/wizard-payload.test.ts +++ b/src/components/medications/wizard/__tests__/wizard-payload.test.ts @@ -5,6 +5,7 @@ import { buildCreateBody, commitActiveDraft, emptyWizardPayload, + firstInvalidIndex, hydrateWizardPayload, landingStepForEdit, type MedicationPayload, @@ -13,6 +14,7 @@ import { rowFromTreatment, setActiveSchedule, summariseCadence, + summariseScheduleDraft, validateStep, type WizardPayload, WIZARD_TREATMENT_MAPPING, @@ -193,6 +195,102 @@ describe("validateStep", () => { }); }); +/** + * v1.8.6 W4b — `firstInvalidIndex` is the multi-step forward lookahead + * that powers the dot stepper's jump gate (`goToStep`) and its + * `reachableUntil` ceiling. The dialog's rule: + * - backward jumps are always allowed; + * - a forward jump to path-index `j` is allowed iff every gate from + * the active slot up to (but not including) `j` validates, which is + * exactly `firstInvalidIndex(payload, stepList, from) >= j`. + */ +describe("firstInvalidIndex — forward-jump lookahead", () => { + /** A daily-path payload with every gate satisfied. */ + function completeDaily(): WizardPayload { + return { + ...withCadence("daily"), + startsOn: new Date(Date.UTC(2026, 5, 1)), + endsOn: null, + timesOfDay: ["08:00"], + }; + } + + it("returns stepList.length when every slot from the index validates", () => { + const payload = completeDaily(); + const list = progressIndices(payload.mode, payload.cadence.kind); + expect(firstInvalidIndex(payload, list, 0)).toBe(list.length); + }); + + it("stops at the first failing slot — blank name gates the path at 0", () => { + const payload = { ...completeDaily(), name: " " }; + const list = progressIndices(payload.mode, payload.cadence.kind); + // Step 1 (index 0) fails its gate, so no forward jump is allowed. + expect(firstInvalidIndex(payload, list, 0)).toBe(0); + }); + + it("a missing dose gates the path at Step 3 (path-index 2)", () => { + const payload = { ...completeDaily(), doseAmount: "" }; + const list = progressIndices(payload.mode, payload.cadence.kind); + // [1,2,3,...] — Step 3 sits at index 2; it's the first to fail. + expect(firstInvalidIndex(payload, list, 0)).toBe(2); + }); + + it("blocks a forward jump past an invalid intervening step", () => { + // Missing times (Step 7) — daily path is [1,2,3,4,5,7,8]; Step 7 + // sits at index 5. A jump from Step 1 (index 0) to the review slot + // (Step 8, index 6) must be refused because index 5 fails. + const payload = { ...completeDaily(), timesOfDay: [] }; + const list = progressIndices(payload.mode, payload.cadence.kind); + const firstInvalid = firstInvalidIndex(payload, list, 0); + expect(firstInvalid).toBe(5); + const targetIndex = list.indexOf(8); + // Gate rule: forward jump allowed iff firstInvalid >= targetIndex. + expect(firstInvalid >= targetIndex).toBe(false); + }); + + it("allows a forward jump when all intervening gates pass", () => { + const payload = completeDaily(); + const list = progressIndices(payload.mode, payload.cadence.kind); + const fromIndex = 0; + const targetIndex = list.indexOf(8); + expect(firstInvalidIndex(payload, list, fromIndex) >= targetIndex).toBe( + true, + ); + }); + + it("a fully-hydrated edit payload makes jump-to-last (review) reachable", () => { + // Edit hydration populates every field, so on the recurring path + // every gate validates and the whole path is reachable — including + // the final review slot, so jump-to-last is one tap. + const initial: MedicationPayload = { + id: "med-1", + name: "Ramipril", + dose: "5 mg", + category: "BLOOD_PRESSURE", + treatmentClass: "GENERIC", + deliveryForm: "ORAL", + notificationsEnabled: true, + startsOn: new Date(Date.UTC(2026, 5, 1)), + endsOn: null, + oneShot: false, + schedules: [ + { + windowStart: "06:00", + windowEnd: "22:00", + timesOfDay: ["08:00"], + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + }, + ], + }; + const payload = hydrateWizardPayload(initial); + const list = progressIndices(payload.mode, payload.cadence.kind); + expect(firstInvalidIndex(payload, list, 0)).toBe(list.length); + // Whole-path validity → jump-to-last enabled. + expect(firstInvalidIndex(payload, list, 0) >= list.length).toBe(true); + }); +}); + describe("WIZARD_TREATMENT_MAPPING — Step 2 row → request body", () => { it("maps Blutdruck → (GENERIC, BLOOD_PRESSURE)", () => { expect(WIZARD_TREATMENT_MAPPING.bloodPressure).toEqual({ @@ -462,6 +560,49 @@ describe("summariseCadence", () => { }); }); +describe("summariseScheduleDraft — shared cadence-line helper", () => { + const t = makeStubT(); + + // The per-schedule summary and the medication-wide summary build their + // cadence segment from the same extracted helper, so for an equivalent + // cadence shape the draft summary equals the leading segment of the + // full summary (which trails the times / course-window phrases). + it("matches summariseCadence's leading cadence segment", () => { + const cases: WizardPayload[] = [ + withCadence("daily"), + withCadence("everyNWeeks", { intervalWeeks: 2, weekdays: ["MO"] }), + withCadence("rolling", { rollingDays: 14 }), + withCadence("weekdays", { weekdays: ["MO", "WE", "FR"] }), + withCadence("monthly", { dayOfMonth: 15 }), + ]; + for (const base of cases) { + // `withCadence` only sets the flat mirror fields; commit them onto + // the active schedule draft so the draft carries the same cadence. + const p = commitActiveDraft(base); + const draft = p.schedules[p.activeScheduleIndex]; + const draftLine = summariseScheduleDraft(draft, t); + const fullSummary = summariseCadence(p, t); + // The draft line's cadence segment is the leading " · " segment of + // the full summary (which trails times / course-window phrases). + const firstSegment = fullSummary.split(" · ")[0]; + expect(draftLine.split(" · ")[0]).toBe(firstSegment); + } + }); + + it("threads the everyNWeeks interval into the cadence key", () => { + const p = commitActiveDraft(withCadence("everyNWeeks", { intervalWeeks: 3 })); + const out = summariseScheduleDraft(p.schedules[p.activeScheduleIndex], t); + expect(out).toMatch(/cadence\.everyNWeeks[^|]*"n":3/); + }); + + it("omits the cadence detail for a one-shot draft", () => { + const p = commitActiveDraft(withCadence("oneShot")); + const out = summariseScheduleDraft(p.schedules[p.activeScheduleIndex], t); + expect(out).not.toContain("weekdaysDetail"); + expect(out).not.toContain("dayOfMonthDetail"); + }); +}); + describe("hydrateWizardPayload", () => { function makeInitial( overrides: Partial = {}, diff --git a/src/components/medications/wizard/wizard-payload.ts b/src/components/medications/wizard/wizard-payload.ts index 984a05707..21e7f27ce 100644 --- a/src/components/medications/wizard/wizard-payload.ts +++ b/src/components/medications/wizard/wizard-payload.ts @@ -465,6 +465,36 @@ export function validateStep(payload: WizardPayload, step: number): boolean { } } +// ──────────────────────────────────────────────────────────────────── +// firstInvalidIndex — multi-step forward lookahead +// ──────────────────────────────────────────────────────────────────── + +/** + * Walks `stepList` from `fromIndex` (inclusive) and returns the index of + * the first slot whose `validateStep` gate fails, or `stepList.length` + * when every slot from `fromIndex` onward validates. + * + * This generalises the single-step `canContinue` gate + * (`validateStep(payload, step)`) into the forward-jump lookahead the + * dot stepper uses: a forward jump to path-index `j` is allowed iff + * `firstInvalidIndex(payload, stepList, i) >= j` — i.e. every gate + * between the current slot `i` and the target `j` passes. The dot + * stepper reuses the same value as its `reachableUntil` ceiling so the + * gate and the visual reachability never drift. + * + * Pure: reuses `validateStep` verbatim, no new validation logic. + */ +export function firstInvalidIndex( + payload: WizardPayload, + stepList: readonly number[], + fromIndex: number, +): number { + for (let k = Math.max(0, fromIndex); k < stepList.length; k++) { + if (!validateStep(payload, stepList[k])) return k; + } + return stepList.length; +} + // ──────────────────────────────────────────────────────────────────── // buildCreateBody — POST /api/medications request body // ──────────────────────────────────────────────────────────────────── @@ -682,10 +712,6 @@ function summaryKeyForDraft( } } -function summaryKeyForCadence(payload: WizardPayload): string { - return summaryKeyForDraft(payload.mode, payload.cadence, payload.subControls); -} - function weekdaysDetail( tokens: readonly string[], t: (key: string, params?: Record) => string, @@ -702,25 +728,71 @@ function weekdaysDetail( }); } +/** + * The cadence-bearing slice both {@link WizardPayload} and + * {@link ScheduleDraft} share. Summary helpers only ever read these three + * fields, so the cadence-line builder below stays draft/payload-agnostic. + */ +type CadenceShape = Pick; + +/** + * The interpolation count the cadence summary keys read. `everyNWeeks` + * surfaces the week interval; `rolling` the day interval; everything else + * leaves it at zero. Extracted so the medication-wide and per-schedule + * summaries can't drift apart. + */ +function cadenceInterpolationN(shape: CadenceShape): number { + if (shape.cadence.kind === "everyNWeeks") return shape.subControls.intervalWeeks; + if (shape.cadence.kind === "rolling") + return shape.cadence.rollingIntervalDays ?? 0; + return 0; +} + +/** + * The trailing detail clause appended to a cadence phrase (the weekday + * list or the day-of-month). Empty for one-shot doses and for cadences + * that carry no extra detail. + */ function cadenceDetailPhrase( - payload: WizardPayload, + shape: CadenceShape, t: (key: string, params?: Record) => string, ): string { - if (payload.mode === "oneShot") return ""; - switch (payload.cadence.kind) { + if (shape.mode === "oneShot") return ""; + switch (shape.cadence.kind) { case "weekdays": - return weekdaysDetail(payload.subControls.weekdays, t); + return weekdaysDetail(shape.subControls.weekdays, t); case "everyNWeeks": - return weekdaysDetail(payload.subControls.weekdays, t); + return weekdaysDetail(shape.subControls.weekdays, t); case "monthly": return t("medications.wizard.summary.dayOfMonthDetail", { - day: payload.subControls.dayOfMonth, + day: shape.subControls.dayOfMonth, }); default: return ""; } } +/** + * The full cadence line — phrase (`every N weeks`, `daily`, …) plus its + * detail clause. Both {@link summariseCadence} and + * {@link summariseScheduleDraft} build their first segment from this so + * the two summaries render byte-identical cadence text. + */ +function cadenceLineFor( + shape: CadenceShape, + t: (key: string, params?: Record) => string, +): string { + const cadenceKey = summaryKeyForDraft( + shape.mode, + shape.cadence, + shape.subControls, + ); + const cadencePhrase = t(`medications.wizard.summary.cadence.${cadenceKey}`, { + n: cadenceInterpolationN(shape), + }); + return cadencePhrase + cadenceDetailPhrase(shape, t); +} + /** * Plain-language summary for Step 8. Mirrors v1.5.3's helper shape so * the test surface stays familiar. The `t` callback is passed in so @@ -730,20 +802,7 @@ export function summariseCadence( payload: WizardPayload, t: (key: string, params?: Record) => string, ): string { - const cadenceKey = summaryKeyForCadence(payload); - const cadencePhrase = t( - `medications.wizard.summary.cadence.${cadenceKey}`, - { - n: - payload.cadence.kind === "everyNWeeks" - ? payload.subControls.intervalWeeks - : payload.cadence.kind === "rolling" - ? (payload.cadence.rollingIntervalDays ?? 0) - : 0, - }, - ); - const cadenceDetail = cadenceDetailPhrase(payload, t); - const cadenceLine = cadencePhrase + cadenceDetail; + const cadenceLine = cadenceLineFor(payload, t); const timesPhrase = payload.mode !== "oneShot" && payload.timesOfDay.length > 0 ? t("medications.wizard.summary.times", { @@ -777,36 +836,7 @@ export function summariseScheduleDraft( draft: ScheduleDraft, t: (key: string, params?: Record) => string, ): string { - const cadenceKey = summaryKeyForDraft( - draft.mode, - draft.cadence, - draft.subControls, - ); - const cadencePhrase = t( - `medications.wizard.summary.cadence.${cadenceKey}`, - { - n: - draft.cadence.kind === "everyNWeeks" - ? draft.subControls.intervalWeeks - : draft.cadence.kind === "rolling" - ? (draft.cadence.rollingIntervalDays ?? 0) - : 0, - }, - ); - let cadenceDetail = ""; - if (draft.mode !== "oneShot") { - if ( - draft.cadence.kind === "weekdays" || - draft.cadence.kind === "everyNWeeks" - ) { - cadenceDetail = weekdaysDetail(draft.subControls.weekdays, t); - } else if (draft.cadence.kind === "monthly") { - cadenceDetail = t("medications.wizard.summary.dayOfMonthDetail", { - day: draft.subControls.dayOfMonth, - }); - } - } - const cadenceLine = cadencePhrase + cadenceDetail; + const cadenceLine = cadenceLineFor(draft, t); const timesPhrase = draft.mode !== "oneShot" && draft.timesOfDay.length > 0 ? t("medications.wizard.summary.times", { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index d8f729cd6..cabaee497 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -32,6 +32,12 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { @@ -416,12 +422,25 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { ) - - {entry.tags.length > 0 ? entry.tags.join(", ") : "-"} + + + {entry.tags.length > 0 ? entry.tags.join(", ") : "-"} + {entry.note && ( -

    - {entry.note} -

    + + + +

    + {entry.note} +

    +
    + +

    + {entry.note} +

    +
    +
    +
    )}
    diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 4830d8f62..387c07b9a 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -59,6 +59,7 @@ function buildUser(disableCoach: boolean): AuthUser { disableCoach, fullName: null, insurerName: null, + insurerIkNumber: null, insuranceNumber: null, }; } diff --git a/src/components/settings/__tests__/unit-preference-card.test.tsx b/src/components/settings/__tests__/unit-preference-card.test.tsx index 13fd2a391..cbc19421f 100644 --- a/src/components/settings/__tests__/unit-preference-card.test.tsx +++ b/src/components/settings/__tests__/unit-preference-card.test.tsx @@ -50,6 +50,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { disableCoach: false, fullName: null, insurerName: null, + insurerIkNumber: null, insuranceNumber: null, }; } diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index ed3ffd7b8..35c6903d6 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -216,7 +216,7 @@ function TargetEditSheetBody({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); - queryClient.invalidateQueries({ queryKey: ["insights", "targets"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("targets.edit.saveSuccess")); @@ -248,7 +248,7 @@ function TargetEditSheetBody({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] }); - queryClient.invalidateQueries({ queryKey: ["insights", "targets"] }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: ["insights"] }); toast.success(t("targets.edit.resetSuccess")); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 25156041f..e18298e09 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -53,6 +53,11 @@ export interface AuthUser { */ fullName: string | null; insurerName: string | null; + /** + * v1.8.6 — optional German insurer institution number (IKNR, 9 digits). + * Surfaced on the FHIR `Coverage` resource's payor Organization. + */ + insurerIkNumber: string | null; insuranceNumber: string | null; } diff --git a/src/lib/__tests__/query-keys.test.ts b/src/lib/__tests__/query-keys.test.ts index 63ff50f52..82bacdb27 100644 --- a/src/lib/__tests__/query-keys.test.ts +++ b/src/lib/__tests__/query-keys.test.ts @@ -131,11 +131,6 @@ describe("queryKeys factory", () => { "med1", "compliance", ]); - expect(queryKeys.medicationTitration("med1")).toEqual([ - "medications", - "med1", - "titration", - ]); expect(queryKeys.medicationCadence("med1")).toEqual([ "medications", "med1", diff --git a/src/lib/analytics/__tests__/compliance.test.ts b/src/lib/analytics/__tests__/compliance.test.ts index 6c9b91459..33e0652d7 100644 --- a/src/lib/analytics/__tests__/compliance.test.ts +++ b/src/lib/analytics/__tests__/compliance.test.ts @@ -1027,7 +1027,13 @@ describe("classifyIntakeTiming", () => { // `buildComplianceDisplay` decides whether the card keeps the 7-/30-day // percentage bars (dense cadences) or swaps to a per-dose timeline // (sparse cadences). The 30-day window is the density yardstick. -describe("buildComplianceDisplay — density rule + dose timeline", () => { +// v1.8.6 — the compliance display is always two percentage rows; only the +// window day-counts scale with the dosing cadence. `selectComplianceWindows` +// walks a `[7,30] → [30,90] → [90,365]` ladder and returns the densest rung +// whose BOTH windows clear `MIN_STABLE_DOSES` realised expected doses, so a +// daily med stays on 7 / 30, a weekly med steps up, and a rare injection +// lands on the widest rung. The timeline rendering is gone. +describe("buildComplianceDisplay — two rows, cadence-scaled windows", () => { const NOW = new Date("2025-06-15T12:00:00Z"); beforeEach(() => { @@ -1054,17 +1060,25 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { ); } - it("echoes the threshold so a client can re-derive the mode", () => { + it("echoes the density floor so a client can re-derive the rung", () => { const display = buildComplianceDisplay( [], - [{ windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, rrule: "FREQ=DAILY", timesOfDay: ["08:00"] }], + [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ], ctx(), { now: NOW }, ); expect(display.minStableDoses).toBe(MIN_STABLE_DOSES); }); - it("daily med → percent mode (30-day window clears the 4-dose floor)", () => { + it("daily med → 7 / 30-day windows (the densest rung clears the floor)", () => { const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", @@ -1075,13 +1089,37 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { }, ]; const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); - expect(display.mode).toBe("percent"); - expect(display.expected30).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + expect(display.shortDays).toBe(7); + expect(display.longDays).toBe(30); + // Both windows hold ≥ 4 expected doses. + expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); }); - it("35-day-interval med → timeline mode (sparse: <4 doses in 30 days)", () => { - // A monthly injection: one dose every 5 weeks. Anchored on a recent - // start so the engine emits at most one slot inside the 30-day window. + it("weekly med → steps up to 30 / 90-day windows", () => { + // Weekly Mondays-only: the 7-day window holds at most one Monday (< 4), + // so the ladder steps up. The 30-day window holds ~4 Mondays and the + // 90-day window ~13 — the [30, 90] rung is the densest that clears the + // floor on both rows. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=WEEKLY;BYDAY=MO", + timesOfDay: ["08:00"], + }, + ]; + const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); + expect(display.shortDays).toBe(30); + expect(display.longDays).toBe(90); + expect(display.expectedShort).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + expect(display.expectedLong).toBeGreaterThanOrEqual(MIN_STABLE_DOSES); + }); + + it("35-day rolling injection → widest 90 / 365-day windows", () => { + // One dose every five weeks: the 30-day window never holds ≥ 4, so the + // ladder climbs to the top rung where a full year holds ~10 doses. const schedules: ComplianceSchedule[] = [ { windowStart: "10:00", @@ -1095,129 +1133,79 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { [], schedules, ctx({ - startsOn: new Date("2025-05-01T00:00:00Z"), - // Rolling cadences re-anchor on the last non-skipped intake. + startsOn: new Date("2024-06-01T00:00:00Z"), lastIntakeAt: new Date("2025-05-20T10:00:00Z"), }), { now: NOW }, ); - expect(display.mode).toBe("timeline"); - expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES); + expect(display.shortDays).toBe(90); + expect(display.longDays).toBe(365); }); - it("weekly med → timeline mode (≥4 needs ~28 days; 30d holds ~4 but <4 when phased)", () => { - // Weekly Mondays-only: across a 30-day trailing window this yields ~4 - // Mondays. With a start mid-window the realised count drops below 4 → - // timeline. Pin the realised count rather than asserting a fixed mode - // independent of phase. + it("a brand-new sparse med still returns two rows on the widest rung", () => { + // Started two days ago: no rung clears the floor. The selection falls + // back to the widest rung so the card still shows two honest rows. const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, - rrule: "FREQ=WEEKLY;BYDAY=MO", + rollingIntervalDays: 35, timesOfDay: ["08:00"], }, ]; const display = buildComplianceDisplay( [], schedules, - ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), + ctx({ startsOn: new Date("2025-06-13T00:00:00Z") }), { now: NOW }, ); - // 2025-05-26 .. 2025-06-15 → Mondays 5/26, 6/2, 6/9 → 3 expected < 4. - expect(display.expected30).toBeLessThan(MIN_STABLE_DOSES); - expect(display.mode).toBe("timeline"); + expect(display.shortDays).toBe(90); + expect(display.longDays).toBe(365); }); - it("builds taken / missed / skipped cells from the paired engine timeline", () => { - // Weekly Mondays, three past Mondays inside the window: one taken on - // time, one skipped, one missed (no intake). The strip mirrors that. + it("the row rates match calculateCompliance over the chosen windows", () => { + // Daily med, perfect adherence over the trailing two weeks → 100% both + // rows. Pins that the display rate equals the cadence-aware calculator. const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null, - rrule: "FREQ=WEEKLY;BYDAY=MO", + rrule: "FREQ=DAILY", timesOfDay: ["08:00"], }, ]; - const events = [ - // 2025-06-09 Monday — taken on time. - { - scheduledFor: new Date("2025-06-09T08:00:00Z"), - takenAt: new Date("2025-06-09T08:10:00Z"), - skipped: false, - }, - // 2025-06-02 Monday — skipped. - { - scheduledFor: new Date("2025-06-02T08:00:00Z"), - takenAt: null, - skipped: true, - }, - // 2025-05-26 Monday — no event → missed. - ]; - const display = buildComplianceDisplay( + const events = []; + for (let d = 1; d <= 30; d++) { + const at = new Date(NOW.getTime() - d * DAY_MS); + at.setUTCHours(8, 10, 0, 0); + events.push({ scheduledFor: at, takenAt: at, skipped: false }); + } + const context = ctx(); + const display = buildComplianceDisplay(events, schedules, context, { + now: NOW, + }); + const short = calculateCompliance( events, schedules, - ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), - { now: NOW }, + display.shortDays, + context.createdAt, + { now: NOW, medicationContext: context }, ); - const past = display.doseTimeline.filter((c) => c.status !== "upcoming"); - const statuses = past.map((c) => c.status); - expect(statuses).toContain("taken"); - expect(statuses).toContain("skipped"); - expect(statuses).toContain("missed"); - - const takenCell = past.find((c) => c.status === "taken")!; - expect(takenCell.takenAt).not.toBeNull(); - expect(takenCell.timing).toBe("on_time"); - - const skippedCell = past.find((c) => c.status === "skipped")!; - expect(skippedCell.takenAt).toBeNull(); - expect(skippedCell.timing).toBeNull(); - - // Summary: taken counts in numerator + denominator; skipped excluded - // from both; missed in denominator only. - expect(display.recentDoseSummary.taken).toBe(1); - expect(display.recentDoseSummary.total).toBe(2); // taken + missed - }); - - it("dose-streak: counts consecutive taken-or-skipped back from the newest, breaks on a miss", () => { - // Weekly Mondays: 5/26 missed, 6/2 taken, 6/9 taken → newest-back - // streak = 2 (stops at the 5/26 miss). - const schedules: ComplianceSchedule[] = [ - { - windowStart: "08:00", - windowEnd: "09:00", - daysOfWeek: null, - rrule: "FREQ=WEEKLY;BYDAY=MO", - timesOfDay: ["08:00"], - }, - ]; - const events = [ - { - scheduledFor: new Date("2025-06-09T08:00:00Z"), - takenAt: new Date("2025-06-09T08:05:00Z"), - skipped: false, - }, - { - scheduledFor: new Date("2025-06-02T08:00:00Z"), - takenAt: new Date("2025-06-02T08:05:00Z"), - skipped: false, - }, - // 5/26 missed. - ]; - const display = buildComplianceDisplay( + const long = calculateCompliance( events, schedules, - ctx({ startsOn: new Date("2025-05-26T00:00:00Z") }), - { now: NOW }, + display.longDays, + context.createdAt, + { now: NOW, medicationContext: context }, ); - expect(display.recentDoseSummary.doseStreak).toBe(2); + expect(display.short.rate).toBe(short.rate); + expect(display.long.rate).toBe(long.rate); + expect(display.short.streak).toBe(short.streak); }); - it("caps the past strip at 12 cells for a dense daily med", () => { + it("never reports a timeline mode field — the display is two rows only", () => { const schedules: ComplianceSchedule[] = [ { windowStart: "08:00", @@ -1228,8 +1216,37 @@ describe("buildComplianceDisplay — density rule + dose timeline", () => { }, ]; const display = buildComplianceDisplay([], schedules, ctx(), { now: NOW }); - const past = display.doseTimeline.filter((c) => c.status !== "upcoming"); - expect(past.length).toBeLessThanOrEqual(12); + expect(display).not.toHaveProperty("mode"); + expect(display).not.toHaveProperty("doseTimeline"); + expect(display.short).toBeDefined(); + expect(display.long).toBeDefined(); + }); + + it("a 2-day-old daily med's expected counts reflect its real age", () => { + // Created two days ago: the expected-dose counts must reflect ~2 days + // of daily doses, not the 7 / 30 a full-window walk would report. No + // rung clears the floor, so the selection falls back to the widest + // rung while keeping the counts age-accurate. + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const createdAt = new Date(NOW.getTime() - 2 * DAY_MS); + const display = buildComplianceDisplay([], schedules, ctx({ createdAt }), { + now: NOW, + }); + // ~2 days of daily doses — nowhere near the 7 a 7-day window or the 30 + // a 30-day window would report before the createdAt clamp. + expect(display.expectedShort).toBeLessThanOrEqual(3); + expect(display.expectedLong).toBeLessThanOrEqual(3); + // No rung clears the floor → widest windows still render two rows. + expect(display.shortDays).toBe(90); + expect(display.longDays).toBe(365); }); }); @@ -1293,4 +1310,57 @@ describe("expectedSlotsBetween", () => { ); expect(slots.length).toBe(0); }); + + it("clamps the window lower bound to createdAt for a young med", () => { + // A daily med created two days before the window's upper bound: the + // legacy weekday walker floors on `startsOn` (none here) but not on + // `createdAt`, so without the clamp this 30-day window would count + // ~30 days of pre-creation slots. With the clamp only the ~2 days the + // med actually existed emit a slot. + const to = new Date("2025-06-15T00:00:00Z"); + const from = new Date(to.getTime() - 30 * DAY_MS); + const createdAt = new Date(to.getTime() - 2 * DAY_MS); + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const slots = expectedSlotsBetween(schedules, from, to, ctx({ createdAt })); + // Daily cadence over ~2 days → at most 3 slots, never the ~30 a full + // 30-day window would otherwise emit. + expect(slots.length).toBeLessThanOrEqual(3); + expect(slots.length).toBeGreaterThanOrEqual(1); + // Every emitted slot lands at or after the creation instant. + for (const s of slots) { + expect(s.at.getTime()).toBeGreaterThanOrEqual(createdAt.getTime()); + } + }); + + it("leaves the window untouched when createdAt predates it", () => { + // createdAt well before `from` → the clamp is a no-op and the full + // window's slots survive. + const to = new Date("2025-06-15T00:00:00Z"); + const from = new Date(to.getTime() - 7 * DAY_MS); + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + timesOfDay: ["08:00"], + }, + ]; + const slots = expectedSlotsBetween( + schedules, + from, + to, + ctx({ createdAt: new Date("2025-01-01T00:00:00Z") }), + ); + // A full week of daily doses → ~7 slots. + expect(slots.length).toBeGreaterThanOrEqual(7); + }); }); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index 8bcc7a20c..ba45e05ad 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -23,7 +23,6 @@ import { buildCadenceTimeline, type CadenceEngineContext, type IntakeEventLike, - type PairedDose, type ScheduleLike, } from "@/lib/medications/scheduling/cadence"; import { @@ -33,7 +32,6 @@ import { type RecurrenceContext, type ScheduleType, } from "@/lib/medications/scheduling/recurrence"; -import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; interface IntakeEvent { takenAt: Date | null; @@ -342,6 +340,15 @@ export function expectedSlotCountForDay( * `occurrencesBetween` is inclusive of both ends; the caller passes a * `[from, to]` window and we sort the union of every schedule's slots so * a multi-schedule medication interleaves its slots in time order. + * + * v1.8.6 QA — the window lower bound is clamped to `ctx.createdAt`. The + * legacy weekday walker floors on `startsOn` but not on `createdAt`, so a + * brand-new daily med queried over a 30-day window would otherwise emit + * slots for every day before it existed (7/30 expected on a 2-day-old med). + * Clamping here keeps the expected-dose denominator — and the window + * selection that reads it — honest about the medication's real age. The + * displayed rates clamp independently via `calculateCompliance`'s + * `medicationCreatedAt` argument, so this only fixes the slot counts. */ export function expectedSlotsBetween( schedules: ComplianceSchedule[], @@ -350,12 +357,14 @@ export function expectedSlotsBetween( ctx: ComplianceMedicationContext, ): Occurrence[] { const recurrenceCtx = toRecurrenceCtx(ctx, "compliance-slots"); + const effectiveFrom = + ctx.createdAt.getTime() > from.getTime() ? ctx.createdAt : from; const all: Occurrence[] = []; for (let i = 0; i < schedules.length; i++) { all.push( ...occurrencesBetween( toCanonicalSchedule(schedules[i], `compliance-slots-${i}`), - from, + effectiveFrom, to, recurrenceCtx, ), @@ -365,122 +374,142 @@ export function expectedSlotsBetween( } /** - * v1.8.5 — render mode for the per-medication compliance display. A - * window's percentage is only meaningful once the denominator is large - * enough that a single miss moves the rate by a small amount; below that - * the bar swings violently and tells the user nothing. So: - * - * - `"percent"`: keep the 7-/30-day percentage bars (dense cadences: - * daily, multi-daily, weekday meds). The window holds ≥ - * `MIN_STABLE_DOSES` expected doses. - * - `"timeline"`: swap to an uptime-style per-dose strip (sparse cadences: - * weekly, bi-/tri-weekly, rolling 35-day injections) where each cell is - * one expected dose, taken=green / missed=red / skipped=grey. - */ -export type ComplianceDisplayMode = "percent" | "timeline"; - -/** - * v1.8.5 — one cell of the dose-adherence timeline. A projection of the - * canonical engine's expected-dose slots paired with the recorded intake, - * oldest → newest. `site` is reserved for injection meds so the strip can - * dual-encode rotation; it is `null` until the per-dose injection-site - * write path lands. - */ -export interface DoseTimelineCell { - /** ISO instant of the expected dose slot. */ - scheduledFor: string; - status: "taken" | "missed" | "skipped" | "upcoming"; - /** ISO instant the dose was logged, or null when not taken. */ - takenAt: string | null; - /** Punctuality bucket for a taken dose; null when not taken. */ - timing: IntakeTimingClass | null; - /** Injection zone for injection meds; null otherwise / when unrecorded. */ - site: InjectionSiteKey | null; -} - -/** - * v1.8.5 — the additive compliance-display block returned alongside the - * existing `compliance7` / `compliance30` fields (which iOS + the Health - * Score still read verbatim). The server decides `mode` from the density - * rule so the client stays dumb. - */ -export interface ComplianceDisplay { - mode: ComplianceDisplayMode; - /** Realised expected dose count over the 7-day window. */ - expected7: number; - /** Realised expected dose count over the 30-day window. */ - expected30: number; - /** Echo of the density threshold so a client can re-derive the mode. */ - minStableDoses: number; - /** Uptime strip — last N expected dose slots, oldest → newest. */ - doseTimeline: DoseTimelineCell[]; - /** Compact summary for the "last N doses" line. */ - recentDoseSummary: { taken: number; total: number; doseStreak: number }; -} - -/** - * v1.8.5 — the floor at which a window's percentage is stable. Four + * v1.8.6 — the floor at which a window's percentage is stable. Four * expected doses is the point where a single miss moves the rate by ≤25% - * rather than ±50–100%. A daily med clears it in 4 days; a weekly med - * needs ~28 days; a tri-weekly / 35-day-rolling med never clears it in a - * 30-day window — which is exactly the signal to show the timeline. + * rather than ±50–100%. A daily med clears it in a 7-day window; a weekly + * med needs ~30 days; a tri-weekly / 35-day-rolling med needs a quarter or + * more. The window ladder below steps up until both rows clear this floor. */ export const MIN_STABLE_DOSES = 4; -/** Number of past dose cells the card strip carries. */ -const TIMELINE_PAST_DOSES = 12; -/** Number of upcoming dose cells appended so the strip shows what's next. */ -const TIMELINE_UPCOMING_DOSES = 2; +/** + * v1.8.6 — the rung ladder for the two compliance windows. Each rung is a + * `[short, long]` pair of day-counts. The card always shows two percentage + * rows; the only thing that scales with cadence is which rung the windows + * sit on. Dense meds (daily / weekday) sit on `[7, 30]`; as the expected + * dose frequency drops, both windows step up so each row still spans enough + * expected doses to mean something, up to a 12-month long window for very + * rare meds. + */ +export const COMPLIANCE_WINDOW_LADDER: ReadonlyArray = + [ + [7, 30], + [30, 90], + [90, 365], + ]; -/** A single paired dose, distilled into a timeline cell. */ -function toTimelineCell(dose: PairedDose): DoseTimelineCell { - const takenAt = dose.match?.takenAt ?? null; - return { - scheduledFor: dose.windowStart.toISOString(), - status: dose.status, - takenAt: takenAt ? takenAt.toISOString() : null, - // Punctuality is computed against the slot window the engine already - // emitted. `windowStart` / `windowEnd` are full instants here; reuse - // the HH:mm classifier by formatting them back to wall-clock on the - // slot's own day so an early / late dose reads correctly. - timing: - dose.status === "taken" && takenAt - ? classifyIntakeTiming( - takenAt, - hhmmOf(dose.windowStart), - hhmmOf(dose.windowEnd), - dose.windowStart, - ) - : null, - // Injection-site dual-encoding is reserved for the per-dose write - // path; the compliance read does not yet carry it. - site: null, - }; -} - -/** Format a Date's UTC wall-clock as "HH:mm" for the punctuality classifier. */ -function hhmmOf(d: Date): string { - const h = String(d.getUTCHours()).padStart(2, "0"); - const m = String(d.getUTCMinutes()).padStart(2, "0"); - return `${h}:${m}`; +/** + * v1.8.6 — the day-counts of the two compliance windows a medication's + * cadence resolves to, plus the realised expected-dose count each window + * holds. `shortDays` / `longDays` drive the row labels; the expected counts + * are surfaced so a client can show the denominator or re-derive the rung. + */ +export interface ComplianceWindowSelection { + shortDays: number; + longDays: number; + expectedShort: number; + expectedLong: number; } /** - * v1.8.5 — compute the additive {@link ComplianceDisplay} block. + * v1.8.6 — pick the two compliance windows for a medication from its + * dosing cadence. * - * The mode is decided purely by density: when the 30-day window holds at - * least {@link MIN_STABLE_DOSES} expected doses the percentage bars stay - * (`"percent"`); otherwise the per-dose timeline takes over (`"timeline"`). - * The 30-day window is the honest denominator — a weekly med clears the - * floor there, a sparse injection never does. + * Walks {@link COMPLIANCE_WINDOW_LADDER} from densest to sparsest and + * returns the first rung whose BOTH windows clear {@link MIN_STABLE_DOSES} + * realised expected doses. A daily med clears `[7, 30]` immediately; a + * weekly med fails the 7-day row (one dose) and lands on `[30, 90]`; a + * 35-day-rolling injection needs the top rung `[90, 365]`. When even the + * top rung can't clear the floor (a brand-new prescription, a very rare + * med) the top rung is returned anyway so the card still shows two honest + * percentage rows over the widest windows available. * - * The timeline itself is a projection of {@link buildCadenceTimeline}: the - * engine already pairs each expected slot to its closest intake, so we - * take the last {@link TIMELINE_PAST_DOSES} past slots plus a couple of - * upcoming ones and distil each into a cell. `doseStreak` counts - * consecutive taken-or-skipped doses back from the newest non-upcoming - * dose — dose-indexed, unlike the day-indexed `streak` on - * {@link ComplianceResult} which inflates on off-cadence days. + * The expected count routes through {@link expectedSlotsBetween} (the + * canonical recurrence engine), so PRN / off-cadence / pre-creation days + * never inflate the denominator. + */ +export function selectComplianceWindows( + schedules: ComplianceSchedule[], + ctx: ComplianceMedicationContext, + options?: { now?: Date }, +): ComplianceWindowSelection { + const now = options?.now ?? new Date(); + const DAY_MS = 24 * 60 * 60 * 1000; + + const expectedOver = (days: number): number => + expectedSlotsBetween( + schedules, + new Date(now.getTime() - days * DAY_MS), + now, + ctx, + ).length; + + // Memoise per distinct window so a shared rung boundary (e.g. 30 / 90) + // isn't re-walked across rungs. + const cache = new Map(); + const expected = (days: number): number => { + const hit = cache.get(days); + if (hit !== undefined) return hit; + const v = expectedOver(days); + cache.set(days, v); + return v; + }; + + for (const [shortDays, longDays] of COMPLIANCE_WINDOW_LADDER) { + const expectedShort = expected(shortDays); + const expectedLong = expected(longDays); + if ( + expectedShort >= MIN_STABLE_DOSES && + expectedLong >= MIN_STABLE_DOSES + ) { + return { shortDays, longDays, expectedShort, expectedLong }; + } + } + + // No rung cleared the floor — fall back to the widest rung so both rows + // still render over the most data the cadence affords. + const [shortDays, longDays] = + COMPLIANCE_WINDOW_LADDER[COMPLIANCE_WINDOW_LADDER.length - 1]; + return { + shortDays, + longDays, + expectedShort: expected(shortDays), + expectedLong: expected(longDays), + }; +} + +/** + * v1.8.6 — the compliance-display block returned alongside the existing + * `compliance7` / `compliance30` fields (which iOS + the Health Score read + * verbatim). The card always renders two percentage rows; the server picks + * the two windows from the medication's cadence and computes each row's + * rate over the chosen span. A dense med keeps `7` / `30`; a sparse med + * steps both windows up so each row covers enough expected doses to be + * meaningful. + */ +export interface ComplianceDisplay { + shortDays: number; + longDays: number; + /** Realised expected dose count over the short window. */ + expectedShort: number; + /** Realised expected dose count over the long window. */ + expectedLong: number; + /** Echo of the density floor so a client can re-derive the rung. */ + minStableDoses: number; + /** Compliance percentage + day-streak over the short window. */ + short: { rate: number; streak: number }; + /** Compliance percentage over the long window. */ + long: { rate: number }; +} + +/** + * v1.8.6 — compute the two-row {@link ComplianceDisplay} block. + * + * The card always shows two percentage rows. {@link selectComplianceWindows} + * decides which windows they span from the medication's cadence, then each + * row's rate is the cadence-aware {@link calculateCompliance} over that + * window. The short row also carries the day-streak. The compliance math is + * unchanged from the legacy 7-/30-day path — only the window day-counts move. */ export function buildComplianceDisplay( events: IntakeEvent[], @@ -489,108 +518,26 @@ export function buildComplianceDisplay( options?: { now?: Date }, ): ComplianceDisplay { const now = options?.now ?? new Date(); - const DAY_MS = 24 * 60 * 60 * 1000; + const { shortDays, longDays, expectedShort, expectedLong } = + selectComplianceWindows(schedules, ctx, { now }); - // Realised expected counts over each window. `expectedSlotsBetween` - // already excludes PRN / off-cadence days through the canonical engine. - const from7 = new Date(now.getTime() - 7 * DAY_MS); - const from30 = new Date(now.getTime() - 30 * DAY_MS); - const expected7 = expectedSlotsBetween(schedules, from7, now, ctx).length; - const expected30 = expectedSlotsBetween(schedules, from30, now, ctx).length; - - const mode: ComplianceDisplayMode = - expected30 >= MIN_STABLE_DOSES ? "percent" : "timeline"; - - // Build the per-dose strip from the same engine the cadence chart uses. - // Pull a generous past window (90 days) so even a 35-day-rolling med - // surfaces a few cells, then keep the last N plus a short look-ahead. - const engineCtx: CadenceEngineContext = { - startsOn: ctx.startsOn, - endsOn: ctx.endsOn, - oneShot: ctx.oneShot, - createdAt: ctx.createdAt, - lastIntakeAt: ctx.lastIntakeAt, - timeZone: ctx.timeZone, - }; - const normalisedSchedules: ScheduleLike[] = schedules.map((s, i) => ({ - id: `display-${i}`, - windowStart: s.windowStart, - windowEnd: s.windowEnd, - daysOfWeek: s.daysOfWeek ?? null, - rrule: s.rrule ?? null, - rollingIntervalDays: s.rollingIntervalDays ?? null, - timesOfDay: s.timesOfDay, - reminderGraceMinutes: s.reminderGraceMinutes ?? null, - scheduleType: s.scheduleType ?? null, - cyclicOnWeeks: s.cyclicOnWeeks ?? null, - cyclicOffWeeks: s.cyclicOffWeeks ?? null, - })); - const normalisedEvents: IntakeEventLike[] = events.map((e) => ({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - })); - - // Past window for the strip — bounded so a daily med doesn't return - // hundreds of cells. `buildCadenceTimeline` anchors on `now - windowDays` - // and projects forward; a small look-ahead is added below. - const STRIP_PAST_DAYS = 90; - const past = buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, + const short = calculateCompliance(events, schedules, shortDays, ctx.createdAt, { now, - STRIP_PAST_DAYS, - ctx.createdAt, - engineCtx.timeZone, - engineCtx, - ) - .filter((d) => d.status !== "upcoming") - .sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime()); - - // Upcoming slots — expand a short forward window and keep the soonest. - const lookahead = new Date(now.getTime() + 60 * DAY_MS); - const upcoming = buildCadenceTimeline( - normalisedSchedules, - normalisedEvents, - lookahead, - 60, - ctx.createdAt, - engineCtx.timeZone, - engineCtx, - ) - .filter((d) => d.status === "upcoming" && d.windowStart.getTime() > now.getTime()) - .sort((a, b) => a.windowStart.getTime() - b.windowStart.getTime()) - .slice(0, TIMELINE_UPCOMING_DOSES); - - const recentPast = past.slice(-TIMELINE_PAST_DOSES); - const doseTimeline = [...recentPast, ...upcoming].map(toTimelineCell); - - // Summary over the past cells the strip shows. - const summaryTaken = recentPast.filter((d) => d.status === "taken").length; - const summaryTotal = recentPast.filter( - (d) => d.status === "taken" || d.status === "missed", - ).length; - - // Dose-streak: consecutive taken-or-skipped doses, newest → oldest, from - // the newest non-upcoming dose. A miss breaks it; a skip does not. - let doseStreak = 0; - for (let i = recentPast.length - 1; i >= 0; i--) { - const s = recentPast[i].status; - if (s === "missed") break; - if (s === "taken" || s === "skipped") doseStreak++; - } + medicationContext: ctx, + }); + const long = calculateCompliance(events, schedules, longDays, ctx.createdAt, { + now, + medicationContext: ctx, + }); return { - mode, - expected7, - expected30, + shortDays, + longDays, + expectedShort, + expectedLong, minStableDoses: MIN_STABLE_DOSES, - doseTimeline, - recentDoseSummary: { - taken: summaryTaken, - total: summaryTotal, - doseStreak, - }, + short: { rate: short.rate, streak: short.streak }, + long: { rate: long.rate }, }; } diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index ccb0b7a9b..6856bb45e 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -74,3 +74,79 @@ describe("applyProfileUpdate timezone validation", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("applyProfileUpdate insurer IK number", () => { + it("accepts a valid 9-digit IKNR and writes it field-by-field", async () => { + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: "101234567", + }); + expect(result.ok).toBe(true); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBe("101234567"); + }); + + it("trims surrounding whitespace before validating", async () => { + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: " 101234567 ", + }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBe("101234567"); + }); + + it("maps an empty string to null (clears the field)", async () => { + const result = await applyProfileUpdate(USER_ID, { insurerIkNumber: "" }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBeNull(); + }); + + it("maps an explicit null to null", async () => { + const result = await applyProfileUpdate(USER_ID, { insurerIkNumber: null }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect(arg.data.insurerIkNumber).toBeNull(); + }); + + it("leaves the field untouched when omitted", async () => { + const result = await applyProfileUpdate(USER_ID, { fullName: "Someone" }); + expect(result.ok).toBe(true); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect("insurerIkNumber" in arg.data).toBe(false); + }); + + it("rejects a non-numeric IKNR with 422", async () => { + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: "12345678X", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(422); + expect(result.message).toMatch(/IK number/i); + } + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an IKNR that is not exactly 9 digits with 422", async () => { + for (const bad of ["1234567", "1234567890"]) { + vi.mocked(prisma.user.update).mockClear(); + const result = await applyProfileUpdate(USER_ID, { + insurerIkNumber: bad, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 2ac5b9af2..e2901c223 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -43,6 +43,7 @@ export interface ApplyProfileResult { // boolean presence flag for the encrypted KVNR. fullName: string | null; insurerName: string | null; + insurerIkNumber: string | null; hasInsuranceNumber: boolean; }; } @@ -116,6 +117,11 @@ export async function applyProfileUpdate( ? null : data.insurerName; } + // v1.8.6 — IKNR plaintext (matches insurerName). The Zod transform + // already mapped empty → null, so a null here means "clear it". + if (data.insurerIkNumber !== undefined) { + updates.insurerIkNumber = data.insurerIkNumber ?? null; + } if (data.insuranceNumber !== undefined) { updates.insuranceNumberEncrypted = data.insuranceNumber === null || data.insuranceNumber === "" @@ -172,6 +178,7 @@ export async function applyProfileUpdate( moodReminderEnabled: updatedUser.moodReminderEnabled, fullName: updatedUser.fullName, insurerName: updatedUser.insurerName, + insurerIkNumber: updatedUser.insurerIkNumber, hasInsuranceNumber: updatedUser.insuranceNumberEncrypted != null, }, }; diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index 3b95a29d9..a40206ade 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -67,6 +67,10 @@ export interface DoctorReportData { // renderer treats absent and null identically. fullName?: string | null; insurerName?: string | null; + // v1.8.6 — German insurer institution number (IKNR), plaintext. Carried + // here for the FHIR `Coverage` payor Organization identifier. Optional + // so pre-v1.8.6 fixtures still typecheck. + insurerIkNumber?: string | null; }; /** * Free-text practice / clinic name printed on the cover. `null` omits the @@ -334,6 +338,7 @@ export async function collectDoctorReportData( // decrypts it and hands it to the builders. fullName: true, insurerName: true, + insurerIkNumber: true, }, }), ]); @@ -575,6 +580,7 @@ export async function collectDoctorReportData( heightCm: userProfile?.heightCm ?? null, fullName: userProfile?.fullName ?? null, insurerName: userProfile?.insurerName ?? null, + insurerIkNumber: userProfile?.insurerIkNumber ?? null, }, practiceName, measurements: filteredByType, diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index 3be32b62b..939418b6d 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -93,6 +93,7 @@ function buildUser(disableCoach: boolean): AuthUser { disableCoach, fullName: null, insurerName: null, + insurerIkNumber: null, insuranceNumber: null, }; } diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 985b7a263..3fb55e4a1 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -5,9 +5,18 @@ import type { FhirObservation, FhirMedicationStatement, FhirPatient, + FhirCoverage, FhirDiagnosticReport, } from "../types"; +function coverageOf( + bundle: ReturnType, +): FhirCoverage | undefined { + return bundle.entry + .map((e) => e.resource) + .find((r): r is FhirCoverage => r.resourceType === "Coverage"); +} + function observationsOf(bundle: ReturnType) { return bundle.entry .map((e) => e.resource) @@ -117,6 +126,132 @@ describe("buildFhirDocumentBundle", () => { expect(patient.identifier).toBeUndefined(); }); + it("emits a Coverage with a contained Organization carrying the IKNR + subscriberId KVNR", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: "1985-06-15T00:00:00.000Z", + gender: "MALE", + heightCm: 182, + fullName: "Sample Patient", + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.status).toBe("active"); + expect(coverage?.beneficiary.reference).toBe("Patient/patient-1"); + expect(coverage?.subscriberId).toBe("A123456780"); + // Payor references the contained Organization via a local #-ref. + const payorRef = coverage?.payor?.[0].reference; + expect(payorRef?.startsWith("#")).toBe(true); + const org = coverage?.contained?.[0]; + expect(org?.resourceType).toBe("Organization"); + expect(`#${org?.id}`).toBe(payorRef); + expect(org?.name).toBe("Example Insurer"); + expect(org?.identifier?.[0].system).toBe( + "http://fhir.de/sid/arge-ik/iknr", + ); + expect(org?.identifier?.[0].value).toBe("101234567"); + }); + + it("places the Coverage right after the Patient in the bundle", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + }, + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const types = bundle.entry.map((e) => e.resource.resourceType); + const patientIdx = types.indexOf("Patient"); + const coverageIdx = types.indexOf("Coverage"); + expect(patientIdx).toBeGreaterThanOrEqual(0); + expect(coverageIdx).toBe(patientIdx + 1); + }); + + it("emits a name-only payor Organization when the IKNR is absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + insurerName: "Example Insurer", + // no insurerIkNumber + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.subscriberId).toBe("A123456780"); + const org = coverage?.contained?.[0]; + expect(org?.name).toBe("Example Insurer"); + expect(org?.identifier).toBeUndefined(); + }); + + it("emits a Coverage with no subscriberId when the KVNR is absent", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + insurerName: "Example Insurer", + insurerIkNumber: "101234567", + }, + }), + { insuranceNumber: null }, + FIXED_NOW, + ); + const coverage = coverageOf(bundle); + expect(coverage).toBeDefined(); + expect(coverage?.subscriberId).toBeUndefined(); + expect(coverage?.contained?.[0].identifier?.[0].value).toBe("101234567"); + }); + + it("omits the Coverage entirely when neither insurer name nor IKNR is present", () => { + const bundle = buildFhirDocumentBundle( + makeData({ + patient: { + username: "sample-user", + dateOfBirth: null, + gender: null, + heightCm: null, + fullName: "Sample Patient", + // neither insurerName nor insurerIkNumber + }, + }), + { insuranceNumber: "A123456780" }, + FIXED_NOW, + ); + expect(coverageOf(bundle)).toBeUndefined(); + // KVNR still rides on Patient.identifier. + const patient = bundle.entry.find( + (e) => e.resource.resourceType === "Patient", + )?.resource as FhirPatient; + expect(patient.identifier?.[0].value).toBe("A123456780"); + }); + it("maps weight to LOINC 29463-7 / UCUM kg with the latest reading", () => { const bundle = buildFhirDocumentBundle( makeData(), diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index f816843de..34a85eb26 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -40,6 +40,8 @@ import type { FhirObservation, FhirMedicationStatement, FhirPatient, + FhirCoverage, + FhirOrganization, FhirComposition, FhirDiagnosticReport, FhirReference, @@ -55,6 +57,9 @@ export interface FhirPatientIdentity { /** KVNR identifier namespace per the gematik SID. */ const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10"; +/** German insurer institution-number (IKNR) identifier namespace. */ +const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr"; + /** Escape the five XML-significant characters for the xhtml narrative. */ function escapeXml(value: string): string { return value @@ -140,6 +145,43 @@ export function buildFhirDocumentBundle( } entries.push({ fullUrl: `urn:uuid:${patientId}`, resource: patient }); + // --- Coverage (insurer; sits right after the Patient) ------------------ + // Emitted whenever ANY payor info is present (insurer name and/or IKNR). + // The payor is a CONTAINED Organization referenced by a local `#`-ref. + // The KVNR stays on `Patient.identifier`; here it doubles as the + // Coverage `subscriberId` (the member id) when present. When neither an + // insurer name nor an IKNR is known, omit Coverage entirely rather than + // emit an empty payor. + const insurerName = data.patient.insurerName ?? null; + const insurerIkNumber = data.patient.insurerIkNumber ?? null; + if (insurerName || insurerIkNumber) { + const orgId = "insurer-org-1"; + const payorOrg: FhirOrganization = { + resourceType: "Organization", + id: orgId, + }; + if (insurerIkNumber) { + payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }]; + } + if (insurerName) payorOrg.name = insurerName; + + const coverage: FhirCoverage = { + resourceType: "Coverage", + id: "coverage-1", + status: "active", + contained: [payorOrg], + beneficiary: patientRef, + payor: [{ reference: `#${orgId}` }], + }; + if (identity.insuranceNumber) { + coverage.subscriberId = identity.insuranceNumber; + } + entries.push({ + fullUrl: `urn:uuid:${coverage.id}`, + resource: coverage, + }); + } + let obsSeq = 0; const pushObservation = (obs: FhirObservation) => { entries.push({ fullUrl: `urn:uuid:${obs.id}`, resource: obs }); diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts index bb84d26b8..7b7484f66 100644 --- a/src/lib/fhir/types.ts +++ b/src/lib/fhir/types.ts @@ -106,6 +106,27 @@ export interface FhirDiagnosticReport { result?: FhirReference[]; } +export interface FhirOrganization { + resourceType: "Organization"; + id: string; + identifier?: FhirIdentifier[]; + name?: string; +} + +export interface FhirCoverage { + resourceType: "Coverage"; + id: string; + status: "active" | "cancelled" | "draft" | "entered-in-error"; + /** Inline payor Organization, referenced from `payor` via a local `#`-ref. */ + contained?: FhirOrganization[]; + /** The covered party — Reference(Patient). */ + beneficiary: FhirReference; + /** German KVNR (the subscriber's member id), when present. */ + subscriberId?: string; + /** Insurer(s) — Reference to the contained Organization. */ + payor?: FhirReference[]; +} + export interface FhirCompositionSection { title: string; code?: FhirCodeableConcept; @@ -128,6 +149,7 @@ export interface FhirComposition { export type FhirResource = | FhirComposition | FhirPatient + | FhirCoverage | FhirObservation | FhirMedicationStatement | FhirDiagnosticReport; diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index b8408320d..6d8deb75f 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -6,7 +6,6 @@ import { computeInTargetPct, computeMoodAggregates, computeMoodMetricCorrelation, - computeNotesTimeline, computeStructuredTagSummary, computeTagSummary, computeWeekdayAverages, @@ -136,24 +135,6 @@ describe("computeStructuredTagSummary", () => { }); }); -describe("computeNotesTimeline", () => { - it("returns noted entries newest-first, skips empty notes, and caps at the limit", () => { - const entries: MoodAggregateEntry[] = [ - { ...entry(5, 2, ["x"]), note: "oldest", mood: "SCHLECHT" }, - { ...entry(3, 4, null), note: " ", mood: "GUT" }, // blank → skipped - { ...entry(2, 5, null), note: "middle", mood: "SUPER_GUT" }, - { ...entry(1, 3, null), note: "newest", mood: "OKAY" }, - ]; - const timeline = computeNotesTimeline(entries, 2); - expect(timeline.map((n) => n.note)).toEqual(["newest", "middle"]); - expect(timeline[0]).toMatchObject({ score: 3, mood: "OKAY", note: "newest" }); - }); - - it("returns an empty array when no entry carries a note", () => { - expect(computeNotesTimeline([entry(1, 4, ["x"])])).toEqual([]); - }); -}); - describe("computeInTargetPct", () => { it("returns the share of the newest 30 daily buckets in the green band", () => { const daily = [ @@ -297,6 +278,14 @@ describe("computeMoodAggregates", () => { // No sleep / steps rows supplied → empty correlation. expect(agg.correlations.sleep.n).toBe(0); expect(agg.correlations.sleep.result).toBeNull(); + // v1.8.6 — the narrative feed rides on the same aggregates and is + // ranked strongest-first. + expect(Array.isArray(agg.narratives)).toBe(true); + for (let i = 1; i < agg.narratives.length; i++) { + expect(agg.narratives[i - 1].strength).toBeGreaterThanOrEqual( + agg.narratives[i].strength, + ); + } }); it("handles an empty entry set without throwing", () => { diff --git a/src/lib/insights/__tests__/mood-narratives.test.ts b/src/lib/insights/__tests__/mood-narratives.test.ts new file mode 100644 index 000000000..35aa8dcfd --- /dev/null +++ b/src/lib/insights/__tests__/mood-narratives.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from "vitest"; + +import { + MOOD_NARRATIVE_MIN_DAYS, + MOOD_NARRATIVE_MIN_EFFECT, + MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES, + MOOD_NARRATIVE_MIN_TAG_COUNT, + MOOD_NARRATIVE_TREND_WINDOW, + MOOD_NARRATIVE_MIN_STREAK, + MOOD_NARRATIVE_MAX_ITEMS, + computeMoodNarratives, + type MoodNarrativeInput, +} from "../mood-narratives"; + +const dayMs = 24 * 60 * 60 * 1000; +/** 2026-06-01 is a Monday in UTC. */ +const NOW = new Date("2026-06-01T12:00:00.000Z"); + +function daily(values: Array<{ dayOffset: number; value: number }>) { + return values; +} + +/** A baseline input that clears nothing — every takeaway silent. */ +function emptyInput(): MoodNarrativeInput { + return { + daily: [], + weekday: [], + tags: [], + structuredTags: [], + inTargetPct: null, + loggedDayKeys: [], + now: NOW, + }; +} + +describe("computeMoodNarratives — anti-platitude thresholds", () => { + it("returns nothing for an empty input set", () => { + expect(computeMoodNarratives(emptyInput())).toEqual([]); + }); + + it("stays silent on the weekday dip when the effect is below the min", () => { + // Mon avg 3.4 vs overall ~3.5 → delta < MIN_EFFECT, no fire. + const input: MoodNarrativeInput = { + ...emptyInput(), + weekday: [ + { weekday: 0, avgScore: 3.45, count: 5 }, + { weekday: 1, avgScore: 3.5, count: 5 }, + { weekday: 2, avgScore: 3.55, count: 5 }, + ], + }; + const out = computeMoodNarratives(input); + expect(out.find((n) => n.kind === "weekday-dip")).toBeUndefined(); + }); + + it("fires the weekday dip only above the effect threshold", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + weekday: [ + { weekday: 0, avgScore: 2.5, count: 5 }, // Monday, clearly low + { weekday: 1, avgScore: 4.5, count: 5 }, + { weekday: 2, avgScore: 4.5, count: 5 }, + ], + }; + const out = computeMoodNarratives(input); + const dip = out.find((n) => n.kind === "weekday-dip"); + expect(dip).toBeDefined(); + expect(dip?.vars.weekdayKey).toBe("charts.weekdaysFull.mon"); + // delta is the gap below the weekly mean, a positive magnitude. + expect(Number(dip?.vars.delta)).toBeGreaterThanOrEqual( + MOOD_NARRATIVE_MIN_EFFECT, + ); + }); + + it("stays silent on the weekday dip when no weekday clears the sample gate", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + weekday: [ + { weekday: 0, avgScore: 2.5, count: MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES - 1 }, + { weekday: 1, avgScore: 4.5, count: MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES - 1 }, + ], + }; + expect( + computeMoodNarratives(input).find((n) => n.kind === "weekday-dip"), + ).toBeUndefined(); + }); + + it("fires the trend takeaway only when the window means differ by the threshold", () => { + // Recent window high, prior window low → upward trend. + const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({ + dayOffset: i, + value: 5, + })); + const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({ + dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW, + value: 2, + })); + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([...recent, ...prior]), + }; + const trend = computeMoodNarratives(input).find((n) => n.kind === "trend"); + expect(trend).toBeDefined(); + expect(trend?.vars.direction).toBe("up"); + }); + + it("stays silent on the trend when the change is below the effect threshold", () => { + const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({ + dayOffset: i, + value: 3.5, + })); + const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({ + dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW, + value: 3.55, + })); + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([...recent, ...prior]), + }; + expect( + computeMoodNarratives(input).find((n) => n.kind === "trend"), + ).toBeUndefined(); + }); + + it("stays silent on the trend below the minimum window sample size", () => { + const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW - 1 }, (_, i) => ({ + dayOffset: i, + value: 5, + })); + const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW - 1 }, (_, i) => ({ + dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW, + value: 2, + })); + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([...recent, ...prior]), + }; + expect( + computeMoodNarratives(input).find((n) => n.kind === "trend"), + ).toBeUndefined(); + }); + + it("fires a tag-lift takeaway only above the count and effect gates", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + // overall mean from daily is ~3 + daily: daily([ + { dayOffset: 0, value: 3 }, + { dayOffset: 1, value: 3 }, + { dayOffset: 2, value: 3 }, + ]), + tags: [ + { tag: "sport", count: MOOD_NARRATIVE_MIN_TAG_COUNT, avgScore: 4.5 }, + { tag: "rare", count: MOOD_NARRATIVE_MIN_TAG_COUNT - 1, avgScore: 5 }, + ], + }; + const out = computeMoodNarratives(input); + const lift = out.find((n) => n.kind === "tag-lift"); + expect(lift?.vars.tag).toBe("sport"); + // the under-count tag never fires + expect(out.some((n) => n.vars.tag === "rare")).toBe(false); + }); + + it("fires a tag-drop takeaway for tags well below the overall mean", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 4 }, + { dayOffset: 1, value: 4 }, + { dayOffset: 2, value: 4 }, + ]), + tags: [{ tag: "work", count: MOOD_NARRATIVE_MIN_TAG_COUNT, avgScore: 2 }], + }; + const drop = computeMoodNarratives(input).find((n) => n.kind === "tag-drop"); + expect(drop?.vars.tag).toBe("work"); + expect(Number(drop?.vars.delta)).toBeGreaterThanOrEqual( + MOOD_NARRATIVE_MIN_EFFECT, + ); + }); + + it("surfaces a structured tag→mood lift via its catalog label key", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 3 }, + { dayOffset: 1, value: 3 }, + { dayOffset: 2, value: 3 }, + ]), + structuredTags: [ + { + key: "happy", + categoryKey: "feelings", + labelKey: "insights.mood.tags.feelings.happy", + icon: null, + count: MOOD_NARRATIVE_MIN_TAG_COUNT, + avgScore: 4.5, + }, + ], + }; + const out = computeMoodNarratives(input); + const lift = out.find((n) => n.kind === "tag-lift"); + // structured tags carry an i18n key the renderer resolves, never a + // raw label string. + expect(lift?.vars.tagKey).toBe("insights.mood.tags.feelings.happy"); + expect(lift?.vars.tag).toBeUndefined(); + }); + + it("ranks flat and structured tags in one shared pool", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 3 }, + { dayOffset: 1, value: 3 }, + { dayOffset: 2, value: 3 }, + ]), + tags: [{ tag: "sport", count: MOOD_NARRATIVE_MIN_TAG_COUNT, avgScore: 4 }], + structuredTags: [ + { + key: "celebrating", + categoryKey: "events", + labelKey: "insights.mood.tags.events.celebrating", + icon: null, + count: MOOD_NARRATIVE_MIN_TAG_COUNT, + avgScore: 5, // stronger lift than the flat "sport" tag + }, + ], + }; + const lift = computeMoodNarratives(input).find((n) => n.kind === "tag-lift"); + // the stronger structured lift wins the single tag-lift slot. + expect(lift?.vars.tagKey).toBe("insights.mood.tags.events.celebrating"); + }); + + it("ignores a structured tag below the occurrence gate", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 3 }, + { dayOffset: 1, value: 3 }, + ]), + structuredTags: [ + { + key: "rare", + categoryKey: "events", + labelKey: "insights.mood.tags.events.rare", + icon: null, + count: MOOD_NARRATIVE_MIN_TAG_COUNT - 1, + avgScore: 5, + }, + ], + }; + const out = computeMoodNarratives(input); + expect(out.some((n) => n.kind === "tag-lift")).toBe(false); + }); + + it("stays silent on a tag whose delta is below the effect threshold", () => { + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 3 }, + { dayOffset: 1, value: 3 }, + ]), + tags: [{ tag: "neutral", count: 10, avgScore: 3.1 }], + }; + const out = computeMoodNarratives(input); + expect(out.some((n) => n.vars.tag === "neutral")).toBe(false); + }); + + it("surfaces the in-target share once data exists", () => { + const input: MoodNarrativeInput = { ...emptyInput(), inTargetPct: 72 }; + const it = computeMoodNarratives(input).find((n) => n.kind === "in-target"); + expect(it?.vars.pct).toBe("72"); + }); + + it("stays silent on in-target when there is no recent data", () => { + expect( + computeMoodNarratives(emptyInput()).find((n) => n.kind === "in-target"), + ).toBeUndefined(); + }); + + it("fires the streak takeaway only at or above the minimum run", () => { + const keys = (n: number) => + Array.from({ length: n }, (_, i) => + new Date(NOW.getTime() - i * dayMs).toISOString().slice(0, 10), + ); + const below: MoodNarrativeInput = { + ...emptyInput(), + loggedDayKeys: keys(MOOD_NARRATIVE_MIN_STREAK - 1), + }; + expect( + computeMoodNarratives(below).find((n) => n.kind === "streak"), + ).toBeUndefined(); + + const above: MoodNarrativeInput = { + ...emptyInput(), + loggedDayKeys: keys(MOOD_NARRATIVE_MIN_STREAK), + }; + const streak = computeMoodNarratives(above).find((n) => n.kind === "streak"); + expect(streak?.vars.days).toBe(String(MOOD_NARRATIVE_MIN_STREAK)); + }); + + it("breaks the streak on a gap in consecutive days", () => { + const today = NOW.toISOString().slice(0, 10); + const twoDaysAgo = new Date(NOW.getTime() - 2 * dayMs) + .toISOString() + .slice(0, 10); + const input: MoodNarrativeInput = { + ...emptyInput(), + // today + a gap → run length 1, below the min. + loggedDayKeys: [today, twoDaysAgo], + }; + expect( + computeMoodNarratives(input).find((n) => n.kind === "streak"), + ).toBeUndefined(); + }); + + it("fires the weekend effect above the effect threshold", () => { + // Build a daily series where weekend days (Sat/Sun) score higher. + // dayOffset 0 = Mon (NOW). offsets 5,6 = Wed/Tue back; use explicit days. + // Sat is offset 2 (Sat May 30), Sun is offset 1 (Sun May 31). + const input: MoodNarrativeInput = { + ...emptyInput(), + daily: daily([ + { dayOffset: 0, value: 2 }, // Mon + { dayOffset: 1, value: 5 }, // Sun + { dayOffset: 2, value: 5 }, // Sat + { dayOffset: 3, value: 2 }, // Fri + { dayOffset: 4, value: 2 }, // Thu + { dayOffset: 8, value: 5 }, // Sun prior + { dayOffset: 9, value: 5 }, // Sat prior + ]), + }; + const weekend = computeMoodNarratives(input).find( + (n) => n.kind === "weekend", + ); + expect(weekend).toBeDefined(); + expect(weekend?.vars.direction).toBe("up"); + }); +}); + +describe("computeMoodNarratives — ranking and capping", () => { + it("ranks by descending strength and caps the list", () => { + // Construct many firing signals; assert ordering by strength + cap. + const weekday = [ + { weekday: 0, avgScore: 1, count: 5 }, + { weekday: 1, avgScore: 5, count: 5 }, + { weekday: 2, avgScore: 5, count: 5 }, + ]; + const recent = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({ + dayOffset: i, + value: 5, + })); + const prior = Array.from({ length: MOOD_NARRATIVE_TREND_WINDOW }, (_, i) => ({ + dayOffset: i + MOOD_NARRATIVE_TREND_WINDOW, + value: 1, + })); + const input: MoodNarrativeInput = { + daily: [...recent, ...prior], + weekday, + tags: [ + { tag: "great", count: 10, avgScore: 5 }, + { tag: "bad", count: 10, avgScore: 1 }, + ], + structuredTags: [], + inTargetPct: 80, + loggedDayKeys: Array.from({ length: 10 }, (_, i) => + new Date(NOW.getTime() - i * dayMs).toISOString().slice(0, 10), + ), + now: NOW, + }; + const out = computeMoodNarratives(input); + expect(out.length).toBeLessThanOrEqual(MOOD_NARRATIVE_MAX_ITEMS); + for (let i = 1; i < out.length; i++) { + expect(out[i - 1].strength).toBeGreaterThanOrEqual(out[i].strength); + } + }); + + it("exposes a stable minimum-days constant", () => { + expect(MOOD_NARRATIVE_MIN_DAYS).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/insights/__tests__/trend-chart-select.test.ts b/src/lib/insights/__tests__/trend-chart-select.test.ts index 49f58b2f1..02a3317f4 100644 --- a/src/lib/insights/__tests__/trend-chart-select.test.ts +++ b/src/lib/insights/__tests__/trend-chart-select.test.ts @@ -126,6 +126,19 @@ describe("selectTrendCharts", () => { expect(charts[0].types).toEqual([]); }); + it("every chart-bearing sourceMetric carries a standard caption key", () => { + // v1.8.6 W8 — a tile without an advisor annotation used to paint + // empty space below the chart. Every chart config now carries a + // `captionKey` so the renderer can always surface a caption. This + // guard keeps a future metric addition from re-opening the gap. + for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) { + if (!config) continue; + expect(config.captionKey, `${metric} captionKey`).toMatch( + /^insights\.trendsRow\.caption\./, + ); + } + }); + it("every chart-bearing sourceMetric resolves to a non-empty health-chart series", () => { for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) { if (!config) continue; diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 7f17e4886..3c02d75b8 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -25,6 +25,10 @@ import { type DailyBucket, } from "@/lib/insights/bucket-series"; import { round, summarizeSeries } from "@/lib/insights/status-shared"; +import { + computeMoodNarratives, + type MoodNarrative, +} from "@/lib/insights/mood-narratives"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -87,53 +91,6 @@ export interface MoodAggregateEntry { * aggregate tests (flat tags only) keep their narrow fixtures. */ structuredTags?: StructuredTagRef[]; - /** v1.8.5 — free-text note (C1). */ - note?: string | null; - /** Mood enum (for the notes timeline label / colour). */ - mood?: string; -} - -// --- Notes timeline (v1.8.5 C1 — qualitative reflection feed) --- - -export interface NoteTimelineEntry { - /** TZ-anchored day key. */ - date: string; - /** ISO timestamp of the log. */ - loggedAt: string; - score: number; - mood: string | null; - note: string; - /** Flat free-text tags on the entry. */ - tags: string[]; - /** Structured-tag labels (i18n keys) on the entry. */ - structuredTagLabelKeys: string[]; -} - -/** - * Recent entries that carry a free-text note, newest first. Bounded to - * `limit` so the cached aggregate payload stays small. Entries without a - * note are skipped — the timeline is the qualitative-reflection feed. - */ -export function computeNotesTimeline( - entries: MoodAggregateEntry[], - limit = 20, -): NoteTimelineEntry[] { - return entries - .filter((entry) => typeof entry.note === "string" && entry.note.trim()) - .slice() - .reverse() // entries arrive oldest-first; show newest first - .slice(0, limit) - .map((entry) => ({ - date: entry.date, - loggedAt: entry.moodLoggedAt.toISOString(), - score: entry.score, - mood: entry.mood ?? null, - note: entry.note!.trim(), - tags: Array.isArray(entry.tags) ? (entry.tags as string[]) : [], - structuredTagLabelKeys: (entry.structuredTags ?? []).map( - (tag) => tag.labelKey, - ), - })); } /** Cross-metric row shape (WEIGHT / BLOOD_PRESSURE_SYS / PULSE / …). */ @@ -518,8 +475,12 @@ export interface MoodAggregates { tags: TagSummaryRow[]; /** v1.8.5 — structured-tag breakdown from the taxonomy join. */ structuredTags: StructuredTagRow[]; - /** v1.8.5 — recent notes timeline (qualitative-reflection feed). */ - notesTimeline: NoteTimelineEntry[]; + /** + * v1.8.6 — ranked, threshold-gated narrative takeaways. The "read + * this first" layer above the charts; the same array feeds the LLM + * snapshot so prose and feed never drift. + */ + narratives: MoodNarrative[]; correlations: Record; } @@ -571,6 +532,14 @@ export function computeMoodAggregates(args: { const windowDays = selectHeatmapWindow(totalSpanDays); + const weekday = computeWeekdayAverages(moodDaily, now); + const tags = computeTagSummary(entries, now); + const structuredTags = computeStructuredTagSummary(entries, now); + const inTargetPct = computeInTargetPct(moodDaily); + + // Distinct day keys the user logged on — drives the streak takeaway. + const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date))); + return { summary: { points: moodSummary?.points ?? 0, @@ -580,7 +549,7 @@ export function computeMoodAggregates(args: { latest: latest?.value ?? null, delta: latest && previous ? round(latest.value - previous.value, 2) : null, - inTargetPct: computeInTargetPct(moodDaily), + inTargetPct, totalEntries: entries.length, totalSpanDays, newestEntryDaysAgo, @@ -590,10 +559,18 @@ export function computeMoodAggregates(args: { cells: computeHeatmapCells(entries, now, windowDays), }, distribution: computeDistribution(moodDaily), - weekday: computeWeekdayAverages(moodDaily, now), - tags: computeTagSummary(entries, now), - structuredTags: computeStructuredTagSummary(entries, now), - notesTimeline: computeNotesTimeline(entries), + weekday, + tags, + structuredTags, + narratives: computeMoodNarratives({ + daily: moodDaily, + weekday, + tags, + structuredTags, + inTargetPct, + loggedDayKeys, + now, + }), correlations: Object.fromEntries( Object.entries(CORRELATION_METRICS).map(([key, type]) => [ key, @@ -627,8 +604,6 @@ export async function fetchMoodAggregates( score: true, tags: true, moodLoggedAt: true, - mood: true, - note: true, // v1.8.5 — pull the structured-tag links + their catalog rows so // the breakdown can fold structured tags next to the flat ones. tagLinks: { @@ -651,8 +626,6 @@ export async function fetchMoodAggregates( score: row.score, tags: parseTags(row.tags), moodLoggedAt: row.moodLoggedAt, - mood: row.mood, - note: row.note, structuredTags: row.tagLinks.map((link) => ({ key: link.moodTag.key, categoryKey: link.moodTag.category.key, diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts new file mode 100644 index 000000000..81078a01e --- /dev/null +++ b/src/lib/insights/mood-narratives.ts @@ -0,0 +1,395 @@ +/** + * v1.8.6 — ranked narrative takeaways for the mood surface. + * + * Plain-language, threshold-gated one-liners ("Your mood dips most on + * Mondays") that turn the already-computed mood aggregates into the + * "read this first" layer above the charts. Pure functions over the + * existing aggregate shapes so the same takeaways feed BOTH + * `/api/mood/insights` (the visible feed) and the LLM snapshot in + * `mood-status.ts` — shown == sent, no prose/chart drift. + * + * The anti-platitude rule is enforced structurally: every minimum + * sample / minimum effect bar is a NAMED CONSTANT, and a takeaway is + * only emitted when its bar is cleared. Empty data → empty feed; no + * "no correlation" filler. + */ + +import { dayOffsetToBerlinDayKey } from "@/lib/insights/bucket-series"; +import { round } from "@/lib/insights/status-shared"; +import type { + StructuredTagRow, + TagSummaryRow, + WeekdayRow, +} from "@/lib/insights/mood-aggregates"; + +// --- Named thresholds (the anti-platitude contract) --- + +/** Minimum distinct daily points before any trend/weekend takeaway fires. */ +export const MOOD_NARRATIVE_MIN_DAYS = 7; +/** Minimum score-point magnitude (1..5 scale) for an effect to count. */ +export const MOOD_NARRATIVE_MIN_EFFECT = 0.3; +/** Minimum daily samples behind a weekday before its dip/peak is trusted. */ +export const MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES = 3; +/** Minimum tagged occurrences before a tag→mood delta is surfaced. */ +export const MOOD_NARRATIVE_MIN_TAG_COUNT = 3; +/** Days per slice when comparing the recent window against the prior one. */ +export const MOOD_NARRATIVE_TREND_WINDOW = 7; +/** Minimum consecutive logged days before the streak takeaway congratulates. */ +export const MOOD_NARRATIVE_MIN_STREAK = 3; +/** Maximum tag-delta takeaways surfaced (lift + drop combined are still capped). */ +export const MOOD_NARRATIVE_MAX_TAG_ITEMS = 2; +/** Hard cap on the rendered feed length. */ +export const MOOD_NARRATIVE_MAX_ITEMS = 8; + +export type MoodNarrativeKind = + | "weekday-dip" + | "weekday-peak" + | "trend" + | "weekend" + | "tag-lift" + | "tag-drop" + | "in-target" + | "streak"; + +/** + * A single takeaway. `messageKey` resolves to an i18n template; `vars` + * are the interpolation values (strings so they survive JSON transport + * to the client and the LLM snapshot unchanged). `strength` is the + * ranking weight — higher sorts first — and is never rendered. + */ +export interface MoodNarrative { + kind: MoodNarrativeKind; + messageKey: string; + vars: Record; + /** Ranking weight (effect size, normalised); higher = stronger. */ + strength: number; +} + +type DailyPoint = { dayOffset: number; value: number }; + +export interface MoodNarrativeInput { + daily: DailyPoint[]; + weekday: WeekdayRow[]; + tags: TagSummaryRow[]; + structuredTags: StructuredTagRow[]; + inTargetPct: number | null; + /** Day keys (YYYY-MM-DD) the user logged a mood on. */ + loggedDayKeys: string[]; + now: Date; +} + +const WEEKDAY_LABEL_KEYS = [ + "charts.weekdaysFull.mon", + "charts.weekdaysFull.tue", + "charts.weekdaysFull.wed", + "charts.weekdaysFull.thu", + "charts.weekdaysFull.fri", + "charts.weekdaysFull.sat", + "charts.weekdaysFull.sun", +] as const; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function mean(values: number[]): number | null { + if (values.length === 0) return null; + return values.reduce((sum, v) => sum + v, 0) / values.length; +} + +/** Weekday (0 = Monday … 6 = Sunday) for a daily-bucket offset. */ +function weekdayOfOffset(now: Date, dayOffset: number): number { + const dayKey = dayOffsetToBerlinDayKey(now, dayOffset); + const d = new Date(dayKey + "T00:00:00Z"); + return (d.getUTCDay() + 6) % 7; +} + +// --- Individual takeaway builders --- + +/** + * Best / worst weekday vs the weekly mean. Fires the dip (worst day) and + * the peak (best day) independently, each gated on the per-weekday sample + * count and the effect magnitude against the overall weekday mean. + */ +function weekdayNarratives(input: MoodNarrativeInput): MoodNarrative[] { + const populated = input.weekday.filter( + ( + row, + ): row is WeekdayRow & { avgScore: number } => + row.avgScore != null && row.count >= MOOD_NARRATIVE_MIN_WEEKDAY_SAMPLES, + ); + if (populated.length < 2) return []; + + const overall = mean(populated.map((r) => r.avgScore)); + if (overall == null) return []; + + let best = populated[0]; + let worst = populated[0]; + for (const row of populated) { + if (row.avgScore > best.avgScore) best = row; + if (row.avgScore < worst.avgScore) worst = row; + } + + const out: MoodNarrative[] = []; + + const dipDelta = round(overall - worst.avgScore, 2); + if (dipDelta >= MOOD_NARRATIVE_MIN_EFFECT) { + out.push({ + kind: "weekday-dip", + messageKey: "insights.mood.narrative.weekdayDip", + vars: { + weekdayKey: WEEKDAY_LABEL_KEYS[worst.weekday], + delta: dipDelta.toFixed(1), + }, + strength: dipDelta, + }); + } + + const peakDelta = round(best.avgScore - overall, 2); + if (peakDelta >= MOOD_NARRATIVE_MIN_EFFECT) { + out.push({ + kind: "weekday-peak", + messageKey: "insights.mood.narrative.weekdayPeak", + vars: { + weekdayKey: WEEKDAY_LABEL_KEYS[best.weekday], + value: best.avgScore.toFixed(1), + }, + strength: peakDelta, + }); + } + + return out; +} + +/** + * Trend direction: mean of the most recent window vs the prior window. + * Both windows must carry the full sample count and their means must + * differ by at least the effect threshold. + */ +function trendNarrative(input: MoodNarrativeInput): MoodNarrative | null { + const w = MOOD_NARRATIVE_TREND_WINDOW; + const recent = input.daily.filter((b) => b.dayOffset < w); + const prior = input.daily.filter( + (b) => b.dayOffset >= w && b.dayOffset < 2 * w, + ); + if (recent.length < w || prior.length < w) return null; + + const recentMean = mean(recent.map((b) => b.value)); + const priorMean = mean(prior.map((b) => b.value)); + if (recentMean == null || priorMean == null) return null; + + const delta = round(recentMean - priorMean, 2); + if (Math.abs(delta) < MOOD_NARRATIVE_MIN_EFFECT) return null; + + return { + kind: "trend", + messageKey: + delta > 0 + ? "insights.mood.narrative.trendUp" + : "insights.mood.narrative.trendDown", + vars: { + direction: delta > 0 ? "up" : "down", + delta: Math.abs(delta).toFixed(1), + days: String(w), + }, + strength: Math.abs(delta), + }; +} + +/** + * Weekend vs weekday effect over the daily means. Both buckets must + * carry data and the gap must clear the effect threshold. + */ +function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null { + if (input.daily.length < MOOD_NARRATIVE_MIN_DAYS) return null; + + const weekend: number[] = []; + const weekday: number[] = []; + for (const bucket of input.daily) { + const wd = weekdayOfOffset(input.now, bucket.dayOffset); + if (wd >= 5) weekend.push(bucket.value); + else weekday.push(bucket.value); + } + const weekendMean = mean(weekend); + const weekdayMean = mean(weekday); + if (weekendMean == null || weekdayMean == null) return null; + + const delta = round(weekendMean - weekdayMean, 2); + if (Math.abs(delta) < MOOD_NARRATIVE_MIN_EFFECT) return null; + + return { + kind: "weekend", + messageKey: + delta > 0 + ? "insights.mood.narrative.weekendUp" + : "insights.mood.narrative.weekendDown", + vars: { + direction: delta > 0 ? "up" : "down", + delta: Math.abs(delta).toFixed(1), + }, + strength: Math.abs(delta), + }; +} + +/** + * Top tag→mood deltas against the overall daily mean. Flat free-text tags + * AND structured taxonomy tags share one ranked pool: the strongest lift + * and the strongest drop across both axes survive (capped). Each candidate + * needs the min occurrence count and an effect that clears the magnitude + * threshold. + * + * The two axes carry different label shapes. A flat tag's label is the + * raw free-text string, emitted as `vars.tag` and rendered verbatim. A + * structured tag's label is an i18n message key from the catalog, emitted + * as `vars.tagKey` so the renderer resolves it in the active locale + * (mirroring the `weekdayKey` → `weekday` pattern) before interpolation. + */ +function tagNarratives(input: MoodNarrativeInput): MoodNarrative[] { + const overall = mean(input.daily.map((b) => b.value)); + if (overall == null) return []; + + type Candidate = { + /** Free-text label for flat tags; null for structured (use `labelKey`). */ + label: string | null; + /** i18n key for structured tags; null for flat (use `label`). */ + labelKey: string | null; + delta: number; + count: number; + }; + + const candidates: Candidate[] = []; + for (const row of input.tags) { + if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue; + candidates.push({ + label: row.tag, + labelKey: null, + delta: round(row.avgScore - overall, 2), + count: row.count, + }); + } + for (const row of input.structuredTags) { + if (row.count < MOOD_NARRATIVE_MIN_TAG_COUNT) continue; + candidates.push({ + label: null, + labelKey: row.labelKey, + delta: round(row.avgScore - overall, 2), + count: row.count, + }); + } + + const lifts = candidates + .filter((c) => c.delta >= MOOD_NARRATIVE_MIN_EFFECT) + .sort((a, b) => b.delta - a.delta); + const drops = candidates + .filter((c) => c.delta <= -MOOD_NARRATIVE_MIN_EFFECT) + .sort((a, b) => a.delta - b.delta); + + const tagVars = (c: Candidate): Record => + c.labelKey != null ? { tagKey: c.labelKey } : { tag: c.label ?? "" }; + + const out: MoodNarrative[] = []; + const topLift = lifts[0]; + if (topLift) { + out.push({ + kind: "tag-lift", + messageKey: "insights.mood.narrative.tagLift", + vars: { ...tagVars(topLift), delta: topLift.delta.toFixed(1) }, + strength: topLift.delta, + }); + } + const topDrop = drops[0]; + if (topDrop) { + out.push({ + kind: "tag-drop", + messageKey: "insights.mood.narrative.tagDrop", + vars: { + ...tagVars(topDrop), + delta: Math.abs(topDrop.delta).toFixed(1), + }, + strength: Math.abs(topDrop.delta), + }); + } + + return out.slice(0, MOOD_NARRATIVE_MAX_TAG_ITEMS); +} + +/** + * In-target share takeaway. Surfaces the already-computed `inTargetPct` + * (the share of recent days in the good-mood band) once any recent data + * exists. Strength is fixed-low so the more specific signals outrank it. + */ +function inTargetNarrative(input: MoodNarrativeInput): MoodNarrative | null { + if (input.inTargetPct == null) return null; + return { + kind: "in-target", + messageKey: "insights.mood.narrative.inTarget", + vars: { pct: String(Math.round(input.inTargetPct)) }, + strength: 0.2, + }; +} + +/** + * Current consecutive logging streak ending today (or the most recent + * logged day). Fires only at or above the minimum run. Strength is + * fixed-low — encouragement, not analysis. + */ +function streakNarrative(input: MoodNarrativeInput): MoodNarrative | null { + if (input.loggedDayKeys.length === 0) return null; + + const keys = new Set(input.loggedDayKeys); + const todayKey = input.now.toISOString().slice(0, 10); + const todayMs = Date.parse(todayKey + "T00:00:00Z"); + + // Anchor the streak at today if logged, else the most recent logged day. + let anchorMs = todayMs; + if (!keys.has(todayKey)) { + const sorted = [...keys].sort().reverse(); + anchorMs = Date.parse(sorted[0] + "T00:00:00Z"); + // A streak that does not include today is stale — don't congratulate. + if (anchorMs !== todayMs) return null; + } + + let run = 0; + let cursor = anchorMs; + while (keys.has(new Date(cursor).toISOString().slice(0, 10))) { + run += 1; + cursor -= MS_PER_DAY; + } + + if (run < MOOD_NARRATIVE_MIN_STREAK) return null; + + return { + kind: "streak", + messageKey: "insights.mood.narrative.streak", + vars: { days: String(run) }, + strength: 0.15, + }; +} + +/** + * Build the ranked, threshold-gated narrative feed. Pure over the + * supplied aggregates; emits only takeaways whose named thresholds are + * cleared, sorted strongest-first, capped at `MOOD_NARRATIVE_MAX_ITEMS`. + */ +export function computeMoodNarratives( + input: MoodNarrativeInput, +): MoodNarrative[] { + const all: MoodNarrative[] = [ + ...weekdayNarratives(input), + ...tagNarratives(input), + ]; + + const trend = trendNarrative(input); + if (trend) all.push(trend); + + const weekend = weekendNarrative(input); + if (weekend) all.push(weekend); + + const inTarget = inTargetNarrative(input); + if (inTarget) all.push(inTarget); + + const streak = streakNarrative(input); + if (streak) all.push(streak); + + return all + .sort((a, b) => b.strength - a.strength) + .slice(0, MOOD_NARRATIVE_MAX_ITEMS); +} diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 5852677fc..d54173f17 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -13,9 +13,12 @@ import { MOOD_ORANGE_MAX, MOOD_ORANGE_MIN, computeInTargetPct, + computeStructuredTagSummary, computeTagSummary, + computeWeekdayAverages, pairDailyBuckets, } from "@/lib/insights/mood-aggregates"; +import { computeMoodNarratives } from "@/lib/insights/mood-narratives"; import { buildGradedSeriesFromPoints, degradeStatusSnapshotToBudget, @@ -113,9 +116,37 @@ export async function generateMoodStatusForUser( score: true, tags: true, moodLoggedAt: true, + // v1.8.6 — pull the structured-tag links so the snapshot feeds + // `computeMoodNarratives` the same structured-tag pool the visible + // mood page does (shown == sent for the tag→mood takeaways). + tagLinks: { + select: { + moodTag: { + select: { + key: true, + labelKey: true, + icon: true, + category: { select: { key: true } }, + }, + }, + }, + }, }, }) - .then((rows) => rows.reverse()); + .then((rows) => + rows.reverse().map((row) => ({ + date: row.date, + score: row.score, + tags: row.tags, + moodLoggedAt: row.moodLoggedAt, + structuredTags: (row.tagLinks ?? []).map((link) => ({ + key: link.moodTag.key, + categoryKey: link.moodTag.category.key, + labelKey: link.moodTag.labelKey, + icon: link.moodTag.icon, + })), + })), + ); const now = new Date(); @@ -222,6 +253,22 @@ export async function generateMoodStatusForUser( // tag patterns. Shared with the `/api/mood/insights` tag breakdown // via `computeTagSummary` so the prose and the chart never drift. const tagSummary = computeTagSummary(entries, now); + const structuredTagSummary = computeStructuredTagSummary(entries, now); + + // v1.8.6 — the same threshold-gated narrative feed the user sees on + // the mood page, computed from the same aggregates so the prose the + // model writes never contradicts the takeaways on screen (shown == + // sent). Both the flat free-text tags and the structured taxonomy tags + // feed the tag→mood pool, matching the visible feed exactly. + const narratives = computeMoodNarratives({ + daily: moodSeries.daily, + weekday: computeWeekdayAverages(moodSeries.daily, now), + tags: tagSummary, + structuredTags: structuredTagSummary, + inTargetPct: inTargetPctLast30DailyPoints, + loggedDayKeys: Array.from(new Set(entries.map((entry) => entry.date))), + now, + }); const snapshot = { locale, @@ -253,6 +300,7 @@ export async function generateMoodStatusForUser( inTargetPctLast30DailyPoints, }, tags: tagSummary.length > 0 ? tagSummary : null, + narratives: narratives.length > 0 ? narratives : null, }, crossMetricContext: weightSeries.daily.length >= 3 || diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index bfdd9f8d5..25f4ff7eb 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -59,10 +59,19 @@ export interface TrendChartConfig { yAxisUnit?: string; /** i18n key for the chart title (`charts.*`). */ titleKey: string; + /** + * i18n key for the slot's standard one-line description + * (`insights.trendsRow.caption.*`). Every slot carries one so a card + * never paints caption-less. For the legacy triple it is the + * fallback the renderer shows when the advisor supplies no + * annotation; for additive metrics it is the only caption (those + * slots carry no advisor annotation). + */ + captionKey: string; /** * When set, the slot carries an advisor annotation under the chart * and uses the typed `` empty-state copy. Omitted - * for additive metrics that render chart-only. + * for additive metrics, which fall back to {@link captionKey}. */ annotationKey?: TrendAnnotationKey; } @@ -89,6 +98,7 @@ export const TREND_CHART_CONFIG: Record< unit: "mmHg", yAxisUnit: "mmHg", titleKey: "charts.bloodPressure", + captionKey: "insights.trendsRow.caption.bp", annotationKey: "bp", }, weight: { @@ -98,6 +108,7 @@ export const TREND_CHART_CONFIG: Record< colors: ["#bd93f9"], unit: "kg", titleKey: "charts.weight", + captionKey: "insights.trendsRow.caption.weight", annotationKey: "weight", }, pulse: { @@ -108,6 +119,7 @@ export const TREND_CHART_CONFIG: Record< unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.pulse", + captionKey: "insights.trendsRow.caption.pulse", }, mood: { metric: "mood", @@ -115,6 +127,7 @@ export const TREND_CHART_CONFIG: Record< types: [], colors: [], titleKey: "charts.mood", + captionKey: "insights.trendsRow.caption.mood", annotationKey: "mood", }, sleep: { @@ -125,6 +138,7 @@ export const TREND_CHART_CONFIG: Record< unit: "h", yAxisUnit: "h", titleKey: "charts.sleep", + captionKey: "insights.trendsRow.caption.sleep", }, steps: { metric: "steps", @@ -134,6 +148,7 @@ export const TREND_CHART_CONFIG: Record< unit: "steps", yAxisUnit: "steps", titleKey: "charts.steps", + captionKey: "insights.trendsRow.caption.steps", }, hrv: { metric: "hrv", @@ -143,6 +158,7 @@ export const TREND_CHART_CONFIG: Record< unit: "ms", yAxisUnit: "ms", titleKey: "charts.hrv", + captionKey: "insights.trendsRow.caption.hrv", }, resting_hr: { metric: "resting_hr", @@ -152,6 +168,7 @@ export const TREND_CHART_CONFIG: Record< unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.restingHeartRate", + captionKey: "insights.trendsRow.caption.resting_hr", }, active_energy: { metric: "active_energy", @@ -161,6 +178,7 @@ export const TREND_CHART_CONFIG: Record< unit: "kcal", yAxisUnit: "kcal", titleKey: "charts.activeEnergy", + captionKey: "insights.trendsRow.caption.active_energy", }, flights: { metric: "flights", @@ -170,6 +188,7 @@ export const TREND_CHART_CONFIG: Record< unit: "flights", yAxisUnit: "flights", titleKey: "charts.flights", + captionKey: "insights.trendsRow.caption.flights", }, distance: { metric: "distance", @@ -179,6 +198,7 @@ export const TREND_CHART_CONFIG: Record< unit: "km", yAxisUnit: "km", titleKey: "charts.distance", + captionKey: "insights.trendsRow.caption.distance", }, vo2_max: { metric: "vo2_max", @@ -188,6 +208,7 @@ export const TREND_CHART_CONFIG: Record< unit: "mL/kg·min", yAxisUnit: "mL/kg·min", titleKey: "charts.vo2Max", + captionKey: "insights.trendsRow.caption.vo2_max", }, body_temp: { metric: "body_temp", @@ -197,6 +218,7 @@ export const TREND_CHART_CONFIG: Record< unit: "°C", yAxisUnit: "°C", titleKey: "charts.bodyTemperature", + captionKey: "insights.trendsRow.caption.body_temp", }, // Adherence-context findings — no single plottable trend series in // this row. A finding on one of these is skipped during selection so diff --git a/src/lib/medications/titration/__tests__/ladder.test.ts b/src/lib/medications/titration/__tests__/ladder.test.ts deleted file mode 100644 index caea3820a..000000000 --- a/src/lib/medications/titration/__tests__/ladder.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * v1.4.25 W19f — pure titration-ladder helper tests. - * - * Pins the contract the API route and the detail-page section read: - * - every EMA-approved drug exposes a non-empty, strictly ascending - * ladder; - * - `findCurrentStep` snaps within a ±10 % tolerance and returns - * null outside that window; - * - `nextStep` returns null at the ladder ceiling; - * - `weeksOnCurrentStep` is 0 with no matching dose-change rows; - * - `escalationDue` toggles at the EMA-reference dwell-time - * boundary (and never fires past the ceiling). - */ - -import { describe, expect, it } from "vitest"; -import { - escalationDue, - findCurrentStep, - getLadder, - ladderFromRecord, - nextStep, - weeksOnCurrentStep, - type DoseChangeLike, -} from "../ladder"; -import { - GLP1_DRUG_IDS, - GLP1_DRUGS, - type Glp1DrugId, -} from "@/lib/medications/glp1-knowledge"; - -function d(iso: string): Date { - return new Date(iso); -} - -describe("getLadder / ladderFromRecord", () => { - it("returns a non-empty ladder for every EMA-approved drug", () => { - for (const id of GLP1_DRUG_IDS) { - const ladder = getLadder(id); - expect(ladder.length).toBeGreaterThan(0); - } - }); - - it("produces strictly ascending doses with sequential indices", () => { - for (const id of GLP1_DRUG_IDS) { - const ladder = getLadder(id); - for (let i = 0; i < ladder.length; i++) { - expect(ladder[i].stepIndex).toBe(i); - if (i > 0) { - expect(ladder[i].doseMg).toBeGreaterThan(ladder[i - 1].doseMg); - } - } - } - }); - - it("carries the EMA-reference dwell time on every step", () => { - for (const id of GLP1_DRUG_IDS) { - const expected = GLP1_DRUGS[id].titrationIntervalWeeks; - const ladder = getLadder(id); - for (const step of ladder) { - expect(step.typicalWeeks).toBe(expected); - } - } - }); - - it("accepts the record-direct overload", () => { - const ladderById = getLadder("tirzepatide"); - const ladderByRecord = ladderFromRecord(GLP1_DRUGS.tirzepatide); - expect(ladderByRecord).toEqual(ladderById); - }); -}); - -describe("findCurrentStep", () => { - it("returns null when the latest dose is null or non-finite", () => { - expect(findCurrentStep("tirzepatide", null)).toBeNull(); - expect(findCurrentStep("tirzepatide", Number.NaN)).toBeNull(); - expect(findCurrentStep("tirzepatide", 0)).toBeNull(); - }); - - it("matches an exact dose on the ladder", () => { - // Tirzepatide ladder: 2.5, 5, 7.5, 10, 12.5, 15 - expect(findCurrentStep("tirzepatide", 5)?.doseMg).toBe(5); - expect(findCurrentStep("tirzepatide", 15)?.stepIndex).toBe(5); - }); - - it("snaps within the ±10 % tolerance window", () => { - // 5 mg ± 10 % = [4.5, 5.5]; 4.7 should round to step 5 mg. - expect(findCurrentStep("tirzepatide", 4.7)?.doseMg).toBe(5); - expect(findCurrentStep("tirzepatide", 5.4)?.doseMg).toBe(5); - }); - - it("returns null outside the tolerance window", () => { - // 6 mg is > 5 mg + 10 % AND < 7.5 mg − 10 % → unmatched. - expect(findCurrentStep("tirzepatide", 6.0)).toBeNull(); - // 3 mg also outside any step (2.5 + 10 % = 2.75; 5 − 10 % = 4.5). - expect(findCurrentStep("tirzepatide", 3.5)).toBeNull(); - }); - - it("works for semaglutide (smallest dose step on the catalog)", () => { - expect(findCurrentStep("semaglutide", 0.25)?.doseMg).toBe(0.25); - expect(findCurrentStep("semaglutide", 1.0)?.stepIndex).toBe(2); - }); -}); - -describe("nextStep", () => { - it("returns the immediate-next step from the bottom of the ladder", () => { - const current = findCurrentStep("tirzepatide", 2.5); - expect(nextStep("tirzepatide", current)?.doseMg).toBe(5); - }); - - it("returns null at the ladder ceiling", () => { - const top = findCurrentStep("tirzepatide", 15); - expect(top?.doseMg).toBe(15); - expect(nextStep("tirzepatide", top)).toBeNull(); - }); - - it("returns null when given a null current step", () => { - expect(nextStep("tirzepatide", null)).toBeNull(); - }); -}); - -describe("weeksOnCurrentStep", () => { - const ASOF = d("2026-05-14T00:00:00.000Z"); - - it("returns 0 with no dose-change history", () => { - const current = findCurrentStep("tirzepatide", 5); - expect(weeksOnCurrentStep("tirzepatide", current, [], ASOF)).toBe(0); - }); - - it("returns 0 when current step is null", () => { - expect(weeksOnCurrentStep("tirzepatide", null, [], ASOF)).toBe(0); - }); - - it("returns the elapsed whole-weeks since the latest matching change", () => { - const current = findCurrentStep("tirzepatide", 5); - const events: DoseChangeLike[] = [ - // Switched onto 5 mg six weeks ago. - { effectiveFrom: d("2026-04-02T00:00:00.000Z"), doseValue: 5 }, - ]; - expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(6); - }); - - it("ignores dose-change rows for other steps", () => { - const current = findCurrentStep("tirzepatide", 5); - const events: DoseChangeLike[] = [ - // A 2.5 mg row from 12 weeks ago should not count. - { effectiveFrom: d("2026-02-19T00:00:00.000Z"), doseValue: 2.5 }, - // A 5 mg row from 2 weeks ago — this is the latest matching row. - { effectiveFrom: d("2026-04-30T00:00:00.000Z"), doseValue: 5 }, - ]; - expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(2); - }); - - it("returns 0 when the only matching change is in the future", () => { - const current = findCurrentStep("tirzepatide", 5); - const events: DoseChangeLike[] = [ - { effectiveFrom: d("2027-01-01T00:00:00.000Z"), doseValue: 5 }, - ]; - expect(weeksOnCurrentStep("tirzepatide", current, events, ASOF)).toBe(0); - }); -}); - -describe("escalationDue", () => { - const TIRZEPATIDE_DWELL = GLP1_DRUGS.tirzepatide.titrationIntervalWeeks; - - it("returns false when current step is null", () => { - expect(escalationDue("tirzepatide", null, 99)).toBe(false); - }); - - it("returns false below the dwell-time boundary", () => { - const current = findCurrentStep("tirzepatide", 5); - expect( - escalationDue("tirzepatide", current, TIRZEPATIDE_DWELL - 1), - ).toBe(false); - }); - - it("toggles true at the dwell-time boundary", () => { - const current = findCurrentStep("tirzepatide", 5); - expect(escalationDue("tirzepatide", current, TIRZEPATIDE_DWELL)).toBe( - true, - ); - }); - - it("never fires past the ladder ceiling", () => { - const top = findCurrentStep("tirzepatide", 15); - expect(escalationDue("tirzepatide", top, 999)).toBe(false); - }); -}); - -describe("catalog coverage", () => { - it("exposes a ladder for every drug id in the catalog", () => { - const ids: Glp1DrugId[] = [...GLP1_DRUG_IDS]; - expect(ids.length).toBe(5); - for (const id of ids) { - const ladder = getLadder(id); - expect(ladder.length).toBeGreaterThan(0); - } - }); -}); diff --git a/src/lib/medications/titration/ladder.ts b/src/lib/medications/titration/ladder.ts deleted file mode 100644 index c007541ce..000000000 --- a/src/lib/medications/titration/ladder.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * v1.4.25 W19f — pure titration-ladder helpers for the GLP-1 detail page. - * - * The W19a knowledge layer (`glp1-knowledge.ts`) already carries the - * EMA-approved escalation schedules under `titrationStepsMg` + - * `titrationIntervalWeeks`. This module turns that static reference - * into a small contract the API route and the detail-page section - * can both read: - * - * - `getLadder(drugId)` — normalises the catalog row into an - * ordered `TitrationStep[]` with - * `stepIndex` and `typicalWeeks`. - * - `findCurrentStep(...)` — matches the user's latest dose to - * the closest ladder step within a - * ±10 % tolerance window; returns - * null when the dose is outside any - * ladder bucket (e.g. user is on a - * non-standard dose). - * - `nextStep(...)` — immediate-next step, null at the - * ladder ceiling. - * - `weeksOnCurrentStep(...)` — how long the user has actually been - * on the matched step, derived from - * the `MedicationDoseChange` stream. - * - `escalationDue(...)` — true when (a) there's a next step - * and (b) the user has been on the - * current step at least - * `titrationIntervalWeeks`. Display - * copy is strictly observational - * ("ladder typically steps up around - * N weeks") — never prescriptive - * (parallel to the W19c-Safety - * GROUND RULE: MDR boundary). - * - * No DB access; every function takes pre-fetched rows. Same shape as - * the W19d / W19e pure modules so the route owns prisma and this - * module owns the math. - */ - -import { - GLP1_DRUGS, - type Glp1DrugId, - type Glp1DrugRecord, -} from "@/lib/medications/glp1-knowledge"; - -/** - * Normalised ladder step — easier to consume in the UI than the bare - * `number[]` on the catalog record because each step carries its own - * position and the typical-weeks figure attached to the drug. - */ -export interface TitrationStep { - /** 0-based position in the ladder. */ - stepIndex: number; - /** Step dose in mg (always > 0, strictly ascending across the ladder). */ - doseMg: number; - /** EMA-reference minimum weeks before stepping up from this dose. */ - typicalWeeks: number; -} - -/** Dose-change row this module needs — a tiny subset of the model. */ -export interface DoseChangeLike { - /** When the user transitioned onto a new dose. */ - effectiveFrom: Date; - /** Numeric dose, paired with `doseUnit` for display elsewhere. */ - doseValue: number; -} - -/** ±10 % match radius for snapping a logged dose to a ladder step. */ -const STEP_MATCH_TOLERANCE = 0.1; - -const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; - -/** - * Build the ordered ladder for a given drug-id. Returns an empty - * array if the id is not in the catalog (defensive — the catalog is - * static so this branch is unreachable in practice). - */ -export function getLadder(drugId: Glp1DrugId): TitrationStep[] { - const record = GLP1_DRUGS[drugId]; - if (!record) return []; - return ladderFromRecord(record); -} - -/** - * Convenience overload that takes the record directly (the chart and - * the API route already resolve the record once via `findDrugByBrand` - * — passing it through avoids a second lookup). - */ -export function ladderFromRecord(record: Glp1DrugRecord): TitrationStep[] { - return record.titrationStepsMg.map((doseMg, stepIndex) => ({ - stepIndex, - doseMg, - typicalWeeks: record.titrationIntervalWeeks, - })); -} - -/** - * Match the user's most recent dose to the closest step on the - * ladder, within ±10 % tolerance. - * - * Returns null when the latest dose is outside any step's tolerance - * window — this is the "user is on a non-standard dose" path the UI - * surfaces with an explanatory caption instead of a misleading - * highlight. - */ -export function findCurrentStep( - drugId: Glp1DrugId, - latestDoseMg: number | null, -): TitrationStep | null { - if (latestDoseMg === null || !Number.isFinite(latestDoseMg)) return null; - if (latestDoseMg <= 0) return null; - const ladder = getLadder(drugId); - if (ladder.length === 0) return null; - - let best: { step: TitrationStep; diff: number } | null = null; - for (const step of ladder) { - const diff = Math.abs(step.doseMg - latestDoseMg); - const tolerance = step.doseMg * STEP_MATCH_TOLERANCE; - if (diff > tolerance) continue; - if (!best || diff < best.diff) { - best = { step, diff }; - } - } - return best?.step ?? null; -} - -/** - * Return the immediate-next step above `currentStep`, or null when - * the user is already on the ceiling. Identity-by-stepIndex so callers - * that round-tripped the step through JSON still match. - */ -export function nextStep( - drugId: Glp1DrugId, - currentStep: TitrationStep | null, -): TitrationStep | null { - if (!currentStep) return null; - const ladder = getLadder(drugId); - const idx = ladder.findIndex((s) => s.stepIndex === currentStep.stepIndex); - if (idx < 0) return null; - if (idx + 1 >= ladder.length) return null; - return ladder[idx + 1]; -} - -/** - * Count how long the user has been on the matched step, in whole - * weeks rounded down. Reads the `MedicationDoseChange` stream and - * scans backwards from `asOf` for the most recent transition onto - * (or past) the matched dose; the elapsed time since that transition - * is the answer. - * - * Returns 0 when: - * - no dose-change rows match the step at all (e.g. brand-new med - * where the user hasn't logged a titration row yet); - * - the latest matching change is in the future; - * - `currentStep` is null. - * - * Rationale: matching by step (not by exact mg) is what makes this - * resilient to a user logging "0.5 mg" and the ladder declaring - * "0.5 mg" — we already snap to a step in `findCurrentStep`, so we - * snap dose-change rows the same way here. - */ -export function weeksOnCurrentStep( - drugId: Glp1DrugId, - currentStep: TitrationStep | null, - doseChanges: DoseChangeLike[], - asOf: Date, -): number { - if (!currentStep) return 0; - const tolerance = currentStep.doseMg * STEP_MATCH_TOLERANCE; - // Sort defensively — the API route orders by effectiveFrom asc but - // we don't trust caller ordering. - const matches = doseChanges - .filter((dc) => { - const diff = Math.abs(dc.doseValue - currentStep.doseMg); - return diff <= tolerance; - }) - .sort((a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime()); - if (matches.length === 0) return 0; - const latest = matches[0]; - if (latest.effectiveFrom.getTime() > asOf.getTime()) return 0; - const elapsedMs = asOf.getTime() - latest.effectiveFrom.getTime(); - return Math.max(0, Math.floor(elapsedMs / MS_PER_WEEK)); -} - -/** - * Display flag — true when the EMA-reference minimum dwell time has - * elapsed AND a next ladder step exists. - * - * Parameters: - * - `drugIdForCeiling`: identifies which ladder to walk so the - * "next step exists" guard knows when the user has hit the - * ceiling. The dwell-time check itself reads `typicalWeeks` - * straight off `currentStep`; the drug id is only needed to - * answer "is there a step above this one?". - * - `currentStep`: the step the user is sitting on today. Null - * when titration is paused or the user is at the bottom of the - * ladder with no recorded dose history. - * - `weeksOnStep`: how many complete weeks have elapsed since the - * user landed on `currentStep`. Computed by `weeksOnCurrentStep`. - * - * IMPORTANT: this is a *reference* signal, not advice. The UI copy - * tied to this boolean is strictly observational ("ladder typically - * steps up around N weeks") — never prescriptive. MDR boundary, see - * the W19c safety ground-rules. - */ -export function escalationDue( - drugIdForCeiling: Glp1DrugId, - currentStep: TitrationStep | null, - weeksOnStep: number, -): boolean { - if (!currentStep) return false; - if (!nextStep(drugIdForCeiling, currentStep)) return false; - return weeksOnStep >= currentStep.typicalWeeks; -} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index aa2a52e74..448b91a42 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -151,6 +151,101 @@ const disableCoachFlag = z "Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.", }); +// v1.8.6 — profile read/write surface for the native client. The +// runtime validation lives in `src/lib/validations/auth.ts` +// (`profileSchema`, whose transforms normalise empty → null and run the +// KVNR/IKNR refines); the OpenAPI shapes below mirror the wire contract +// without those transforms so the spec stays a clean nullable-field +// document. `insurerIkNumber` is the new v1.8.6 field: optional, 9-digit +// German IKNR, surfaced on the FHIR `Coverage` payor. +const profileUpdateRequest = z + .object({ + email: z.email().nullable().optional(), + heightCm: z.number().min(50).max(300).nullable().optional(), + dateOfBirth: z.string().nullable().optional(), + gender: z.enum(["MALE", "FEMALE"]).nullable().optional(), + displayName: z.string().min(1).max(80).nullable().optional(), + locale: z.enum(["de", "en"]).nullable().optional(), + timezone: z.string().min(1).max(64).optional(), + moodReminderEnabled: z.boolean().optional(), + fullName: z.string().max(120).nullable().optional(), + insurerName: z.string().max(120).nullable().optional(), + insuranceNumber: z + .string() + .nullable() + .optional() + .describe("German KVNR. Empty/null clears it; mod-10 check enforced."), + insurerIkNumber: z + .string() + .nullable() + .optional() + .describe( + "German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly 9 digits (no checksum enforced).", + ), + }) + .meta({ + id: "ProfileUpdateRequest", + description: + "Partial profile update. Every field is optional; an omitted field is left untouched, an explicit null (or empty string) clears it. `userId` is never accepted — it is narrowed from the session/token.", + }); + +const profileResponse = z + .object({ + username: z.string(), + displayName: z.string().nullable().optional(), + email: z.string().nullable(), + dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + heightCm: z.number().nullable(), + locale: z.string().nullable(), + timezone: z.string(), + moodReminderEnabled: z.boolean(), + fullName: z.string().nullable(), + insurerName: z.string().nullable(), + insurerIkNumber: z + .string() + .nullable() + .describe("German insurer institution number (IKNR), 9 digits."), + insuranceNumber: z + .string() + .nullable() + .describe("German KVNR, decrypted for the form prefill."), + }) + .meta({ + id: "ProfileResponse", + description: + "Flattened profile fields for the native client (GET). The KVNR is decrypted server-side; the IKNR (v1.8.6) is plaintext at rest.", + }); + +// The PATCH echo mirrors GET but reports KVNR presence as a boolean +// (`hasInsuranceNumber`) rather than re-emitting the decrypted value. +const profileUpdateResponse = z + .object({ + username: z.string(), + displayName: z.string().nullable().optional(), + email: z.string().nullable(), + dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + heightCm: z.number().nullable(), + locale: z.string().nullable(), + timezone: z.string(), + moodReminderEnabled: z.boolean(), + fullName: z.string().nullable(), + insurerName: z.string().nullable(), + insurerIkNumber: z + .string() + .nullable() + .describe("German insurer institution number (IKNR), 9 digits."), + hasInsuranceNumber: z + .boolean() + .describe("Whether a KVNR is on file (the value itself is not echoed)."), + }) + .meta({ + id: "ProfileUpdateResponse", + description: + "Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.", + }); + // ── Sub-schemas owned here (route-specific shapes) ─────────────────── const passkeyLoginVerifyRequest = z @@ -283,6 +378,17 @@ const batchEntrySchema = z .max(120) .describe("HKSample.uuid string — the dedup key."), externalSourceVersion: z.string().min(1).max(120).optional(), + // v1.8.6 W6 — optional per-entry source tag. Defaults to + // `APPLE_HEALTH` server-side when omitted, so legacy clients are + // unchanged. Restricted to the `{APPLE_HEALTH, MANUAL}` subset of + // `MeasurementSource`: `WITHINGS` / `IMPORT` are server-owned and + // rejected on this client-facing route. + source: z + .enum(["APPLE_HEALTH", "MANUAL"]) + .optional() + .describe( + "Source attribution for the row. Defaults to `APPLE_HEALTH` when omitted. Send `MANUAL` to tag rows the user entered by hand on-device (e.g. the standalone adopt-on-pair backfill). Part of the `(userId, type, source, externalId)` dedup key, so the same externalId under a different source is a distinct row. `WITHINGS` / `IMPORT` are not accepted here.", + ), // v1.4.25 W8c — optional device-type tag fed into the canonical // source picker's second axis. NULL is treated as `unknown`; // legacy iOS builds that don't ship the field continue to work. @@ -2029,6 +2135,49 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/user/profile": { + get: { + tags: ["Auth"], + summary: "Read the calling user's profile", + description: + "Flattened profile fields for the native client. Aliased over the same data exposed by /api/auth/me. The KVNR is decrypted server-side; the IKNR (v1.8.6) is returned plaintext.", + responses: { + "200": { + description: "The calling user's profile.", + content: { + "application/json": { + schema: dataEnvelope(profileResponse, "GetProfileResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Update the calling user's profile", + description: + "Partial profile update — every field is optional. An explicit null (or empty string) clears a field. A non-empty `insurerIkNumber` must be exactly 9 digits (422 otherwise); a non-empty `insuranceNumber` (KVNR) must pass the mod-10 check. `userId` is never accepted from the body.", + requestBody: { + required: true, + content: { "application/json": { schema: profileUpdateRequest } }, + }, + responses: { + "200": { + description: "Saved profile echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + profileUpdateResponse, + "PatchProfileResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/layout": { get: { tags: ["Insights"], diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 4c223cffa..dc967e3aa 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -116,8 +116,6 @@ export const queryKeys = { */ medicationCompliance: (medicationId: string) => ["medications", medicationId, "compliance"] as const, - medicationTitration: (medicationId: string) => - ["medications", medicationId, "titration"] as const, medicationCadence: (medicationId: string) => ["medications", medicationId, "cadence"] as const, medicationGlp1Details: (medicationId: string) => diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index 7c3dd841e..7c899cf61 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -58,6 +58,24 @@ export const profileSchema = z.object({ .refine((v) => v === null || v === undefined || isValidKvnr(v), { message: "Invalid insurance number (KVNR)", }), + // v1.8.6 — German insurer institution number (IKNR). Optional; + // empty string / null clears it. A non-empty value is trimmed and + // must be exactly 9 digits (no checksum — the IKNR carries a mod-10 + // check digit but we deliberately do not enforce it here, mirroring + // the iOS client contract). Surfaced on the FHIR `Coverage` payor. + insurerIkNumber: z + .string() + .max(32) + .nullable() + .optional() + .transform((v) => { + if (v === null || v === undefined) return v; + const trimmed = v.trim(); + return trimmed.length === 0 ? null : trimmed; + }) + .refine((v) => v === null || v === undefined || /^[0-9]{9}$/.test(v), { + message: "Invalid insurer IK number (expected 9 digits)", + }), }); export const changePasswordSchema = z diff --git a/tests/integration/measurements-batch.test.ts b/tests/integration/measurements-batch.test.ts index 8b120c3b8..81fabf5a8 100644 --- a/tests/integration/measurements-batch.test.ts +++ b/tests/integration/measurements-batch.test.ts @@ -69,6 +69,7 @@ interface BatchEntryFixture { externalId: string; externalSourceVersion?: string; sleepStage?: number; + source?: string; } function makeRequest( @@ -698,4 +699,140 @@ describe("POST /api/measurements/batch (real Postgres)", () => { expect(stepsRow?.value).toBeCloseTo(8400, 5); }); }); + + // v1.8.6 W6 — optional per-entry `source`. The standalone iOS client + // tags adopt-on-pair backfill rows it knows were entered by hand as + // `MANUAL`; every other caller (web + current iOS) omits the field and + // must keep storing rows as `APPLE_HEALTH`. `source` is part of the + // `(userId, type, source, externalId)` dedup key. + describe("optional source field (W6)", () => { + it("persists a row as APPLE_HEALTH when source is absent (backward-compat)", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 80.2, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: "uuid-source-absent-001", + }, + ], + }), + ); + expect(response.status).toBe(200); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: "uuid-source-absent-001" }, + }); + expect(stored).toHaveLength(1); + expect(stored[0]!.source).toBe("APPLE_HEALTH"); + }); + + it("persists a row as MANUAL when source is explicitly MANUAL", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 79.8, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: "uuid-source-manual-001", + source: "MANUAL", + }, + ], + }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { data: { inserted: number } }; + expect(json.data.inserted).toBe(1); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: "uuid-source-manual-001" }, + }); + expect(stored).toHaveLength(1); + expect(stored[0]!.source).toBe("MANUAL"); + }); + + it("treats the same externalId under different sources as two distinct rows", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const sharedExternalId = "uuid-source-collision-001"; + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.0, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: sharedExternalId, + source: "APPLE_HEALTH", + }, + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 81.0, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: sharedExternalId, + source: "MANUAL", + }, + ], + }), + ); + expect(response.status).toBe(200); + const json = (await response.json()) as { + data: { inserted: number; duplicates: number }; + }; + // Distinct dedup keys — both insert, neither is a duplicate. + expect(json.data.inserted).toBe(2); + expect(json.data.duplicates).toBe(0); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: sharedExternalId }, + }); + expect(stored).toHaveLength(2); + expect(new Set(stored.map((r) => r.source))).toEqual( + new Set(["APPLE_HEALTH", "MANUAL"]), + ); + }); + + it("rejects an out-of-range source value with 422", async () => { + const { POST } = await import("@/app/api/measurements/batch/route"); + + const response = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 80.5, + unit: "kg", + startDate: "2026-05-09T07:30:00.000Z", + endDate: "2026-05-09T07:30:00.000Z", + externalId: "uuid-source-bad-001", + // WITHINGS is server-owned and not accepted on this + // client-facing route — must surface as a 422, not silently + // mint a forged Withings-attributed row. + source: "WITHINGS", + }, + ], + }), + ); + expect(response.status).toBe(422); + + const stored = await getPrismaClient().measurement.findMany({ + where: { userId: TEST_USER_ID, externalId: "uuid-source-bad-001" }, + }); + expect(stored).toHaveLength(0); + }); + }); });