mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #237 from MBombeck/release/v1.8.6
v1.8.6 — Compliance two-row return, insights polish, targets deprecation
This commit is contained in:
104 files changed
+4951
-2919
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
+294
-1
@@ -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:
|
||||
|
||||
@@ -217,10 +217,16 @@ export async function expectStep(
|
||||
displayIndex: number,
|
||||
totalSteps?: number,
|
||||
): Promise<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,13 +8,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
*
|
||||
* Two contracts under test:
|
||||
*
|
||||
* 1. Every routed Insights sub-page mounts a `<CoachLaunchButton>`.
|
||||
* The component renders BOTH a sticky-bottom FAB
|
||||
* (`data-slot="coach-launch-fab"`, visible on `<lg`) and an
|
||||
* inline action (`data-slot="coach-launch-inline"`, visible on
|
||||
* `lg+`). The DOM carries both nodes; CSS picks the visible one.
|
||||
* The spec asserts the FAB renders on Pixel-5 and the inline
|
||||
* action renders on Desktop Chrome.
|
||||
* 1. Every routed Insights sub-page mounts a Coach launch surface.
|
||||
* The layout renders a sticky-bottom FAB
|
||||
* (`data-slot="coach-launch-fab"`, visible on `<lg`); v1.8.6 moved
|
||||
* the per-page action into the sub-page header as an icon-only
|
||||
* button (`data-slot="coach-launch-icon"`, mounted by the shell at
|
||||
* heading height across breakpoints). The spec asserts the FAB
|
||||
* renders on Pixel-5 and the header icon renders on Desktop Chrome.
|
||||
*
|
||||
* 2. Opening the Coach drawer on `<sm` mounts it as a bottom-sheet
|
||||
* (`data-variant="bottom-sheet"`). 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 });
|
||||
|
||||
+66
-37
@@ -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": {
|
||||
|
||||
+66
-37
@@ -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": {
|
||||
|
||||
+66
-37
@@ -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": {
|
||||
|
||||
+66
-37
@@ -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": {
|
||||
|
||||
+66
-37
@@ -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": {
|
||||
|
||||
+66
-37
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
// (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<Response> {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -5,14 +5,20 @@ import { join } from "node:path";
|
||||
/**
|
||||
* v1.8.0 — explainer-metric key guard.
|
||||
*
|
||||
* `<MetricExplainer>` 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 `<metric>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/**`
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<HealthChartDynamic
|
||||
chartKey="bp"
|
||||
@@ -126,8 +126,6 @@ export default function InsightsBlutdruckPage() {
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export default function InsightsBmiPage() {
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
description={t("insights.subPage.bmiDescription")}
|
||||
explainerMetric="bmi"
|
||||
coachLaunch
|
||||
>
|
||||
<HealthChartDynamic
|
||||
chartKey="bmi"
|
||||
@@ -135,8 +136,6 @@ export default function InsightsBmiPage() {
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -187,6 +187,7 @@ export default function InsightsMedikamentePage() {
|
||||
title={t("insights.medicationCompliance")}
|
||||
description={t("insights.subPage.medikamenteDescription")}
|
||||
explainerMetric="medications"
|
||||
coachLaunch
|
||||
>
|
||||
<div
|
||||
className={
|
||||
@@ -290,8 +291,6 @@ export default function InsightsMedikamentePage() {
|
||||
without an active GLP-1 medication, so the page collapses
|
||||
back to the compliance grid for everyone else. */}
|
||||
<TherapyTimeline />
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<MoodChart
|
||||
chartKey="mood"
|
||||
@@ -120,8 +120,6 @@ export default function InsightsStimmungPage() {
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<HealthChartDynamic
|
||||
@@ -166,8 +166,6 @@ export default function InsightsPulsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
<SleepOverview />
|
||||
|
||||
@@ -78,8 +78,6 @@ export default function InsightsSchlafPage() {
|
||||
structural slot waits for v1.5 where the route + hook key will
|
||||
wire in.
|
||||
*/}
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<HealthChartDynamic
|
||||
@@ -111,8 +111,6 @@ export default function InsightsGewichtPage() {
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<div data-slot="workouts-loading" className="space-y-2">
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<WorkoutList workouts={data.workouts} />
|
||||
<CoachLaunchButton />
|
||||
</>
|
||||
<WorkoutList workouts={data.workouts} />
|
||||
) : null}
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
<details
|
||||
className="border-border/60 rounded-md border"
|
||||
@@ -142,6 +144,7 @@ export default function IntakeHistoryPage({
|
||||
{t("medications.detail.history.drugLevelDisclosure")}
|
||||
</summary>
|
||||
<div className="border-border/60 space-y-6 border-t px-3 py-3">
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
@@ -149,7 +152,6 @@ export default function IntakeHistoryPage({
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<TitrationSection medicationId={id} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -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 && <SideEffectsSection medicationId={id} />}
|
||||
|
||||
{/* 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 && (
|
||||
<details
|
||||
className="border-border/60 rounded-md border"
|
||||
@@ -248,6 +249,7 @@ export default function MedicationDetailPage({
|
||||
{t("medications.detail.history.drugLevelDisclosure")}
|
||||
</summary>
|
||||
<div className="border-border/60 space-y-6 border-t px-3 py-3">
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
@@ -255,7 +257,6 @@ export default function MedicationDetailPage({
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<TitrationSection medicationId={id} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<TargetsPage> 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");
|
||||
});
|
||||
});
|
||||
+49
-14
@@ -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 <TargetEditSheet>), 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 <TargetEditSheet> 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() {
|
||||
<p className="mt-2 text-sm">{t("targets.introText")}</p>
|
||||
</div>
|
||||
|
||||
{/* Profile incomplete hint */}
|
||||
{profileIncomplete && (
|
||||
<Card className="border-warning border-l-4">
|
||||
<CardContent className="flex gap-3 py-3">
|
||||
<AlertCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
|
||||
{/* 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. */}
|
||||
<Card className="border-warning border-l-4" data-slot="targets-deprecation">
|
||||
<CardContent className="flex gap-3 py-3">
|
||||
<AlertCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("targets.profileIncomplete")}
|
||||
{t("targets.deprecation.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{!data.profile.heightCm && !data.profile.age
|
||||
? t("targets.profileIncompleteHeightAge")
|
||||
: !data.profile.heightCm
|
||||
? t("targets.profileIncompleteHeight")
|
||||
: t("targets.profileIncompleteAge")}
|
||||
{t("targets.deprecation.body")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{profileIncomplete && (
|
||||
<div data-slot="targets-profile-incomplete">
|
||||
<p className="text-sm font-medium">
|
||||
{t("targets.profileIncomplete")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{!data.profile.heightCm && !data.profile.age
|
||||
? t("targets.profileIncompleteHeightAge")
|
||||
: !data.profile.heightCm
|
||||
? t("targets.profileIncompleteHeight")
|
||||
: t("targets.profileIncompleteAge")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* v1.4.25 W3e — page-level summary line. Renders nothing when
|
||||
the API hasn't shipped pageSummary yet (older clients during
|
||||
|
||||
@@ -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 — `<MetricExplainer>` 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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div>
|
||||
<span data-slot="probe-title">
|
||||
{t(`insights.subPage.explainer.${metric}Title`)}
|
||||
</span>
|
||||
<span data-slot="probe-body">
|
||||
{t(`insights.subPage.explainer.${metric}Body`)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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("<MetricExplainer>", () => {
|
||||
it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => {
|
||||
const html = ssr(<MetricExplainer metric="bloodPressure" />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(<MetricExplainer metric="pulse" />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(<MetricExplainer metric="hrv" />);
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(<MetricExplainer metric="weight" />);
|
||||
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(<MetricExplainer metric="sleep" />, "de");
|
||||
const trigger = html.match(
|
||||
/<button[^>]*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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<KeyProbe metric={metric} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// A missing key surfaces as the literal `insights.subPage…`
|
||||
// path, which never contains a space.
|
||||
expect(html).not.toContain(`insights.subPage.explainer.${metric}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,13 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
// The inline <TargetEditSheet> (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("<MetricTargetSummary>", () => {
|
||||
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 <TargetEditSheet open={false}> 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 <a>/<Link> routing to
|
||||
// the deprecated /targets page.
|
||||
const html = renderWith("weight", WEIGHT_DATA);
|
||||
const button = html.match(/<button[^>]*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", () => {
|
||||
|
||||
@@ -64,13 +64,42 @@ describe("<SubPageShell>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<SubPageShell title="Blood pressure" explainerMetric="bloodPressure">
|
||||
<span />
|
||||
</SubPageShell>,
|
||||
);
|
||||
// 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(
|
||||
<SubPageShell
|
||||
title="Weight"
|
||||
diversityNudge={<span data-slot="diversity-probe" />}
|
||||
>
|
||||
<span />
|
||||
</SubPageShell>,
|
||||
);
|
||||
// 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("</header>");
|
||||
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(
|
||||
<SubPageShell title="Pulse">
|
||||
<span />
|
||||
</SubPageShell>,
|
||||
);
|
||||
// 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"');
|
||||
});
|
||||
});
|
||||
@@ -213,11 +213,40 @@ describe("<TrendsRow>", () => {
|
||||
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
|
||||
// `<TrendAnnotation>` slot is reserved for the legacy triple.
|
||||
const html = render(<TrendsRow briefing={briefing(["pulse"])} />);
|
||||
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 `<TrendAnnotation>` slot (legacy triple) or the
|
||||
// standard `trends-row-caption` description (additive metrics).
|
||||
const html = render(
|
||||
<TrendsRow briefing={briefing(["hrv", "steps", "distance"])} />,
|
||||
);
|
||||
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", () => {
|
||||
|
||||
@@ -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 `<lg` for that legacy surface).
|
||||
*/
|
||||
variant?: "inline" | "icon";
|
||||
}
|
||||
|
||||
export function CoachLaunchButton({
|
||||
label,
|
||||
prefill,
|
||||
className,
|
||||
variant = "inline",
|
||||
}: CoachLaunchButtonProps) {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
@@ -59,6 +71,26 @@ export function CoachLaunchButton({
|
||||
|
||||
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
|
||||
|
||||
if (variant === "icon") {
|
||||
// v1.8.6 — heading-height icon button mounted by the sub-page shell.
|
||||
// Icon-only so it reads as a compact action beside the title; the
|
||||
// CTA copy moves to `aria-label` + a native tooltip.
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="coach-launch-icon"
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
className={cn("text-muted-foreground hover:text-foreground", className)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline header-style action on `lg+`. Hidden below `lg` because the
|
||||
// layout-level FAB (`<LayoutCoachFab>`) covers that breakpoint —
|
||||
// mounting both at the same time would put two affordances in front
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InsightMetric } from "@/lib/insights/metric-availability";
|
||||
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
@@ -33,8 +32,9 @@ import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
* pass `null` to render an onboarding hint without a dead link.
|
||||
* 3. `<HealthChartDynamic>` mounted on the canonical
|
||||
* `ChartOverlayKey` so the chart-cog popover persists per metric.
|
||||
* 4. `<CoachLaunchButton>` at the foot — feature-flag gating runs
|
||||
* inside the button so this scaffold stays agnostic.
|
||||
* 4. A header-height Coach launch icon (`coachLaunch` on the shell) —
|
||||
* feature-flag gating runs inside the button so this scaffold stays
|
||||
* agnostic.
|
||||
*
|
||||
* Adding a new metric is a four-line page module that hands the right
|
||||
* Zod / MeasurementType + ChartOverlayKey + i18n-key prefix to this
|
||||
@@ -181,6 +181,7 @@ export function HealthKitMetricPage({
|
||||
timeZone={user?.timezone ?? undefined}
|
||||
/>
|
||||
}
|
||||
coachLaunch
|
||||
showAllValuesType={measurementType}
|
||||
>
|
||||
<HealthChartDynamic
|
||||
@@ -198,7 +199,6 @@ export function HealthKitMetricPage({
|
||||
{targetSummarySlug ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
) : null}
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="measurement-diversity-nudge"
|
||||
role="note"
|
||||
className="bg-muted/40 text-muted-foreground flex items-start gap-2.5 rounded-lg px-3 py-2.5 text-sm"
|
||||
>
|
||||
<Lightbulb
|
||||
className="text-dracula-yellow mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="leading-relaxed">{message}</p>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="measurement-diversity-nudge"
|
||||
aria-label={triggerLabel}
|
||||
className="text-dracula-yellow hover:text-dracula-yellow/80 focus-visible:ring-ring/50 -my-3 -mx-2 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<Lightbulb className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
data-slot="measurement-diversity-nudge-body"
|
||||
align="start"
|
||||
className="max-w-xs leading-relaxed"
|
||||
>
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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.<metric>Title
|
||||
* insights.subPage.explainer.<metric>Body
|
||||
*
|
||||
* The surface choice mirrors `<HealthScoreDeltaExplainer>`: 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 `<button>` (Enter / Space activate it
|
||||
* natively) carrying a descriptive `aria-label`, `aria-expanded`, and
|
||||
* `aria-controls` pointing at the body. The optical glyph stays 14 px so
|
||||
* it reads at the heading's height, but the hit surface lifts to the
|
||||
* 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`; negative margins
|
||||
* collapse the surrounding row back to its natural stride so the chip
|
||||
* stays visually small while the reach is full-size.
|
||||
*/
|
||||
|
||||
interface MetricExplainerProps {
|
||||
/**
|
||||
* The metric key. Drives the title + body lookup under
|
||||
* `insights.subPage.explainer.<metric>{Title,Body}`.
|
||||
*/
|
||||
metric: string;
|
||||
/** Optional className forwarded to the trigger button. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MetricExplainer({ metric, className }: MetricExplainerProps) {
|
||||
const { t } = useTranslations();
|
||||
const isMobile = useIsMobile();
|
||||
const [open, setOpen] = useState(false);
|
||||
const bodyId = useId();
|
||||
|
||||
const title = t(`insights.subPage.explainer.${metric}Title`);
|
||||
const body = t(`insights.subPage.explainer.${metric}Body`);
|
||||
const triggerLabel = t("insights.subPage.explainer.trigger", { title });
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="metric-explainer-trigger"
|
||||
aria-label={triggerLabel}
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-my-3 -mx-2",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
<ResponsiveSheet open={open} onOpenChange={setOpen} title={title}>
|
||||
<p
|
||||
id={bodyId}
|
||||
data-slot="metric-explainer-body"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="metric-explainer-popover"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
<p
|
||||
data-slot="metric-explainer-title"
|
||||
className="text-foreground text-xs font-semibold"
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
<p
|
||||
id={bodyId}
|
||||
data-slot="metric-explainer-body"
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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: `<ConsistencyStrip>` — the last seven days at a glance.
|
||||
* • Footer: the "Adjust target range" link to `/targets`.
|
||||
* • Footer: an "Adjust target range" button that opens the
|
||||
* `<TargetEditSheet>` 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 (
|
||||
<div className="space-y-2" data-slot="metric-target-summary-group">
|
||||
{panels.map((panel) => (
|
||||
@@ -179,12 +182,8 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
|
||||
key={panel.type}
|
||||
target={panel}
|
||||
heading={panel.label}
|
||||
hideAdjustLink
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-end px-1">
|
||||
<AdjustTargetLink />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Link
|
||||
href="/targets"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.subPage.target.adjustLink")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
// `<TargetEditSheet>` 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. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 pt-0.5">
|
||||
{sourceLink ? (
|
||||
<a
|
||||
@@ -395,8 +382,29 @@ function TargetReferencePanel({
|
||||
{t("targets.sourceLabel", { source: target.source })}
|
||||
</span>
|
||||
)}
|
||||
{hideAdjustLink ? null : <AdjustTargetLink />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
|
||||
data-slot="metric-target-adjust"
|
||||
>
|
||||
{t("insights.subPage.target.adjustLink")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<TargetEditSheet
|
||||
targetType={target.type}
|
||||
targetLabel={heading ?? target.label}
|
||||
unit={unit}
|
||||
initialRange={range}
|
||||
initialDiastolicRange={bpDiastolic?.range ?? null}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<MoodInTargetTile>`; 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<string, string>;
|
||||
}>;
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<MoodInsightsSections />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<MoodInsightsSections> — 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");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<MoodNarrativeFeed>", () => {
|
||||
it("renders nothing for an empty feed (no platitude placeholders)", () => {
|
||||
expect(render(<MoodNarrativeFeed items={[]} />)).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(<MoodNarrativeFeed items={items} />);
|
||||
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(<MoodNarrativeFeed items={items} />);
|
||||
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(<MoodNarrativeFeed items={items} />);
|
||||
expect((html.match(/<li/g) ?? []).length).toBe(2);
|
||||
expect(html).toContain("72%");
|
||||
expect(html).toContain("5 days in a row");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MoodInTargetTile>", () => {
|
||||
it("renders nothing when the percentage is unavailable", () => {
|
||||
expect(render(<MoodInTargetTile pct={null} />)).toBe("");
|
||||
});
|
||||
|
||||
it("shows the rounded in-target percentage", () => {
|
||||
const html = render(<MoodInTargetTile pct={71.6} />);
|
||||
expect(html).toContain("72%");
|
||||
expect(html).toContain("good-mood range");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-full">
|
||||
<Target className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground text-2xl font-semibold tabular-nums">
|
||||
{t("insights.mood.inTargetValue", { pct: String(rounded) })}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.mood.inTargetCaption")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<MoodInTargetTile pct={data.summary.inTargetPct} />
|
||||
|
||||
<MoodNarrativeFeed items={narratives} />
|
||||
|
||||
<SectionCard title={t("insights.mood.heatmapTitle")}>
|
||||
<MoodHeatmap
|
||||
cells={heatmapCells}
|
||||
@@ -156,12 +170,6 @@ export function MoodInsightsSections() {
|
||||
pulse={data.correlations.pulse}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasNotes && (
|
||||
<SectionCard title={t("insights.mood.notesTimelineTitle")}>
|
||||
<MoodNotesTimeline notes={data.notesTimeline} />
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
const ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||
"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 (
|
||||
<ul
|
||||
className="space-y-2"
|
||||
data-slot="mood-narrative-feed"
|
||||
aria-label={t("insights.mood.narrative.title")}
|
||||
>
|
||||
{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<string, string> = { ...item.vars };
|
||||
if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
|
||||
if (vars.tagKey) vars.tag = t(vars.tagKey);
|
||||
return (
|
||||
<li
|
||||
key={`${item.kind}-${index}`}
|
||||
className="bg-card border-border flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<p className="text-foreground/90 text-sm leading-snug">
|
||||
{t(item.messageKey, vars)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ul className="space-y-3" data-slot="mood-notes-timeline">
|
||||
{notes.map((entry) => {
|
||||
const moodLabel =
|
||||
entry.mood && MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood;
|
||||
return (
|
||||
<li
|
||||
key={`${entry.loggedAt}-${entry.date}`}
|
||||
className="border-border/60 border-l-2 pl-3"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<span className="text-foreground font-semibold tabular-nums">
|
||||
{entry.score}
|
||||
</span>
|
||||
{moodLabel && <span>({moodLabel})</span>}
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(entry.loggedAt)}</span>
|
||||
</div>
|
||||
<p className="text-foreground/90 mt-0.5 text-sm whitespace-pre-wrap">
|
||||
{entry.note}
|
||||
</p>
|
||||
{(entry.structuredTagLabelKeys.length > 0 ||
|
||||
entry.tags.length > 0) && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{entry.structuredTagLabelKeys.map((labelKey) => (
|
||||
<span
|
||||
key={labelKey}
|
||||
className="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
))}
|
||||
{entry.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="border-border/70 text-muted-foreground rounded-full border px-2 py-0.5 text-[10px]"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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.<metric>{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.<metric>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.<metric>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 `<MeasurementDiversityNudge>`, 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 `<CoachLaunchButton>` 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 (
|
||||
<div data-slot="insights-subpage" className="space-y-4 md:space-y-5">
|
||||
<header className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1
|
||||
ref={headingRef}
|
||||
id="insights-subpage-title"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"text-xl font-semibold sm:text-2xl",
|
||||
// a11y: sighted-keyboard users (e.g. "Skip to content")
|
||||
// need a visible focus indicator on the programmatic
|
||||
// `headingRef.focus()` call below. Match the focus ring
|
||||
// vocabulary used on insights pills + Coach affordances.
|
||||
"rounded-sm focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{explainerMetric ? (
|
||||
<MetricExplainer metric={explainerMetric} />
|
||||
) : null}
|
||||
{badge ? (
|
||||
<Badge variant="outline" className="border text-xs">
|
||||
{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. */}
|
||||
<div className="flex min-h-10 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1
|
||||
ref={headingRef}
|
||||
id="insights-subpage-title"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"text-xl font-semibold sm:text-2xl",
|
||||
// a11y: sighted-keyboard users (e.g. "Skip to content")
|
||||
// need a visible focus indicator on the programmatic
|
||||
// `headingRef.focus()` call below. Match the focus ring
|
||||
// vocabulary used on insights pills + Coach affordances.
|
||||
"rounded-sm focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{/* 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 variant="outline" className="border text-xs">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{/* 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 ? (
|
||||
<CoachLaunchButton variant="icon" className="shrink-0" />
|
||||
) : null}
|
||||
</div>
|
||||
{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.
|
||||
<p
|
||||
data-slot="metric-explainer-inline"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
@@ -176,23 +221,26 @@ export function SubPageShell({
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
{/* 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 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="metric-show-all-values"
|
||||
className="w-full sm:w-auto"
|
||||
className="h-10 w-full sm:w-auto"
|
||||
>
|
||||
<Link href={`/insights/values/${showAllValuesType}`}>
|
||||
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
|
||||
|
||||
@@ -39,9 +39,16 @@ import {
|
||||
*
|
||||
* Annotations come from `trendAnnotations.{bp,weight,mood}` on the
|
||||
* Insights payload and only attach to those three slots (the advisor
|
||||
* authors annotations for the legacy triple). Additive metrics render
|
||||
* chart-only. When a slot's annotation is null, `<TrendAnnotation>`'s
|
||||
* empty state hints at the gap without breaking the row's rhythm.
|
||||
* authors annotations for the legacy triple). When a slot's annotation
|
||||
* is null, `<TrendAnnotation>`'s empty state hints at the gap without
|
||||
* breaking the row's rhythm.
|
||||
*
|
||||
* v1.8.6 W8 — every card carries a caption. Additive metrics (the
|
||||
* briefing-driven slots beyond the legacy triple) carry no advisor
|
||||
* annotation, so they fall back to a standard one-line description
|
||||
* (`config.captionKey`). Before this every additive card painted the
|
||||
* chart with empty space underneath; now the row never shows a
|
||||
* caption-less card.
|
||||
*/
|
||||
|
||||
const MoodChart = dynamic(
|
||||
@@ -205,7 +212,18 @@ export function TrendsRow({
|
||||
confidence={confidence?.[config.annotationKey]}
|
||||
status={statusFor(annotationFor(config.annotationKey))}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
// v1.8.6 W8 — additive metrics carry no advisor
|
||||
// annotation. Paint the metric's standard one-line
|
||||
// description so the card is never caption-less.
|
||||
<p
|
||||
data-slot="trends-row-caption"
|
||||
data-metric={config.metric}
|
||||
className="text-muted-foreground line-clamp-3 text-xs"
|
||||
>
|
||||
{t(config.captionKey)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
* - exactly five primary links + one "More" button render in the strip
|
||||
* - the More button is keyboard-reachable as a `<button>` with
|
||||
* `aria-haspopup="dialog"`
|
||||
* - Targets + Achievements live in the More sheet (closed by default
|
||||
* under SSR), not in the always-visible strip
|
||||
* - Achievements lives in the More sheet (closed by default under SSR),
|
||||
* not in the always-visible strip
|
||||
* - WCAG 2.5.5 floor: every entry is `min-h-11 min-w-11`
|
||||
*
|
||||
* v1.8.6 — the Targets (Zielwerte) page is deprecated and dropped from
|
||||
* the menu, so it no longer appears in the strip or the overflow set.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -60,6 +63,15 @@ describe("<BottomNav> 5+More layout", () => {
|
||||
expect(matches.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it("no longer lists the deprecated /targets entry (v1.8.6)", () => {
|
||||
// Targets was dropped from the OVERFLOW set; the strip never carried
|
||||
// it. The closed-sheet SSR render must therefore contain no /targets
|
||||
// link — a regression net against the entry creeping back into the
|
||||
// primary strip or the overflow set.
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/targets"');
|
||||
});
|
||||
|
||||
it("More button carries an accessible label", () => {
|
||||
const html = render();
|
||||
// Attribute order isn't guaranteed by React's renderer, so match
|
||||
|
||||
@@ -84,6 +84,18 @@ describe("<SidebarNav> bug-report toggle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> targets deprecation (v1.8.6)", () => {
|
||||
it("no longer renders the deprecated /targets entry", () => {
|
||||
// The Targets (Zielwerte) page is deprecated; target editing moved
|
||||
// inline into Insights, so the sidebar drops the entry. The main nav
|
||||
// links render into the SSR markup, so asserting absence here is a
|
||||
// real regression net (Insights, the sibling that stays, still shows).
|
||||
const html = render();
|
||||
expect(html).not.toContain('href="/targets"');
|
||||
expect(html).toContain('href="/insights"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
|
||||
// v1.4.16 A1: the maintainer reported the global sidebar expanding admin
|
||||
// sub-items on `/admin/*` was unwanted UX — the in-shell `<AdminShell>`
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Lightbulb,
|
||||
MoreHorizontal,
|
||||
Pill,
|
||||
Target,
|
||||
Trophy,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
@@ -35,10 +34,15 @@ interface NavLink {
|
||||
// just-pass on iPhone-13-mini-class screens. The mobile audit
|
||||
// recommended 5+More — so the four most-used surfaces stay in the
|
||||
// strip (Home, Measurements, Mood, Medications) plus Insights as
|
||||
// the fifth, and Targets + Achievements move into a "More" sheet.
|
||||
// the fifth, and Achievements moves into a "More" sheet.
|
||||
//
|
||||
// Order is preserved relative to the v1.4.15 bottom-nav so muscle
|
||||
// memory keeps working for the four core actions.
|
||||
//
|
||||
// v1.8.6 — the Targets (Zielwerte) page is deprecated: target editing
|
||||
// moved inline into Insights, so its overflow entry is gone. The page
|
||||
// stays routable (the `/zielwerte`→`/targets` proxy redirect remains)
|
||||
// but no longer appears in the menu.
|
||||
const PRIMARY: ReadonlyArray<NavLink> = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||||
@@ -48,7 +52,6 @@ const PRIMARY: ReadonlyArray<NavLink> = [
|
||||
];
|
||||
|
||||
const OVERFLOW: ReadonlyArray<NavLink> = [
|
||||
{ href: "/targets", tKey: "nav.targets", icon: Target },
|
||||
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
|
||||
];
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
Settings,
|
||||
Shield,
|
||||
Sun,
|
||||
Target,
|
||||
Trophy,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
@@ -73,12 +72,6 @@ const navItems = [
|
||||
icon: Lightbulb,
|
||||
tourId: "nav-insights",
|
||||
},
|
||||
{
|
||||
href: "/targets",
|
||||
tKey: "nav.targets",
|
||||
icon: Target,
|
||||
tourId: "nav-targets",
|
||||
},
|
||||
{
|
||||
href: "/achievements",
|
||||
tKey: "nav.achievements",
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, ExternalLink } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19f — GLP-1 titration-ladder section.
|
||||
*
|
||||
* Sits at the bottom of the medication detail page below the W19e
|
||||
* SchedulingSection (v1.4.28 retired the IntakeHistoryList block).
|
||||
* Same chrome as the W19d / W19e sections so the panels read as one
|
||||
* visual group — chrome is now composed via the shared
|
||||
* `<MedicationDetailSection>` wrapper.
|
||||
*
|
||||
* Layout:
|
||||
* - Header: drug INN + "Standard ladder (EMA reference)"
|
||||
* - Vertical step list (mobile) — same nodes laid horizontally
|
||||
* from `sm` upwards. Each step shows dose, typical-weeks badge,
|
||||
* and a status dot (current / past / upcoming).
|
||||
* - Current-step ring with "You are here" caption.
|
||||
* - Next-step caption + observational dwell hint when the EMA
|
||||
* dwell-time has elapsed. Copy is strictly observational; the
|
||||
* ladder is a *reference*, never a prescription (MDR boundary).
|
||||
* - Disclaimer + EMA-source link.
|
||||
*
|
||||
* v1.4.25 W21 Fix-N — dropped the hand-rolled `templateFill` helper;
|
||||
* the i18n `t()` function already accepts the `params` bag with
|
||||
* `{key}` placeholder substitution. One source of truth for the
|
||||
* template-fill semantics.
|
||||
*
|
||||
* Data comes from `/api/medications/[id]/titration`; that route owns
|
||||
* the prisma queries and delegates math to the pure ladder helpers.
|
||||
*/
|
||||
|
||||
interface TitrationStep {
|
||||
stepIndex: number;
|
||||
doseMg: number;
|
||||
typicalWeeks: number;
|
||||
}
|
||||
|
||||
interface TitrationResponse {
|
||||
drugId: string;
|
||||
drugInn: string;
|
||||
ladder: TitrationStep[];
|
||||
currentStep: TitrationStep | null;
|
||||
currentStepIndex: number | null;
|
||||
weeksOnCurrentStep: number;
|
||||
nextStep: TitrationStep | null;
|
||||
escalationDue: boolean;
|
||||
sourceEMA: string;
|
||||
}
|
||||
|
||||
interface TitrationSectionProps {
|
||||
medicationId: string;
|
||||
}
|
||||
|
||||
export function TitrationSection({ medicationId }: TitrationSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.medicationTitration(medicationId),
|
||||
queryFn: async (): Promise<TitrationResponse> => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/titration`);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load titration: ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.data as TitrationResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const hasLadder = !!data && data.ladder.length > 0;
|
||||
const currentIdx = data?.currentStepIndex ?? null;
|
||||
|
||||
// Compose the optional dwell hint AND the next-step caption. The
|
||||
// dwell hint is strictly observational — no "you should step up";
|
||||
// the boolean only gates *display*, never *action*.
|
||||
const dwellHint = useMemo(() => {
|
||||
if (!data || data.currentStep === null) return null;
|
||||
if (data.weeksOnCurrentStep <= 0) return null;
|
||||
if (data.escalationDue) {
|
||||
return t("medications.titration.escalationDueHint", {
|
||||
weeks: data.weeksOnCurrentStep,
|
||||
typical: data.currentStep.typicalWeeks,
|
||||
});
|
||||
}
|
||||
return t("medications.titration.currentStepDwell", {
|
||||
weeks: data.weeksOnCurrentStep,
|
||||
});
|
||||
}, [data, t]);
|
||||
|
||||
const nextStepCaption = useMemo(() => {
|
||||
if (!data) return null;
|
||||
if (!data.nextStep) return null;
|
||||
return t("medications.titration.nextStepCaption", {
|
||||
dose: data.nextStep.doseMg,
|
||||
weeks: data.currentStep?.typicalWeeks ?? data.nextStep.typicalWeeks,
|
||||
});
|
||||
}, [data, t]);
|
||||
|
||||
const headerExtras = data ? (
|
||||
<span
|
||||
className="text-muted-foreground text-xs uppercase tracking-wide"
|
||||
data-slot="titration-drug-inn"
|
||||
>
|
||||
{data.drugInn}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="titration-heading"
|
||||
title={t("medications.titration.section")}
|
||||
headerExtras={headerExtras}
|
||||
dataSlot="titration-section"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
<span>{t("medications.titration.loading")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!error && !isLoading && (
|
||||
<p className="text-destructive">
|
||||
{t("medications.titration.loadFailed")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && !isLoading && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("medications.titration.drugLadderHeader")}
|
||||
</p>
|
||||
|
||||
{hasLadder ? (
|
||||
<ol
|
||||
// v1.4.27 MB7 / CF-48 — keep the vertical (`flex-col`)
|
||||
// layout until `md:` instead of breaking to horizontal
|
||||
// at `sm:`. On Pixel 5 (375 px) the horizontal ladder
|
||||
// had 4-5 visible steps but each step's "Standard 0.5
|
||||
// mg" + "typical 4 weeks" label combo wrapped to 3
|
||||
// lines per step, which read as a denser block than
|
||||
// the vertical layout below. From `md:` upwards the
|
||||
// wider container can absorb the horizontal stride.
|
||||
className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-stretch"
|
||||
aria-label={t("medications.titration.drugLadderHeader")}
|
||||
>
|
||||
{data.ladder.map((step) => {
|
||||
const isCurrent = currentIdx === step.stepIndex;
|
||||
const isPast =
|
||||
currentIdx !== null && step.stepIndex < currentIdx;
|
||||
return (
|
||||
<li
|
||||
key={step.stepIndex}
|
||||
className={[
|
||||
"border-border/70 flex flex-1 flex-col rounded-md border px-2.5 py-2 md:min-w-[7rem]",
|
||||
isCurrent
|
||||
? "border-primary ring-primary/40 ring-2"
|
||||
: "",
|
||||
].join(" ")}
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
data-slot="titration-step"
|
||||
data-step-index={step.stepIndex}
|
||||
data-step-state={
|
||||
isCurrent ? "current" : isPast ? "past" : "upcoming"
|
||||
}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{t("medications.titration.stepLabel", {
|
||||
n: step.stepIndex + 1,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{step.doseMg} {t("medications.titration.doseUnitMg")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("medications.titration.typicalWeeksOnStep", {
|
||||
weeks: step.typicalWeeks,
|
||||
})}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-primary mt-1 text-xs font-medium uppercase tracking-wide">
|
||||
{t("medications.titration.youAreHere")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.titration.emptyState")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data.currentStep === null && hasLadder && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.titration.nonStandardDose")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dwellHint && (
|
||||
<p
|
||||
className="text-muted-foreground"
|
||||
data-slot="titration-dwell-hint"
|
||||
>
|
||||
{dwellHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{nextStepCaption && (
|
||||
<p className="text-muted-foreground">{nextStepCaption}</p>
|
||||
)}
|
||||
|
||||
{data.nextStep === null && data.currentStep !== null && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.titration.ceilingMessage")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="border-border/60 border-t pt-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.titration.disclaimer")}
|
||||
</p>
|
||||
<a
|
||||
href={data.sourceEMA}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground/80 hover:text-foreground mt-1 inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("medications.titration.emaSourceCta")}
|
||||
<ExternalLink className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19f — TitrationSection SSR smoke tests.
|
||||
*
|
||||
* Same testing convention as `SchedulingSection.test.tsx`:
|
||||
* `renderToStaticMarkup` + seeded react-query cache. Interactive
|
||||
* branches (loading spinner, fetch-error) are covered by the API
|
||||
* route tests + the pure ladder tests; surface tests here pin the
|
||||
* static-render contract:
|
||||
*
|
||||
* 1. Header + drug INN render.
|
||||
* 2. Each ladder step renders with dose + typical-weeks label.
|
||||
* 3. The current step has a "You are here" caption.
|
||||
* 4. The next-step caption renders when a next step exists.
|
||||
* 5. The ceiling message renders when the user is on the top
|
||||
* step (no next step).
|
||||
* 6. The non-standard dose caption renders when current step is
|
||||
* null but the ladder is non-empty.
|
||||
* 7. The disclaimer + EMA source link always render with data.
|
||||
* 8. Empty ladder → empty-state copy renders.
|
||||
* 9. The escalationDueHint copy is observational, not prescriptive.
|
||||
* 10. German locale renders.
|
||||
*/
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface SeedShape {
|
||||
drugId: string;
|
||||
drugInn: string;
|
||||
ladder: Array<{ stepIndex: number; doseMg: number; typicalWeeks: number }>;
|
||||
currentStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
|
||||
currentStepIndex: number | null;
|
||||
weeksOnCurrentStep: number;
|
||||
nextStep: { stepIndex: number; doseMg: number; typicalWeeks: number } | null;
|
||||
escalationDue: boolean;
|
||||
sourceEMA: string;
|
||||
}
|
||||
|
||||
function seed(client: QueryClient, medId: string, payload: SeedShape) {
|
||||
client.setQueryData(["medications", medId, "titration"], payload);
|
||||
}
|
||||
|
||||
function render(
|
||||
node: React.ReactNode,
|
||||
client: QueryClient,
|
||||
locale: "en" | "de" = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const TIRZEPATIDE_LADDER = [
|
||||
{ stepIndex: 0, doseMg: 2.5, typicalWeeks: 4 },
|
||||
{ stepIndex: 1, doseMg: 5, typicalWeeks: 4 },
|
||||
{ stepIndex: 2, doseMg: 7.5, typicalWeeks: 4 },
|
||||
{ stepIndex: 3, doseMg: 10, typicalWeeks: 4 },
|
||||
{ stepIndex: 4, doseMg: 12.5, typicalWeeks: 4 },
|
||||
{ stepIndex: 5, doseMg: 15, typicalWeeks: 4 },
|
||||
];
|
||||
|
||||
const SEED_ON_STEP_5MG: SeedShape = {
|
||||
drugId: "tirzepatide",
|
||||
drugInn: "Tirzepatide",
|
||||
ladder: TIRZEPATIDE_LADDER,
|
||||
currentStep: TIRZEPATIDE_LADDER[1],
|
||||
currentStepIndex: 1,
|
||||
weeksOnCurrentStep: 2,
|
||||
nextStep: TIRZEPATIDE_LADDER[2],
|
||||
escalationDue: false,
|
||||
sourceEMA:
|
||||
"https://www.ema.europa.eu/en/documents/product-information/mounjaro-epar-product-information_en.pdf",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("<TitrationSection> — surface render", () => {
|
||||
it("renders the section heading and drug INN", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", SEED_ON_STEP_5MG);
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Titration ladder");
|
||||
expect(html).toContain("Tirzepatide");
|
||||
expect(html).toContain("Standard ladder (EMA reference)");
|
||||
});
|
||||
|
||||
it("renders every step on the ladder", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", SEED_ON_STEP_5MG);
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
for (const step of TIRZEPATIDE_LADDER) {
|
||||
// Each step's dose label appears (e.g. "2.5 mg", "15 mg").
|
||||
expect(html).toContain(`${step.doseMg} mg`);
|
||||
}
|
||||
// Step ladder count = 6 steps; each step renders one card.
|
||||
const stepCards = html.match(/data-slot="titration-step"/g) ?? [];
|
||||
expect(stepCards.length).toBe(6);
|
||||
});
|
||||
|
||||
it('renders "You are here" on the current step only', () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", SEED_ON_STEP_5MG);
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("You are here");
|
||||
// Only one "you are here" caption rendered, on the matched step.
|
||||
expect(html.match(/You are here/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders the next-step caption when a next step exists", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", SEED_ON_STEP_5MG);
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("Next step (typical): 7.5 mg");
|
||||
});
|
||||
|
||||
it("renders the ceiling message at the top of the ladder", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", {
|
||||
...SEED_ON_STEP_5MG,
|
||||
currentStep: TIRZEPATIDE_LADDER[5],
|
||||
currentStepIndex: 5,
|
||||
nextStep: null,
|
||||
});
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
expect(html).toContain("You are at the top of the standard ladder.");
|
||||
});
|
||||
|
||||
it("renders the non-standard dose caption when current step is null", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", {
|
||||
...SEED_ON_STEP_5MG,
|
||||
currentStep: null,
|
||||
currentStepIndex: null,
|
||||
nextStep: null,
|
||||
weeksOnCurrentStep: 0,
|
||||
});
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
expect(html).toContain(
|
||||
"Current dose is outside the standard ladder buckets.",
|
||||
);
|
||||
});
|
||||
|
||||
it("always renders the disclaimer and the EMA-source link", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", SEED_ON_STEP_5MG);
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
expect(html).toContain(
|
||||
"EMA reference values. Talk to your doctor before changing doses.",
|
||||
);
|
||||
expect(html).toContain("EMA source");
|
||||
expect(html).toContain("mounjaro-epar-product-information");
|
||||
});
|
||||
|
||||
it("renders the empty-state copy when the ladder is empty", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", {
|
||||
...SEED_ON_STEP_5MG,
|
||||
ladder: [],
|
||||
currentStep: null,
|
||||
currentStepIndex: null,
|
||||
nextStep: null,
|
||||
});
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
expect(html).toContain(
|
||||
"No dose history yet. Log a dose to see where you are on the ladder.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the escalation-due hint as an observation, not a prescription", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", {
|
||||
...SEED_ON_STEP_5MG,
|
||||
weeksOnCurrentStep: 6,
|
||||
escalationDue: true,
|
||||
});
|
||||
const html = render(<TitrationSection medicationId="med-1" />, client);
|
||||
// The copy must NOT prescribe an action ("you should step up").
|
||||
expect(html).not.toMatch(/should step up/i);
|
||||
expect(html).not.toMatch(/must step up/i);
|
||||
expect(html).not.toMatch(/recommend.*step up/i);
|
||||
// The observational template must be rendered.
|
||||
expect(html).toContain("the ladder typically steps up around");
|
||||
// The escaped apostrophe is the SSR render of the curly-apostrophe-free
|
||||
// template; we assert on the unencoded plain segment instead of the
|
||||
// contraction itself to stay robust to the renderer's HTML entity choice.
|
||||
expect(html).toContain("been on this step for 6 weeks");
|
||||
});
|
||||
|
||||
it("renders the section heading in German", () => {
|
||||
const client = makeClient();
|
||||
seed(client, "med-1", SEED_ON_STEP_5MG);
|
||||
const html = render(
|
||||
<TitrationSection medicationId="med-1" />,
|
||||
client,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Titrationsstufen");
|
||||
expect(html).toContain("Standard-Schema (EMA-Referenz)");
|
||||
expect(html).toContain("Hier stehst du");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* v1.7.0 — AdvancedSettingsSheet composition contract.
|
||||
*
|
||||
* The sheet hosts four labelled groups — Data → Reminders → Lifecycle →
|
||||
* Danger zone — composed from bare section bodies. `<ResponsiveSheet>`
|
||||
* The sheet hosts four blocks — Reminders → Lifecycle → Data → Danger
|
||||
* zone — composed from bare section bodies. `<ResponsiveSheet>`
|
||||
* wraps Radix portals that `renderToStaticMarkup` does not materialise,
|
||||
* so we mock it to a passthrough that renders children only when `open`
|
||||
* and echoes `contentWidth`. The bodies are mocked to ordered markers.
|
||||
* The test pins:
|
||||
* - the four groups render in the documented order;
|
||||
* - the sheet opens at the `4xl` width token;
|
||||
* - the four blocks render in the documented order;
|
||||
* - the sheet opens at the `2xl` width token;
|
||||
* - `onRequestPhaseSheet` threads through to the phases row;
|
||||
* - `onOpenImport` wires the import button (co-located with export);
|
||||
* - the export control sits beside the import control;
|
||||
@@ -107,27 +107,27 @@ function baseProps(
|
||||
}
|
||||
|
||||
describe("AdvancedSettingsSheet (v1.7.0)", () => {
|
||||
it("renders the four groups in Data → Reminders → Lifecycle → Danger order", () => {
|
||||
it("renders the four blocks in Reminders → Lifecycle → Data → Danger order", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet
|
||||
{...baseProps(true, { onRequestPhaseSheet: () => {} })}
|
||||
/>,
|
||||
);
|
||||
const data = html.indexOf("advanced-group-data");
|
||||
const reminders = html.indexOf("advanced-group-reminders");
|
||||
const lifecycle = html.indexOf("advanced-group-lifecycle");
|
||||
const danger = html.indexOf("advanced-group-danger");
|
||||
expect(data).toBeGreaterThan(-1);
|
||||
expect(reminders).toBeGreaterThan(data);
|
||||
const data = html.indexOf("advanced-group-data");
|
||||
const danger = html.indexOf("mock-danger");
|
||||
expect(reminders).toBeGreaterThan(-1);
|
||||
expect(lifecycle).toBeGreaterThan(reminders);
|
||||
expect(danger).toBeGreaterThan(lifecycle);
|
||||
expect(data).toBeGreaterThan(lifecycle);
|
||||
expect(danger).toBeGreaterThan(data);
|
||||
});
|
||||
|
||||
it("opens at the 4xl width token", () => {
|
||||
it("opens at the 2xl width token", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AdvancedSettingsSheet {...baseProps(true)} />,
|
||||
);
|
||||
expect(html).toContain('data-content-width="4xl"');
|
||||
expect(html).toContain('data-content-width="2xl"');
|
||||
});
|
||||
|
||||
it("threads onRequestPhaseSheet through to the phases row", () => {
|
||||
|
||||
@@ -56,6 +56,23 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
expect(html).not.toContain("text-orange-400");
|
||||
});
|
||||
|
||||
it("compliance bars scale the row labels to the chosen windows", () => {
|
||||
// v1.8.6 — a sparse cadence steps both windows up; the labels follow.
|
||||
const html = render(
|
||||
<MedicationComplianceBars
|
||||
rate7={75}
|
||||
rate30={80}
|
||||
streak={0}
|
||||
shortDays={90}
|
||||
longDays={365}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("90-day compliance");
|
||||
expect(html).toContain("365-day compliance");
|
||||
expect(html).not.toContain("7-day compliance");
|
||||
expect(html).not.toContain("30-day compliance");
|
||||
});
|
||||
|
||||
it("compliance bars hide the streak flame when streak is zero", () => {
|
||||
const html = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={0} />,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* v1.8.6 W4c — dose-strength (titration) curve tests.
|
||||
*
|
||||
* Pins the three honest data states and the pure point-builder:
|
||||
* - 0 dose-change rows → empty state, current-dose absent.
|
||||
* - exactly 1 row → empty state, current dose surfaced (no flat
|
||||
* single-point chart).
|
||||
* - 2+ rows → the step-curve SVG body renders.
|
||||
*
|
||||
* Recharts in `renderToStaticMarkup` paints the static frame only, so we
|
||||
* assert on the `data-slot` seams rather than measured geometry — the
|
||||
* same approach the sibling DrugLevelChart test uses.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const queryResults: Record<string, unknown> = {};
|
||||
|
||||
function setQueryResult(keyJoined: string, data: unknown) {
|
||||
queryResults[keyJoined] = data;
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = (queryKey as Array<string | number>).join("/");
|
||||
return { data: queryResults[key], isLoading: false };
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
DoseStrengthCurve,
|
||||
buildCurvePoints,
|
||||
} from "../dose-strength-curve";
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const KEY = "medications/med-1/glp1-details";
|
||||
const ASOF = new Date("2026-06-01T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(queryResults)) delete queryResults[k];
|
||||
});
|
||||
|
||||
describe("buildCurvePoints", () => {
|
||||
it("returns an empty list for no dose changes", () => {
|
||||
expect(buildCurvePoints([], ASOF)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the single row unchanged (no carry-forward) for one change", () => {
|
||||
const pts = buildCurvePoints(
|
||||
[
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
ASOF,
|
||||
);
|
||||
expect(pts).toHaveLength(1);
|
||||
expect(pts[0].dose).toBe(2.5);
|
||||
});
|
||||
|
||||
it("sorts by effectiveFrom and carries the latest dose forward to asOf", () => {
|
||||
const pts = buildCurvePoints(
|
||||
[
|
||||
{
|
||||
id: "b",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
ASOF,
|
||||
);
|
||||
// two source rows + the carried-forward "now" anchor
|
||||
expect(pts).toHaveLength(3);
|
||||
expect(pts.map((p) => p.dose)).toEqual([2.5, 5, 5]);
|
||||
expect(pts[2].t).toBe(ASOF.getTime());
|
||||
});
|
||||
|
||||
it("drops rows with unparseable dates or non-finite doses", () => {
|
||||
const pts = buildCurvePoints(
|
||||
[
|
||||
{
|
||||
id: "bad-date",
|
||||
effectiveFrom: "not-a-date",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "ok",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "ok-2",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 7.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
ASOF,
|
||||
);
|
||||
expect(pts.map((p) => p.dose)).toEqual([2.5, 7.5, 7.5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DoseStrengthCurve>", () => {
|
||||
it("renders the empty state with no current dose for zero rows", () => {
|
||||
setQueryResult(KEY, { doseChanges: [] });
|
||||
|
||||
const html = render(
|
||||
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="dose-strength-curve-empty"');
|
||||
expect(html).toContain("No dose recorded yet.");
|
||||
expect(html).toContain("No titration history yet.");
|
||||
expect(html).not.toContain('data-slot="dose-strength-curve-area"');
|
||||
});
|
||||
|
||||
it("surfaces the current dose but no curve for a single dose change", () => {
|
||||
setQueryResult(KEY, {
|
||||
doseChanges: [
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="dose-strength-curve-empty"');
|
||||
expect(html).toContain("Current dose: 5 mg");
|
||||
expect(html).not.toContain('data-slot="dose-strength-curve-area"');
|
||||
});
|
||||
|
||||
it("renders the step curve for two or more dose changes", () => {
|
||||
setQueryResult(KEY, {
|
||||
doseChanges: [
|
||||
{
|
||||
id: "a",
|
||||
effectiveFrom: "2026-04-01T00:00:00Z",
|
||||
doseValue: 2.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
effectiveFrom: "2026-05-01T00:00:00Z",
|
||||
doseValue: 5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = render(
|
||||
<DoseStrengthCurve medicationId="med-1" asOf={ASOF} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="dose-strength-curve-area"');
|
||||
expect(html).toContain('data-slot="dose-strength-curve-caption"');
|
||||
// Mounted inside the shared section chrome.
|
||||
expect(html).toContain('aria-labelledby="dose-strength-curve-title"');
|
||||
expect(html).not.toContain('data-slot="dose-strength-curve-empty"');
|
||||
});
|
||||
});
|
||||
@@ -3,24 +3,21 @@
|
||||
/**
|
||||
* v1.7.0 — Advanced settings sheet.
|
||||
* v1.7.2 — widened to `4xl` and re-laid into a responsive two-column
|
||||
* grid so a normal desktop viewport shows every group without forcing
|
||||
* a long single-column scroll.
|
||||
* grid.
|
||||
* v1.8.6 — rebuilt as a single column. The two-column data|reminders
|
||||
* grid read as overloaded; the sheet now narrows to `2xl` (~672px) and
|
||||
* stacks four bordered section cards in most-used-first order:
|
||||
* Reminders → Lifecycle → Data → Danger. One spacing rule throughout —
|
||||
* `space-y-4` between cards, `divide-y` + `py-3` rows inside each card
|
||||
* (the `<SettingsGroup>` card owns the dividers).
|
||||
*
|
||||
* Four labelled groups under one consistent visual system (R-medui §5):
|
||||
*
|
||||
* - Data: intake Import + medications CSV Export (co-located) +
|
||||
* external-API endpoint / token.
|
||||
* - Reminders: notifications switch + reminder window (grace).
|
||||
* - Lifecycle: pause/resume + end course + phases.
|
||||
* - Danger zone: purge intake history + delete medication.
|
||||
*
|
||||
* Layout: on `lg+` the first three groups flow into a two-column grid —
|
||||
* the Data group (tallest, with the export toggle + token snippets)
|
||||
* holds the left column; Reminders + Lifecycle stack down the right.
|
||||
* The Danger zone always spans the full width at the bottom, set off by
|
||||
* its own destructive-tinted card, so an irreversible action never
|
||||
* shares a column with a routine toggle. On `<lg` everything stacks in
|
||||
* the documented Data → Reminders → Lifecycle → Danger order.
|
||||
* - Data: intake Import + medications CSV Export (co-located) +
|
||||
* external-API endpoint / token.
|
||||
* - Danger zone: purge intake history + delete medication, set off by
|
||||
* its own destructive-tinted card so an irreversible action never
|
||||
* sits next to a routine toggle.
|
||||
*
|
||||
* Button styling is consistent: reversible / neutral actions are
|
||||
* `outline`, destructive actions are `destructive font-semibold`,
|
||||
@@ -32,7 +29,6 @@
|
||||
* self-saves, so the footer slot stays empty.
|
||||
*/
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { SettingsGroup } from "@/components/medications/settings-group";
|
||||
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
|
||||
@@ -95,96 +91,86 @@ export function AdvancedSettingsSheet({
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.detail.advanced.title")}
|
||||
contentWidth="4xl"
|
||||
bodyClassName="gap-6"
|
||||
contentWidth="2xl"
|
||||
bodyClassName="gap-4"
|
||||
>
|
||||
<div className="space-y-6" data-slot="advanced-settings-sheet-body">
|
||||
{/* Top region: Data + Reminders + Lifecycle in a two-column grid
|
||||
on lg+, single column below. `items-start` keeps each group
|
||||
top-aligned so the shorter right column never stretches. */}
|
||||
<div className="grid items-start gap-6 lg:grid-cols-2">
|
||||
{/* DATA — import + export + external API. Tallest group → left
|
||||
column on lg+ so the right column's two shorter groups
|
||||
balance against it. */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.data")}
|
||||
dataSlot="advanced-group-data"
|
||||
>
|
||||
<div className="space-y-4" data-slot="advanced-settings-sheet-body">
|
||||
{/* REMINDERS — notifications + reminder window. Most-used group
|
||||
first; each row is a padded `<div>` so the group card's
|
||||
`divide-y` draws the hairline between them. */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.reminders")}
|
||||
dataSlot="advanced-group-reminders"
|
||||
>
|
||||
<div className="py-3">
|
||||
<NotificationsBody
|
||||
medicationId={medicationId}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<GraceRow
|
||||
medicationId={medicationId}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
/>
|
||||
</div>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* LIFECYCLE — pause/resume + end course + phases */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.lifecycle")}
|
||||
dataSlot="advanced-group-lifecycle"
|
||||
>
|
||||
<div className="py-3">
|
||||
<LifecycleManageBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
active={active}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
{isGlp1 && (
|
||||
<div className="py-3">
|
||||
<PhasesRow
|
||||
medicationId={medicationId}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
{/* DATA — import + export + external API */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.data")}
|
||||
dataSlot="advanced-group-data"
|
||||
>
|
||||
<div className="py-3">
|
||||
<DataPortabilityRow
|
||||
medicationId={medicationId}
|
||||
onOpenImport={onOpenImport}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
</div>
|
||||
<div className="py-3">
|
||||
<ApiTokensRow
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* Right column: Reminders + Lifecycle stacked. */}
|
||||
<div className="space-y-6">
|
||||
{/* REMINDERS — notifications + reminder window */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.reminders")}
|
||||
dataSlot="advanced-group-reminders"
|
||||
>
|
||||
<NotificationsBody
|
||||
medicationId={medicationId}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<GraceRow
|
||||
medicationId={medicationId}
|
||||
reminderGraceMinutes={reminderGraceMinutes}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* LIFECYCLE — pause/resume + end course + phases */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.lifecycle")}
|
||||
dataSlot="advanced-group-lifecycle"
|
||||
>
|
||||
<LifecycleManageBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
active={active}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
|
||||
{isGlp1 && (
|
||||
<>
|
||||
<Separator />
|
||||
<PhasesRow
|
||||
medicationId={medicationId}
|
||||
treatmentClass={treatmentClass}
|
||||
startsOn={startsOn}
|
||||
endsOn={endsOn}
|
||||
onRequestPhaseSheet={onRequestPhaseSheet}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DANGER ZONE — purge + delete. Full-width at the bottom, set
|
||||
off by the destructive-tinted card inside the body so an
|
||||
irreversible action never shares a column with a toggle. */}
|
||||
<SettingsGroup
|
||||
label={t("medications.detail.advanced.group.danger")}
|
||||
dataSlot="advanced-group-danger"
|
||||
>
|
||||
<DangerZoneBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
intakeCount={intakeCount}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
{/* DANGER ZONE — purge + delete. Set off by the destructive-
|
||||
tinted card so an irreversible action never sits next to a
|
||||
routine toggle. The body supplies its own matched-pair card,
|
||||
so it slots in without the neutral group frame. */}
|
||||
<DangerZoneBody
|
||||
medicationId={medicationId}
|
||||
medicationName={medicationName}
|
||||
intakeCount={intakeCount}
|
||||
onAfterAction={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.8.5 — uptime-style per-dose adherence strip for sparse cadences.
|
||||
*
|
||||
* The medication cards keep the 7-/30-day percentage bars for dense
|
||||
* cadences (daily / multi-daily / weekday meds) where the denominator is
|
||||
* large enough for the percentage to be stable. For sparse cadences
|
||||
* (weekly, bi-/tri-weekly, rolling 35-day injections) a rolling percentage
|
||||
* swings violently off one sample and tells the user nothing; the server
|
||||
* flips `complianceDisplay.mode` to `"timeline"` and this strip takes over.
|
||||
*
|
||||
* Each cell is one EXPECTED dose (not a calendar day), oldest → newest:
|
||||
* taken=green / missed=red / skipped=grey / upcoming=outline. The color
|
||||
* tokens and the tap-to-pin tooltip mirror the 90-day compliance heatmap
|
||||
* (`src/components/charts/compliance-heatmap.tsx`) so the two surfaces read
|
||||
* the same. Plain flex divs, not Recharts — a fixed-size status-cell row
|
||||
* needs no chart container, axis, or tooltip plumbing.
|
||||
*/
|
||||
|
||||
export type DoseStatus = "taken" | "missed" | "skipped" | "upcoming";
|
||||
export type DoseTiming = "early" | "on_time" | "late" | "very_late" | "missed";
|
||||
|
||||
export interface DoseAdherenceCell {
|
||||
scheduledFor: string;
|
||||
status: DoseStatus;
|
||||
takenAt: string | null;
|
||||
timing: DoseTiming | null;
|
||||
site: string | null;
|
||||
}
|
||||
|
||||
interface DoseAdherenceTimelineProps {
|
||||
doses: DoseAdherenceCell[];
|
||||
summary: { taken: number; total: number; doseStreak: number };
|
||||
}
|
||||
|
||||
// v1.4.27 MB7 / CF-10 precedent — clear the 14 px touch floor (with the
|
||||
// gap the row stays tap-friendly on narrow viewports). Mirrors the
|
||||
// heatmap's `CELL_FLOOR_PX`.
|
||||
const CELL_PX = 14;
|
||||
|
||||
/** Token mirror of the heatmap's status palette. */
|
||||
function cellColor(status: DoseStatus, timing: DoseTiming | null): string {
|
||||
if (status === "upcoming") return "transparent";
|
||||
if (status === "skipped") return "var(--secondary)";
|
||||
if (status === "missed") return "var(--dracula-red)";
|
||||
// taken — shade by punctuality, same ladder the heatmap uses.
|
||||
if (timing === "very_late") return "var(--dracula-orange)";
|
||||
if (timing === "late") return "var(--dracula-yellow)";
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
|
||||
export function DoseAdherenceTimeline({
|
||||
doses,
|
||||
summary,
|
||||
}: DoseAdherenceTimelineProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
pinned?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Mirror the heatmap: an outside tap clears a pinned tooltip so a touch
|
||||
// user can dismiss the per-cell detail. Gated on `pinned` so the hover
|
||||
// flow never pays the indirection.
|
||||
useEffect(() => {
|
||||
if (!tooltip?.pinned) return;
|
||||
const handlePointer = (event: PointerEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target as Node)) setTooltip(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointer, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointer, true);
|
||||
};
|
||||
}, [tooltip?.pinned]);
|
||||
|
||||
function statusLabel(status: DoseStatus): string {
|
||||
if (status === "taken") return t("medications.doseTaken");
|
||||
if (status === "missed") return t("medications.doseMissed");
|
||||
if (status === "skipped") return t("medications.doseSkipped");
|
||||
return t("medications.doseUpcoming");
|
||||
}
|
||||
|
||||
function cellText(cell: DoseAdherenceCell): string {
|
||||
const when = fmt.dateTime(cell.scheduledFor);
|
||||
return `${when} — ${statusLabel(cell.status)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" ref={containerRef}>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex flex-wrap gap-1"
|
||||
role="img"
|
||||
aria-label={t("medications.doseTimelineAriaLabel")}
|
||||
>
|
||||
{doses.map((cell, i) => {
|
||||
const isUpcoming = cell.status === "upcoming";
|
||||
return (
|
||||
<div
|
||||
key={`${cell.scheduledFor}-${i}`}
|
||||
className={`shrink-0 cursor-pointer rounded-sm ${
|
||||
isUpcoming ? "border-border border border-dashed" : ""
|
||||
}`}
|
||||
style={{
|
||||
width: CELL_PX,
|
||||
height: CELL_PX,
|
||||
backgroundColor: cellColor(cell.status, cell.timing),
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cellText(cell),
|
||||
});
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
setTooltip((prev) => (prev?.pinned ? prev : null));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.pointerType !== "touch") return;
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
text: cellText(cell),
|
||||
pinned: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tooltip && (
|
||||
<div
|
||||
className="bg-popover text-popover-foreground border-border pointer-events-none fixed z-50 rounded-md border px-2 py-1 text-xs shadow-md"
|
||||
style={{ left: tooltip.x + 10, top: tooltip.y - 30 }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<span>
|
||||
{t("medications.lastNDoses", {
|
||||
total: summary.total,
|
||||
taken: summary.taken,
|
||||
})}
|
||||
</span>
|
||||
{summary.doseStreak > 0 && (
|
||||
<span className="text-dracula-orange flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{t("medications.doseStreak", { count: summary.doseStreak })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,13 +7,28 @@ interface MedicationComplianceBarsProps {
|
||||
rate7: number;
|
||||
rate30: number;
|
||||
streak: number;
|
||||
/**
|
||||
* v1.8.6 — the span of the short row in days. The server scales the two
|
||||
* windows to the dosing cadence (7 / 30 for dense meds, stepping up to
|
||||
* 90 / 365 for sparse ones), so the labels follow the chosen windows
|
||||
* instead of a hardcoded 7 / 30. Defaults to 7 / 30 so older callers and
|
||||
* fixtures keep their prior labels.
|
||||
*/
|
||||
shortDays?: number;
|
||||
/** v1.8.6 — the span of the long row in days. */
|
||||
longDays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared 7-day / 30-day compliance bars plus the day-streak flame for the
|
||||
* Shared two-row compliance bars plus the day-streak flame for the
|
||||
* medication cards. Extracted from the generic and GLP-1 cards so the bars
|
||||
* are structurally identical rather than hand-synced.
|
||||
*
|
||||
* v1.8.6 — the two windows scale with the dosing cadence. A daily med shows
|
||||
* 7-day / 30-day; a weekly med 30-day / 90-day; a rare injection up to a
|
||||
* 365-day long window. The labels are parametrised on the chosen day-counts
|
||||
* so each row names the window it actually covers.
|
||||
*
|
||||
* The streak flame uses the canonical `text-dracula-orange` warning/streak
|
||||
* token (globals.css). The generic card historically drifted onto Tailwind
|
||||
* stock `text-orange-400`; unifying here closes that token gap.
|
||||
@@ -22,48 +37,43 @@ export function MedicationComplianceBars({
|
||||
rate7,
|
||||
rate30,
|
||||
streak,
|
||||
shortDays = 7,
|
||||
longDays = 30,
|
||||
}: MedicationComplianceBarsProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const shortLabel = t("medications.complianceWindow", { days: shortDays });
|
||||
const longLabel = t("medications.complianceWindow", { days: longDays });
|
||||
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.compliance7d")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{shortLabel}</span>
|
||||
<span className="font-medium">{rate7}%</span>
|
||||
</div>
|
||||
{/* aria-label so the bar has an accessible name. */}
|
||||
<Progress
|
||||
value={rate7}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance7d")}
|
||||
/>
|
||||
<Progress value={rate7} className="h-2" aria-label={shortLabel} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.compliance30d")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{longLabel}</span>
|
||||
<span className="font-medium">{rate30}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={rate30}
|
||||
className="h-2"
|
||||
aria-label={t("medications.compliance30d")}
|
||||
/>
|
||||
<Progress value={rate30} className="h-2" aria-label={longLabel} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
{streak > 0 && (
|
||||
{/* Streak flame — only mounted when there's a streak so an empty
|
||||
row doesn't leave a residual gap below the bars. */}
|
||||
{streak > 0 && (
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<span className="text-dracula-orange flex items-center gap-1 font-medium">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
{streak} {t("medications.dayStreak")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.8.6 W4c — GLP-1 dose-strength (titration) curve.
|
||||
*
|
||||
* Replaces the EMA *reference*-ladder block that showed no values when
|
||||
* the medication's brand fell outside the catalog. This surface plots
|
||||
* the user's OWN dose-strength history — the `MedicationDoseChange`
|
||||
* stream (`effectiveFrom` + `doseValue` + `doseUnit`) returned by
|
||||
* `GET /api/medications/[id]/glp1` — as a step curve over time, so a
|
||||
* 2.5 → 5 → 7.5 → 10 mg escalation reads at a glance.
|
||||
*
|
||||
* Honest data contract:
|
||||
* - 2+ dose-change rows → step curve, the latest dose carried forward
|
||||
* to "now" so the current strength stays visible at the right edge.
|
||||
* - exactly 1 row → no escalation to draw; surface the single current
|
||||
* dose plainly with a "no titration history yet" caption rather than
|
||||
* a flat single-point chart.
|
||||
* - 0 rows → empty state with the same caption. The
|
||||
* `MedicationDoseChange` table is the history of record; when it is
|
||||
* empty there is genuinely no time series to plot.
|
||||
*
|
||||
* No Research-Mode gate — unlike the estimated-PK `DrugLevelChart`, this
|
||||
* curve plots only logged facts (the user's recorded dose strengths), so
|
||||
* it carries no pharmacokinetic estimate and needs no disclaimer gate.
|
||||
*
|
||||
* Dracula purple tokens + the shared `<MedicationDetailSection>` chrome
|
||||
* so the GLP-1 disclosure reads as one visual group with the drug-level
|
||||
* chart that sits beside it.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, TrendingUp } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
||||
|
||||
const CURVE_COLOR = "var(--dracula-purple)";
|
||||
|
||||
interface DoseChange {
|
||||
id: string;
|
||||
effectiveFrom: string;
|
||||
doseValue: number;
|
||||
doseUnit: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
interface Glp1DetailsResponse {
|
||||
doseChanges: DoseChange[];
|
||||
}
|
||||
|
||||
export interface DoseStrengthCurveProps {
|
||||
medicationId: string;
|
||||
/** Override "now" for deterministic snapshot tests. */
|
||||
asOf?: Date;
|
||||
}
|
||||
|
||||
interface CurvePoint {
|
||||
t: number;
|
||||
dose: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the step-curve points from the dose-change stream. The series is
|
||||
* sorted by `effectiveFrom`, finite-guarded, and the latest dose is
|
||||
* carried forward to `asOf` so the current strength anchors the right
|
||||
* edge. Exported for the unit test.
|
||||
*/
|
||||
export function buildCurvePoints(
|
||||
doseChanges: readonly DoseChange[],
|
||||
asOf: Date,
|
||||
): CurvePoint[] {
|
||||
const sorted = doseChanges
|
||||
.map((dc) => ({
|
||||
t: Date.parse(dc.effectiveFrom),
|
||||
dose: dc.doseValue,
|
||||
unit: dc.doseUnit,
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.dose))
|
||||
.sort((a, b) => a.t - b.t);
|
||||
|
||||
if (sorted.length < 2) return sorted;
|
||||
|
||||
// Carry the latest dose forward to "now" so the most-recent strength
|
||||
// reads as a flat segment up to the right edge rather than ending at
|
||||
// the last change date.
|
||||
const last = sorted[sorted.length - 1];
|
||||
const nowMs = asOf.getTime();
|
||||
if (nowMs > last.t) {
|
||||
sorted.push({ t: nowMs, dose: last.dose, unit: last.unit });
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function DoseStrengthCurve({
|
||||
medicationId,
|
||||
asOf,
|
||||
}: DoseStrengthCurveProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data: details, isLoading } = useQuery<Glp1DetailsResponse | null>({
|
||||
queryKey: queryKeys.medicationGlp1Details(medicationId),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/glp1`);
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as Glp1DetailsResponse;
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const now = useMemo(() => asOf ?? new Date(), [asOf]);
|
||||
const doseChanges = useMemo(
|
||||
() => details?.doseChanges ?? [],
|
||||
[details],
|
||||
);
|
||||
const points = useMemo(
|
||||
() => buildCurvePoints(doseChanges, now),
|
||||
[doseChanges, now],
|
||||
);
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
// The latest logged dose — surfaced in the single-row / empty states so
|
||||
// the user always sees their current strength even without a curve.
|
||||
const latest = useMemo(() => {
|
||||
if (doseChanges.length === 0) return null;
|
||||
const sorted = [...doseChanges].sort(
|
||||
(a, b) => Date.parse(a.effectiveFrom) - Date.parse(b.effectiveFrom),
|
||||
);
|
||||
return sorted[sorted.length - 1];
|
||||
}, [doseChanges]);
|
||||
|
||||
const hasCurve = points.length >= 2;
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
points.map((p) => ({
|
||||
t: p.t,
|
||||
dose: p.dose,
|
||||
})),
|
||||
[points],
|
||||
);
|
||||
|
||||
// Four evenly-spaced date ticks across the plotted span so the strip
|
||||
// reads start → … → now without crowding.
|
||||
const ticks = useMemo(() => {
|
||||
if (chartData.length < 2) return [];
|
||||
const first = chartData[0].t;
|
||||
const last = chartData[chartData.length - 1].t;
|
||||
const span = last - first;
|
||||
if (span <= 0) return [first];
|
||||
return [0, 0.33, 0.66, 1].map((f) => Math.round(first + span * f));
|
||||
}, [chartData]);
|
||||
|
||||
const unit = latest?.doseUnit ?? "mg";
|
||||
|
||||
const body = isLoading ? (
|
||||
<div
|
||||
className="flex h-[200px] min-h-[200px] items-center justify-center"
|
||||
data-slot="dose-strength-curve-loading"
|
||||
>
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !hasCurve ? (
|
||||
<div
|
||||
className="text-muted-foreground bg-muted/40 flex flex-col items-start gap-2 rounded-md p-4 text-sm"
|
||||
data-slot="dose-strength-curve-empty"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{latest ? (
|
||||
<p>
|
||||
{t("medications.doseStrength.currentDose", {
|
||||
dose: fmt.number(latest.doseValue),
|
||||
unit: latest.doseUnit,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p>{t("medications.doseStrength.noCurrentDose")}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs italic">{t("medications.doseStrength.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="touch-pan-y"
|
||||
style={{ height: "200px" }}
|
||||
data-slot="dose-strength-curve-area"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 14, bottom: 16, left: 4 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
ticks={ticks}
|
||||
tickFormatter={(v) => fmt.dateShort(new Date(v as number))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, "auto"]}
|
||||
width={34}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v) => fmt.number(v as number)}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
labelFormatter={(v) => fmt.dateShort(new Date(v as number))}
|
||||
formatter={(value) => [
|
||||
`${fmt.number(value as number)} ${unit}`,
|
||||
t("medications.doseStrength.tooltipLabel"),
|
||||
]}
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.375rem",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="dose"
|
||||
stroke={CURVE_COLOR}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: CURVE_COLOR, strokeWidth: 0 }}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="dose-strength-curve-title"
|
||||
title={t("medications.doseStrength.title")}
|
||||
dataSlot="dose-strength-curve"
|
||||
>
|
||||
{hasCurve && (
|
||||
<p
|
||||
className="text-muted-foreground mb-2 text-xs"
|
||||
data-slot="dose-strength-curve-caption"
|
||||
>
|
||||
{t("medications.doseStrength.axisLabel", { unit })}
|
||||
</p>
|
||||
)}
|
||||
{body}
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import {
|
||||
DoseAdherenceTimeline,
|
||||
type DoseAdherenceCell,
|
||||
} from "@/components/medications/card-parts/dose-adherence-timeline";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
@@ -33,6 +29,7 @@ import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
} from "@/lib/medications/window-status";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication card variant.
|
||||
@@ -103,22 +100,15 @@ export interface Glp1Medication {
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
interface ComplianceDisplay {
|
||||
mode: "percent" | "timeline";
|
||||
expected7: number;
|
||||
expected30: number;
|
||||
minStableDoses: number;
|
||||
doseTimeline: DoseAdherenceCell[];
|
||||
recentDoseSummary: { taken: number; total: number; doseStreak: number };
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: { rate: number; streak: number };
|
||||
compliance30: { rate: number };
|
||||
/**
|
||||
* v1.8.5 — server-decided render mode. GLP-1 injections are commonly
|
||||
* weekly, so this card flips to the per-dose timeline more often than
|
||||
* the generic one. Additive — older mocks omit it.
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. GLP-1
|
||||
* injections are commonly weekly, so this card's windows step up to the
|
||||
* 30-/90-day rung where the bars stay meaningful. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
@@ -309,9 +299,15 @@ export function Glp1MedicationCard({
|
||||
const schedule = medication.schedules[0] ?? null;
|
||||
const now = new Date();
|
||||
const next = nextInjectionFromServer(medication.nextDueAt, now);
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
// v1.8.6 — the two compliance windows scale with the dosing cadence. When
|
||||
// the server supplies `complianceDisplay` the card reads its cadence-scaled
|
||||
// rows; otherwise it falls back to the static 7-/30-day fields.
|
||||
const display = compliance?.complianceDisplay;
|
||||
const shortDays = display?.shortDays ?? 7;
|
||||
const longDays = display?.longDays ?? 30;
|
||||
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
|
||||
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
|
||||
|
||||
// v1.4.37 W4b — symmetric take-now / overdue pill with the generic
|
||||
// card. Sort schedules by `windowStart` so the most-actionable
|
||||
@@ -480,24 +476,19 @@ export function Glp1MedicationCard({
|
||||
Glp1InventoryDTO slot on /api/medications/[id]/glp1 stays
|
||||
in the response shape; only the web mounts are gone. */}
|
||||
|
||||
{/* Compliance region — shared decision with the generic card.
|
||||
Dense cadences keep the bars; sparse cadences (most weekly
|
||||
GLP-1 injections) swap to the per-dose uptime strip. Falls back
|
||||
to the bars when the older payload omits `complianceDisplay`. */}
|
||||
{medication.active &&
|
||||
compliance &&
|
||||
(compliance.complianceDisplay?.mode === "timeline" ? (
|
||||
<DoseAdherenceTimeline
|
||||
doses={compliance.complianceDisplay.doseTimeline}
|
||||
summary={compliance.complianceDisplay.recentDoseSummary}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
))}
|
||||
{/* Compliance bars — always two rows, shared with the generic
|
||||
card so the page grid stays harmonious. The server scales the
|
||||
two windows to the dosing cadence; most weekly GLP-1 injections
|
||||
land on the 30-/90-day rung where the bars stay meaningful. */}
|
||||
{medication.active && compliance && (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Primary actions row — shared with the generic medication
|
||||
card. The GLP-1-specific side-effect quick-log lives in the
|
||||
|
||||
@@ -9,10 +9,6 @@ import { MedicationCardMenu } from "@/components/medications/medication-card-men
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationComplianceBars } from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import {
|
||||
DoseAdherenceTimeline,
|
||||
type DoseAdherenceCell,
|
||||
} from "@/components/medications/card-parts/dose-adherence-timeline";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { formatDateTime, formatTime } from "@/lib/format";
|
||||
@@ -30,6 +26,7 @@ import {
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -75,15 +72,6 @@ interface Medication {
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
interface ComplianceDisplay {
|
||||
mode: "percent" | "timeline";
|
||||
expected7: number;
|
||||
expected30: number;
|
||||
minStableDoses: number;
|
||||
doseTimeline: DoseAdherenceCell[];
|
||||
recentDoseSummary: { taken: number; total: number; doseStreak: number };
|
||||
}
|
||||
|
||||
interface ComplianceData {
|
||||
compliance7: {
|
||||
totalExpected: number;
|
||||
@@ -97,9 +85,11 @@ interface ComplianceData {
|
||||
rate: number;
|
||||
};
|
||||
/**
|
||||
* v1.8.5 — server-decided render mode for the compliance region. Dense
|
||||
* cadences keep the 7-/30-day bars (`"percent"`); sparse cadences swap to
|
||||
* the per-dose uptime strip (`"timeline"`). Additive — older mocks omit it.
|
||||
* v1.8.6 — the two compliance windows scaled to the dosing cadence. The
|
||||
* card always shows two percentage rows; `shortDays` / `longDays` name the
|
||||
* windows and `short` / `long` carry their rates. Additive — older mocks
|
||||
* omit it, in which case the card falls back to the static 7-/30-day
|
||||
* `compliance7` / `compliance30` fields.
|
||||
*/
|
||||
complianceDisplay?: ComplianceDisplay;
|
||||
}
|
||||
@@ -222,9 +212,15 @@ export function MedicationCard({
|
||||
}
|
||||
}
|
||||
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
// v1.8.6 — the two compliance windows scale with the dosing cadence. When
|
||||
// the server supplies `complianceDisplay` the card reads its cadence-scaled
|
||||
// rows; otherwise it falls back to the static 7-/30-day fields.
|
||||
const display = compliance?.complianceDisplay;
|
||||
const shortDays = display?.shortDays ?? 7;
|
||||
const longDays = display?.longDays ?? 30;
|
||||
const rate7 = display?.short.rate ?? compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = display?.long.rate ?? compliance?.compliance30?.rate ?? 0;
|
||||
const streak = display?.short.streak ?? compliance?.compliance7?.streak ?? 0;
|
||||
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
|
||||
const sortedSchedules = [...medication.schedules].sort(
|
||||
(a, b) =>
|
||||
@@ -391,24 +387,19 @@ export function MedicationCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Compliance region — the server decides whether the percentage
|
||||
bars stay (dense cadences) or the per-dose uptime strip takes
|
||||
over (sparse cadences). Falls back to the bars when the older
|
||||
payload omits `complianceDisplay`. */}
|
||||
{medication.active &&
|
||||
compliance &&
|
||||
(compliance.complianceDisplay?.mode === "timeline" ? (
|
||||
<DoseAdherenceTimeline
|
||||
doses={compliance.complianceDisplay.doseTimeline}
|
||||
summary={compliance.complianceDisplay.recentDoseSummary}
|
||||
/>
|
||||
) : (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
/>
|
||||
))}
|
||||
{/* Compliance bars — always two rows. The server scales the two
|
||||
windows to the dosing cadence (7 / 30 days for dense meds,
|
||||
stepping up to 90 / 365 for sparse ones); the labels follow the
|
||||
chosen windows. */}
|
||||
{medication.active && compliance && (
|
||||
<MedicationComplianceBars
|
||||
rate7={rate7}
|
||||
rate30={rate30}
|
||||
streak={streak}
|
||||
shortDays={shortDays}
|
||||
longDays={longDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick actions — primary buttons of the medication card. */}
|
||||
{medication.active && (
|
||||
|
||||
@@ -318,10 +318,12 @@ export function DangerZoneBody({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="border-destructive/40 space-y-4 p-4"
|
||||
className="border-destructive/40 bg-destructive/5 divide-destructive/20 gap-0 divide-y px-4 py-1"
|
||||
data-slot="destructive-zone-card-b"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Tier 3a — Verlauf löschen. Matched pair with the delete row
|
||||
below: identical `py-3` spacing, identical destructive button. */}
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.zone.purge.title")}
|
||||
@@ -343,9 +345,8 @@ export function DangerZoneBody({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* Tier 3b — Medikament löschen */}
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.detail.zone.delete.title")}
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
/**
|
||||
* v1.7.0 — advanced-settings group header.
|
||||
*
|
||||
* Lightweight presentational grouping for the redesigned
|
||||
* `<AdvancedSettingsSheet>`: 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 `<div>` per row; the card
|
||||
* supplies the dividers so no row interleaves its own `<Separator>`.
|
||||
*
|
||||
* The label is a plain `<p>` (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 (
|
||||
<section
|
||||
className="space-y-4"
|
||||
className={cn("bg-card rounded-lg border px-4 py-1", className)}
|
||||
{...(dataSlot ? { "data-slot": dataSlot } : {})}
|
||||
>
|
||||
<p className="border-border/60 text-muted-foreground border-t pt-1 text-xs font-medium tracking-wide uppercase">
|
||||
<p className="text-muted-foreground border-border/60 border-b py-2.5 text-xs font-medium tracking-wide uppercase">
|
||||
{label}
|
||||
</p>
|
||||
{children}
|
||||
<div className="divide-border/60 divide-y">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<number, string> = {};
|
||||
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
|
||||
`<Progress>` 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. */}
|
||||
<div className="border-border/70 space-y-1.5 border-b p-4 pr-12 sm:p-6 sm:pr-14">
|
||||
<Progress
|
||||
value={progress}
|
||||
// v1.5.5 D-3 §8 — width-only shadcn `<Progress>`. 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}
|
||||
<WizardStepper
|
||||
steps={stepList}
|
||||
current={step}
|
||||
reachableUntil={reachableUntil}
|
||||
labels={stepperLabels}
|
||||
onJump={goToStep}
|
||||
onFirst={() => 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}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">{stepOf}</p>
|
||||
{step === 1 && mode === "create" && (
|
||||
{step === 1 && mode === "create" && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -429,8 +501,8 @@ function WizardDialogShell({
|
||||
/>
|
||||
{t("medications.wizard.nl.button")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step body — v1.5.5 D-3 §8 spacing buckets:
|
||||
@@ -480,6 +552,19 @@ function WizardDialogShell({
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{stepSubline}
|
||||
</p>
|
||||
{/* 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). */}
|
||||
<p
|
||||
className="text-muted-foreground/80 flex items-center gap-0.5 pt-0.5 text-xs"
|
||||
data-slot="wizard-next-hint"
|
||||
>
|
||||
<ChevronRight
|
||||
className="h-3 w-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{nextHint}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.8.6 W4b — dot/bullet stepper for the medication wizard.
|
||||
*
|
||||
* Replaces the continuous `<Progress>` 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<number, string>;
|
||||
/** 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 (
|
||||
<div
|
||||
className="flex flex-col gap-1.5"
|
||||
data-slot="wizard-stepper"
|
||||
role="group"
|
||||
aria-label={srLabel}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onFirst}
|
||||
disabled={!firstEnabled}
|
||||
data-slot="wizard-stepper-first"
|
||||
aria-label={firstLabel}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<ol className="flex min-w-0 flex-1 items-center justify-between">
|
||||
{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 (
|
||||
<li
|
||||
key={stepNumber}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center",
|
||||
index === 0 ? "flex-initial" : "flex-1",
|
||||
)}
|
||||
>
|
||||
{index > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mx-1 h-0 flex-1 border-t",
|
||||
connectorSolid
|
||||
? "border-primary"
|
||||
: "border-muted-foreground/30 border-dashed",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(stepNumber)}
|
||||
disabled={!enabled}
|
||||
data-slot="wizard-stepper-dot"
|
||||
data-step-dot={stepNumber}
|
||||
data-active={isActive || undefined}
|
||||
data-completed={isCompleted || undefined}
|
||||
aria-current={isActive ? "step" : undefined}
|
||||
aria-label={`${index + 1}. ${label}`}
|
||||
className={cn(
|
||||
// 44 px hit target via padding even though the visual
|
||||
// dot is small.
|
||||
"group focus-visible:ring-ring/50 flex shrink-0 flex-col items-center gap-1 rounded-md p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
enabled
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-3 w-3 place-items-center rounded-full border transition-colors",
|
||||
isActive
|
||||
? "border-primary bg-primary ring-primary/30 ring-2 ring-offset-1 ring-offset-transparent"
|
||||
: isCompleted
|
||||
? "border-primary bg-primary"
|
||||
: isReachable
|
||||
? "border-primary bg-transparent"
|
||||
: "border-muted-foreground/30 bg-transparent",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-[6rem] truncate text-[0.6875rem] leading-none",
|
||||
isActive
|
||||
? "text-foreground font-medium"
|
||||
: "text-muted-foreground",
|
||||
// Crowding guard: narrow viewports drop the per-dot
|
||||
// labels entirely (the caption below names the active
|
||||
// step instead); from `sm+` every dot is labelled.
|
||||
"hidden sm:inline",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onLast}
|
||||
disabled={!lastEnabled}
|
||||
data-slot="wizard-stepper-last"
|
||||
aria-label={lastLabel}
|
||||
>
|
||||
<SkipForward className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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`. */}
|
||||
<p
|
||||
className="text-muted-foreground truncate text-xs sm:hidden"
|
||||
aria-hidden="true"
|
||||
data-slot="wizard-stepper-caption"
|
||||
>
|
||||
{srLabel} · {activeLabel}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<MedicationPayload> = {},
|
||||
|
||||
@@ -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, string | number>) => 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<ScheduleDraft, "mode" | "cadence" | "subControls">;
|
||||
|
||||
/**
|
||||
* 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 | number>) => 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 | number>) => 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 | number>) => 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 | number>) => 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", {
|
||||
|
||||
@@ -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 = {}) {
|
||||
)
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{entry.tags.length > 0 ? entry.tags.join(", ") : "-"}
|
||||
<TableCell className="text-muted-foreground max-w-[18rem] text-sm">
|
||||
<span className="block truncate">
|
||||
{entry.tags.length > 0 ? entry.tags.join(", ") : "-"}
|
||||
</span>
|
||||
{entry.note && (
|
||||
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-muted-foreground/80 mt-0.5 line-clamp-2 cursor-default text-xs italic">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-xs whitespace-pre-wrap">
|
||||
{entry.note}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -59,6 +59,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
disableCoach,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
|
||||
disableCoach: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,11 +131,6 @@ describe("queryKeys factory", () => {
|
||||
"med1",
|
||||
"compliance",
|
||||
]);
|
||||
expect(queryKeys.medicationTitration("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
"titration",
|
||||
]);
|
||||
expect(queryKeys.medicationCadence("med1")).toEqual([
|
||||
"medications",
|
||||
"med1",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+153
-206
@@ -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<readonly [number, number]> =
|
||||
[
|
||||
[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<number, number>();
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
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<string, unknown>;
|
||||
};
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -93,6 +93,7 @@ function buildUser(disableCoach: boolean): AuthUser {
|
||||
disableCoach,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: null,
|
||||
insuranceNumber: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,18 @@ import type {
|
||||
FhirObservation,
|
||||
FhirMedicationStatement,
|
||||
FhirPatient,
|
||||
FhirCoverage,
|
||||
FhirDiagnosticReport,
|
||||
} from "../types";
|
||||
|
||||
function coverageOf(
|
||||
bundle: ReturnType<typeof buildFhirDocumentBundle>,
|
||||
): FhirCoverage | undefined {
|
||||
return bundle.entry
|
||||
.map((e) => e.resource)
|
||||
.find((r): r is FhirCoverage => r.resourceType === "Coverage");
|
||||
}
|
||||
|
||||
function observationsOf(bundle: ReturnType<typeof buildFhirDocumentBundle>) {
|
||||
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(),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CorrelationKey, MoodMetricCorrelation>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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<string, string> =>
|
||||
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);
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 `<TrendAnnotation>` 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user