diff --git a/CHANGELOG.md b/CHANGELOG.md index b9dd9de30..d72c050b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [1.9.0] — 2026-06-02 — Insights time-ranges, deeper mood, medication coding, stability + +### Added + +- **A time range you can choose on the Insights pages.** Each metric now carries range pills (week, month, quarter, year) and shows how the period compares with the one before it — the change in your average, stated plainly with its direction — so a trend reads at a glance instead of by eye. +- **Deeper mood insights.** The mood page now surfaces your time-of-day pattern (when your mood tends to sit highest and lowest), a stability read, and correlation cards that line mood up against weight and blood pressure — each shown only when there are enough paired days to mean something. +- **Standard drug codes on a medication.** A medication can carry an ATC and an RxNorm code (entered, never guessed). These flow into the health-record export: the exported `MedicationStatement` now codes the drug with the WHO ATC system and RxNorm alongside the plain name, and the export adds a `MedicationAdministration` for every dose you actually took or skipped, so a clinician's system can read the record without relying on free text. +- **A deploy-verification script** (`scripts/assert-deploy.ts`) that checks a target reports the expected version after a release. + +### Changed + +- **The medication advanced-settings page is rebuilt.** Import, intake-import and export sit in their own group, the external-API endpoints are listed one by one with collapsible request examples, and the layout is tidied so the technical settings read clearly instead of crowding together. +- **Targets are edited inline where they belong.** The separate `/targets` page is retired — you set a metric's target range from the metric itself, with no detour to a standalone editor. +- **The health-record export emits insurer coverage from a bare insurance number**, not only when a full insurer organisation is present, and carries a top-level narrative summary. +- **Notification settings wording** is tightened for clarity. + +### Fixed + +- **The app stays responsive under load.** The database connection pool was sized far too small by default and could starve while a background insight warm and a foreground request competed, which showed up as the occasional "server not responding" that recovered on its own; the pool is now sized for real concurrency and tunable for larger self-hosts. +- **A background insight warm no longer blocks a page**, and a burst of Apple Health / Withings sync no longer triggers a storm of regenerations — the warm is decoupled and the invalidation is debounced. +- **The medication card rows stay aligned.** "Last dose" and "next dose" could break onto different heights and look misaligned; the rows now hold an equal height. +- **Glucose entered in mmol/L converts correctly** in the editor instead of being stored against the wrong unit. +- **The admin and global layout no longer shifts** when a scrollbar appears, by reserving the gutter. +- **The legacy status endpoints are documented** — six older `*-status` routes were missing from the API contract and are now included. + ## [1.8.7.1] — 2026-06-02 — Assessments for every HealthKit metric, one-tap pre-generation ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2e4cf533e..7db80f1fe 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.8.7.1 + version: 1.9.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -30,6 +30,7 @@ tags: - name: Measurements - name: Medications - name: Mood + - name: Analytics - name: Insights - name: Dashboard - name: Notifications @@ -1954,6 +1955,254 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/analytics/range: + get: + tags: + - Analytics + summary: Single-metric period-over-period range delta + description: v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for + ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is + single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — + no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer. + parameters: + - in: query + name: type + schema: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." + required: true + description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." + - in: query + name: range + schema: + type: string + enum: + - 7d + - 30d + - 90d + - 1y + description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span + immediately before it." + required: true + description: "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span + immediately before it." + responses: + "200": + description: Current + previous window aggregates and the delta. + content: + application/json: + schema: + $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/blood-pressure-status: + get: + tags: + - Insights + summary: Blood-pressure assessment + description: "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache + miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via + cookie or Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/pulse-status: + get: + tags: + - Insights + summary: Pulse assessment + description: "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss + warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie + or Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/PulseStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/weight-status: + get: + tags: + - Insights + summary: Weight assessment + description: "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or + Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/WeightStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/bmi-status: + get: + tags: + - Insights + summary: BMI assessment + description: "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or + Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BmiStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/mood-status: + get: + tags: + - Insights + summary: Mood assessment + description: "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a + generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or + Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/MoodStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 + /api/insights/medication-compliance-status: + get: + tags: + - Insights + summary: Medication-compliance assessment + description: "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a + per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good + envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer." + parameters: + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Compliance assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 /api/insights/metric-status: get: tags: @@ -2301,6 +2550,23 @@ components: description: iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it. type: boolean + atcCode: + description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched. + anyOf: + - type: string + pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$ + - type: "null" + rxNormCode: + description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under + `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. + NULL clears it; absent leaves it untouched. + anyOf: + - type: string + maxLength: 20 + pattern: ^\d+$ + - type: "null" startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -2503,6 +2769,23 @@ components: description: iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. type: boolean + atcCode: + description: Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched. + anyOf: + - type: string + pattern: ^[A-Z]\d{2}[A-Z]{2}\d{2}$ + - type: "null" + rxNormCode: + description: Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under + `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. + NULL clears it; absent leaves it untouched. + anyOf: + - type: string + maxLength: 20 + pattern: ^\d+$ + - type: "null" startsOn: description: Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). @@ -4108,6 +4391,19 @@ components: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. + atcCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL = no code captured. + rxNormCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under + `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. pausedAt: anyOf: - type: string @@ -4186,6 +4482,8 @@ components: - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled + - atcCode + - rxNormCode - pausedAt - snoozedUntil - nextDueAt @@ -4268,6 +4566,19 @@ components: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. + atcCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL = no code captured. + rxNormCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under + `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. pausedAt: anyOf: - type: string @@ -4333,6 +4644,8 @@ components: - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled + - atcCode + - rxNormCode - pausedAt - snoozedUntil - nextDueAt @@ -6334,6 +6647,305 @@ components: additionalProperties: false description: Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes. + AnalyticsRangeResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/AnalyticsRangeResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AnalyticsRangeResponse: + type: object + properties: + range: + type: string + enum: + - 7d + - 30d + - 90d + - 1y + description: The range that was read (echoes the request). + windowDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Trailing-window length in days for the chosen range. + granularity: + type: string + description: Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage + miss). + current: + description: Aggregate over the current window `[now-N, now)`. + $ref: "#/components/schemas/AnalyticsWindowAggregate" + previous: + description: Aggregate over the previous comparable window `[now-2N, now-N)`. + $ref: "#/components/schemas/AnalyticsWindowAggregate" + delta: + anyOf: + - type: number + - type: "null" + description: "`current.mean - previous.mean`; null when either window has no data (never a misleading 0)." + deltaPct: + anyOf: + - type: number + - type: "null" + description: "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no + divide-by-zero). The client shows 'no prior-period data' in that case." + required: + - range + - windowDays + - granularity + - current + - previous + - delta + - deltaPct + additionalProperties: false + description: Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY + rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across + buckets; SD/slope/r² are intentionally excluded (not composable). + AnalyticsWindowAggregate: + type: object + properties: + count: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Reading count composed across buckets. + min: + anyOf: + - type: number + - type: "null" + description: Window minimum; null when empty. + max: + anyOf: + - type: number + - type: "null" + description: Window maximum; null when empty. + mean: + anyOf: + - type: number + - type: "null" + description: Count-weighted mean across buckets; null when empty. + sum: + anyOf: + - type: number + - type: "null" + description: Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum. + required: + - count + - min + - max + - mean + - sum + additionalProperties: false + BloodPressureStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightStatusResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + text: + anyOf: + - type: string + - type: "null" + description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is + preparing. + cached: + type: boolean + description: True when `text` is served from cache (incl. last-good). + updatedAt: + 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" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls + until it lands. + type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the + warmed assessment without a remount. + type: boolean + required: + - hasProvider + - text + - cached + - updatedAt + additionalProperties: false + description: "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to + the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + PulseStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + WeightStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BmiStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MoodStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationComplianceStatusResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationComplianceStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationComplianceStatusResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `summary` then carries the generic no-key guidance. + summary: + anyOf: + - type: string + - type: "null" + description: The overall compliance narrative (plain text). Null while a first generation is preparing. + medications: + type: array + items: + $ref: "#/components/schemas/MedicationComplianceStatusItem" + description: Per-medication compliance notes. Empty while preparing or when no medication qualifies. + cached: + type: boolean + description: True when the envelope is served from cache (incl. last-good). + updatedAt: + 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" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior summary exists yet — the client + polls until it lands. + type: boolean + revalidating: + description: True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is + in flight. The client keeps polling on `preparing || revalidating` (bounded). + type: boolean + required: + - hasProvider + - summary + - medications + - cached + - updatedAt + additionalProperties: false + description: Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` + plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate. + MedicationComplianceStatusItem: + type: object + properties: + medicationId: + type: string + description: The medication this note belongs to. + text: + type: string + description: Per-medication compliance note (plain text). + required: + - medicationId + - text + additionalProperties: false MetricStatusResponseEnvelope: type: object properties: @@ -6377,6 +6989,11 @@ components: description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands. type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) + so the open card upgrades to the warmed assessment without a remount. + type: boolean insufficient: description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state. @@ -6616,6 +7233,19 @@ components: type: boolean description: v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only. + atcCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). + User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under + `http://www.whocc.no/atc`. NULL = no code captured. + rxNormCode: + anyOf: + - type: string + - type: "null" + description: v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under + `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured. pausedAt: anyOf: - type: string @@ -6679,6 +7309,8 @@ components: - notificationsEnabled - liveActivityEnabled - criticalAlarmEnabled + - atcCode + - rxNormCode - pausedAt - snoozedUntil - nextDueAt diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index f5bfb0082..e22a1d7f7 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -59,3 +59,20 @@ roll back: dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, commit, and re-deploy. Never `migrate resolve --rolled-back` against production unless you've taken a fresh `pg_dump` first. + +## Verify the served version + +The queued-deploy status is **not** the source of truth — the version +`/api/version` actually answers is. A BuildKit layer-cache hit can +re-ship the prior image even when Coolify reports a clean deploy, so +always confirm the served build after a deploy: + +```sh +# Asserts prod + demo serve 1.9.0, prints each target's buildSha, +# exits non-zero on a mismatch or timeout after a bounded retry loop. +pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 +``` + +Pass `--only=prod` / `--only=demo` to check a single target, or +`--attempts=N --interval=ms` to widen the retry window while a slow +pull settles. diff --git a/messages/de.json b/messages/de.json index b8f1a6c9f..32dd32d17 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1214,6 +1214,14 @@ "unit": "Minuten", "saved": "Erinnerungsfenster gespeichert", "failed": "Erinnerungsfenster konnte nicht gespeichert werden." + }, + "codes": { + "label": "Klinische Codes (ATC / RxNorm)", + "note": "Optional. Werden beim FHIR-Export der Gesundheitsakte verwendet. Codes werden genau wie eingegeben übernommen — nie geraten.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Klinische Codes gespeichert", + "failed": "Klinische Codes konnten nicht gespeichert werden." } }, "zone": { @@ -1278,7 +1286,8 @@ "group": { "data": "Daten", "reminders": "Erinnerungen", - "lifecycle": "Lebenszyklus" + "lifecycle": "Lebenszyklus", + "externalApi": "Externe API" }, "dataPortability": { "import": { @@ -1601,6 +1610,23 @@ "confidenceLow": "Geringe Konfidenz", "pendingLabel": "Trend-Kommentar wird generiert" }, + "range": { + "label": "Zeitraum", + "noPriorData": "keine Daten im Vorzeitraum", + "option": { + "7d": "7T", + "30d": "30T", + "90d": "90T", + "1y": "1J" + }, + "priorLabel": { + "7d": "ggü. vorherigen 7T", + "30d": "ggü. vorherigen 30T", + "90d": "ggü. vorherigen 90T", + "1y": "ggü. Vorjahr" + }, + "noChange": "keine Änderung" + }, "correlationRow": { "title": "Zusammenhänge", "subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05", @@ -2396,7 +2422,11 @@ "strengthNone": "Keine", "source": "{n} gepaarte Tage · r = {r}", "emptyTitle": "Noch nicht genug gepaarte Daten", - "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen." + "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.", + "weightTitle": "Stimmung vs. Gewicht", + "bloodPressureTitle": "Stimmung vs. Blutdruck", + "weightAxis": "Gewicht", + "bloodPressureAxis": "Systolisch (mmHg)" }, "structuredTagsTitle": "Tag-Auswertung", "inTargetValue": "{pct} %", @@ -2412,7 +2442,23 @@ "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." + "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so.", + "timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}." + }, + "timeOfDay": { + "title": "Stimmung nach Tageszeit", + "morning": "Morgen", + "afternoon": "Nachmittag", + "evening": "Abend", + "night": "Nacht", + "bestCaption": "Am {bucket} fühlst du dich tendenziell am besten." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Deine Stimmung war sehr gleichmäßig", + "bandSteady": "Deine Stimmung war recht gleichmäßig", + "bandVariable": "Deine Stimmung war merklich schwankend", + "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt" } } }, @@ -2626,7 +2672,7 @@ "description": "Withings, moodLog und weitere verbundene Dienste." }, "notifications": { - "title": "Benachrichtigungs-Kanäle", + "title": "Benachrichtigungen", "description": "Live-Übersicht aller konfigurierten Kanäle." }, "dashboard": { @@ -3994,12 +4040,6 @@ } }, "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": { @@ -4021,23 +4061,11 @@ "sixDaysAgo": "vor 6 Tagen" } }, - "card": { - "lastMet": "Zuletzt im Ziel: {when}", - "streak": "{count} Tage in Folge" - }, "relativeDay": { "today": "heute", "yesterday": "gestern", "daysAgo": "vor {count} Tagen" }, - "summary": { - "weekTitle": "{met} von {total} Zielwerten diese Woche erreicht", - "weekTitleAll": "Alle {total} Zielwerte diese Woche erreicht", - "streakHighlight": "{count} Tage in Folge: {metric}" - }, - "coach": { - "cta": "Coach fragen" - }, "edit": { "openLabel": "Zielbereich für {metric} bearbeiten", "title": "Zielbereich bearbeiten — {metric}", @@ -4069,31 +4097,10 @@ "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", "diastolic": "Diastolisch", - "noMeasurementYet": "Noch keine Messungen", - "noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.", - "profileIncomplete": "Profil unvollständig", - "profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.", - "profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.", - "profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.", - "compliancePerMedication": "Einnahmetreue pro Medikament", "glucoseFasting": "Glukose — nüchtern", "glucosePostprandial": "Glukose — postprandial", "glucoseRandom": "Glukose — beliebig", "glucoseBedtime": "Glukose — vor dem Schlafen", - "loadError": "Zielwerte konnten nicht geladen werden", - "label": { - "WEIGHT": "Gewicht", - "BLOOD_PRESSURE": "Blutdruck", - "BLOOD_PRESSURE_IN_TARGET": "Blutdruck im Zielbereich", - "PULSE": "Ruhepuls", - "SLEEP_DURATION": "Schlafdauer", - "BMI": "BMI", - "BODY_FAT": "Körperfett", - "ACTIVITY_STEPS": "Schritte/Tag", - "MEDICATION_COMPLIANCE": "Einnahmetreue", - "MOOD_SCORE": "Stimmung", - "MOOD_STABILITY": "Stimmungsstabilität" - }, "status": { "underweight": "Untergewicht", "normal": "Normal", diff --git a/messages/en.json b/messages/en.json index ca23e5853..07974000f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Clinical codes (ATC / RxNorm)", + "note": "Optional. Used on the FHIR health-record export. Codes are taken exactly as entered — never guessed.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Clinical codes saved", + "failed": "Could not save the clinical codes." } }, "zone": { @@ -1278,7 +1286,8 @@ "group": { "data": "Data", "reminders": "Reminders", - "lifecycle": "Lifecycle" + "lifecycle": "Lifecycle", + "externalApi": "External API" }, "dataPortability": { "import": { @@ -1601,6 +1610,23 @@ "confidenceLow": "Low confidence", "pendingLabel": "Generating trend commentary" }, + "range": { + "label": "Time range", + "noPriorData": "no prior-period data", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1y" + }, + "priorLabel": { + "7d": "vs prior 7d", + "30d": "vs prior 30d", + "90d": "vs prior 90d", + "1y": "vs prior year" + }, + "noChange": "no change" + }, "correlationRow": { "title": "Relationships", "subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05", @@ -2396,7 +2422,11 @@ "strengthNone": "None", "source": "{n} paired days · r = {r}", "emptyTitle": "Not enough paired data yet", - "emptyDescription": "Log mood and this metric on the same days to see a correlation." + "emptyDescription": "Log mood and this metric on the same days to see a correlation.", + "weightTitle": "Mood vs. weight", + "bloodPressureTitle": "Mood vs. blood pressure", + "weightAxis": "Weight", + "bloodPressureAxis": "Systolic (mmHg)" }, "structuredTagsTitle": "Tag breakdown", "inTargetValue": "{pct}%", @@ -2412,7 +2442,23 @@ "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." + "streak": "You've logged your mood {days} days in a row — keep it up.", + "timeOfDay": "You feel best in the {bucket}, averaging {value}." + }, + "timeOfDay": { + "title": "Mood by time of day", + "morning": "Morning", + "afternoon": "Afternoon", + "evening": "Evening", + "night": "Night", + "bestCaption": "You tend to feel best in the {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Your mood has been very steady", + "bandSteady": "Your mood has been fairly steady", + "bandVariable": "Your mood has been noticeably variable", + "bandVeryVariable": "Your mood has swung a lot lately" } } }, @@ -2626,7 +2672,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Notification channels", + "title": "Notifications", "description": "Live overview of every configured channel." }, "dashboard": { @@ -3994,12 +4040,6 @@ } }, "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": { @@ -4021,23 +4061,11 @@ "sixDaysAgo": "6 days ago" } }, - "card": { - "lastMet": "Last met goal: {when}", - "streak": "{count}-day streak" - }, "relativeDay": { "today": "today", "yesterday": "yesterday", "daysAgo": "{count} days ago" }, - "summary": { - "weekTitle": "{met} of {total} targets met this week", - "weekTitleAll": "All {total} targets met this week", - "streakHighlight": "{count}-day streak: {metric}" - }, - "coach": { - "cta": "Ask Coach" - }, "edit": { "openLabel": "Edit target range for {metric}", "title": "Edit target range — {metric}", @@ -4069,31 +4097,10 @@ "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", "diastolic": "Diastolic", - "noMeasurementYet": "No measurements yet", - "noMeasurementData": "No measurement data available. Add some measurements to see your targets.", - "profileIncomplete": "Profile incomplete", - "profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.", - "profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.", - "profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.", - "compliancePerMedication": "Compliance per medication", "glucoseFasting": "Glucose — fasting", "glucosePostprandial": "Glucose — post-meal", "glucoseRandom": "Glucose — random", "glucoseBedtime": "Glucose — bedtime", - "loadError": "Failed to load targets", - "label": { - "WEIGHT": "Weight", - "BLOOD_PRESSURE": "Blood pressure", - "BLOOD_PRESSURE_IN_TARGET": "Blood pressure on target", - "PULSE": "Resting pulse", - "SLEEP_DURATION": "Sleep duration", - "BMI": "BMI", - "BODY_FAT": "Body fat", - "ACTIVITY_STEPS": "Steps/day", - "MEDICATION_COMPLIANCE": "Medication compliance", - "MOOD_SCORE": "Mood", - "MOOD_STABILITY": "Mood stability" - }, "status": { "underweight": "Underweight", "normal": "Normal", diff --git a/messages/es.json b/messages/es.json index b34fa799b..ce163e91d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Códigos clínicos (ATC / RxNorm)", + "note": "Opcional. Se usan en la exportación FHIR del historial de salud. Los códigos se toman tal cual se introducen — nunca se adivinan.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Códigos clínicos guardados", + "failed": "No se pudieron guardar los códigos clínicos." } }, "zone": { @@ -1278,7 +1286,8 @@ "group": { "data": "Datos", "reminders": "Recordatorios", - "lifecycle": "Ciclo de vida" + "lifecycle": "Ciclo de vida", + "externalApi": "API externa" }, "dataPortability": { "import": { @@ -1601,6 +1610,23 @@ "confidenceLow": "Confianza baja", "pendingLabel": "Generando comentario de tendencia" }, + "range": { + "label": "Período", + "noPriorData": "sin datos del período anterior", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1a" + }, + "priorLabel": { + "7d": "vs 7d anteriores", + "30d": "vs 30d anteriores", + "90d": "vs 90d anteriores", + "1y": "vs año anterior" + }, + "noChange": "sin cambios" + }, "correlationRow": { "title": "Correlaciones", "subtitle": "Hipótesis predefinidas, con umbral n ≥ 14 y p < 0,05", @@ -2396,7 +2422,11 @@ "strengthNone": "Ninguna", "source": "{n} días emparejados · r = {r}", "emptyTitle": "Aún no hay suficientes datos emparejados", - "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación." + "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.", + "weightTitle": "Ánimo vs. peso", + "bloodPressureTitle": "Ánimo vs. presión arterial", + "weightAxis": "Peso", + "bloodPressureAxis": "Sistólica (mmHg)" }, "structuredTagsTitle": "Desglose de etiquetas", "inTargetValue": "{pct} %", @@ -2412,7 +2442,23 @@ "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í." + "streak": "Has registrado tu ánimo {days} días seguidos: sigue así.", + "timeOfDay": "Te sientes mejor por la {bucket}, con una media de {value}." + }, + "timeOfDay": { + "title": "Estado de ánimo por hora del día", + "morning": "Mañana", + "afternoon": "Tarde", + "evening": "Noche", + "night": "Madrugada", + "bestCaption": "Sueles sentirte mejor por la {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Tu estado de ánimo ha sido muy estable", + "bandSteady": "Tu estado de ánimo ha sido bastante estable", + "bandVariable": "Tu estado de ánimo ha sido notablemente variable", + "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente" } } }, @@ -2626,7 +2672,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Canales de notificación", + "title": "Notificaciones", "description": "Live overview of every configured channel." }, "dashboard": { @@ -3994,12 +4040,6 @@ } }, "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": { @@ -4021,23 +4061,11 @@ "sixDaysAgo": "hace 6 días" } }, - "card": { - "lastMet": "Última vez en objetivo: {when}", - "streak": "{count} días seguidos" - }, "relativeDay": { "today": "Hoy", "yesterday": "Ayer", "daysAgo": "hace {count} días" }, - "summary": { - "weekTitle": "{met} de {total} objetivos cumplidos esta semana", - "weekTitleAll": "Todo{total} targets met this week", - "streakHighlight": "{count} días seguidos: {metric}" - }, - "coach": { - "cta": "Preguntar al coach" - }, "edit": { "openLabel": "Editar el rango objetivo de {metric}", "title": "Editar rango objetivo — {metric}", @@ -4069,31 +4097,10 @@ "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", "diastolic": "Diastólica", - "noMeasurementYet": "Aún no hay mediciones", - "noMeasurementData": "No hay datos de mediciones. Añade mediciones para ver tus objetivos.", - "profileIncomplete": "Perfil incompleto", - "profileIncompleteHeight": "Indica tu altura en el perfil para activar los objetivos de IMC.", - "profileIncompleteAge": "Indica tu fecha de nacimiento en el perfil para activar los objetivos por edad.", - "profileIncompleteHeightAge": "Indica altura y fecha de nacimiento en el perfil para ver objetivos personalizados.", - "compliancePerMedication": "Cumplimiento por medicamento", "glucoseFasting": "Glucemia — en ayunas", "glucosePostprandial": "Glucemia — posprandial", "glucoseRandom": "Glucemia — cualquiera", "glucoseBedtime": "Glucemia — antes de dormir", - "loadError": "No se han podido cargar los valores objetivo", - "label": { - "WEIGHT": "Peso", - "BLOOD_PRESSURE": "Presión arterial", - "BLOOD_PRESSURE_IN_TARGET": "Tensión arterial en rango", - "PULSE": "Pulso en reposo", - "SLEEP_DURATION": "Duración del sueño", - "BMI": "IMC", - "BODY_FAT": "Grasa corporal", - "ACTIVITY_STEPS": "Pasos/día", - "MEDICATION_COMPLIANCE": "Cumplimiento", - "MOOD_SCORE": "Estado de ánimo", - "MOOD_STABILITY": "Estabilidad del ánimo" - }, "status": { "underweight": "Peso insuficiente", "normal": "Normal", diff --git a/messages/fr.json b/messages/fr.json index cf4ceb691..07d74a4ce 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Codes cliniques (ATC / RxNorm)", + "note": "Facultatif. Utilisés lors de l'export FHIR du dossier de santé. Les codes sont repris tels qu'ils sont saisis — jamais devinés.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Codes cliniques enregistrés", + "failed": "Impossible d'enregistrer les codes cliniques." } }, "zone": { @@ -1278,7 +1286,8 @@ "group": { "data": "Données", "reminders": "Rappels", - "lifecycle": "Cycle de vie" + "lifecycle": "Cycle de vie", + "externalApi": "API externe" }, "dataPortability": { "import": { @@ -1601,6 +1610,23 @@ "confidenceLow": "Confiance faible", "pendingLabel": "Commentaire de tendance en cours de génération" }, + "range": { + "label": "Période", + "noPriorData": "aucune donnée sur la période précédente", + "option": { + "7d": "7j", + "30d": "30j", + "90d": "90j", + "1y": "1an" + }, + "priorLabel": { + "7d": "vs 7j précédents", + "30d": "vs 30j précédents", + "90d": "vs 90j précédents", + "1y": "vs année précédente" + }, + "noChange": "aucun changement" + }, "correlationRow": { "title": "Corrélations", "subtitle": "Hypothèses prédéfinies, validées si n ≥ 14 et p < 0,05", @@ -2396,7 +2422,11 @@ "strengthNone": "Aucune", "source": "{n} jours appariés · r = {r}", "emptyTitle": "Pas encore assez de données appariées", - "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation." + "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.", + "weightTitle": "Humeur vs. poids", + "bloodPressureTitle": "Humeur vs. tension artérielle", + "weightAxis": "Poids", + "bloodPressureAxis": "Systolique (mmHg)" }, "structuredTagsTitle": "Répartition des étiquettes", "inTargetValue": "{pct} %", @@ -2412,7 +2442,23 @@ "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." + "streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez.", + "timeOfDay": "Vous vous sentez mieux le {bucket}, en moyenne {value}." + }, + "timeOfDay": { + "title": "Humeur selon l'heure de la journée", + "morning": "Matin", + "afternoon": "Après-midi", + "evening": "Soir", + "night": "Nuit", + "bestCaption": "Vous vous sentez généralement mieux le {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Votre humeur a été très stable", + "bandSteady": "Votre humeur a été plutôt stable", + "bandVariable": "Votre humeur a été nettement variable", + "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment" } } }, @@ -2626,7 +2672,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Canaux de notification", + "title": "Notifications", "description": "Live overview of every configured channel." }, "dashboard": { @@ -3994,12 +4040,6 @@ } }, "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": { @@ -4021,23 +4061,11 @@ "sixDaysAgo": "il y a 6 jours" } }, - "card": { - "lastMet": "Dernier objectif atteint : {when}", - "streak": "{count} jours d’affilée" - }, "relativeDay": { "today": "Aujourd'hui", "yesterday": "Hier", "daysAgo": "il y a {count} jours" }, - "summary": { - "weekTitle": "{met} cibles sur {total} atteintes cette semaine", - "weekTitleAll": "Tout{total} targets met this week", - "streakHighlight": "{count} jours d’affilée : {metric}" - }, - "coach": { - "cta": "Demander au coach" - }, "edit": { "openLabel": "Modifier la plage cible de {metric}", "title": "Modifier la plage cible — {metric}", @@ -4069,31 +4097,10 @@ "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", "diastolic": "Diastolique", - "noMeasurementYet": "Pas encore de mesures", - "noMeasurementData": "Pas de données de mesure. Ajoute des mesures pour voir tes objectifs.", - "profileIncomplete": "Profil incomplet", - "profileIncompleteHeight": "Renseigne ta taille dans le profil pour activer les objectifs IMC.", - "profileIncompleteAge": "Renseigne ta date de naissance dans le profil pour activer les objectifs liés à l’âge.", - "profileIncompleteHeightAge": "Renseigne taille et date de naissance dans le profil pour voir des objectifs personnalisés.", - "compliancePerMedication": "Observance par médicament", "glucoseFasting": "Glycémie — à jeun", "glucosePostprandial": "Glycémie — postprandiale", "glucoseRandom": "Glycémie — indifférente", "glucoseBedtime": "Glycémie — avant le coucher", - "loadError": "Impossible de charger les valeurs cibles", - "label": { - "WEIGHT": "Poids", - "BLOOD_PRESSURE": "Tension artérielle", - "BLOOD_PRESSURE_IN_TARGET": "Tension dans la cible", - "PULSE": "Pouls au repos", - "SLEEP_DURATION": "Durée du sommeil", - "BMI": "IMC", - "BODY_FAT": "Masse grasse", - "ACTIVITY_STEPS": "Pas/jour", - "MEDICATION_COMPLIANCE": "Observance", - "MOOD_SCORE": "Humeur", - "MOOD_STABILITY": "Stabilité de l’humeur" - }, "status": { "underweight": "Insuffisance pondérale", "normal": "Normal", diff --git a/messages/it.json b/messages/it.json index 175434875..59c1d98bb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Codici clinici (ATC / RxNorm)", + "note": "Facoltativo. Usati nell'esportazione FHIR della cartella sanitaria. I codici vengono presi esattamente come inseriti — mai indovinati.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Codici clinici salvati", + "failed": "Impossibile salvare i codici clinici." } }, "zone": { @@ -1278,7 +1286,8 @@ "group": { "data": "Dati", "reminders": "Promemoria", - "lifecycle": "Ciclo di vita" + "lifecycle": "Ciclo di vita", + "externalApi": "API esterna" }, "dataPortability": { "import": { @@ -1601,6 +1610,23 @@ "confidenceLow": "Confidenza bassa", "pendingLabel": "Commento di trend in generazione" }, + "range": { + "label": "Periodo", + "noPriorData": "nessun dato nel periodo precedente", + "option": { + "7d": "7g", + "30d": "30g", + "90d": "90g", + "1y": "1a" + }, + "priorLabel": { + "7d": "vs 7g precedenti", + "30d": "vs 30g precedenti", + "90d": "vs 90g precedenti", + "1y": "vs anno precedente" + }, + "noChange": "nessuna variazione" + }, "correlationRow": { "title": "Correlazioni", "subtitle": "Ipotesi predefinite, vincolate a n ≥ 14 e p < 0,05", @@ -2396,7 +2422,11 @@ "strengthNone": "Nessuna", "source": "{n} giorni appaiati · r = {r}", "emptyTitle": "Non ci sono ancora abbastanza dati appaiati", - "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione." + "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.", + "weightTitle": "Umore vs. peso", + "bloodPressureTitle": "Umore vs. pressione sanguigna", + "weightAxis": "Peso", + "bloodPressureAxis": "Sistolica (mmHg)" }, "structuredTagsTitle": "Analisi etichette", "inTargetValue": "{pct} %", @@ -2412,7 +2442,23 @@ "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ì." + "streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così.", + "timeOfDay": "Ti senti meglio di {bucket}, con una media di {value}." + }, + "timeOfDay": { + "title": "Umore per fascia oraria", + "morning": "Mattina", + "afternoon": "Pomeriggio", + "evening": "Sera", + "night": "Notte", + "bestCaption": "Tendi a sentirti meglio di {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Il tuo umore è stato molto stabile", + "bandSteady": "Il tuo umore è stato abbastanza stabile", + "bandVariable": "Il tuo umore è stato notevolmente variabile", + "bandVeryVariable": "Il tuo umore ha oscillato molto di recente" } } }, @@ -2626,7 +2672,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Canali di notifica", + "title": "Notifiche", "description": "Live overview of every configured channel." }, "dashboard": { @@ -3994,12 +4040,6 @@ } }, "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": { @@ -4021,23 +4061,11 @@ "sixDaysAgo": "6 giorni fa" } }, - "card": { - "lastMet": "Ultima volta in obiettivo: {when}", - "streak": "{count} giorni di fila" - }, "relativeDay": { "today": "Oggi", "yesterday": "Ieri", "daysAgo": "{count} giorni fa" }, - "summary": { - "weekTitle": "{met} di {total} obiettivi raggiunti questa settimana", - "weekTitleAll": "Tutto{total} targets met this week", - "streakHighlight": "{count} giorni di fila: {metric}" - }, - "coach": { - "cta": "Chiedi al coach" - }, "edit": { "openLabel": "Modifica il range obiettivo per {metric}", "title": "Modifica range obiettivo — {metric}", @@ -4069,31 +4097,10 @@ "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", "diastolic": "Diastolica", - "noMeasurementYet": "Ancora nessuna misurazione", - "noMeasurementData": "Nessun dato di misurazione. Aggiungi misurazioni per vedere gli obiettivi.", - "profileIncomplete": "Profilo incompleto", - "profileIncompleteHeight": "Inserisci la tua altezza nel profilo per attivare gli obiettivi BMI.", - "profileIncompleteAge": "Inserisci la tua data di nascita nel profilo per attivare gli obiettivi legati all’età.", - "profileIncompleteHeightAge": "Inserisci altezza e data di nascita nel profilo per vedere obiettivi personalizzati.", - "compliancePerMedication": "Aderenza per farmaco", "glucoseFasting": "Glicemia — a digiuno", "glucosePostprandial": "Glicemia — postprandiale", "glucoseRandom": "Glicemia — qualsiasi", "glucoseBedtime": "Glicemia — prima di coricarsi", - "loadError": "Impossibile caricare i valori obiettivo", - "label": { - "WEIGHT": "Peso", - "BLOOD_PRESSURE": "Pressione arteriosa", - "BLOOD_PRESSURE_IN_TARGET": "Pressione nel range", - "PULSE": "Pulsazioni a riposo", - "SLEEP_DURATION": "Durata del sonno", - "BMI": "IMC", - "BODY_FAT": "Massa grassa", - "ACTIVITY_STEPS": "Passi/giorno", - "MEDICATION_COMPLIANCE": "Aderenza", - "MOOD_SCORE": "Umore", - "MOOD_STABILITY": "Stabilità umore" - }, "status": { "underweight": "Sottopeso", "normal": "Normal", diff --git a/messages/pl.json b/messages/pl.json index 22b4b7bc5..3d5f69844 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1214,6 +1214,14 @@ "unit": "minutes", "saved": "Reminder window saved", "failed": "Could not save the reminder window." + }, + "codes": { + "label": "Kody kliniczne (ATC / RxNorm)", + "note": "Opcjonalne. Używane w eksporcie FHIR dokumentacji zdrowotnej. Kody są zapisywane dokładnie tak, jak wprowadzone — nigdy nie są zgadywane.", + "atcLabel": "ATC", + "rxNormLabel": "RxNorm", + "saved": "Kody kliniczne zapisane", + "failed": "Nie udało się zapisać kodów klinicznych." } }, "zone": { @@ -1278,7 +1286,8 @@ "group": { "data": "Dane", "reminders": "Przypomnienia", - "lifecycle": "Cykl życia" + "lifecycle": "Cykl życia", + "externalApi": "Zewnętrzne API" }, "dataPortability": { "import": { @@ -1601,6 +1610,23 @@ "confidenceLow": "Niska pewność", "pendingLabel": "Komentarz trendu jest generowany" }, + "range": { + "label": "Okres", + "noPriorData": "brak danych z poprzedniego okresu", + "option": { + "7d": "7d", + "30d": "30d", + "90d": "90d", + "1y": "1r" + }, + "priorLabel": { + "7d": "vs poprzednie 7d", + "30d": "vs poprzednie 30d", + "90d": "vs poprzednie 90d", + "1y": "vs poprzedni rok" + }, + "noChange": "bez zmian" + }, "correlationRow": { "title": "Korelacje", "subtitle": "Hipotezy zdefiniowane wcześniej, z progiem n ≥ 14 i p < 0,05", @@ -2396,7 +2422,11 @@ "strengthNone": "Brak", "source": "{n} sparowanych dni · r = {r}", "emptyTitle": "Za mało sparowanych danych", - "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację." + "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.", + "weightTitle": "Nastrój vs. waga", + "bloodPressureTitle": "Nastrój vs. ciśnienie krwi", + "weightAxis": "Waga", + "bloodPressureAxis": "Skurczowe (mmHg)" }, "structuredTagsTitle": "Podział tagów", "inTargetValue": "{pct}%", @@ -2412,7 +2442,23 @@ "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." + "streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj.", + "timeOfDay": "Najlepiej czujesz się {bucket}, średnio {value}." + }, + "timeOfDay": { + "title": "Nastrój według pory dnia", + "morning": "Rano", + "afternoon": "Popołudnie", + "evening": "Wieczór", + "night": "Noc", + "bestCaption": "Zwykle najlepiej czujesz się {bucket}." + }, + "stability": { + "value": "{score}/100", + "bandVerySteady": "Twój nastrój był bardzo stabilny", + "bandSteady": "Twój nastrój był dość stabilny", + "bandVariable": "Twój nastrój był zauważalnie zmienny", + "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał" } } }, @@ -2626,7 +2672,7 @@ "description": "Withings, moodLog, and other connected services." }, "notifications": { - "title": "Kanały powiadomień", + "title": "Powiadomienia", "description": "Live overview of every configured channel." }, "dashboard": { @@ -3994,12 +4040,6 @@ } }, "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": { @@ -4021,23 +4061,11 @@ "sixDaysAgo": "6 dni temu" } }, - "card": { - "lastMet": "Ostatnio w celu: {when}", - "streak": "{count} dni z rzędu" - }, "relativeDay": { "today": "Dzisiaj", "yesterday": "Wczoraj", "daysAgo": "{count} dni temu" }, - "summary": { - "weekTitle": "{met} z {total} celów osiągniętych w tym tygodniu", - "weekTitleAll": "Wszystko{total} targets met this week", - "streakHighlight": "{count} dni z rzędu: {metric}" - }, - "coach": { - "cta": "Zapytaj coacha" - }, "edit": { "openLabel": "Edytuj zakres docelowy dla {metric}", "title": "Edytuj zakres docelowy — {metric}", @@ -4069,31 +4097,10 @@ "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", "diastolic": "Rozkurczowe", - "noMeasurementYet": "Brak pomiarów", - "noMeasurementData": "Brak danych pomiarowych. Dodaj pomiary, aby zobaczyć cele.", - "profileIncomplete": "Profil niekompletny", - "profileIncompleteHeight": "Wpisz swój wzrost w profilu, aby aktywować cele BMI.", - "profileIncompleteAge": "Wpisz datę urodzenia w profilu, aby aktywować cele zależne od wieku.", - "profileIncompleteHeightAge": "Wpisz wzrost i datę urodzenia w profilu, aby zobaczyć spersonalizowane cele.", - "compliancePerMedication": "Przestrzeganie według leku", "glucoseFasting": "Glikemia — na czczo", "glucosePostprandial": "Glikemia — po posiłku", "glucoseRandom": "Glikemia — dowolna", "glucoseBedtime": "Glikemia — przed snem", - "loadError": "Nie udało się załadować wartości docelowych", - "label": { - "WEIGHT": "Waga", - "BLOOD_PRESSURE": "Ciśnienie krwi", - "BLOOD_PRESSURE_IN_TARGET": "Ciśnienie w zakresie", - "PULSE": "Tętno spoczynkowe", - "SLEEP_DURATION": "Czas snu", - "BMI": "BMI", - "BODY_FAT": "Tkanka tłuszczowa", - "ACTIVITY_STEPS": "Kroki/dzień", - "MEDICATION_COMPLIANCE": "Przestrzeganie", - "MOOD_SCORE": "Nastrój", - "MOOD_STABILITY": "Stabilność nastroju" - }, "status": { "underweight": "Niedowaga", "normal": "Normal", diff --git a/package.json b/package.json index 4d89906d8..3f99bd8ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.8.7.1", + "version": "1.9.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0103_v190_medication_drug_codes/migration.sql b/prisma/migrations/0103_v190_medication_drug_codes/migration.sql new file mode 100644 index 000000000..67c46d0cf --- /dev/null +++ b/prisma/migrations/0103_v190_medication_drug_codes/migration.sql @@ -0,0 +1,38 @@ +-- v1.9.0 — optional drug-classification codes on the medication row. +-- +-- Two additive, nullable, NULL-defaulted columns on `medications`: +-- +-- * `atc_code` — the WHO ATC active-substance class (e.g. "A10BX10"). +-- Emitted on the FHIR `MedicationStatement` / +-- `MedicationAdministration` `medicationCodeableConcept` +-- under the system URI `http://www.whocc.no/atc`. +-- * `rxnorm_code` — the RxNorm RxCUI (US identifier). Secondary coding +-- under `http://www.nlm.nih.gov/research/umls/rxnorm`. +-- +-- Both are user/clinician-asserted, never machine-guessed: a wrong code +-- is a clinical-safety problem, an absent code is merely a (conformant) +-- text-only concept. The application validates the format on write +-- (ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`) and 422s on a +-- malformed value; the DB column is a plain nullable TEXT field. +-- +-- No backfill — pre-v1.9.0 rows read NULL, which the exporter treats +-- identically to an absent value (the medicationCodeableConcept stays +-- text-only, the pre-v1.9.0 shape). +-- +-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only. +-- +-- Reversibility: +-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "atc_code"; +-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "rxnorm_code"; +-- A roll-back loses the captured codes; the FHIR export falls back to the +-- text-only medicationCodeableConcept and the application reads tolerate a +-- missing column / NULL value. +-- +-- No index — reads are per-user at request time (`WHERE user_id = $1`), +-- matching the other display-identity columns on the same table. + +ALTER TABLE "medications" + ADD COLUMN IF NOT EXISTS "atc_code" TEXT; + +ALTER TABLE "medications" + ADD COLUMN IF NOT EXISTS "rxnorm_code" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 971c85626..2881102b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -957,6 +957,18 @@ model Medication { /// stores the preference only — it does not itself authorise anything /// server-side. criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled") + /// v1.9.0 — optional ATC classification code (active-substance class, + /// e.g. `A10BX10`). User/clinician-asserted; never machine-guessed on + /// the export path. NULL = no code (the FHIR exporter emits a text-only + /// `medicationCodeableConcept`, the pre-v1.9.0 behaviour). The WHO URI + /// `http://www.whocc.no/atc` is the emitted system. Validated on write + /// against `^[A-Z]\d{2}[A-Z]{2}\d{2}$`. + atcCode String? @map("atc_code") + /// v1.9.0 — optional RxNorm RxCUI (US; secondary coding). Same NULL + /// semantics as `atcCode`. Validated on write against `^\d+$`. Emitted + /// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any + /// ATC code, never instead of the free-text `.text` anchor. + rxNormCode String? @map("rxnorm_code") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/scripts/assert-deploy.ts b/scripts/assert-deploy.ts new file mode 100644 index 000000000..ea1e677d7 --- /dev/null +++ b/scripts/assert-deploy.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env tsx +/** + * Post-deploy version assertion. + * + * Polls `/api/version` on production + demo and asserts the running + * build's `data.version` matches an expected value, printing each + * target's `buildSha`. Exits non-zero on a mismatch or after the retry + * window elapses — so a CI step or a manual deploy can fail loudly when + * the `:latest` pull served the prior image (the recurring BuildKit + * layer-cache pitfall where Coolify reports "deployed" while + * `/api/version` still answers the old number). + * + * Run it after every deploy — the queued-deploy status is not the source + * of truth, the served version is: + * + * pnpm dlx tsx scripts/assert-deploy.ts + * + * Examples: + * + * # assert both prod + demo serve 1.9.0 + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 + * + * # assert prod only + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --only=prod + * + * # widen the retry window (default 24 attempts × 5s = 2 min) + * pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --attempts=60 --interval=5000 + * + * Pure script — strips the leading `v` from the expected version so both + * `v1.9.0` and `1.9.0` work. No app-library import, no runtime wiring; + * the standalone image never ships this file. + */ + +interface Target { + name: string; + url: string; +} + +const TARGETS: Record = { + prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" }, + demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" }, +}; + +interface VersionPayload { + version?: string; + buildSha?: string | null; + builtAt?: string | null; +} + +function parseArgs(argv: string[]): { + expected: string; + attempts: number; + intervalMs: number; + only: string | null; +} { + const positional: string[] = []; + let attempts = 24; + let intervalMs = 5_000; + let only: string | null = null; + + for (const arg of argv) { + if (arg.startsWith("--attempts=")) { + attempts = Number.parseInt(arg.slice("--attempts=".length), 10); + } else if (arg.startsWith("--interval=")) { + intervalMs = Number.parseInt(arg.slice("--interval=".length), 10); + } else if (arg.startsWith("--only=")) { + only = arg.slice("--only=".length); + } else { + positional.push(arg); + } + } + + const expected = (positional[0] ?? "").trim().replace(/^v/, ""); + if (!expected) { + console.error( + "usage: pnpm dlx tsx scripts/assert-deploy.ts [--only=prod|demo] [--attempts=N] [--interval=ms]", + ); + process.exit(2); + } + if (!Number.isFinite(attempts) || attempts < 1) attempts = 24; + if (!Number.isFinite(intervalMs) || intervalMs < 0) intervalMs = 5_000; + + return { expected, attempts, intervalMs, only }; +} + +const sleep = (ms: number): Promise => + new Promise((res) => setTimeout(res, ms)); + +/** One probe. Returns the parsed payload or null on any transport / shape error. */ +async function probe(url: string): Promise { + try { + const res = await fetch(url, { + redirect: "manual", + signal: AbortSignal.timeout(10_000), + headers: { accept: "application/json" }, + }); + if (!res.ok) return null; + const body = (await res.json()) as { data?: VersionPayload } | null; + return body?.data ?? null; + } catch { + return null; + } +} + +/** + * Poll one target until its served version matches `expected` or the + * retry window elapses. Resolves true on a match, false otherwise. + */ +async function assertTarget( + target: Target, + expected: string, + attempts: number, + intervalMs: number, +): Promise { + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const payload = await probe(target.url); + const version = payload?.version?.trim() ?? null; + if (version === expected) { + console.log( + `[${target.name}] OK version=${version} buildSha=${payload?.buildSha ?? "—"}`, + ); + return true; + } + const seen = version ?? "unreachable"; + console.log( + `[${target.name}] attempt ${attempt}/${attempts}: expected ${expected}, got ${seen}`, + ); + if (attempt < attempts) await sleep(intervalMs); + } + console.error( + `[${target.name}] FAIL: ${target.url} did not report ${expected} within ${attempts} attempts`, + ); + return false; +} + +async function main(): Promise { + const { expected, attempts, intervalMs, only } = parseArgs( + process.argv.slice(2), + ); + + let selected: Target[]; + if (only) { + const picked = TARGETS[only]; + if (!picked) { + console.error(`unknown --only target: ${only} (use prod or demo)`); + process.exit(2); + } + selected = [picked]; + } else { + selected = Object.values(TARGETS); + } + + console.log( + `Asserting version ${expected} on: ${selected.map((t) => t.name).join(", ")}`, + ); + + // Probe targets in parallel; each runs its own bounded retry loop. + const results = await Promise.all( + selected.map((t) => assertTarget(t, expected, attempts, intervalMs)), + ); + + if (results.every((ok) => ok)) { + console.log("All targets serve the expected version."); + process.exit(0); + } + process.exit(1); +} + +void main(); diff --git a/src/app/__tests__/targets-coach-mount.test.tsx b/src/app/__tests__/targets-coach-mount.test.tsx deleted file mode 100644 index 5700b1695..000000000 --- a/src/app/__tests__/targets-coach-mount.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.25 W3e — Coach drawer mount + per-card CTA gate. - * - * Three scenarios: - * 1. AI provider configured → per-card "Ask Coach" CTAs render - * alongside each card; the drawer is mounted at the page level. - * 2. AI provider NOT configured → CTAs are completely absent (no - * broken-button state). - * 3. v1.4.37 W5 — operator's global Coach flag off → both the CTAs - * and the page-level drawer mount vanish. The flag default is - * all-on for fresh installs and on any fetch error, so the path - * stays open by default (mocked via `useFeatureFlags` below). - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -let coachFlag = true; -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => ({ - ...actual.DEFAULT_ASSISTANT_FLAGS, - coach: coachFlag, - }), - }; -}); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 78, - average30: 78, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: false, - consistency7d: ["in", "in", "in", "in", "in", "in", "in"], - }, - ], - pageSummary: { - targetsMetThisWeek: 1, - totalTargets: 1, - streakHighlight: null, - }, - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -let providerChainResponse: { activeProvider: string | null } | null = null; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: providerChainResponse, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render() { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — Coach drawer mount + CTA gate", () => { - beforeEach(() => { - coachFlag = true; - }); - - it("renders the per-card Coach CTA when an AI provider is configured", () => { - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain("Ask Coach"); - }); - - it("hides every per-card Coach CTA when no AI provider is configured", () => { - providerChainResponse = { activeProvider: null }; - const html = render(); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("renders nothing different when chainStatus query is still loading", () => { - providerChainResponse = null; - const html = render(); - // Same as the "no provider" branch — until we know the provider is - // configured we don't render a CTA whose click would 404. - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - // v1.4.37 W5 — Marc directive: when the operator turns off the - // global Coach flag, every Coach affordance on /targets must vanish. - // That is BOTH the per-card CTAs (currently gated through `aiEnabled`) - // AND the page-level mount — so the SSE machinery, - // Sheet portal and source-chip thread never load. - it("hides every per-card Coach CTA when the global Coach flag is off", () => { - coachFlag = false; - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - expect(html).not.toContain('data-slot="target-coach-cta"'); - }); - - it("does not mount the CoachDrawer when the global Coach flag is off", () => { - coachFlag = false; - providerChainResponse = { activeProvider: "openai" }; - const html = render(); - // The drawer renders a Sheet that the body markup exposes via a - // role + the i18n key it carries. We grep on the localised - // composer label that the drawer always paints. - expect(html).not.toContain('data-slot="coach-drawer"'); - expect(html).not.toContain('data-slot="coach-drawer-composer"'); - }); -}); diff --git a/src/app/__tests__/targets-i18n.test.tsx b/src/app/__tests__/targets-i18n.test.tsx deleted file mode 100644 index f24de103c..000000000 --- a/src/app/__tests__/targets-i18n.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page - * still surfaces English status labels even when the rest of the UI - * renders in German: "Low / On Target / Stable / Moderate" stayed in - * the source language. The card titles ("Weight", "Blood pressure", - * "Body fat" …) had the same issue — server returns English - * strings, the page renders them verbatim. - * - * Translate every classification category and card label on the - * client. The server keeps emitting stable English keys (so external - * consumers / logs don't churn); the page resolves them to i18n - * strings via `useTranslations()`. - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 88.2, - average30: 88.6, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - }, - { - type: "BLOOD_PRESSURE_IN_TARGET", - label: "Blood pressure on target", - current: 65, - average30: 65, - trend: null, - unit: "%", - range: { min: 70, max: 100 }, - classification: { category: "Moderate", color: "#f1fa8c" }, - source: "ESH 2023", - }, - { - type: "PULSE", - label: "Resting pulse", - current: 72, - average30: 72, - trend: "stable", - unit: "bpm", - range: { min: 60, max: 100 }, - classification: { category: "On target", color: "#50fa7b" }, - source: "AHA", - }, - { - type: "MOOD_STABILITY", - label: "Mood stability", - current: 0.6, - average30: 0.6, - trend: null, - unit: "σ", - range: { min: 0, max: 0.5 }, - classification: { category: "Stable", color: "#f1fa8c" }, - source: "moodLog", - }, - { - type: "MEDICATION_COMPLIANCE", - label: "Medication compliance", - current: 60, - average30: 60, - trend: null, - unit: "%", - range: { min: 90, max: 100 }, - classification: { category: "Low", color: "#ff5555" }, - source: "7-day", - }, - ], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - // v1.4.25 W3e — the targets page now also queries - // ["insights", "provider-chain"] to gate the per-card Coach - // CTA. Return null for that query (no AI provider configured) - // so the CTA hides and the existing test cases keep asserting - // the unchanged visual surface. - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: null, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly with the -// all-on default so the i18n assertions below run against the visible -// Coach surfaces. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — i18n status labels and titles", () => { - it("translates card titles to German when the locale is `de`", () => { - const html = render("de"); - expect(html).toContain("Gewicht"); // Weight - expect(html).toContain("Ruhepuls"); // Resting pulse - expect(html).toContain("Stimmungsstabilität"); // Mood stability - expect(html).toContain("Einnahmetreue"); // Medication compliance - // Server-emitted English labels must not leak. - expect(html).not.toContain("Resting pulse"); - expect(html).not.toContain("Mood stability"); - expect(html).not.toContain("Medication compliance"); - }); - - it("translates every status badge category to German", () => { - const html = render("de"); - // the maintainer explicitly named these in his bug report. - expect(html).not.toMatch(/>LowOn targetStableModerate { - const html = render("en"); - expect(html).toContain("Normal"); - expect(html).toContain("Moderate"); - expect(html).toContain("On target"); - expect(html).toContain("Stable"); - expect(html).toContain("Low"); - }); -}); diff --git a/src/app/__tests__/targets-responsive.test.tsx b/src/app/__tests__/targets-responsive.test.tsx deleted file mode 100644 index 6d016d1bb..000000000 --- a/src/app/__tests__/targets-responsive.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.25 W3e — mobile-first responsive layout audit. - * - * Marc directive: "mobile layout must be designed alongside desktop, - * not as an afterthought". This test pins the Tailwind responsive - * utilities so a future refactor cannot quietly collapse the grid - * back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col - * row. - * - * The three breakpoints we care about: - * • default (<640px): one column, full-width cards - * • sm (640-1023px): two columns - * • lg (1024px+): three columns, slightly wider gutters - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -const sampleData = { - targets: [ - { - type: "WEIGHT", - label: "Weight", - current: 78, - average30: 78, - trend: "stable", - unit: "kg", - range: { min: 60, max: 80 }, - classification: { category: "Normal", color: "#50fa7b" }, - source: "WHO BMI", - daysInRange7d: 5, - daysLogged7d: 7, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: false, - consistency7d: ["in", "in", "in", "in", "in", "in", "in"], - }, - ], - pageSummary: { - targetsMetThisWeek: 1, - totalTargets: 1, - streakHighlight: null, - }, - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, -}; - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: { activeProvider: "openai" }, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: sampleData, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly so the page -// runs with the all-on default (CTAs + drawer mount visible) the -// responsive assertions below pin. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — mobile-first responsive grid", () => { - it("uses one column by default, two at sm, three at lg", () => { - const html = render(); - expect(html).toMatch(/grid-cols-1/); - expect(html).toMatch(/sm:grid-cols-2/); - expect(html).toMatch(/lg:grid-cols-3/); - }); - - it("uses gap-4 at mobile/sm, gap-6 at lg", () => { - const html = render(); - expect(html).toMatch(/\bgap-4\b/); - expect(html).toMatch(/lg:gap-6/); - }); - - it("renders the Coach CTA as an icon-only affordance on every target card", () => { - const html = render(); - expect(html).toContain('data-slot="target-coach-cta"'); - expect(html).toContain('aria-label="Ask Coach"'); - expect(html).toContain("size-10"); - }); -}); diff --git a/src/app/__tests__/targets-spacing.test.tsx b/src/app/__tests__/targets-spacing.test.tsx deleted file mode 100644 index e0f097e0e..000000000 --- a/src/app/__tests__/targets-spacing.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -/** - * v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between - * the page header (h1 + intro) and the first card grid. At Pixel-5 - * the maintainer reported "relativ viel Platz" wasted between the overview - * and the first values. Tighten the rhythm to `space-y-6` (24px) - * which matches the rest of the admin / settings pages. - */ - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), - usePathname: () => "/targets", -})); - -vi.mock("@tanstack/react-query", () => { - const noopClient = { - invalidateQueries: vi.fn(), - setQueryData: vi.fn(), - getQueryData: vi.fn(), - refetchQueries: vi.fn(), - }; - return { - useQuery: ({ queryKey }: { queryKey: unknown[] }) => { - if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { - return { - data: null, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - } - return { - data: { - targets: [], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { - heightCm: null, - age: null, - gender: null, - glucoseUnit: null, - }, - }, - isLoading: false, - isError: false, - refetch: vi.fn(), - }; - }, - useQueryClient: () => noopClient, - useMutation: () => ({ - mutate: vi.fn(), - mutateAsync: vi.fn(), - isPending: false, - reset: vi.fn(), - }), - }; -}); - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - user: { - id: "u1", - username: "marc", - email: "marc@example.com", - role: "USER", - }, - isAuthenticated: true, - isLoading: false, - refetch: vi.fn(), - }), -})); - -// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate -// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext -// from `@tanstack/react-query`, and this test mocks the library -// without exposing that context. Mock the hook directly with the -// all-on default so the spacing assertions below run against the -// fully-painted page shape. -vi.mock("@/hooks/use-feature-flags", async () => { - const actual = await vi.importActual< - typeof import("@/hooks/use-feature-flags") - >("@/hooks/use-feature-flags"); - return { - ...actual, - useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS, - }; -}); - -import { I18nProvider } from "@/lib/i18n/context"; -import TargetsPage from "../targets/page"; - -function render(locale: "en" | "de" = "en") { - return renderToStaticMarkup( - - - , - ); -} - -describe("/targets page — vertical spacing", () => { - it("uses space-y-6 (not space-y-8) between header and card grid", () => { - const html = render(); - // Outer wrapper rhythm. `space-y-8` would leave ~32px of - // unnecessary breathing room between the intro paragraph and the - // first card; `space-y-6` (24px) brings it in line with the - // admin / settings pages. - expect(html).toMatch(/]*\bspace-y-6\b/); - expect(html).not.toMatch(/]*\bspace-y-8\b/); - }); -}); diff --git a/src/app/api/analytics/range/route.ts b/src/app/api/analytics/range/route.ts new file mode 100644 index 000000000..bebb0a292 --- /dev/null +++ b/src/app/api/analytics/range/route.ts @@ -0,0 +1,72 @@ +/** + * v1.9.0 — single-metric period-over-period range read. + * + * `GET /api/analytics/range?type=&range=7d|30d|90d|1y` + * returns the current-window aggregate, the previous comparable window, and + * the composed delta. Single metric type by construction — the Insights + * metric page is single-metric — so the read is one + * `readBestGranularityRollups` covering the trailing `2N` days, sliced into + * the two halves. No per-type fan-out, no Prisma-pool burst (the opposite of + * the deleted 15-way live walk). + * + * The cached `/api/analytics?slice=summaries` slice is deliberately left + * untouched: adding a range dimension to its single shared cache key would + * fragment the hottest cache on the dashboard. This dedicated route carries + * its own per-(type, range) cache slot on the client (`queryKeys.analyticsRange`). + * + * iOS-safe: additive new route, no change to the `/api/analytics` envelope. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { + ANALYTICS_RANGES, + computeRangeDelta, + type AnalyticsRange, +} from "@/lib/analytics/range-delta"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +const rangeQuerySchema = z.object({ + type: measurementTypeEnum, + range: z.enum(ANALYTICS_RANGES), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = rangeQuerySchema.safeParse({ + type: request.nextUrl.searchParams.get("type"), + range: request.nextUrl.searchParams.get("range"), + }); + if (!parsed.success) { + annotate({ + action: { name: "analytics.range.invalid-params" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const type = parsed.data.type as MeasurementType; + const range = parsed.data.range as AnalyticsRange; + + const result = await computeRangeDelta(user.id, type, range); + + annotate({ + action: { name: "analytics.range.read" }, + meta: { + type, + range, + granularity: result.granularity, + current_count: result.current.count, + previous_count: result.previous.count, + }, + }); + + return apiSuccess(result); +}); diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index f64973294..8b4b61ac7 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -44,6 +44,10 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // same `insightStatus` sub-flag as the seven specialised status routes. "src/app/api/insights/metric-status/route.ts", "src/app/api/insights/mood-status/route.ts", + // v1.9.0 — on-demand full assessment warm. Warms the same assessment + // cards the status routes serve, so it gates on `insightStatus`, not + // `coach`: a user with assessments enabled but Coach disabled can warm. + "src/app/api/insights/pregenerate/route.ts", "src/app/api/insights/pulse-status/route.ts", "src/app/api/insights/weight-status/route.ts", ]; diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts new file mode 100644 index 000000000..9b9cdb332 --- /dev/null +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Always allow the anti-spam bucket so the surface gate is what the test +// exercises. +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), +})); + +// Stub the worker enqueue so the route test stays isolated from pg-boss. +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueForceWarm: vi.fn(async () => undefined), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +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, locale: "en" }, +}; + +const callPost = POST as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest( + new URL("http://localhost/api/insights/pregenerate"), + { method: "POST" }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); +}); + +describe("POST /api/insights/pregenerate", () => { + it("enqueues a warm when insightStatus is enabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { queued: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.queued).toBe(true); + expect(enqueueForceWarm).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: false, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: true, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(200); + expect(enqueueForceWarm).toHaveBeenCalledTimes(1); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callPost(makeReq()); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callPost(makeReq()); + expect(res.status).toBe(401); + expect(enqueueForceWarm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/pregenerate/route.ts b/src/app/api/insights/pregenerate/route.ts index a18936cda..ec5e41380 100644 --- a/src/app/api/insights/pregenerate/route.ts +++ b/src/app/api/insights/pregenerate/route.ts @@ -38,9 +38,10 @@ const WARM_WINDOW_MS = 3 * 60 * 1000; export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - // Same surface gate as the read-only status routes — when the operator - // disables the assistant surface there is nothing to warm. - await requireAssistantSurface("coach"); + // Gate on the same surface as the read-only status routes: this warms + // the assessment cards (the `insightStatus` surface), not the Coach. + // A user with assessments enabled but Coach disabled can still warm. + await requireAssistantSurface("insightStatus"); const userId = user.id; // Short per-user bucket so the bypassed nightly budget can't be abused diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c1abfc1bf..ae5837a94 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -136,6 +136,8 @@ export const PUT = apiHandler( notificationsEnabled, liveActivityEnabled, criticalAlarmEnabled, + atcCode, + rxNormCode, schedules, startsOn, endsOn, @@ -234,6 +236,10 @@ export const PUT = apiHandler( // v1.7.0 — iOS reminder flags, field-by-field. ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Field-by-field; `null` is a valid explicit value (clears the code). + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), // v1.5 scheduling primitives — pass-through when supplied. // `startsOn` / `endsOn` are `Date | null | undefined` (the // schema lets the user clear them explicitly with null). diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 8ab1cf5e6..844535a7b 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -160,6 +160,8 @@ export const POST = apiHandler(async (request: NextRequest) => { notificationsEnabled, liveActivityEnabled, criticalAlarmEnabled, + atcCode, + rxNormCode, schedules, startsOn, endsOn, @@ -228,6 +230,10 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.7.0 — iOS reminder flags; Prisma defaults both to false. ...(liveActivityEnabled !== undefined && { liveActivityEnabled }), ...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }), + // v1.9.0 — optional drug-classification codes (ATC / RxNorm). + // Validated for format by Zod; stored verbatim when present. + ...(atcCode !== undefined && { atcCode }), + ...(rxNormCode !== undefined && { rxNormCode }), // v1.5 — wizard's reminders toggle now ships through the create // payload (was orphaned in the initial diff). Prisma defaults to // true when omitted, matching the legacy form's behaviour. diff --git a/src/app/globals.css b/src/app/globals.css index a3e673edc..cc0895e5b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -252,6 +252,24 @@ * { @apply border-border outline-ring/50; } + /* + * Reserve the scrollbar gutter on the document root ONLY for the + * body-scrolled shells (login / register / onboarding / standalone legal + * pages), where the document itself overflows. Those keep a fixed content + * width whether or not the page paints the bar, so any `mx-auto` content + * does not recentre a few px sideways on navigation. `AuthShell` sets + * `data-scroll="body"` on `` for exactly those branches. + * + * The authenticated app shell is height-locked (`flex h-dvh`) and scrolls + * inside its own `
`, which reserves its own gutter. Applying this + * rule globally there reserved a SECOND, never-painted gutter on classic + * (non-overlay) scrollbar platforms (Windows / Linux) — a ~15px dead band. + * Scoping it to the body-scrolled branch leaves exactly one gutter per + * branch. + */ + html[data-scroll="body"] { + scrollbar-gutter: stable; + } body { @apply bg-background text-foreground; } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 57cb7cadd..682d70da4 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -63,6 +63,9 @@ interface MedicationComplianceStatusData { // cache miss while the worker warms the assessment; the card polls until // it lands. See `use-insight-status.ts` for the shared rationale. preparing?: boolean; + // v1.9.0 — last-good narrative served while a refresh is in flight; the + // card keeps polling (bounded) so the open page upgrades in-session. + revalidating?: boolean; } // v1.8.3 — client ceiling on the round-trip, mirroring the shared @@ -129,6 +132,7 @@ export default function InsightsMedikamentePage() { nextStatusPollInterval( query.state.data?.preparing, query.state.dataUpdateCount, + query.state.data?.revalidating, ), }); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 10d81def0..f6bdb4177 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -64,6 +64,10 @@ interface Medication { endsOn?: string | null; /** v1.5 — single-administration medication. */ oneShot?: boolean; + /** v1.9.0 — optional WHO ATC classification code for the FHIR export. */ + atcCode?: string | null; + /** v1.9.0 — optional RxNorm RxCUI (secondary FHIR coding). */ + rxNormCode?: string | null; schedules: Schedule[]; } @@ -357,6 +361,8 @@ export default function MedicationsPage() { advancedMed.schedules[0]?.reminderGraceMinutes ?? null } intakeCount={advancedIntakeCount ?? 0} + atcCode={advancedMed.atcCode} + rxNormCode={advancedMed.rxNormCode} /> )} diff --git a/src/app/page.tsx b/src/app/page.tsx index 6da5b170f..f9f989b65 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -466,7 +466,8 @@ export default function DashboardPage() { * misleading zero. The corrected gate requires at least one window * (7d, 30d, all-time) to report a non-zero share. When every * window is zero, the tile is hidden — the user sees the BP charts - * + the `/targets` Blutdruck card for the deeper analysis instead. + * + the Insights blood-pressure target panel for the deeper analysis + * instead. * The audit's F4 reproduction sat on 540 BP samples with all sub- * windows reading 0 % because the seed data straddled the target * ceiling; the tile keeps its place once even one sub-window @@ -1096,9 +1097,10 @@ export default function DashboardPage() { // small fractional float that the TrendCard's date-shaped // formatter pipeline rendered as "1.1." — the regression the // maintainer flagged in the post-v1.4.27 walk-through. The - // all-time aggregate moves to the `/targets` BP card which - // already shows the same number with more context; the - // dashboard tile no longer needs to carry it. `avgAllTime` + // all-time aggregate moves to the Insights blood-pressure + // target panel which already shows the same number with more + // context; the dashboard tile no longer needs to carry it. + // `avgAllTime` // also retires from the TrendCard API (this was its only // consumer). const bp7 = data?.bpInTargetPct7d ?? null; diff --git a/src/app/targets/__tests__/page.test.tsx b/src/app/targets/__tests__/page.test.tsx deleted file mode 100644 index 1fcbe607e..000000000 --- a/src/app/targets/__tests__/page.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; - -/** - * v1.8.6 — `/targets` deprecation banner. - * - * The Targets (Zielwerte) page is deprecated: target-range editing moved - * inline into the Insights category pages. The page stays routable but - * carries a top banner telling the user where editing lives now and that - * the page will be removed in a future release. This SSR test pins the - * banner copy + structure. - * - * The page leans on `useAuth`, two `useQuery` reads, `useFeatureFlags` - * and `useCoachHandoff`; all are stubbed so the render reaches the - * banner without standing up an App-Router runtime or a QueryClient. - */ - -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ - isAuthenticated: true, - user: { id: "u1", disableCoach: false }, - }), -})); - -// The page issues two `useQuery` reads (targets payload + provider -// chain). Drive both off the same stub: the first call resolves the -// targets payload, the rest return a null provider chain. -let queryCall = 0; -const TARGETS_PAYLOAD = { - targets: [], - bpDiastolic: { current: null, average30: null, range: null }, - profile: { heightCm: 175, age: 40, gender: "male", glucoseUnit: "mg/dL" }, -}; -vi.mock("@tanstack/react-query", () => ({ - useQuery: () => { - queryCall += 1; - if (queryCall === 1) { - return { data: TARGETS_PAYLOAD, isLoading: false }; - } - return { data: null, isLoading: false }; - }, -})); - -// Turn Coach off so the page never mounts the SSE drawer machinery. -vi.mock("@/hooks/use-feature-flags", () => ({ - useFeatureFlags: () => ({ - enabled: false, - coach: false, - briefing: false, - insightStatus: false, - correlations: false, - healthScoreExplainer: false, - }), -})); - -vi.mock("@/hooks/use-coach-handoff", () => ({ - useCoachHandoff: () => ({ - coachOpen: false, - setCoachOpen: vi.fn(), - coachPrefill: null, - coachScope: null, - askCoach: vi.fn(), - }), -})); - -const { default: TargetsPage } = await import("../page"); - -function render(locale: "en" | "de" = "en") { - queryCall = 0; - return renderToStaticMarkup( - - - , - ); -} - -describe(" deprecation banner", () => { - it("renders the deprecation banner with title + body", () => { - const html = render(); - expect(html).toContain('data-slot="targets-deprecation"'); - expect(html).toContain("This page is being retired"); - expect(html).toContain("Insights"); - // Reuses the warning-card visual pattern. - expect(html).toContain("border-warning"); - }); - - it("localises the banner copy", () => { - const html = render("de"); - expect(html).toContain("Diese Seite wird eingestellt"); - }); -}); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx deleted file mode 100644 index 20f8a80c2..000000000 --- a/src/app/targets/page.tsx +++ /dev/null @@ -1,313 +0,0 @@ -"use client"; - -// DEPRECATED since v1.8.6. Target-range editing moved inline into the -// Insights category pages (the per-metric reference panel now mounts the -// same ), so this standalone page no longer appears in -// the navigation. It stays routable behind the `/zielwerte`→`/targets` -// proxy redirect and is a remove candidate after ~10 releases. -// -// What stays in place because Insights consumes the same API: -// • GET /api/insights/targets — the read powering this page is the -// same payload the Insights panel reads (`queryKeys.insightsTargets()`). -// • PUT/DELETE /api/user/thresholds — the threshold override write the -// inline calls. -// • src/components/targets/* — TargetCard, TargetEditSheet, RangeBar, -// ConsistencyStrip, TargetStatusPill, source-link helper are all -// reused by the inline Insights panel. -// Removing the page later is a UI-only change; no data or API moves. - -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, Loader2 } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { Card, CardContent } from "@/components/ui/card"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; -import { - TargetCard, - type TargetCardData, -} from "@/components/targets/target-card"; -import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header"; -import { getTargetSourceLink } from "@/lib/targets/source-link"; -import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; -import { useCoachHandoff } from "@/hooks/use-coach-handoff"; -import { useFeatureFlags } from "@/hooks/use-feature-flags"; - -type TargetData = TargetCardData; - -interface BpDiastolic { - current: number | null; - average30: number | null; - range: { min: number; max: number } | null; -} - -interface TargetPageSummary { - targetsMetThisWeek: number; - totalTargets: number; - streakHighlight: { metric: string; days: number } | null; -} - -interface TargetsResponse { - targets: TargetData[]; - pageSummary?: TargetPageSummary; - bpDiastolic: BpDiastolic; - profile: { - heightCm: number | null; - age: number | null; - gender: string | null; - glucoseUnit?: string | null; - }; -} - -/** - * v1.4.25 W3e — provider-chain status used to gate the per-card Coach - * CTA. Returns true when at least one provider is configured AND - * enabled. The Settings → AI surface owns the same query (via - * `queryKeys.insightsProviderChain()`) so the cache is shared. - */ -interface ProviderChainStatus { - activeProvider: string | null; - cachedActiveProvider: string | null; - configuredChain: Array<{ - providerType: string; - enabled: boolean; - available: boolean; - }>; -} - -const GLUCOSE_TYPES = new Set([ - "BLOOD_GLUCOSE_FASTING", - "BLOOD_GLUCOSE_POSTPRANDIAL", - "BLOOD_GLUCOSE_RANDOM", - "BLOOD_GLUCOSE_BEDTIME", -]); - -/** - * Fixed card order per Marc directive — explicitly NOT status-sorted. - * Marc wants a stable visual hierarchy so the user's eye lands on the - * same metric in the same place every visit. Cards whose type is not - * in this list (glucose contexts, future metrics) sort after the - * core six in their server-emitted order. - */ -const FIXED_TARGET_ORDER: Record = { - BLOOD_PRESSURE: 0, - BLOOD_PRESSURE_IN_TARGET: 1, - WEIGHT: 2, - PULSE: 3, - BMI: 4, - MOOD_SCORE: 5, - MOOD_STABILITY: 6, - MEDICATION_COMPLIANCE: 7, - SLEEP_DURATION: 8, - BODY_FAT: 9, - ACTIVITY_STEPS: 10, -}; - -function sortKey(target: TargetData): number { - return FIXED_TARGET_ORDER[target.type] ?? 100; -} - -export default function TargetsPage() { - const { t } = useTranslations(); - const { isAuthenticated, user } = useAuth(); - - const { data, isLoading } = useQuery({ - queryKey: queryKeys.insightsTargets(), - queryFn: async () => { - const res = await fetch("/api/insights/targets"); - if (!res.ok) throw new Error(t("targets.loadError")); - const json = await res.json(); - return json.data as TargetsResponse; - }, - enabled: isAuthenticated, - }); - - // v1.4.25 W3e — share the cache with Settings → AI section. When the - // user has no configured provider, `activeProvider` is null and the - // per-card Coach CTA hides entirely (no broken-button state). - const { data: chainStatus } = useQuery({ - queryKey: queryKeys.insightsProviderChain(), - queryFn: async () => { - const res = await fetch("/api/insights/provider-chain"); - if (!res.ok) return null; - const json = await res.json(); - return json.data as ProviderChainStatus; - }, - enabled: isAuthenticated, - }); - // v1.4.37 W5 — operator's global Coach flag layered on top of the - // per-user provider chain. Either gate disables every Coach - // affordance on this page: the per-card CTAs (via `aiEnabled` - // threaded into ) and the page-level mount - // below. The flag default is all-on for fresh installs and on any - // fetch error, so the path stays open by default. - const flags = useFeatureFlags(); - // v1.4.47 W3 — per-user "Hide Coach" toggle is a peer gate to the - // operator's master flag. When either is off, the per-card CTAs + - // the page-level mount below both go dark. - const coachEnabled = flags.coach && !user?.disableCoach; - const aiEnabled = coachEnabled && chainStatus?.activeProvider != null; - - // v1.4.25 W3e — Coach drawer state owned by the page. The per-card - // CTA + summary header both feed the same drawer so the user only - // ever sees one drawer instance. - const { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach } = - useCoachHandoff(); - - const visibleTargets = useMemo(() => { - if (!data) return [] as TargetData[]; - const displayGlucoseUnit = resolveGlucoseUnit( - data.profile.glucoseUnit ?? null, - ); - return data.targets - .filter((target) => target.current != null) - .map((target) => { - if (!GLUCOSE_TYPES.has(target.type)) return target; - // Server label is an i18n key for glucose; resolve here. - const label = t(target.label); - // Convert mg/dL canonical values to the user's display unit. - const convert = (v: number | null) => - v == null ? null : convertGlucose(v, displayGlucoseUnit); - return { - ...target, - label, - unit: displayGlucoseUnit, - current: convert(target.current), - average30: convert(target.average30), - range: target.range - ? { - min: convertGlucose(target.range.min, displayGlucoseUnit), - max: convertGlucose(target.range.max, displayGlucoseUnit), - } - : null, - }; - }) - .sort((a, b) => sortKey(a) - sortKey(b)); - }, [data, t]); - - if (isLoading) { - return ( -
- -
- ); - } - - if (!data) { - return ( -
- {t("common.noData")} -
- ); - } - - const profileIncomplete = !data.profile.heightCm || !data.profile.age; - - return ( - // v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted - // between the overview header and the first values on `/targets`. - // Tighten the rhythm from `space-y-8` (32 px) to `space-y-6` - // (24 px) — that matches the admin / settings pages. -
-
-

{t("targets.title")}

-

{t("targets.introText")}

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

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

-

- {t("targets.deprecation.body")} -

-
- {profileIncomplete && ( -
-

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

-

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

-
- )} -
-
-
- - {/* v1.4.25 W3e — page-level summary line. Renders nothing when - the API hasn't shipped pageSummary yet (older clients during - rollout / mocked test fixtures that pre-date this addition). */} - {data.pageSummary && ( - - )} - - {/* v1.4.25 W3e — responsive grid. Mobile (default): single - column. sm (640px+): two columns. lg (1024px+): three columns, - matching the dashboard / insights rhythm. Cards reflow - internally too — see . */} - {visibleTargets.length > 0 ? ( -
- {visibleTargets.map((target) => ( - - ))} -
- ) : ( -
- {t("targets.noMeasurementData")} -
- )} - - {/* v1.4.25 W3e — Coach drawer mounted at the page level. The - drawer is fully-controlled; per-card CTAs flip `coachOpen` - and seed `coachPrefill` via the `askCoach()` hook callback. - v1.4.37 W5 — short-circuit when the operator turned the - global Coach flag off so the SSE machinery + Sheet portal - never mount. The per-card CTAs already hide via `aiEnabled` - above, so the drawer can never be triggered with the flag - off; the gate is defence-in-depth. */} - {coachEnabled && ( - - )} -
- ); -} diff --git a/src/components/charts/trend-card.tsx b/src/components/charts/trend-card.tsx index cfc73b02a..e69183791 100644 --- a/src/components/charts/trend-card.tsx +++ b/src/components/charts/trend-card.tsx @@ -11,6 +11,17 @@ import { } from "@/components/ui/tooltip"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import type { ComparisonBaseline } from "@/lib/dashboard-layout"; +import { + getTrendSentiment, + sentimentColorClass, + type TrendDirectionSentiment, + type TrendSentimentDirection, +} from "@/lib/insights/trend-sentiment"; + +// v1.9.0 — the metric-aware sentiment mapping moved to a shared module so the +// range-delta caption paints the same colour for the same signal. Re-exported +// here so existing `@/components/charts/trend-card` importers keep the type. +export type { TrendDirectionSentiment, TrendSentimentDirection }; interface SecondaryMetric { /** Sub-value latest reading (e.g. diastolic when latest is systolic). */ @@ -19,70 +30,6 @@ interface SecondaryMetric { avg30: number | null; } -/** - * Maps a metric's "up means" direction to colour sentiment for the small - * trend arrow on each tile. v1.4.6 P4 stripped the colour entirely after - * the original up=red / down=green mapping was wrong for half the metrics - * (mood up = good, BP up = bad, pulse up = neutral). v1.5 phase-5 restores - * the colour but per-metric: - * - * - `up-good` — higher value is better (mood, sleep hours, steps). - * ↑ green, ↓ orange. - * - `up-bad` — higher value is worse (BP, weight, body fat). - * ↑ orange, ↓ green. - * - `neutral` — direction doesn't carry a value judgement (pulse, - * "BP in target %" — those have their own range - * colouring on the avg7/avg30 numbers already). - * - * Strictly affects the ↑/↓/→ arrow next to the latest reading. Chart - * lines, axes, and the avg7/avg30 colour classes are untouched. - */ -export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral"; - -/** - * v1.4.33 F5 — single sentiment helper so the headline arrow, the 7-day - * delta value, and the comparison-overlay caption all paint the same - * colour for the same signal. Before this helper each consumer ran its - * own branch — the arrow read `slope30.direction`, the delta value read - * `trend7Delta > 0`, and the comparison caption read `compareDelta > 0`. - * When the 30-day regression and the 7-day delta disagreed (weight down - * over a month, up over a week) the tile painted a green arrow next to - * an orange value, which read as "two metrics in one tile". - * - * `change` is a signed magnitude of the movement under inspection (the - * 7-day delta in units, or the slope-projected-over-7-days for tiles - * with only `slope30`). `sentiment` is the metric's improvement - * direction. - * - * - `'positive'` — the change moves the metric toward its goal - * (e.g. weight on `up-bad` going down, mood on `up-good` going up). - * Renders green. - * - `'negative'` — the change moves away from the goal. Renders orange. - * - `'neutral'` — either the sentiment is neutral (pulse, BP-in-target), - * or the change is below the noise floor (|change| < 0.05). Renders - * muted. - */ -export type TrendSentimentDirection = "positive" | "negative" | "neutral"; - -function getTrendSentiment( - change: number | null | undefined, - sentiment: TrendDirectionSentiment, -): TrendSentimentDirection { - if (change == null || Math.abs(change) < 0.05) return "neutral"; - if (sentiment === "neutral") return "neutral"; - const isUp = change > 0; - const isGood = - (sentiment === "up-good" && isUp) || - (sentiment === "up-bad" && !isUp); - return isGood ? "positive" : "negative"; -} - -function sentimentColorClass(direction: TrendSentimentDirection): string { - if (direction === "positive") return "text-dracula-green"; - if (direction === "negative") return "text-dracula-orange"; - return "text-muted-foreground"; -} - interface TrendCardProps { label: string; latest: number | null; @@ -476,8 +423,8 @@ export function TrendCard({ {/* v1.4.28 FB-C2 — the all-time third sub-value retired alongside the `avgAllTime*` props. The BD-Zielbereich tile was the only consumer; the same number stays - visible on the `/targets` Blutdruck card with full - context. Every dashboard tile now ships the same two + visible on the Insights blood-pressure target panel with + full context. Every dashboard tile now ships the same two sub-rows. */} diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index d03212622..5a398339f 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -33,6 +33,13 @@ vi.mock("@/hooks/use-auth", () => ({ vi.mock("@/hooks/use-insights-layout-prefs", () => ({ useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }), + useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }), +})); + +// v1.9.0 — the page reads the period-over-period range delta; mock the hook +// so the test stays deterministic and never touches `fetch`. +vi.mock("@/hooks/use-analytics-range", () => ({ + useAnalyticsRange: () => ({ data: undefined, isLoading: false }), })); const analyticsMock = vi.fn(); diff --git a/src/components/insights/__tests__/metric-range-delta.test.tsx b/src/components/insights/__tests__/metric-range-delta.test.tsx new file mode 100644 index 000000000..dd5006a09 --- /dev/null +++ b/src/components/insights/__tests__/metric-range-delta.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { MetricRangeDelta } from "../metric-range-delta"; +import type { AnalyticsRangeData } from "@/hooks/use-analytics-range"; + +/** + * v1.9.0 QA (Design M1 / Correctness L-1) — a perfectly stable metric + * (delta === 0 / rounded 0 %) must read neutral: a flat dash and "no + * change", never a down-arrow with "−0%". + */ +function render(data: AnalyticsRangeData | undefined) { + return renderToStaticMarkup( + + + , + ); +} + +const base: AnalyticsRangeData = { + range: "30d", + windowDays: 30, + granularity: "DAY", + current: { count: 1, min: 1, max: 1, mean: 1, sum: 1 }, + previous: { count: 1, min: 1, max: 1, mean: 1, sum: 1 }, + delta: 0, + deltaPct: 0, +}; + +describe("MetricRangeDelta — zero-delta neutral", () => { + it("renders neutral copy + no directional arrow for a zero delta", () => { + const html = render(base); + expect(html).toContain('data-direction="neutral"'); + expect(html).toContain("no change"); + // No misleading "−0%". + expect(html).not.toContain("0%"); + expect(html).not.toContain("−"); + }); + + it("still renders a directional +pct for a real increase", () => { + const html = render({ ...base, delta: 2, deltaPct: 0.1 }); + expect(html).toContain('data-direction='); + expect(html).not.toContain('data-direction="neutral"'); + expect(html).toContain("+10%"); + }); + + it("shows the no-prior-data line when the comparison is null", () => { + const html = render({ ...base, delta: null, deltaPct: null }); + expect(html).toContain("metric-range-delta-empty"); + }); +}); diff --git a/src/components/insights/__tests__/time-range-pills.test.tsx b/src/components/insights/__tests__/time-range-pills.test.tsx new file mode 100644 index 000000000..0138a7f8e --- /dev/null +++ b/src/components/insights/__tests__/time-range-pills.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { TimeRangePills } from "../time-range-pills"; +import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; + +/** + * v1.9.0 QA (Design H1) — the range pills are a WAI-ARIA APG radiogroup, so + * the group must be a SINGLE tab stop: only the checked radio carries + * `tabIndex={0}`, every other radio is `-1` (roving tabindex). Before the + * fix all four were native buttons (four tab stops) with no arrow-key + * handler. Project convention is SSR-only tests, so we assert the rendered + * roving-tabindex contract + radio semantics rather than driving live key + * events. + */ +function render(value: (typeof ANALYTICS_RANGES)[number]) { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe("TimeRangePills — roving tabindex (single tab stop)", () => { + it("renders a radiogroup with one radio per range", () => { + const html = render("30d"); + expect(html).toContain('role="radiogroup"'); + const radios = html.match(/role="radio"/g) ?? []; + expect(radios).toHaveLength(ANALYTICS_RANGES.length); + }); + + it("makes only the checked radio tabbable (tabindex 0); the rest are -1", () => { + const html = render("30d"); + // Exactly one tabindex=0 (the checked radio is the single tab stop). + const tabbable = html.match(/tabindex="0"/g) ?? []; + expect(tabbable).toHaveLength(1); + // The remaining three are removed from the tab order. + const untabbable = html.match(/tabindex="-1"/g) ?? []; + expect(untabbable).toHaveLength(ANALYTICS_RANGES.length - 1); + }); + + it("marks the chosen value selected (aria-checked + data-selected)", () => { + const html = render("90d"); + // Exactly one radio is checked / selected. + expect(html.match(/aria-checked="true"/g) ?? []).toHaveLength(1); + expect(html.match(/data-selected="true"/g) ?? []).toHaveLength(1); + // The chosen pill is present and selected. + expect(html).toMatch(/data-range="90d"/); + }); +}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index dcbe22f2f..2fa7b6ff8 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -7,11 +7,21 @@ import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; -import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { + useInsightsLayoutPrefs, + useInsightsRangePref, +} from "@/hooks/use-insights-layout-prefs"; +import { useAnalyticsRange } from "@/hooks/use-analytics-range"; import { useTranslations } from "@/lib/i18n/context"; import type { InsightMetric } from "@/lib/insights/metric-availability"; +import { + getMetricStatusMeta, + metricIdForMeasurementType, +} from "@/lib/insights/metric-status-registry"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; +import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; +import type { MeasurementType } from "@/generated/prisma/client"; import { Button } from "@/components/ui/button"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricStatusCard } from "@/components/insights/metric-status-card"; @@ -19,6 +29,8 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; +import { TimeRangePills } from "@/components/insights/time-range-pills"; +import { MetricRangeDelta } from "@/components/insights/metric-range-delta"; import { SubPageShell } from "@/components/insights/sub-page-shell"; /** @@ -139,9 +151,30 @@ export function HealthKitMetricPage({ const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + const { range, setRange } = useInsightsRangePref(); const { data: analytics, isEmpty } = useInsightsAnalytics(insightMetric); + // v1.9.0 — period-over-period range read. Single-metric, additive route; + // gated off the empty-data branch so a brand-new metric never fires it. + const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange( + measurementType, + range, + !isEmpty, + ); + + // The delta's sentiment colour follows the metric's "good direction" from + // the metric-status registry (higher-better → up-good, lower-better → + // up-bad, target-band → neutral). Every HealthKit metric page is backed by + // a registry entry; a metric absent from the registry falls back to neutral. + const registryMeta = (() => { + const id = metricIdForMeasurementType(measurementType as MeasurementType); + return id ? getMetricStatusMeta(id) : null; + })(); + const directionSentiment = registryMeta + ? sentimentFromMetricDirection(registryMeta.direction) + : "neutral"; + const rawSummary = analytics?.summaries?.[measurementType] ?? null; // The stat strip renders display-unit values. The summary holds stored // values, so when the page renders a scaled unit (e.g. WALKING_SPEED @@ -207,6 +240,21 @@ export function HealthKitMetricPage({ coachLaunch showAllValuesType={measurementType} > + {/* v1.9.0 — time-range pills + period-over-period delta, between the + stat strip and the chart. The selected range persists across metrics + via `useInsightsRangePref`. */} +
+ + +