Merge pull request #240 from MBombeck/release/v1.9.0

Release v1.9.0 — Insights time-ranges, deeper mood, medication coding, stability
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-02 18:16:10 +02:00
commit ed5d453431
114 files changed
+6134 -3355

No files matched your search

+25
View File
@@ -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
+633 -1
View File
@@ -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
+17
View File
@@ -59,3 +59,20 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_<col>`), 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.
+50 -43
View File
@@ -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",
+50 -43
View File
@@ -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",
+50 -43
View File
@@ -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",
+50 -43
View File
@@ -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",
+50 -43
View File
@@ -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",
+50 -43
View File
@@ -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",
+1 -1
View File
@@ -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",
@@ -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;
+12
View File
@@ -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")
+169
View File
@@ -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 <expected-version>
*
* 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<string, Target> = {
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 <expected-version> [--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<void> =>
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<VersionPayload | null> {
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<boolean> {
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<void> {
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();
@@ -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(
<I18nProvider initialLocale="en">
<TargetsPage />
</I18nProvider>,
);
}
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 <CoachDrawer> 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"');
});
});
-204
View File
@@ -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(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
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(/>Low</);
expect(html).not.toMatch(/>On target</);
expect(html).not.toMatch(/>Stable</);
expect(html).not.toMatch(/>Moderate</);
// Their German renderings must show up instead.
expect(html).toContain("Niedrig"); // Low
expect(html).toContain("Im Zielbereich"); // On target
expect(html).toContain("Stabil"); // Stable
expect(html).toContain("Moderat"); // Moderate
// "Normal" is identical in EN and DE — the badge should still
// render the localised key (`targets.status.normal` → "Normal"),
// not the raw server string.
expect(html).toContain("Normal");
});
it("English locale still surfaces the original English status labels (no breakage)", () => {
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");
});
});
@@ -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(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
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");
});
});
-111
View File
@@ -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(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
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(/<div[^>]*\bspace-y-6\b/);
expect(html).not.toMatch(/<div[^>]*\bspace-y-8\b/);
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* v1.9.0 — single-metric period-over-period range read.
*
* `GET /api/analytics/range?type=<MeasurementType>&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);
});
@@ -44,6 +44,10 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// 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",
];
@@ -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<Response>;
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();
});
});
+4 -3
View File
@@ -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
+6
View File
@@ -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).
+6
View File
@@ -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.
+18
View File
@@ -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 `<html>` for exactly those branches.
*
* The authenticated app shell is height-locked (`flex h-dvh`) and scrolls
* inside its own `<main>`, 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;
}
+4
View File
@@ -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,
),
});
+6
View File
@@ -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}
/>
)}
</div>
+6 -4
View File
@@ -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;
-93
View File
@@ -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(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("<TargetsPage> deprecation banner", () => {
it("renders the deprecation banner with title + body", () => {
const html = render();
expect(html).toContain('data-slot="targets-deprecation"');
expect(html).toContain("This page is being retired");
expect(html).toContain("Insights");
// Reuses the warning-card visual pattern.
expect(html).toContain("border-warning");
});
it("localises the banner copy", () => {
const html = render("de");
expect(html).toContain("Diese Seite wird eingestellt");
});
});
-313
View File
@@ -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 <TargetEditSheet>), so this standalone page no longer appears in
// the navigation. It stays routable behind the `/zielwerte`→`/targets`
// proxy redirect and is a remove candidate after ~10 releases.
//
// What stays in place because Insights consumes the same API:
// • GET /api/insights/targets — the read powering this page is the
// same payload the Insights panel reads (`queryKeys.insightsTargets()`).
// • PUT/DELETE /api/user/thresholds — the threshold override write the
// inline <TargetEditSheet> calls.
// • src/components/targets/* — TargetCard, TargetEditSheet, RangeBar,
// ConsistencyStrip, TargetStatusPill, source-link helper are all
// reused by the inline Insights panel.
// Removing the page later is a UI-only change; no data or API moves.
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Loader2 } from "lucide-react";
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<string, number> = {
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 <TargetCard>) and the page-level <CoachDrawer> 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 <CoachDrawer> 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 (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
if (!data) {
return (
<div className="text-muted-foreground py-20 text-center">
{t("common.noData")}
</div>
);
}
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.
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("targets.title")}</h1>
<p className="mt-2 text-sm">{t("targets.introText")}</p>
</div>
{/* v1.8.6 — single warning banner. Target editing moved inline into
the Insights category pages; this page stays reachable but will
be removed in a future release.
Design Low fix: the deprecation notice and the profile-incomplete
hint used to stack as two near-identical warning cards. They are
folded into one card now — the deprecation message stays the
headline, and the profile-incomplete detail (height / age missing)
renders as a secondary line beneath it only when relevant, so the
page never shows two warning banners. */}
<Card className="border-warning border-l-4" data-slot="targets-deprecation">
<CardContent className="flex gap-3 py-3">
<AlertCircle className="text-warning mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-2">
<div>
<p className="text-sm font-medium">
{t("targets.deprecation.title")}
</p>
<p className="text-muted-foreground text-sm">
{t("targets.deprecation.body")}
</p>
</div>
{profileIncomplete && (
<div data-slot="targets-profile-incomplete">
<p className="text-sm font-medium">
{t("targets.profileIncomplete")}
</p>
<p className="text-muted-foreground text-sm">
{!data.profile.heightCm && !data.profile.age
? t("targets.profileIncompleteHeightAge")
: !data.profile.heightCm
? t("targets.profileIncompleteHeight")
: t("targets.profileIncompleteAge")}
</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* v1.4.25 W3e — page-level summary line. Renders nothing when
the API hasn't shipped pageSummary yet (older clients during
rollout / mocked test fixtures that pre-date this addition). */}
{data.pageSummary && (
<TargetsSummaryHeader
targetsMetThisWeek={data.pageSummary.targetsMetThisWeek}
totalTargets={data.pageSummary.totalTargets}
streakHighlight={data.pageSummary.streakHighlight}
/>
)}
{/* 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 <TargetCard>. */}
{visibleTargets.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{visibleTargets.map((target) => (
<TargetCard
key={target.type}
target={target}
bpDiastolic={
target.type === "BLOOD_PRESSURE" ? data.bpDiastolic : undefined
}
aiEnabled={aiEnabled}
onAskCoach={askCoach}
sourceLink={getTargetSourceLink(target)}
/>
))}
</div>
) : (
<div className="text-muted-foreground border-border rounded-xl border p-6 text-sm">
{t("targets.noMeasurementData")}
</div>
)}
{/* 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 && (
<CoachDrawer
open={coachOpen}
onOpenChange={setCoachOpen}
prefill={coachPrefill}
key={`coach-drawer-${coachScope?.sources?.join(",") ?? "default"}`}
/>
)}
</div>
);
}
+13 -66
View File
@@ -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. */}
</div>
</TooltipProvider>
@@ -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();
@@ -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(
<I18nProvider initialLocale="en">
<MetricRangeDelta
data={data}
range="30d"
directionSentiment="up-good"
isLoading={false}
/>
</I18nProvider>,
);
}
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");
});
});
@@ -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(
<I18nProvider initialLocale="en">
<TimeRangePills value={value} onChange={() => {}} />
</I18nProvider>,
);
}
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"/);
});
});
@@ -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`. */}
<div
data-slot="metric-range-controls"
className="flex flex-wrap items-center justify-between gap-2"
>
<TimeRangePills value={range} onChange={setRange} />
<MetricRangeDelta
data={rangeData}
range={range}
directionSentiment={directionSentiment}
isLoading={isRangeLoading}
/>
</div>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
@@ -0,0 +1,106 @@
"use client";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
getTrendSentiment,
sentimentColorClass,
type TrendDirectionSentiment,
} from "@/lib/insights/trend-sentiment";
import type { AnalyticsRangeData } from "@/hooks/use-analytics-range";
import type { AnalyticsRange } from "@/lib/analytics/range-shared";
/**
* v1.9.0 — period-over-period delta caption beside the time-range pills.
*
* Renders "+3 % vs prior 30d" with the metric-aware sentiment colour the
* dashboard tiles use (up-good vs up-bad). When the prior window has no data
* it shows a neutral "no prior-period data" line instead of a misleading 0 %
* (the `deltaPct` is null in that case). For `target-band` metrics the
* sentiment is neutral, so the delta still shows magnitude + direction but
* carries no good/bad colour — matching Oura's deviation framing for metrics
* with no single good direction.
*/
export function MetricRangeDelta({
data,
range,
directionSentiment,
isLoading,
}: {
data: AnalyticsRangeData | undefined;
range: AnalyticsRange;
directionSentiment: TrendDirectionSentiment;
isLoading: boolean;
}) {
const { t } = useTranslations();
if (isLoading) {
return (
<span
data-slot="metric-range-delta-pending"
className="bg-muted/60 inline-block h-3.5 w-28 animate-pulse rounded motion-reduce:animate-none"
aria-hidden="true"
/>
);
}
const priorLabel = t(`insights.range.priorLabel.${range}`);
// No comparison available — prior window empty, or current window empty.
if (!data || data.delta === null || data.deltaPct === null) {
return (
<span
data-slot="metric-range-delta-empty"
className="text-muted-foreground text-xs"
>
{t("insights.range.noPriorData")}
</span>
);
}
const direction = getTrendSentiment(data.delta, directionSentiment);
const colorClass = sentimentColorClass(direction);
const pct = Math.round(Math.abs(data.deltaPct) * 100);
// A perfectly stable metric (current mean == prior mean, or a rounded
// delta of 0 %) reads neutral — a flat dash and "no change", never a
// down-arrow with "−0%". The direction arrow is decoupled from the raw
// sign so a zero delta never paints a misleading decrease.
if (data.delta === 0 || pct === 0) {
return (
<span
data-slot="metric-range-delta"
data-direction="neutral"
className="text-muted-foreground inline-flex items-center gap-1 text-xs"
>
<Minus className="h-3 w-3" aria-hidden="true" />
<span>
{t("insights.range.noChange")} {priorLabel}
</span>
</span>
);
}
const isUp = data.delta > 0;
const sign = isUp ? "+" : "−";
return (
<span
data-slot="metric-range-delta"
data-direction={direction}
className={cn("inline-flex items-center gap-1 text-xs", colorClass)}
>
{isUp ? (
<ArrowUp className="h-3 w-3" aria-hidden="true" />
) : (
<ArrowDown className="h-3 w-3" aria-hidden="true" />
)}
<span>
{sign}
{pct}% {priorLabel}
</span>
</span>
);
}
@@ -18,18 +18,16 @@ import { getTargetSourceLink } from "@/lib/targets/source-link";
* v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each
* insights category page, directly beneath the chart / assessment.
*
* The data is the same `/api/insights/targets` payload that powers the
* `/targets` editing surface — this component reads the shared
* `queryKeys.insightsTargets()` cache, so on a session that has already
* visited `/targets` the read is free, and on a cold sub-page it warms a
* cache the `/targets` page then reuses. We never recompute the ranges
* here: the route is the single source of truth for the age-based ESH
* blood-pressure band, the WHO weight / BMI band, the Karvonen pulse
* band, the AASM sleep band, the ADA / DDG glucose bands, and the
* medication / mood targets.
* The data is the `/api/insights/targets` payload, read through the
* shared `queryKeys.insightsTargets()` cache so warm sessions pay
* nothing and a cold sub-page warms the cache its siblings reuse. We
* never recompute the ranges here: the route is the single source of
* truth for the age-based ESH blood-pressure band, the WHO weight / BMI
* band, the Karvonen pulse band, the AASM sleep band, the ADA / DDG
* glucose bands, and the medication / mood targets.
*
* v1.8.5 W5 — the panel grew from a one-line range card into a compact
* reference panel that ports the context the `/targets` card carries:
* The panel is a compact reference panel carrying the full target
* context:
*
* • Row 1: target range string + `<TargetStatusPill>` + source link
* (the "what is the band / what is it based on" answer).
@@ -38,13 +36,13 @@ import { getTargetSourceLink } from "@/lib/targets/source-link";
* • Row 3: in-target share % + the 30-day average.
* • Row 4: `<ConsistencyStrip>` — the last seven days at a glance.
* • Footer: an "Adjust target range" button that opens the
* `<TargetEditSheet>` inline (v1.8.6 — the `/targets` page is
* deprecated, so the editor mounts here rather than routing).
* `<TargetEditSheet>` inline — this panel is the home of
* target editing.
*
* Each insights slug maps to one target `type`, except blood glucose
* which maps to up to four per-context cards (fasting / postprandial /
* random / bedtime) — those carry mg/dL canonical values that we convert
* to the user's display unit exactly like the `/targets` page does.
* to the user's display unit.
*/
interface TargetRange {
@@ -146,8 +144,8 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
if ((!targetType && !isGlucose) || !data) return null;
// Glucose: one panel per logged context, converting the canonical
// mg/dL values to the user's display unit exactly like the /targets
// page. The route's per-context labels are i18n keys, so resolve them.
// mg/dL values to the user's display unit. The route's per-context
// labels are i18n keys, so resolve them.
if (isGlucose) {
const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null);
const convert = (v: number | null) =>
@@ -222,11 +220,10 @@ function TargetReferencePanel({
}: TargetReferencePanelProps) {
const { t } = useTranslations();
// v1.8.6 — the `/targets` page is deprecated; the per-metric editor
// moved here. The button below opens the same self-contained
// `<TargetEditSheet>` the targets card mounts, seeded with this
// metric's type / unit / range (and the diastolic range for BP). The
// sheet writes `PUT /api/user/thresholds` and invalidates the
// The per-metric editor lives here. The button below opens a
// self-contained `<TargetEditSheet>`, seeded with this metric's
// type / unit / range (and the diastolic range for BP). The sheet
// writes `PUT /api/user/thresholds` and invalidates the
// `insightsTargets()` cache on save, so the panel repaints in place.
const [editOpen, setEditOpen] = useState(false);
@@ -261,7 +258,7 @@ function TargetReferencePanel({
// In-target share: fraction of logged days in the green band over the
// last 30. Suppressed when the route flagged insufficient data so the
// sub-page mirrors the `/targets` strip exactly.
// sub-page suppresses the share line.
const showShare = !target.insufficientData && target.daysLogged30d > 0;
const sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
@@ -32,6 +32,13 @@ type Response = {
heatmap: { windowDays: number; cells: [] };
distribution: [];
weekday: [];
timeOfDay: {
buckets: [];
reliable: boolean;
best: null;
worst: null;
};
stability: null;
tags: [];
structuredTags: [];
narratives: Array<{
@@ -43,6 +50,8 @@ type Response = {
sleep: EmptyCorrelation;
steps: EmptyCorrelation;
pulse: EmptyCorrelation;
weight: EmptyCorrelation;
bloodPressureSystolic: EmptyCorrelation;
};
};
@@ -70,6 +79,8 @@ function baseData(inTargetPct: number | null): Response {
heatmap: { windowDays: 30, cells: [] },
distribution: [],
weekday: [],
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
stability: null,
tags: [],
structuredTags: [],
narratives: [
@@ -88,6 +99,8 @@ function baseData(inTargetPct: number | null): Response {
sleep: emptyCorrelation,
steps: emptyCorrelation,
pulse: emptyCorrelation,
weight: emptyCorrelation,
bloodPressureSystolic: emptyCorrelation,
},
};
}
@@ -45,24 +45,30 @@ export interface MoodMetricCorrelationData {
n: number;
}
type MetricKind = "sleep" | "steps" | "pulse";
type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure";
const TITLE_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepTitle",
steps: "insights.mood.correlation.stepsTitle",
pulse: "insights.mood.correlation.pulseTitle",
weight: "insights.mood.correlation.weightTitle",
bloodPressure: "insights.mood.correlation.bloodPressureTitle",
};
const Y_LABEL_KEY: Record<MetricKind, string> = {
sleep: "insights.mood.correlation.sleepAxis",
steps: "insights.mood.correlation.stepsAxis",
pulse: "insights.mood.correlation.pulseAxis",
weight: "insights.mood.correlation.weightAxis",
bloodPressure: "insights.mood.correlation.bloodPressureAxis",
};
const FILL_BY_KIND: Record<MetricKind, string> = {
sleep: "var(--dracula-purple)",
steps: "var(--dracula-cyan)",
pulse: "var(--dracula-pink)",
weight: "var(--dracula-green)",
bloodPressure: "var(--dracula-orange)",
};
const STRENGTH_KEY: Record<CorrelationResult["strength"], string> = {
@@ -136,16 +142,22 @@ export function MoodCorrelationCards({
sleep,
steps,
pulse,
weight,
bloodPressureSystolic,
}: {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: MoodMetricCorrelationData;
}) {
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<MoodCorrelationCard kind="sleep" data={sleep} />
<MoodCorrelationCard kind="steps" data={steps} />
<MoodCorrelationCard kind="pulse" data={pulse} />
<MoodCorrelationCard kind="weight" data={weight} />
<MoodCorrelationCard kind="bloodPressure" data={bloodPressureSystolic} />
</div>
);
}
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
@@ -33,6 +34,14 @@ import {
type MoodNarrativeItem,
} from "./mood-narrative-feed";
import { MoodInTargetTile } from "./mood-in-target-tile";
import {
MoodTimeOfDayChart,
type MoodTimeOfDayPattern,
} from "./mood-time-of-day-chart";
import {
MoodStabilityTile,
type MoodStabilityData,
} from "./mood-stability-tile";
/**
* v1.8.5 — additional Mood Insights sections.
@@ -55,6 +64,8 @@ interface MoodInsightsResponse {
};
distribution: MoodDistributionRow[];
weekday: MoodWeekdayRow[];
timeOfDay: MoodTimeOfDayPattern;
stability: MoodStabilityData | null;
tags: MoodTagRow[];
structuredTags: MoodStructuredTagRow[];
narratives: MoodNarrativeItem[];
@@ -62,6 +73,8 @@ interface MoodInsightsResponse {
sleep: MoodMetricCorrelationData;
steps: MoodMetricCorrelationData;
pulse: MoodMetricCorrelationData;
weight: MoodMetricCorrelationData;
bloodPressureSystolic: MoodMetricCorrelationData;
};
}
@@ -122,9 +135,28 @@ export function MoodInsightsSections() {
? data.narratives.filter((item) => item.kind !== "in-target")
: data.narratives;
const hasStabilityTile = data.stability != null;
const hasInTargetTile = data.summary.inTargetPct != null;
return (
<div className="space-y-4">
<MoodInTargetTile pct={data.summary.inTargetPct} />
{(hasInTargetTile || hasStabilityTile) && (
// Two-up only when BOTH tiles render; a lone tile spans full width so
// it never leaves a half-width orphan with dead space beside it.
<div
className={cn(
"grid gap-4",
hasInTargetTile && hasStabilityTile && "sm:grid-cols-2",
)}
>
{hasInTargetTile && (
<MoodInTargetTile pct={data.summary.inTargetPct} />
)}
{hasStabilityTile && (
<MoodStabilityTile stability={data.stability} />
)}
</div>
)}
<MoodNarrativeFeed items={narratives} />
@@ -145,6 +177,12 @@ export function MoodInsightsSections() {
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")}>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
@@ -168,6 +206,8 @@ export function MoodInsightsSections() {
sleep={data.correlations.sleep}
steps={data.correlations.steps}
pulse={data.correlations.pulse}
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</div>
</div>
@@ -4,6 +4,7 @@ import {
ArrowDownRight,
ArrowUpRight,
CalendarDays,
Clock,
Flame,
Tag,
Target,
@@ -42,6 +43,7 @@ const ICONS: Record<string, ComponentType<{ className?: string }>> = {
"tag-drop": ArrowDownRight,
"in-target": Target,
streak: Flame,
"time-of-day": Clock,
};
function pickIcon(item: MoodNarrativeItem): ComponentType<{
@@ -76,6 +78,7 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) {
const vars: Record<string, string> = { ...item.vars };
if (vars.weekdayKey) vars.weekday = t(vars.weekdayKey);
if (vars.tagKey) vars.tag = t(vars.tagKey);
if (vars.bucketKey) vars.bucket = t(vars.bucketKey);
return (
<li
key={`${item.kind}-${index}`}
@@ -0,0 +1,66 @@
"use client";
import { Activity } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.9.0 — mood-stability tile.
*
* Surfaces the day-to-day stability score (0..100, higher = steadier)
* computed server-side from the population standard deviation of the
* daily means, plus a descriptive, non-judgemental band. Some variation
* is healthy, so the band reads "steady" / "variable", never
* "good" / "bad". Renders nothing when the score is unavailable (a sparse
* logger below the minimum-days floor) so the page degrades gracefully.
*/
export type MoodStabilityBand =
| "verySteady"
| "steady"
| "variable"
| "veryVariable";
export interface MoodStabilityData {
score: number;
band: MoodStabilityBand;
days: number;
}
const BAND_KEY: Record<MoodStabilityBand, string> = {
verySteady: "insights.mood.stability.bandVerySteady",
steady: "insights.mood.stability.bandSteady",
variable: "insights.mood.stability.bandVariable",
veryVariable: "insights.mood.stability.bandVeryVariable",
};
export function MoodStabilityTile({
stability,
}: {
stability: MoodStabilityData | null;
}) {
const { t } = useTranslations();
if (stability == null) return null;
return (
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-full">
<Activity className="size-5" />
</div>
<div className="min-w-0">
<p className="text-foreground text-2xl font-semibold tabular-nums">
{t("insights.mood.stability.value", {
score: String(stability.score),
})}
</p>
<p className="text-muted-foreground text-sm">
{t(BAND_KEY[stability.band])}
</p>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,149 @@
"use client";
import {
Bar,
BarChart,
Cell,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.9.0 — average mood per part of day.
*
* Four bars (morning / afternoon / evening / night) over the average
* mood logged in each part of the user's local day. The buckets are
* computed server-side in each entry's own timezone and gated on a
* spread floor (`reliable`); the parent only mounts this chart when the
* pattern is trustworthy, so a once-a-day logger never sees a misleading
* single bar dressed up as a daypart preference. Empty buckets render as
* a muted zero-height bar so the four-column rhythm stays intact.
*/
export type MoodTimeOfDayBucket =
| "morning"
| "afternoon"
| "evening"
| "night";
export interface MoodTimeOfDayRow {
bucket: MoodTimeOfDayBucket;
avgScore: number | null;
count: number;
}
export interface MoodTimeOfDayPattern {
buckets: MoodTimeOfDayRow[];
reliable: boolean;
best: MoodTimeOfDayBucket | null;
worst: MoodTimeOfDayBucket | null;
}
const LABEL_KEYS: Record<MoodTimeOfDayBucket, string> = {
morning: "insights.mood.timeOfDay.morning",
afternoon: "insights.mood.timeOfDay.afternoon",
evening: "insights.mood.timeOfDay.evening",
night: "insights.mood.timeOfDay.night",
};
function colorForScore(score: number): string {
if (score < 2) return "var(--dracula-red)";
if (score < 3) return "var(--dracula-orange)";
if (score < 3.5) return "var(--dracula-yellow)";
return "var(--dracula-green)";
}
export function MoodTimeOfDayChart({
pattern,
}: {
pattern: MoodTimeOfDayPattern;
}) {
const { t } = useTranslations();
const data = pattern.buckets.map((row) => ({
bucket: row.bucket,
label: t(LABEL_KEYS[row.bucket]),
value: row.avgScore ?? 0,
avgScore: row.avgScore,
count: row.count,
}));
const bestLabel = pattern.best ? t(LABEL_KEYS[pattern.best]) : null;
return (
<div className="space-y-2">
<div className="aspect-[3/2] min-h-[160px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
stroke="var(--dracula-comment)"
width={24}
/>
<ReferenceLine
y={3.5}
stroke="var(--dracula-green)"
strokeDasharray="3 3"
/>
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.75rem",
}}
itemStyle={{ color: "var(--dracula-fg)" }}
labelStyle={{ color: "var(--dracula-fg)" }}
formatter={(_value, _name, item) => {
const payload = item?.payload as
| { avgScore: number | null; count: number }
| undefined;
if (!payload || payload.avgScore == null) {
return [t("insights.mood.weekdayNoData"), ""];
}
return [
`${payload.avgScore.toFixed(1)} (${payload.count})`,
t("insights.mood.timeOfDay.title"),
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.bucket}
fill={
row.avgScore == null
? "var(--secondary)"
: colorForScore(row.avgScore)
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{bestLabel && (
<p className="text-muted-foreground text-xs">
{t("insights.mood.timeOfDay.bestCaption", { bucket: bestLabel })}
</p>
)}
</div>
);
}
@@ -0,0 +1,109 @@
"use client";
import { useRef, type KeyboardEvent } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
ANALYTICS_RANGES,
type AnalyticsRange,
} from "@/lib/analytics/range-shared";
/**
* v1.9.0 — `7d / 30d / 90d / 1y` segmented control for the Insights metric
* pages. The interaction model Apple Health / Oura users already know (a
* range selector across the top of a metric chart). Built as an accessible
* `radiogroup` of pills over the existing zinc/shadcn primitives rather than
* pulling a new Radix dependency — the project's tab strip uses the same
* custom-pill approach. The selected range drives the period-over-period
* delta caption and (where wired) the chart window.
*
* Follows the WAI-ARIA APG radiogroup pattern: the group is a single tab
* stop (only the checked radio is `tabIndex={0}`, the rest `-1`) and the
* arrow keys / Home / End move selection with a roving focus, so a keyboard
* user does not tab through all four and the arrow keys are not dead.
*/
export function TimeRangePills({
value,
onChange,
className,
}: {
value: AnalyticsRange;
onChange: (range: AnalyticsRange) => void;
className?: string;
}) {
const { t } = useTranslations();
const radiosRef = useRef<Array<HTMLButtonElement | null>>([]);
const selectAt = (index: number) => {
const next = ANALYTICS_RANGES[index];
onChange(next);
// Roving focus follows selection so the newly checked radio is focused.
radiosRef.current[index]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const current = ANALYTICS_RANGES.indexOf(value);
const last = ANALYTICS_RANGES.length - 1;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
selectAt(current >= last ? 0 : current + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
selectAt(current <= 0 ? last : current - 1);
break;
case "Home":
event.preventDefault();
selectAt(0);
break;
case "End":
event.preventDefault();
selectAt(last);
break;
}
};
return (
<div
role="radiogroup"
aria-label={t("insights.range.label")}
data-slot="time-range-pills"
onKeyDown={onKeyDown}
className={cn(
"bg-muted/50 inline-flex items-center gap-0.5 rounded-lg p-0.5",
className,
)}
>
{ANALYTICS_RANGES.map((range, index) => {
const selected = range === value;
return (
<button
key={range}
ref={(el) => {
radiosRef.current[index] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
data-range={range}
data-selected={selected}
onClick={() => onChange(range)}
className={cn(
"focus-visible:ring-ring rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`insights.range.option.${range}`)}
</button>
);
})}
</div>
);
}
+30 -1
View File
@@ -47,6 +47,26 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
const isOnboardingPage = pathname === "/onboarding";
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
// only for the body-scrolled shells (login / register / onboarding /
// standalone legal pages). The authenticated branch is height-locked and
// scrolls inside its own `<main>`, which reserves its own gutter; applying
// the document gutter there too produced a second, never-painted gutter on
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
// so the CSS rule scopes to them.
const isBodyScrolled = isPublicPage || isOnboardingPage;
useEffect(() => {
const root = document.documentElement;
if (isBodyScrolled) {
root.setAttribute("data-scroll", "body");
} else {
root.removeAttribute("data-scroll");
}
return () => {
root.removeAttribute("data-scroll");
};
}, [isBodyScrolled]);
useEffect(() => {
if (!isLoading && !isAuthenticated && !isPublicPage) {
router.replace("/auth/login");
@@ -172,7 +192,16 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
<TopBar />
<main
id="main-content"
className="flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
// `scrollbar-gutter: stable` keeps the scroll viewport's content
// box a fixed width whether or not the vertical scrollbar is
// painted. Without it a route / sub-tab whose body overflows
// (e.g. the admin "Insights Quality" section) shows the bar and
// narrows the content box, while a shorter one (e.g.
// "Integrations") hides it and widens the box — the `mx-auto`
// wrapper then recentres and the whole column, including the
// admin/settings sidebar, shifts a few px sideways on every
// toggle. Reserving the gutter holds the layout still.
className="flex-1 overflow-y-auto [scrollbar-gutter:stable] pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
+3 -4
View File
@@ -39,10 +39,9 @@ interface NavLink {
// Order is preserved relative to the v1.4.15 bottom-nav so muscle
// memory keeps working for the four core actions.
//
// v1.8.6 — the Targets (Zielwerte) page is deprecated: target editing
// moved inline into Insights, so its overflow entry is gone. The page
// stays routable (the `/zielwerte`→`/targets` proxy redirect remains)
// but no longer appears in the menu.
// v1.9.0 — the standalone Targets (Zielwerte) page is retired. Target
// editing lives inline on the Insights metric pages (the per-metric
// reference panel mounts the target editor), so there is no nav entry.
const PRIMARY: ReadonlyArray<NavLink> = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
@@ -1,13 +1,14 @@
/**
* v1.7.0 — AdvancedSettingsSheet composition contract.
*
* The sheet hosts four blocks — Reminders → Lifecycle → Data → Danger
* zone — composed from bare section bodies. `<ResponsiveSheet>`
* wraps Radix portals that `renderToStaticMarkup` does not materialise,
* so we mock it to a passthrough that renders children only when `open`
* and echoes `contentWidth`. The bodies are mocked to ordered markers.
* The test pins:
* - the four blocks render in the documented order;
* The sheet hosts five blocks — Reminders → Lifecycle → Data →
* Externe API → Danger zone — composed from bare section bodies.
* `<ResponsiveSheet>` wraps Radix portals that `renderToStaticMarkup`
* does not materialise, so we mock it to a passthrough that renders
* children only when `open` and echoes `contentWidth`. The bodies are
* mocked to ordered markers. The test pins:
* - the five blocks render in the documented order;
* - the external-API row lives in its own group, not in Data;
* - the sheet opens at the `2xl` width token;
* - `onRequestPhaseSheet` threads through to the phases row;
* - `onOpenImport` wires the import button (co-located with export);
@@ -53,6 +54,7 @@ vi.mock("@/components/medications/sections/notifications-section", () => ({
vi.mock("@/components/medications/sections/settings-section", () => ({
GraceRow: () => <div data-slot="mock-grace" />,
DrugCodingRow: () => <div data-slot="mock-drug-coding" />,
PhasesRow: ({
onRequestPhaseSheet,
}: {
@@ -107,7 +109,7 @@ function baseProps(
}
describe("AdvancedSettingsSheet (v1.7.0)", () => {
it("renders the four blocks in Reminders → Lifecycle → Data → Danger order", () => {
it("renders the five blocks in Reminders → Lifecycle → Data → Externe API → Danger order", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet
{...baseProps(true, { onRequestPhaseSheet: () => {} })}
@@ -116,11 +118,26 @@ describe("AdvancedSettingsSheet (v1.7.0)", () => {
const reminders = html.indexOf("advanced-group-reminders");
const lifecycle = html.indexOf("advanced-group-lifecycle");
const data = html.indexOf("advanced-group-data");
const externalApi = html.indexOf("advanced-group-external-api");
const danger = html.indexOf("mock-danger");
expect(reminders).toBeGreaterThan(-1);
expect(lifecycle).toBeGreaterThan(reminders);
expect(data).toBeGreaterThan(lifecycle);
expect(danger).toBeGreaterThan(data);
expect(externalApi).toBeGreaterThan(data);
expect(danger).toBeGreaterThan(externalApi);
});
it("homes the external-API row in its own group, not in the Data group", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
// The API row marker must sit after the external-API group opens,
// not inside the Data group it used to share.
const dataGroup = html.indexOf("advanced-group-data");
const externalApiGroup = html.indexOf("advanced-group-external-api");
const apiRow = html.indexOf("mock-api-tokens");
expect(apiRow).toBeGreaterThan(externalApiGroup);
expect(externalApiGroup).toBeGreaterThan(dataGroup);
});
it("opens at the 2xl width token", () => {
@@ -247,13 +247,14 @@ describe("<InjectionSitePicker>", () => {
});
it("uses the SVG body outline as the picker surface", () => {
// Sanity check the body outline renders — head circle + torso
// path are the load-bearing visual anchors for the click targets.
// Sanity check the body outline renders — the silhouette path is the
// load-bearing visual anchor for the click targets, drawn on the
// 100x200 viewBox the iOS reference coords are calibrated against.
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
);
expect(html).toMatch(/<svg[^>]*viewBox="0 0 120 300"/);
expect(html).toMatch(/<svg[^>]*viewBox="0 0 100 200"/);
expect(html).toContain('aria-label="Body outline"');
// The outer group has the localised "Once weekly on …" picker
// label so the wrapper announces its role properly.
@@ -6,15 +6,21 @@
* grid.
* v1.8.6 — rebuilt as a single column. The two-column data|reminders
* grid read as overloaded; the sheet now narrows to `2xl` (~672px) and
* stacks four bordered section cards in most-used-first order:
* Reminders → Lifecycle → Data → Danger. One spacing rule throughout —
* `space-y-4` between cards, `divide-y` + `py-3` rows inside each card
* (the `<SettingsGroup>` card owns the dividers).
* stacks bordered section cards in most-used-first order. One spacing
* rule throughout — `space-y-4` between cards, `divide-y` + `py-3` rows
* inside each card (the `<SettingsGroup>` card owns the dividers).
*
* v1.9.0 — the Data group split. It had carried both operator data
* portability and the developer-facing external API in one frame; the
* API endpoint now sits in its own `Externe API` group so the two
* audiences no longer share a card. Order: Reminders → Lifecycle →
* Data → Externe API → Danger.
*
* - Reminders: notifications switch + reminder window (grace).
* - Lifecycle: pause/resume + end course + phases.
* - Data: intake Import + medications CSV Export (co-located) +
* external-API endpoint / token.
* - Data: intake Import + medications CSV Export (co-located).
* - Externe API: the per-medication ingest endpoint + token, with the
* request examples collapsed by default.
* - Danger zone: purge intake history + delete medication, set off by
* its own destructive-tinted card so an irreversible action never
* sits next to a routine toggle.
@@ -35,6 +41,7 @@ import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row";
import { NotificationsBody } from "@/components/medications/sections/notifications-section";
import {
DrugCodingRow,
GraceRow,
PhasesRow,
} from "@/components/medications/sections/settings-section";
@@ -56,6 +63,9 @@ export interface AdvancedSettingsSheetProps {
notificationsEnabled: boolean;
reminderGraceMinutes: number | null;
intakeCount: number;
/** v1.9.0 — optional drug-classification codes for the FHIR export. */
atcCode?: string | null;
rxNormCode?: string | null;
/**
* Sibling-swap the phase sheet: the parent closes this sheet and
* opens `<PhaseConfigSheet>` so the two never stack (G-1 §5).
@@ -80,6 +90,8 @@ export function AdvancedSettingsSheet({
notificationsEnabled,
reminderGraceMinutes,
intakeCount,
atcCode,
rxNormCode,
onRequestPhaseSheet,
onOpenImport,
}: AdvancedSettingsSheetProps) {
@@ -142,7 +154,9 @@ export function AdvancedSettingsSheet({
)}
</SettingsGroup>
{/* DATA — import + export + external API */}
{/* DATA — import + export. The external API lives in its own
group below; this group stays scoped to operator data
portability so the two audiences don't share one frame. */}
<SettingsGroup
label={t("medications.detail.advanced.group.data")}
dataSlot="advanced-group-data"
@@ -153,12 +167,29 @@ export function AdvancedSettingsSheet({
onOpenImport={onOpenImport}
/>
</div>
</SettingsGroup>
{/* EXTERNAL API — the per-medication ingest endpoint + token.
Pulled out of the Data group into its own frame so the
developer/integration surface reads as a distinct, lowest-
frequency block right before the danger zone. */}
<SettingsGroup
label={t("medications.detail.advanced.group.externalApi")}
dataSlot="advanced-group-external-api"
>
<div className="py-3">
<ApiTokensRow
medicationId={medicationId}
medicationName={medicationName}
/>
</div>
<div className="py-3">
<DrugCodingRow
medicationId={medicationId}
atcCode={atcCode}
rxNormCode={rxNormCode}
/>
</div>
</SettingsGroup>
{/* DANGER ZONE — purge + delete. Set off by the destructive-
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
@@ -394,7 +395,7 @@ export function Glp1MedicationCard({
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
@@ -405,7 +406,7 @@ export function Glp1MedicationCard({
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="space-y-3.5">
<CardContent className="flex flex-1 flex-col space-y-3.5">
{/* Take-now / overdue / very-overdue pill, shared with the
generic medication card. The GLP-1 card historically
omitted this row, which made Mounjaro feel different from
@@ -419,8 +420,17 @@ export function Glp1MedicationCard({
/>
)}
{/* Injection state — last + next */}
<div className="space-y-1 text-sm">
{/* Injection state — last + next. The reserved min-height keeps a
card whose last-injection line is absent the same vertical
footprint as one that renders both lines, matching the generic
medication card's reserved last/next slot. This — together with
`h-full` + `mt-auto` — equalises the card height and pins a
shared bottom action baseline across the grid row. Note it does
NOT align the compliance bars row-for-row with a generic
sibling: the GLP-1-only rotation-hint block below pushes the
bars down by its own height, so only the header, the reserved
slot, and the pinned action row share a Y. */}
<div className="min-h-[2.75rem] space-y-1 text-sm">
{medication.lastTakenAt && (
<p className="text-muted-foreground">
{lastSite
@@ -493,12 +503,17 @@ export function Glp1MedicationCard({
{/* Primary actions row — shared with the generic medication
card. The GLP-1-specific side-effect quick-log lives in the
header-actions overflow (kebab), not this row, so Mounjaro
and Ramipril share the canonical two-button primary row. */}
and Ramipril share the canonical two-button primary row.
Pinned to the bottom with `mt-auto` so the action baseline is
shared across every card in the grid row, identical to the
generic medication card. */}
{medication.active && (
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
<div className="mt-auto pt-3.5">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
</div>
)}
</CardContent>
@@ -1,38 +1,58 @@
"use client";
import { useState } from "react";
import { useId, useRef, useState } from "react";
import { useTranslations } from "@/lib/i18n/context";
import {
INJECTION_SITE_KEYS,
SITE_COORDS,
describeInjectionSite,
nearestSiteAt,
nextInjectionSite,
type InjectionSiteKey,
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d — injection-site picker.
* Injection-site picker — anatomical front-of-body silhouette with 8
* tappable sites.
*
* Stylized front-of-body SVG with 8 click-targets. The active site
* highlights, the recommended next-site is ringed with a dashed outline
* so the user can rotate sensibly without consulting a separate guide.
* The silhouette is an INLINE SVG so it inherits the app's theme tokens
* (`currentColor` + CSS variables) and tints correctly in light/dark and
* across the zinc/Dracula palettes with no raster asset and no luminance
* key. The body is traced to the proportions the iOS client calibrated
* against (see `.planning/ios-coord/v0.12-…-injection-bodymap-coords.md`)
* so dots land on the same anatomy on both platforms.
*
* Per Marc's "nichts brickt" directive the picker is opt-in — it's only
* `viewBox="0 0 100 200"` (aspect ≈0.5). `SITE_COORDS` is expressed on
* the same viewBox; the recommended-next ring, the last-used marker and
* the selected fill all read directly from those coordinates.
*
* Per the long-standing "nichts brickt" directive the picker is opt-in —
* mounted from the GLP-1 dashboard tile and the optional log dialog.
* Existing medication intake flows continue to work without it (the
* intake-event row's `injectionSite` field stays NULL).
* Existing intake flows keep working without it (the row's
* `injectionSite` stays NULL).
*
* Mobile-first: the picker is centred in a 320px column and remains
* touch-friendly at Pixel-5 width. SVG buttons have 44px hit areas
* (the visible circle is smaller but the <button> wrapper meets WCAG).
* Pointer routing: a single transparent overlay rectangle spans the
* whole SVG and resolves every tap to the NEAREST site centre via
* {@link nearestSiteAt} (the overlay projects the client point into
* viewBox space through the live `getScreenCTM()`). This replaces the
* old per-dot r=14 hit-circles, whose Ø=28u targets overlapped the
* Δy=14u-apart abdomen quadrants by ~14u — SVG hit-testing resolves to
* the topmost-painted element, not the nearest centre, so taps in that
* band logged the WRONG quadrant. The overlay makes "nearest-centre
* wins" actually true and keeps full-area (≥44px-equivalent) routing
* since every pixel maps to its closest site; the visible dots stay
* small (r=4.5). Keyboard / assistive-tech users get a per-site
* focusable r=6 circle (non-overlapping at this spacing) carrying the
* `role="button"` + `aria-label` + Space/Enter handler.
*/
interface InjectionSitePickerProps {
/** Current selection, if any. */
value: InjectionSiteKey | null;
/** Recent rotation history (most recent first). Powers the
* "recommended next site" dashed-ring annotation. */
* "recommended next site" dashed-ring annotation and the subtle
* "last used" marker. */
history?: ReadonlyArray<InjectionSiteKey>;
/**
* v1.8.5 — the effective allowed set (per-medication allowed sites
@@ -58,6 +78,7 @@ export function InjectionSitePicker({
}: InjectionSitePickerProps) {
const { t } = useTranslations();
const recommended = nextInjectionSite(history, 4, allowed);
const lastUsed = history[0] ?? null;
const allowedSet = allowed ? new Set(allowed) : null;
const isAllowed = (site: InjectionSiteKey) =>
allowedSet === null || allowedSet.has(site);
@@ -67,6 +88,27 @@ export function InjectionSitePicker({
// map (WCAG 2.4.7).
const [focusedSite, setFocusedSite] = useState<InjectionSiteKey | null>(null);
// Stable, instance-unique ids for the SVG paint servers so two pickers
// on one page can't collide their <defs>.
const uid = useId().replace(/[:]/g, "");
const bodyFillId = `inj-body-${uid}`;
// The overlay maps a pointer event to viewBox space through the live
// CTM, then resolves the nearest allowed site centre and selects it.
const svgRef = useRef<SVGSVGElement>(null);
const handleOverlayPick = (clientX: number, clientY: number) => {
const svg = svgRef.current;
if (!svg) return;
const ctm = svg.getScreenCTM();
if (!ctm) return;
const point = svg.createSVGPoint();
point.x = clientX;
point.y = clientY;
const local = point.matrixTransform(ctm.inverse());
const site = nearestSiteAt(local.x, local.y, allowed);
if (site) onChange(site);
};
// Accessible name: fold the rotation recommendation into the label so a
// screen-reader user gets the "recommended next site" cue that is
// otherwise conveyed only by the dashed ring.
@@ -84,90 +126,108 @@ export function InjectionSitePicker({
aria-label={t("medications.injectionSitePickerAriaLabel")}
>
<svg
viewBox="0 0 120 300"
className="text-foreground/40 h-[320px] w-auto max-w-full"
ref={svgRef}
viewBox="0 0 100 200"
className="text-foreground/45 h-[320px] w-auto max-w-full"
role="img"
aria-label={t("medications.injectionSiteBodyOutlineAriaLabel")}
>
{/* Head */}
<circle
cx="60"
cy="22"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Neck */}
<defs>
{/* A whisper-soft vertical wash gives the flat silhouette a
little depth without fighting the theme — both stops are the
current text colour at low alpha, so it tints with the body. */}
<linearGradient id={bodyFillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.06" />
<stop offset="1" stopColor="currentColor" stopOpacity="0.10" />
</linearGradient>
</defs>
{/* Anatomical front-of-body silhouette — one continuous outline
traced to the iOS reference proportions (upright, arms slightly
out, plain oval head). Filled with the soft wash + a hairline
stroke so it reads as a calm medical illustration. */}
<path
d="M 56 36 L 56 46 L 64 46 L 64 36"
fill="none"
d="M 50 4
C 44.2 4 39.8 8.6 39.8 15
C 39.8 19.6 41.4 23.4 44 25.6
C 43.4 28.4 42 30.4 39.2 31.4
C 35 32.9 30.4 34.2 27.4 36.6
C 24.4 39 23 42.8 22.2 47.4
C 21.2 53.2 20.2 60 18.2 67
C 16.4 73.4 14 80.4 12.6 86.4
C 11.8 89.8 12.4 92.2 14.2 92.8
C 16 93.4 17.6 91.8 18.4 88.6
C 19.8 83 21.8 76.6 23.6 71
C 24.4 68.4 25.4 67.8 25.8 69
C 26.2 70.2 26 74 25.6 79
C 25.2 84.6 24.8 90.6 25.2 95.6
C 25.6 100.4 26.8 104.4 28 107.8
C 27.2 119 26.2 133 26.6 145
C 27 156.6 28.6 170 30 182
C 30.6 187.4 31.4 191.6 33 193.4
C 34.4 195 37 195.2 39 194.6
C 40.8 194 41.4 192.2 41.6 189.4
C 42.2 181.4 43 169 44 158
C 44.8 149.4 45.6 141.4 46.4 136
C 47 131.8 48.2 129.6 50 129.6
C 51.8 129.6 53 131.8 53.6 136
C 54.4 141.4 55.2 149.4 56 158
C 57 169 57.8 181.4 58.4 189.4
C 58.6 192.2 59.2 194 61 194.6
C 63 195.2 65.6 195 67 193.4
C 68.6 191.6 69.4 187.4 70 182
C 71.4 170 73 156.6 73.4 145
C 73.8 133 72.8 119 72 107.8
C 73.2 104.4 74.4 100.4 74.8 95.6
C 75.2 90.6 74.8 84.6 74.4 79
C 74 74 73.8 70.2 74.2 69
C 74.6 67.8 75.6 68.4 76.4 71
C 78.2 76.6 80.2 83 81.6 88.6
C 82.4 91.8 84 93.4 85.8 92.8
C 87.6 92.2 88.2 89.8 87.4 86.4
C 86 80.4 83.6 73.4 81.8 67
C 79.8 60 78.8 53.2 77.8 47.4
C 77 42.8 75.6 39 72.6 36.6
C 69.6 34.2 65 32.9 60.8 31.4
C 58 30.4 56.6 28.4 56 25.6
C 58.6 23.4 60.2 19.6 60.2 15
C 60.2 8.6 55.8 4 50 4 Z"
fill={`url(#${bodyFillId})`}
stroke="currentColor"
strokeWidth="1.5"
strokeWidth="0.9"
strokeLinejoin="round"
/>
{/* Torso */}
<path
d="M 40 50 Q 30 60 28 80 L 32 175 Q 40 185 60 185 Q 80 185 88 175 L 92 80 Q 90 60 80 50 Z"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Arms */}
<path
d="M 32 60 L 16 110 L 14 150"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 88 60 L 104 110 L 106 150"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Thighs */}
<path
d="M 36 185 L 30 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 50 185 L 50 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 70 185 L 70 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 84 185 L 90 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Belt line */}
{/* Centre + waist references — barely-there guides that help the
eye read the abdomen quadrants. Kept low-alpha so they never
compete with the dots. */}
<line
x1="32"
y1="155"
x2="88"
y2="155"
x1="50"
y1="34"
x2="50"
y2="104"
stroke="currentColor"
strokeWidth="0.8"
strokeDasharray="2 3"
strokeWidth="0.4"
strokeOpacity="0.35"
/>
<line
x1="29"
y1="88"
x2="71"
y2="88"
stroke="currentColor"
strokeWidth="0.4"
strokeOpacity="0.35"
/>
{/* Navel reference */}
<circle cx="60" cy="130" r="1.5" fill="currentColor" />
<circle cx="50" cy="88" r="0.9" fill="currentColor" fillOpacity="0.45" />
{/* Click targets */}
{INJECTION_SITE_KEYS.map((site) => {
const coord = SITE_COORDS[site];
const isActive = site === value;
const isRecommended = site === recommended;
const isLastUsed = site === lastUsed && !isActive;
// v1.8.5 — a site outside the effective allowed set renders
// disabled (dimmed, non-interactive) so the user cannot pick a
// site the server would reject.
@@ -182,27 +242,41 @@ export function InjectionSitePicker({
<circle
cx={coord.x}
cy={coord.y}
r="10.5"
r="8"
fill="none"
className="stroke-ring"
strokeWidth="2.5"
strokeWidth="1.8"
/>
)}
{/* Last-used marker — a soft solid ring so the user can see
where they injected most recently at a glance, distinct
from the dashed recommendation ring. Suppressed on the
active selection (the filled dot already says "here"). */}
{isLastUsed && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
r="7"
fill="none"
className="stroke-foreground/35"
strokeWidth="1"
/>
)}
{isRecommended && !isActive && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
r="9"
r="7"
fill="none"
className="stroke-primary"
strokeWidth="1.5"
strokeWidth="1.1"
strokeDasharray="2 2"
/>
)}
<circle
cx={coord.x}
cy={coord.y}
r="6"
r="4.5"
className={
disabled
? "fill-muted/30 stroke-foreground/20"
@@ -210,26 +284,24 @@ export function InjectionSitePicker({
? "fill-primary stroke-primary-foreground"
: "fill-muted stroke-foreground/60 hover:fill-accent"
}
strokeWidth="1.5"
strokeWidth="1.1"
/>
{/* Invisible 24-unit hit-target for touch. The SVG renders
at ≈1.07× scale (320px box / 300-unit viewBox height),
so r=12 gives ≈25.6 CSS px diameter — clearing the
WCAG 2.5.8 Level AA 24×24 floor. Going larger would
overlap the abdomen-left / abdomen-right pair at
Δx=24 units; the picker stays interactive without
spacing collisions. */}
{/* Per-site focusable target for keyboard + assistive tech.
r=6 (Ø=12u) keeps it well inside the Δy=14u abdomen
spacing so adjacent targets never overlap — pointer
routing is owned by the single nearest-centre overlay
below, so this circle no longer needs to be a 44px tap
target. Keyboard activation (Space/Enter) stays here. */}
<circle
cx={coord.x}
cy={coord.y}
r="12"
r="6"
fill="transparent"
role="button"
tabIndex={disabled ? -1 : 0}
aria-pressed={isActive}
aria-disabled={disabled}
aria-label={siteLabel(site)}
onClick={disabled ? undefined : () => onChange(site)}
onFocus={disabled ? undefined : () => setFocusedSite(site)}
onBlur={
disabled
@@ -258,6 +330,26 @@ export function InjectionSitePicker({
</g>
);
})}
{/* Single transparent pointer overlay — topmost in paint order so
it catches every tap/click, projects it into viewBox space via
the live CTM, and routes to the NEAREST site centre. This is
the correctness fix for the overlapping abdomen quadrants: a
tap in the former Δy=14u overlap band now deterministically
picks the nearer quadrant rather than the last-painted dot.
Full-area routing also means there are no sub-44px dead zones.
`aria-hidden` keeps it out of the AT tree — the per-site
focusable circles above own keyboard + screen-reader access. */}
<rect
x="0"
y="0"
width="100"
height="200"
fill="transparent"
aria-hidden="true"
className="cursor-pointer focus:outline-none"
onClick={(e) => handleOverlayPick(e.clientX, e.clientY)}
/>
</svg>
<div className="text-center text-xs">
+80 -67
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useReducer } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
@@ -299,7 +300,7 @@ export function MedicationCard({
);
return (
<Card className={medication.active ? "" : "opacity-60"}>
<Card className={cn("h-full", medication.active ? "" : "opacity-60")}>
<MedicationCardHeader
name={medication.name}
dose={medication.dose}
@@ -310,7 +311,7 @@ export function MedicationCard({
linkLabel={t("medications.openDetailPage")}
/>
<CardContent className="space-y-3.5">
<CardContent className="flex flex-1 flex-col space-y-3.5">
{/* Status, last & next intake info */}
{currentWindowStatus.status && (
<MedicationStatusPill
@@ -320,72 +321,79 @@ export function MedicationCard({
/>
)}
{nextSchedule &&
currentWindowStatus.status !== "in_window" &&
(() => {
const s = nextSchedule;
{/* Next / last intake — wrapped in a fixed min-height slot so a
card missing its last-dose line keeps the same vertical
footprint as a sibling that renders both lines. Without the
reserved height the compliance bars and action row below would
start at a different Y card-to-card across the 2-col grid. */}
<div className="min-h-[2.75rem] space-y-3.5">
{nextSchedule &&
currentWindowStatus.status !== "in_window" &&
(() => {
const s = nextSchedule;
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toBerlinDate(new Date(nextAt));
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
// Format day label relative to today
let dayLabel = "";
if (nextAt) {
const nextDate = toBerlinDate(new Date(nextAt));
const todayStr = `${nowBerlin.getFullYear()}-${nowBerlin.getMonth()}-${nowBerlin.getDate()}`;
const nextStr = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`;
const tomorrow = new Date(nowBerlin);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = `${tomorrow.getFullYear()}-${tomorrow.getMonth()}-${tomorrow.getDate()}`;
const diffDays = Math.round(
(nextDate.getTime() - nowBerlin.getTime()) /
(24 * 60 * 60 * 1000),
);
const diffDays = Math.round(
(nextDate.getTime() - nowBerlin.getTime()) /
(24 * 60 * 60 * 1000),
);
if (nextStr === todayStr) {
dayLabel = t("medications.today");
} else if (nextStr === tomorrowStr) {
dayLabel = t("medications.tomorrow");
} else if (diffDays <= 5) {
const weekdayLabels = [
t("medications.weekdaySunday"),
t("medications.weekdayMonday"),
t("medications.weekdayTuesday"),
t("medications.weekdayWednesday"),
t("medications.weekdayThursday"),
t("medications.weekdayFriday"),
t("medications.weekdaySaturday"),
];
dayLabel = weekdayLabels[nextDate.getDay()];
} else {
dayLabel = fmt.dateWithWeekday(nextDate);
if (nextStr === todayStr) {
dayLabel = t("medications.today");
} else if (nextStr === tomorrowStr) {
dayLabel = t("medications.tomorrow");
} else if (diffDays <= 5) {
const weekdayLabels = [
t("medications.weekdaySunday"),
t("medications.weekdayMonday"),
t("medications.weekdayTuesday"),
t("medications.weekdayWednesday"),
t("medications.weekdayThursday"),
t("medications.weekdayFriday"),
t("medications.weekdaySaturday"),
];
dayLabel = weekdayLabels[nextDate.getDay()];
} else {
dayLabel = fmt.dateWithWeekday(nextDate);
}
}
}
return (
<p className="text-muted-foreground text-sm">
<span className="font-medium">
{t("medications.nextIntake")}
</span>{" "}
{dayLabel && `${dayLabel}, `}
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{s.label && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
{s.dose && (
<span className="hidden font-medium text-purple-400 sm:inline">
{" "}
— {s.dose}
</span>
)}
</p>
);
})()}
return (
<p className="text-muted-foreground text-sm">
<span className="font-medium">
{t("medications.nextIntake")}
</span>{" "}
{dayLabel && `${dayLabel}, `}
{formatTimeWindowRange(s.windowStart, s.windowEnd, locale)}
{s.label && (
<span className="hidden sm:inline"> ({s.label})</span>
)}
{s.dose && (
<span className="hidden font-medium text-purple-400 sm:inline">
{" "}
— {s.dose}
</span>
)}
</p>
);
})()}
{medication.lastTakenAt && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">{t("medications.lastIntake")}</span>{" "}
{formatLastTakenAt(medication.lastTakenAt)}
</p>
)}
{medication.lastTakenAt && (
<p className="text-muted-foreground text-sm">
<span className="font-medium">{t("medications.lastIntake")}</span>{" "}
{formatLastTakenAt(medication.lastTakenAt)}
</p>
)}
</div>
{/* Compliance bars — always two rows. The server scales the two
windows to the dosing cadence (7 / 30 days for dense meds,
@@ -401,12 +409,17 @@ export function MedicationCard({
/>
)}
{/* Quick actions — primary buttons of the medication card. */}
{/* Quick actions — primary buttons of the medication card.
Pinned to the bottom with `mt-auto` so the action baseline is
shared across every card in the grid row regardless of how many
optional rows above it rendered. */}
{medication.active && (
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
<div className="mt-auto pt-3.5">
<MedicationIntakeActions
intakeLoading={intakeLoading}
onRecordIntake={recordIntake}
/>
</div>
)}
</CardContent>
@@ -19,7 +19,7 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Copy, KeyRound } from "lucide-react";
import { ChevronDown, ChevronUp, Copy, KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -58,6 +58,11 @@ export function ApiTokensRow({
const [mintedToken, setMintedToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [exampleType, setExampleType] = useState<ExampleType>("curl");
// The request examples are the tallest, lowest-frequency content in
// the sheet — collapsed by default so the verbose `<pre>` snippets stay
// out of the default viewport (and the live-interpolated bearer token
// stays out of the default-visible DOM until the user opts in).
const [examplesOpen, setExamplesOpen] = useState(false);
// v1.5.5 F-1 H-2 — single source of truth for the per-medication
// api-endpoint key. The earlier inline `useMemo` minted the same
@@ -198,8 +203,12 @@ export function ApiTokensRow({
<p className="text-muted-foreground text-xs">
{t("medications.detail.api.caption", { name: medicationName })}
</p>
{/* The raw endpoint URL is redundant with the copy button below, so
it is rendered muted + single-line-truncated rather than as a
full-width block — the long absolute URL was pure visual noise. */}
<code
className="bg-muted block rounded px-3 py-2 font-mono text-xs break-all"
className="text-muted-foreground bg-muted/60 block truncate rounded px-2 py-1 font-mono text-xs"
title={endpoint}
data-slot="api-endpoint-url"
>
POST {endpoint}
@@ -259,43 +268,69 @@ export function ApiTokensRow({
{/* v1.6.0 — multi-language request snippets (restored from the
v1.5.4 endpoint dialog). The minted token is interpolated
live; until then the snippet carries a `YOUR_TOKEN` stand-in. */}
<div className="space-y-1.5 pt-1" data-slot="api-tokens-examples">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs font-medium">
live; until then the snippet carries a `YOUR_TOKEN` stand-in.
v1.9.0 — collapsed by default. The 4–9-line `<pre>` snippets are
the biggest vertical offender in the sheet, and the language
`Select` + the live-interpolated bearer token only render once
the user opts in. */}
<div className="pt-1" data-slot="api-tokens-examples">
<Button
type="button"
variant="ghost"
size="sm"
className="h-9 w-full justify-between px-2"
aria-expanded={examplesOpen}
onClick={() => setExamplesOpen((prev) => !prev)}
data-slot="api-tokens-examples-toggle"
>
<span className="text-xs font-medium">
{t("medications.requestExample")}
</Label>
<Select
value={exampleType}
onValueChange={(value) => setExampleType(value as ExampleType)}
>
<SelectTrigger size="sm" className="w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="curl">cURL</SelectItem>
<SelectItem value="wget">wget</SelectItem>
<SelectItem value="fetch">JavaScript fetch</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative">
<pre className="bg-muted rounded-lg p-3 pr-10 font-mono text-xs break-all whitespace-pre-wrap">
{selectedExample.value}
</pre>
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-7 w-7"
onClick={() =>
void copy(selectedExample.value, t("common.copied"))
}
aria-label={t("common.copy")}
>
<Copy aria-hidden="true" className="h-3 w-3" />
</Button>
</div>
</span>
{examplesOpen ? (
<ChevronUp aria-hidden="true" className="h-4 w-4" />
) : (
<ChevronDown aria-hidden="true" className="h-4 w-4" />
)}
</Button>
{examplesOpen && (
<div className="mt-2 space-y-1.5" data-slot="api-tokens-examples-body">
<div className="flex items-center justify-end gap-2">
<Label className="sr-only">
{t("medications.requestExample")}
</Label>
<Select
value={exampleType}
onValueChange={(value) => setExampleType(value as ExampleType)}
>
<SelectTrigger size="sm" className="w-[170px]">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="curl">cURL</SelectItem>
<SelectItem value="wget">wget</SelectItem>
<SelectItem value="fetch">JavaScript fetch</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative">
<pre className="bg-muted rounded-lg p-3 pr-10 font-mono text-xs break-all whitespace-pre-wrap">
{selectedExample.value}
</pre>
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-7 w-7"
onClick={() =>
void copy(selectedExample.value, t("common.copied"))
}
aria-label={t("common.copy")}
>
<Copy aria-hidden="true" className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
</div>
);
@@ -131,6 +131,111 @@ export function GraceRow({
);
}
/**
* v1.9.0 — optional drug-classification codes (ATC / RxNorm) row.
* Self-saves via `PUT /api/medications/[id]` with the two top-level
* `atcCode` / `rxNormCode` fields. The codes are user/clinician-asserted
* and surface on the FHIR health-record export's
* `medicationCodeableConcept` (ATC primary, RxNorm secondary); the app
* never machine-guesses a code. An empty input clears the stored code.
*/
export function DrugCodingRow({
medicationId,
atcCode,
rxNormCode,
}: {
medicationId: string;
atcCode?: string | null;
rxNormCode?: string | null;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [atcValue, setAtcValue] = useState(atcCode ?? "");
const [rxValue, setRxValue] = useState(rxNormCode ?? "");
const [busy, setBusy] = useState(false);
async function save() {
if (busy) return;
setBusy(true);
try {
// Empty input clears the column (null); a non-empty value is sent
// verbatim for the server to validate (422 on a malformed code).
const res = await fetch(`/api/medications/${medicationId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
atcCode: atcValue.trim() === "" ? null : atcValue.trim(),
rxNormCode: rxValue.trim() === "" ? null : rxValue.trim(),
}),
});
if (!res.ok) {
toast.error(t("medications.detail.settings.codes.failed"));
return;
}
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("medications.detail.settings.codes.saved"));
} catch {
toast.error(t("medications.detail.settings.codes.failed"));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-2" data-slot="medication-detail-drug-coding-row">
<Label className="text-foreground text-sm font-medium">
{t("medications.detail.settings.codes.label")}
</Label>
<p className="text-muted-foreground text-xs">
{t("medications.detail.settings.codes.note")}
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label
htmlFor="medication-detail-atc-input"
className="text-muted-foreground text-xs"
>
{t("medications.detail.settings.codes.atcLabel")}
</Label>
<Input
id="medication-detail-atc-input"
value={atcValue}
onChange={(e) => setAtcValue(e.target.value.toUpperCase())}
placeholder="A10BX10"
className="w-32"
/>
</div>
<div className="space-y-1">
<Label
htmlFor="medication-detail-rxnorm-input"
className="text-muted-foreground text-xs"
>
{t("medications.detail.settings.codes.rxNormLabel")}
</Label>
<Input
id="medication-detail-rxnorm-input"
inputMode="numeric"
value={rxValue}
onChange={(e) => setRxValue(e.target.value)}
placeholder="2601723"
className="w-32"
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void save()}
disabled={busy}
aria-busy={busy || undefined}
className="min-h-11 sm:min-h-9"
>
{t("common.save")}
</Button>
</div>
</div>
);
}
/**
* v1.7.0 — phases / course-window row. GLP-1 only; the button mounts
* once a course window is set, otherwise an italic hint. When
@@ -145,10 +145,10 @@ describe("<SettingsShell>", () => {
const html = renderShell({ active: "account", locale: "en" });
expect(html).toContain("Account");
expect(html).toContain("Integrations");
// v1.4.33 IW7 — section renamed from "Notifications" to
// "Notification channels" so it doesn't collide with the inbox at
// `/notifications` ("Notification Center").
expect(html).toContain("Notification channels");
// v1.9.0 — the section label is back to the shorter "Notifications"
// (single-line; the longer "Notification channels" wrapped). The
// `/notifications` inbox is still "Notification Center" — distinct enough.
expect(html).toContain("Notifications");
expect(html).toContain("Dashboard");
expect(html).toContain("Insights");
// The ampersand is HTML-escaped by React SSR — assert on the encoded
@@ -173,10 +173,10 @@ describe("<SettingsShell>", () => {
const html = renderShell({ active: "account", locale: "de" });
expect(html).toContain("Konto");
expect(html).toContain("Integrationen");
// v1.4.33 IW7 — section renamed from "Benachrichtigungen" to
// "Benachrichtigungs-Kanäle" so it doesn't collide with the inbox at
// `/notifications` ("Benachrichtigungs-Center").
expect(html).toContain("Benachrichtigungs-Kanäle");
// v1.9.0 — back to the shorter "Benachrichtigungen" (single-line; the
// compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications`
// inbox stays "Benachrichtigungs-Center" — distinct enough.
expect(html).toContain("Benachrichtigungen");
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
// "Dashboard" (matching the term users see in the main nav). The
// per-metric overrides moved out into their own "Persönliche Zielwerte"
@@ -1,227 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { TargetCard, type TargetCardData } from "../target-card";
import { DEFAULT_ASSISTANT_FLAGS } from "@/hooks/use-feature-flags";
// v1.4.37 W4a item 9 — when the operator disables the Coach feature
// flag globally, the per-card CTA must disappear. The hook reads from
// QueryClientContext but falls back to the all-on default in SSR;
// the flag-off path needs an explicit mock so the test can drive the
// negative case without a QueryClientProvider.
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: vi.fn(() => actual.DEFAULT_ASSISTANT_FLAGS),
};
});
const { useFeatureFlags } = await import("@/hooks/use-feature-flags");
/**
* v1.4.25 W3e — `<TargetCard>` composition. Three load-bearing cases:
*
* 1. Full-data card — headline + status pill + range bar +
* consistency strip + Coach CTA + source link all visible.
* 2. Insufficient-data card — strip + recency line + streak chip
* all suppressed; the explicit "not enough data yet" inline
* message renders instead.
* 3. AI gate — Coach CTA suppressed when `aiEnabled` is false.
*/
function render(props: Parameters<typeof TargetCard>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetCard {...props} />
</I18nProvider>,
);
}
const fullDataTarget: TargetCardData = {
type: "WEIGHT",
label: "Weight",
current: 78.2,
average30: 79.1,
trend: "down",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: "2026-05-10",
streakDays: 4,
insufficientData: false,
consistency7d: ["in", "in", "near", "in", "out", "in", "in"],
};
const sparseTarget: TargetCardData = {
type: "PULSE",
label: "Resting pulse",
current: 72,
average30: 72,
trend: null,
unit: "bpm",
range: { min: 60, max: 100 },
classification: { category: "On target", color: "#50fa7b" },
source: "AHA",
daysInRange7d: 0,
daysLogged7d: 0,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: true,
consistency7d: [null, null, null, null, null, null, null],
};
describe("<TargetCard>", () => {
it("renders the full layout when the target has rich data", () => {
const html = render({
target: fullDataTarget,
aiEnabled: true,
onAskCoach: vi.fn(),
});
expect(html).toContain('data-slot="target-card"');
expect(html).toContain('data-slot="target-status-pill"');
expect(html).toContain('data-slot="target-range-bar"');
expect(html).toContain('data-slot="consistency-strip"');
expect(html).toContain('data-slot="target-coach-cta"');
expect(html).toContain('data-slot="target-streak"');
// Streak chip surfaces the count from i18n
expect(html).toContain("4-day streak");
// Headline number (78.2 → 78.2) + the kg unit
expect(html).toContain("78.2");
});
it("hides the consistency strip + recency + streak when insufficient data", () => {
const html = render({
target: sparseTarget,
aiEnabled: true,
onAskCoach: vi.fn(),
});
expect(html).not.toContain('data-slot="consistency-strip"');
expect(html).not.toContain('data-slot="target-last-met"');
expect(html).not.toContain('data-slot="target-streak"');
// The explicit "not enough data yet" message replaces the strip
expect(html).toContain('data-slot="target-insufficient-data"');
});
it("suppresses the Coach CTA when aiEnabled is false", () => {
const html = render({
target: fullDataTarget,
aiEnabled: false,
onAskCoach: vi.fn(),
});
expect(html).not.toContain('data-slot="target-coach-cta"');
});
// v1.4.37 W4a item 9 — operator-level feature flag.
it("suppresses the Coach CTA when the global Coach flag is OFF", () => {
// Swap the mock for this case only — flag-off should hide the
// CTA regardless of the user-level aiEnabled state. Restore the
// all-on default so the rest of the suite doesn't see a leaked
// mock state.
vi.mocked(useFeatureFlags).mockReturnValueOnce({
...DEFAULT_ASSISTANT_FLAGS,
coach: false,
});
const html = render({
target: fullDataTarget,
aiEnabled: true,
onAskCoach: vi.fn(),
});
expect(html).not.toContain('data-slot="target-coach-cta"');
});
it("renders the Coach CTA when both aiEnabled AND the global Coach flag are ON", () => {
// Positive control: the default-mock returns the all-on flag set,
// so the CTA should mount when aiEnabled is also true.
const html = render({
target: fullDataTarget,
aiEnabled: true,
onAskCoach: vi.fn(),
});
expect(html).toContain('data-slot="target-coach-cta"');
});
it("renders MOOD_STABILITY headline as a verbal label, not the σ value", () => {
const moodStability: TargetCardData = {
...fullDataTarget,
type: "MOOD_STABILITY",
label: "Mood stability",
current: 0.6,
unit: "σ",
range: { min: 0, max: 0.5 },
classification: { category: "Stable", color: "#f1fa8c" },
};
const html = render({
target: moodStability,
aiEnabled: false,
onAskCoach: vi.fn(),
});
// The big-number slot should carry the EN verbal label, not "0.6".
expect(html).toContain('data-mood-stability="stable"');
expect(html).toContain("stable");
expect(html).not.toMatch(/>0\.6</);
});
/**
* v1.4.25 W3f — per-card edit cog. The cog must render on EVERY
* card regardless of insufficient-data state so the user always has
* an entry point to adjust the target range.
*/
describe("per-card edit cog (v1.4.25 W3f)", () => {
it("renders the edit cog with an aria-label for the rich-data card", () => {
const html = render({
target: fullDataTarget,
aiEnabled: true,
onAskCoach: vi.fn(),
});
expect(html).toContain('data-slot="target-edit-cog"');
expect(html).toContain('data-target-type="WEIGHT"');
expect(html).toContain("Edit target range for Weight");
});
it("STILL renders the cog when the target is in insufficient-data mode", () => {
// Consistency rule: target-config UI must always be reachable,
// even when the consistency strip is hidden.
const html = render({
target: sparseTarget,
aiEnabled: true,
onAskCoach: vi.fn(),
});
expect(html).toContain('data-slot="target-edit-cog"');
expect(html).toContain('data-target-type="PULSE"');
});
it("renders the cog when the AI provider is disabled (gate is independent)", () => {
// The Coach CTA depends on aiEnabled; the edit cog does not.
const html = render({
target: fullDataTarget,
aiEnabled: false,
onAskCoach: vi.fn(),
});
expect(html).toContain('data-slot="target-edit-cog"');
expect(html).not.toContain('data-slot="target-coach-cta"');
});
it("renders the cog button with adequate touch target (min-h-11 + min-w-11)", () => {
// Mobile-first: tap target ≥ 44 px so the cog isn't an
// accessibility hazard on smaller phones.
const html = render({
target: fullDataTarget,
aiEnabled: true,
onAskCoach: vi.fn(),
});
expect(html).toMatch(
/data-slot="target-edit-cog"[^>]*class="[^"]*min-h-11[^"]*"/,
);
expect(html).toMatch(
/data-slot="target-edit-cog"[^>]*class="[^"]*min-w-11[^"]*"/,
);
});
});
});
@@ -1,70 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { TargetCoachButton } from "../target-coach-button";
/**
* v1.4.25 W3e — gate test for the per-card Coach CTA. The button
* MUST disappear when `aiEnabled` is false (the maintainer's rule:
* no broken-button state for users with no provider configured).
*/
function render(props: Parameters<typeof TargetCoachButton>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetCoachButton {...props} />
</I18nProvider>,
);
}
describe("<TargetCoachButton>", () => {
const baseProps = {
prefill: "How am I doing on weight?",
sources: ["weight"] as const,
onAskCoach: vi.fn(),
};
it("renders the button when aiEnabled is true", () => {
const html = render({ ...baseProps, aiEnabled: true });
expect(html).toContain('data-slot="target-coach-cta"');
// v1.4.28 FB-L1 — the affordance is now icon-only; the label
// survives as an aria-label + title so screen readers + tooltips
// still surface "Ask Coach" / "Coach fragen".
expect(html).toContain('aria-label="Ask Coach"');
expect(html).toContain('title="Ask Coach"');
// No visible text label — only the Sparkles glyph.
expect(html).not.toMatch(/>Ask Coach</);
// UI-H2 — the Coach-launch glyph is one Sparkles vocabulary
// across hero strip, inline pill, layout FAB and per-card icon.
expect(html).toMatch(/lucide-sparkles/i);
expect(html).not.toMatch(/lucide-message-circle/i);
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The icon variant alone ships `size-10` (40 px); the per-card
// Coach button rides `min-h-11 min-w-11` on top so the hit
// surface matches the medication-history and Coach drawer
// buttons. Pinning the class so a future refactor can't drop
// back to the 40 px shadcn default.
const html = render({ ...baseProps, aiEnabled: true });
const trigger = html.match(
/<button[^>]*data-slot="target-coach-cta"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("renders NOTHING when aiEnabled is false (no broken-button state)", () => {
const html = render({ ...baseProps, aiEnabled: false });
expect(html).toBe("");
});
it("does not call onAskCoach during SSR", () => {
const onAskCoach = vi.fn();
render({ ...baseProps, onAskCoach, aiEnabled: true });
// Server-side render does not fire click handlers.
expect(onAskCoach).not.toHaveBeenCalled();
});
});
@@ -96,6 +96,26 @@ describe("<TargetEditSheet>", () => {
).not.toThrow();
});
it("mounts a glucose target seeded in mmol/L without throwing", () => {
// The parent hands a mmol/L-converted range + unit="mmol/L"; the
// sheet now seeds/validates in mmol/L and converts back to mg/dL on
// save. Smoke-check the new conversion path mounts cleanly.
expect(() =>
renderToStaticMarkup(
withProviders(
<TargetEditSheet
targetType="BLOOD_GLUCOSE_FASTING"
targetLabel="Fasting glucose"
unit="mmol/L"
initialRange={{ min: 3.9, max: 5.5 }}
open={true}
onOpenChange={vi.fn()}
/>,
),
),
).not.toThrow();
});
it("mounts the body without throwing for a derived metric (BMI)", () => {
// BMI is derived from weight + height — the body shows the
// explanatory hint instead of the editable inputs.
@@ -1,63 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { TargetsSummaryHeader } from "../targets-summary-header";
/**
* v1.4.25 W3e — page-level summary line. Three load-bearing cases:
* 1. Partial: "4 of 6 targets met this week"
* 2. Full: "All 6 targets met this week" + check mark
* 3. With streak highlight chip
*/
function render(props: Parameters<typeof TargetsSummaryHeader>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetsSummaryHeader {...props} />
</I18nProvider>,
);
}
describe("<TargetsSummaryHeader>", () => {
it("renders the partial-count headline when met < total", () => {
const html = render({
targetsMetThisWeek: 4,
totalTargets: 6,
streakHighlight: null,
});
expect(html).toContain('data-slot="targets-summary-title"');
expect(html).toContain("4 of 6 targets met this week");
// Check mark should NOT render in the partial state.
expect(html).not.toContain("lucide-check");
});
it("renders the all-met headline + check mark when every target is met", () => {
const html = render({
targetsMetThisWeek: 6,
totalTargets: 6,
streakHighlight: null,
});
expect(html).toContain("All 6 targets met this week");
expect(html).toContain("text-[var(--dracula-green)]");
});
it("renders the streak highlight chip when present", () => {
const html = render({
targetsMetThisWeek: 4,
totalTargets: 6,
streakHighlight: { metric: "BLOOD_PRESSURE", days: 5 },
});
expect(html).toContain('data-slot="targets-summary-streak"');
expect(html).toContain("5-day streak: Blood pressure");
});
it("renders nothing when there are no targets at all", () => {
const html = render({
targetsMetThisWeek: 0,
totalTargets: 0,
streakHighlight: null,
});
expect(html).toBe("");
});
});
-4
View File
@@ -9,10 +9,6 @@ import {
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W3e — extracted from `src/app/targets/page.tsx` (the page
* was 790 lines; the redesign breaks it into focused primitives so
* each component carries its own contract + test suite).
*
* A horizontal range bar with green/yellow/red zones showing where
* the current value falls relative to a target range. The marker dot
* carries a tooltip with the current value + the target range + a
-578
View File
@@ -1,578 +0,0 @@
"use client";
import { useState } from "react";
import {
AlertCircle,
Activity,
Droplet,
ExternalLink,
Heart,
Minus,
Moon,
Percent,
Scale,
Settings2,
Smile,
TrendingDown,
TrendingUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
import { ConsistencyStrip } from "./consistency-strip";
import { RangeBar } from "./range-bar";
import {
TargetStatusPill,
statusGroupForCategory,
translateStatus,
} from "./target-status-pill";
import { TargetCoachButton } from "./target-coach-button";
import { TargetEditSheet } from "./target-edit-sheet";
import { buildTargetPrompt } from "@/lib/ai/coach/target-prompts";
import { coachScopeForTarget } from "@/lib/ai/coach/target-scope";
import type { CoachScope } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — `<TargetCard>` extracted from `src/app/targets/page.tsx`
* with a Marc-directive redesign. Visual rationale:
*
* • Headline row pairs the metric label with a verbal status pill —
* Q1 ("am I on track right now?") answered before the user's eye
* moves below the title.
* • Big value with the unit small + muted; the inline trend chevron
* sits next to the value, not in a separate row.
* • Range bar carries the precise placement; the bar is the
* "spatial" answer to Q2 (delta to goal).
* • Consistency strip beneath the bar answers Q7 ("how consistent
* am I?"). Hidden when `insufficientData` is true so cold-start
* accounts don't see a dead-grey row.
* • Recency caption ("last met goal: 2 days ago") + streak chip
* when ≥ 3 days. Both are conditional on real data — no empty
* placeholders.
* • Footer row: per-card Coach CTA on the left (only when the user
* has an AI provider configured), guideline-source link on the
* right.
*
* Per Marc's "muss nicht so sein wie die KI Sache": the card stays
* quiet. No gradients, no chips with emoji, no animation. Editorial
* spacing rhythm; one decisive number and one decisive status pill.
*/
const TYPE_ICONS: Record<string, LucideIcon> = {
WEIGHT: Scale,
BLOOD_PRESSURE: Heart,
BLOOD_PRESSURE_IN_TARGET: Heart,
PULSE: Activity,
SLEEP_DURATION: Moon,
BODY_FAT: Percent,
BMI: Scale,
MOOD_SCORE: Smile,
MOOD_STABILITY: Smile,
BLOOD_GLUCOSE_FASTING: Droplet,
BLOOD_GLUCOSE_POSTPRANDIAL: Droplet,
BLOOD_GLUCOSE_RANDOM: Droplet,
BLOOD_GLUCOSE_BEDTIME: Droplet,
};
const TYPE_COLORS: Record<string, string> = {
WEIGHT: "text-dracula-purple",
BLOOD_PRESSURE: "text-dracula-pink",
BLOOD_PRESSURE_IN_TARGET: "text-dracula-pink",
PULSE: "text-dracula-green",
SLEEP_DURATION: "text-dracula-cyan",
BODY_FAT: "text-dracula-orange",
BMI: "text-dracula-yellow",
MOOD_SCORE: "text-dracula-lavender",
MOOD_STABILITY: "text-dracula-lavender",
BLOOD_GLUCOSE_FASTING: "text-dracula-red",
BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red",
BLOOD_GLUCOSE_RANDOM: "text-dracula-red",
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
};
interface TargetClassification {
category: string;
color: string;
}
interface BpDiastolic {
current: number | null;
average30: number | null;
range: { min: number; max: number } | null;
}
export interface TargetCardData {
type: string;
label: string;
current: number | null;
average30: number | null;
trend: "up" | "down" | "stable" | null;
unit: string;
range: { min: number; max: number } | null;
classification: TargetClassification | null;
source: string;
daysInRange7d?: number;
daysLogged7d?: number;
lastMetGoalAt?: string | null;
streakDays?: number;
insufficientData?: boolean;
consistency7d?: ReadonlyArray<"in" | "near" | "out" | null>;
details?: {
medications?: Array<{
name: string;
compliance7: number;
compliance30: number;
}>;
};
}
export interface TargetCardProps {
target: TargetCardData;
bpDiastolic?: BpDiastolic;
/**
* AI gate. When false, the per-card Coach CTA is suppressed entirely
* (no greyed-out button, no tooltip placeholder). The parent reads
* `/api/insights/provider-chain` and threads this flag.
*/
aiEnabled: boolean;
/**
* Drawer handoff. Receives the pre-formatted prefill + the
* narrowed scope (single source for 1:1 metrics, derived source
* for BMI / mood stability, all sources for the rest).
*/
onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void;
/**
* Optional source URL for the guideline link. Threaded from the
* page rather than computed in the card so the resolution stays
* declarative + testable.
*/
sourceLink?: string | null;
}
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
if (trend === "up") {
return (
<TrendingUp
className="text-dracula-orange size-4"
aria-hidden="true"
data-slot="target-trend-icon"
data-trend="up"
/>
);
}
if (trend === "down") {
return (
<TrendingDown
className="text-dracula-cyan size-4"
aria-hidden="true"
data-slot="target-trend-icon"
data-trend="down"
/>
);
}
if (trend === "stable") {
return (
<Minus
className="text-dracula-green size-4"
aria-hidden="true"
data-slot="target-trend-icon"
data-trend="stable"
/>
);
}
return null;
}
function formatRelativeDay(
isoDate: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
// `isoDate` is a Berlin-tz day key (YYYY-MM-DD). Compare to today's
// Berlin-tz day by parsing both as local Date midnight; we only need
// a day-resolution delta so timezone math is unnecessary.
const today = new Date();
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
if (isoDate === todayKey) return t("targets.relativeDay.today");
const [y, m, d] = isoDate.split("-").map(Number);
const target = new Date(y, (m ?? 1) - 1, d ?? 1);
const todayMid = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const diffDays = Math.round(
(todayMid.getTime() - target.getTime()) / (24 * 60 * 60 * 1000),
);
if (diffDays === 1) return t("targets.relativeDay.yesterday");
if (diffDays < 0) return t("targets.relativeDay.today"); // future, shouldn't happen
return t("targets.relativeDay.daysAgo", { count: String(diffDays) });
}
export function TargetCard({
target,
bpDiastolic,
aiEnabled,
onAskCoach,
sourceLink,
}: TargetCardProps) {
const { t, locale } = useTranslations();
// v1.4.37 W4a item 9 — when the operator disables the Coach
// feature flag globally, every per-card "Ask the coach" CTA must
// disappear (not greyed-out, not a placeholder). The card's
// existing `aiEnabled` gate guards on the user-level provider
// chain; this flag guards on the operator-level feature switch.
// Both must be ON for the CTA to mount.
const flags = useFeatureFlags();
// v1.4.25 W3f — per-card edit affordance. Cog top-right of every
// card opens the edit dialog; the dialog handles the read-only path
// for derived metrics (BMI, MOOD_*, MEDICATION_COMPLIANCE,
// BLOOD_PRESSURE_IN_TARGET) by showing an explanatory caption.
const [editOpen, setEditOpen] = useState(false);
const Icon = TYPE_ICONS[target.type] ?? Activity;
const iconColor = TYPE_COLORS[target.type] ?? "text-primary";
const isBp = target.type === "BLOOD_PRESSURE";
const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE";
const medicationBreakdown = target.details?.medications ?? [];
const localisedLabel = t(`targets.label.${target.type}`);
const titleLabel =
localisedLabel === `targets.label.${target.type}`
? target.label
: localisedLabel;
const statusGroup = target.classification
? statusGroupForCategory(target.classification.category)
: "near";
const statusLabel = target.classification
? translateStatus(target.classification.category, t)
: null;
// MOOD_STABILITY headline switches from numeric σ to a verbal label.
// The numeric stays as a tooltip behind the pill so power users can
// still see the σ value.
const moodStabilityValue =
target.type === "MOOD_STABILITY" && target.current != null
? moodStabilityLabel(target.current)
: null;
const moodStabilityCopy =
moodStabilityValue != null
? t(`targets.mood.stability.${moodStabilityValue}`)
: null;
const showConsistency =
!target.insufficientData &&
target.consistency7d != null &&
target.consistency7d.length > 0;
const showLastMet = target.lastMetGoalAt != null && !target.insufficientData;
const showStreak =
target.streakDays != null &&
target.streakDays >= 3 &&
!target.insufficientData;
// Coach prefill is built from the live state of the card; the helper
// file owns the per-metric template + locale resolution.
const coachPrefill = buildTargetPrompt({
type: target.type,
locale,
current: target.current,
range: target.range,
unit: target.unit,
status: statusLabel ?? null,
streakDays: target.streakDays ?? 0,
daysInRange7d: target.daysInRange7d ?? 0,
});
const coachSources = coachScopeForTarget(target.type);
return (
<Card
data-slot="target-card"
data-target-type={target.type}
data-status={statusGroup}
// v1.4.37 W4a item 3 — override the Card primitive's default
// `gap-4 md:gap-6` to `gap-3 md:gap-4`. The default gap stacked
// on top of the CardHeader's `pb-3` rendered a ~36 px dead
// space between "Blutdruck optimal" and the headline number;
// the tighter rhythm pulls the value back under the label so
// the card reads as one unit instead of two separated blocks.
className="flex h-full flex-col gap-3 md:gap-4"
>
<CardHeader className="gap-2 pb-0 sm:gap-3">
{/* Row 1: icon + label (left) ⋯ status pill + edit-cog (right
on sm+, stacked on mobile). Stacking on mobile keeps the
headline number visible at sub-380px viewports where an
inline pill would push the value below the fold.
v1.4.25 W3f — the per-card cog renders on EVERY card
(consistency rule across Dashboard → Insights → Zielwerte),
with no insufficient-data gate. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<Icon
className={cn("size-4 shrink-0", iconColor)}
aria-hidden="true"
/>
<h3
className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.08em] uppercase"
data-slot="target-card-title"
>
{titleLabel}
</h3>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
{target.classification ? (
<TargetStatusPill
classification={target.classification}
range={target.range}
unit={target.unit}
source={target.source}
/>
) : null}
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground -mr-2 min-h-11 min-w-11 px-0"
onClick={() => setEditOpen(true)}
aria-label={t("targets.edit.openLabel", {
metric: titleLabel,
})}
data-slot="target-edit-cog"
data-target-type={target.type}
>
<Settings2 className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
{/* Row 2: big-number headline + trend chevron + unit */}
{target.current != null ? (
<div className="flex flex-col gap-1">
<div className="flex items-baseline gap-2">
{moodStabilityValue && moodStabilityCopy ? (
// v1.4.25 W3e — verbal label is the headline; the raw σ
// moves into a tooltip so power users keep the precise
// number one hover away without forcing every visitor
// to read "0.42 σ" and decode it.
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className="text-foreground cursor-help text-2xl leading-none font-semibold capitalize sm:text-3xl"
data-slot="target-headline-value"
data-mood-stability={moodStabilityValue}
>
{moodStabilityCopy}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
σ ={" "}
{target.current != null
? target.current.toFixed(2)
: "—"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : isBp && bpDiastolic?.current != null ? (
<span
className="text-foreground flex items-baseline gap-1 text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
>
<span>{Math.round(target.current)}</span>
<span className="text-muted-foreground text-lg">/</span>
<span>{Math.round(bpDiastolic.current)}</span>
</span>
) : (
<span
className="text-foreground text-2xl leading-none font-semibold sm:text-3xl"
data-slot="target-headline-value"
>
{target.type === "BODY_FAT"
? target.current.toFixed(1)
: Math.round(target.current * 10) / 10}
</span>
)}
{!moodStabilityValue && (
<span className="text-muted-foreground text-sm">
{target.unit}
</span>
)}
<span className="ml-auto">
<TrendIcon trend={target.trend} />
</span>
</div>
{target.average30 != null && (
<p className="text-muted-foreground text-xs">
{t("targets.average30d")}{" "}
{isBp && bpDiastolic?.average30 != null
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
: Math.round(target.average30 * 10) / 10}{" "}
{target.unit}
</p>
)}
</div>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<AlertCircle className="size-4" aria-hidden="true" />
<span className="text-sm">{t("targets.noMeasurementYet")}</span>
</div>
)}
{/* Row 3: primary range bar (only for cards whose underlying
metric has a defined band — the helper drops the bar
gracefully for derived ones without a range). */}
{target.range && target.current != null && (
<RangeBar
value={target.current}
min={target.range.min}
max={target.range.max}
unit={target.unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
)}
{/* Row 3b: BP diastolic range bar */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
/>
</div>
)}
{/* Row 4: consistency strip (hidden when insufficient data) */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : target.insufficientData ? (
<p
className="text-muted-foreground text-xs italic"
data-slot="target-insufficient-data"
>
{t("targets.consistency.insufficientData")}
</p>
) : null}
{/* Row 5: recency + streak chips. Both conditional. */}
{(showLastMet || showStreak) && (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{showLastMet && target.lastMetGoalAt && (
<span data-slot="target-last-met">
{t("targets.card.lastMet", {
when: formatRelativeDay(target.lastMetGoalAt, t),
})}
</span>
)}
{showStreak && (
<span
className="text-foreground inline-flex items-center gap-1.5 rounded-full bg-(--dracula-green)/12 px-2 py-0.5 font-medium ring-1 ring-(--dracula-green)/30"
data-slot="target-streak"
>
<span
className="size-1.5 rounded-full bg-(--dracula-green)"
aria-hidden="true"
/>
{t("targets.card.streak", { count: String(target.streakDays) })}
</span>
)}
</div>
)}
{/* Medication breakdown (regimen-specific detail) */}
{isMedicationCompliance && medicationBreakdown.length > 0 && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("targets.compliancePerMedication")}
</p>
<div className="space-y-1">
{medicationBreakdown.map((medication) => (
<div
key={medication.name}
className="text-muted-foreground flex items-center justify-between gap-2 text-xs"
>
<span className="truncate">{medication.name}</span>
<span className="shrink-0">
{medication.compliance7.toFixed(1)}% (7T) ·{" "}
{medication.compliance30.toFixed(1)}% (30T)
</span>
</div>
))}
</div>
</div>
)}
{/* Row 6: footer. v1.4.28 FB-L1 — the Coach CTA is now an
icon-only affordance (chat-bubble glyph + aria-label), so
the row stays horizontal at every breakpoint: icon left,
source link right. `mt-auto` pins to card bottom so grid
cells align. */}
<div className="mt-auto flex flex-row items-center justify-between gap-3 pt-1">
{aiEnabled && flags.coach && (
<TargetCoachButton
prefill={coachPrefill}
sources={coachSources}
onAskCoach={onAskCoach}
aiEnabled={aiEnabled}
className="-ml-2"
/>
)}
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 text-xs"
>
<span>{target.source}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground ml-auto text-xs">
{target.source}
</span>
)}
</div>
</CardContent>
{/* v1.4.25 W3f — edit dialog mounted alongside the card. The
dialog is portalled by Radix so it isn't constrained by the
card's overflow / z-index. Only one card's dialog ever opens
at a time (each card owns its own boolean state). */}
<TargetEditSheet
targetType={target.type}
targetLabel={titleLabel}
unit={target.unit}
initialRange={target.range}
initialDiastolicRange={bpDiastolic?.range ?? null}
open={editOpen}
onOpenChange={setEditOpen}
/>
</Card>
);
}
@@ -1,94 +0,0 @@
"use client";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachScope, CoachScopeSource } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — per-card "Ask Coach about this" CTA.
*
* Only renders when the parent passes `aiEnabled === true`. The gate
* lives one level up (the `TargetsPage` reads `/api/insights/provider-chain`
* and threads the flag down); this component is dumb on purpose so
* tests can pin both branches.
*
* On click the button fires `onAskCoach({ prefill, scope })`. The
* parent owns the `CoachDrawer` state; this button doesn't know about
* the drawer at all.
*
* v1.4.28 R3c FB-L1 — collapsed to an icon-only affordance. The card
* footer was carrying a labelled pill ("Coach fragen"); on a card with
* a status pill, range bar, consistency strip, headline number, edit
* cog and a source link, the labelled pill dominated the visual
* hierarchy. Dropping the label retains the affordance via the
* Sparkles glyph + `aria-label` while shrinking the optical weight
* to a per-card icon button. UI-H2 — the glyph is the same Sparkles
* the hero strip, the inline pill and the layout-level FAB use, so
* the per-card Coach launch reads on one icon vocabulary across the
* app.
*/
export interface TargetCoachButtonProps {
/**
* Pre-formatted question to seed the Coach drawer's composer. Locale
* pre-resolved by the caller (per `buildTargetPrompt(targetType,
* locale)` in `src/lib/ai/coach/target-prompts.ts`).
*/
prefill: string;
/**
* The single CoachScope.sources entry to narrow the snapshot to this
* metric. Empty array means "do not narrow"; the caller decides.
*/
sources: ReadonlyArray<CoachScopeSource>;
onAskCoach: (payload: { prefill: string; scope: CoachScope }) => void;
/**
* Renders ONLY when this is true. The parent gates on
* `chainData?.activeProvider != null` so a user with no provider
* configured never sees a button that opens an empty drawer.
*/
aiEnabled: boolean;
className?: string;
}
export function TargetCoachButton({
prefill,
sources,
onAskCoach,
aiEnabled,
className,
}: TargetCoachButtonProps) {
const { t } = useTranslations();
if (!aiEnabled) return null;
const accessibleLabel = t("targets.coach.cta");
return (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() =>
onAskCoach({
prefill,
scope: {
sources: [...sources],
window: "last30days",
},
})
}
data-slot="target-coach-cta"
aria-label={accessibleLabel}
title={accessibleLabel}
// Lift the icon button to the 44 px WCAG 2.5.5 floor. The
// shadcn `icon` variant ships `size-10` (40 px); the project
// floor is 44 px (matches the medication-history button and the
// Coach drawer cluster). Glyph stays `size-4`.
className={cn("min-h-11 min-w-11", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
</Button>
);
}
+54 -19
View File
@@ -16,18 +16,15 @@ import {
type ThresholdMetric,
type EffectiveRange,
} from "@/lib/analytics/effective-range";
import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose";
/**
* v1.4.25 W3f — per-card edit affordance for the Zielwerte cards.
*
* Marc directive 2026-05-14: the per-card cog pattern (Dashboard charts →
* Insights sub-pages → now Zielwerte) must be consistent. After the W3e
* redesign the target cards lost their edit entry point — this dialog
* closes that gap. Tapping the cog top-right of any TargetCard opens
* this sheet; the user adjusts the min/max for the underlying threshold
* metric, hits Save, and every surface that consumes
* `getEffectiveRange()` (dashboard bands, doctor-report, /targets
* itself) updates on the next query refetch.
* Per-metric target editor, mounted inline on the Insights metric
* reference panel (`<MetricTargetSummary>`). The user adjusts the
* min/max for the underlying threshold metric, hits Save, and every
* surface that consumes `getEffectiveRange()` (dashboard bands,
* doctor-report, the Insights panels themselves) updates on the next
* query refetch.
*
* Server contract: PUT /api/user/thresholds with a partial map
* `{ [metric]: { min, max } }`. The endpoint validates the values
@@ -95,11 +92,11 @@ export interface TargetEditSheetProps {
}
export function TargetEditSheet(props: TargetEditSheetProps) {
// v1.4.25 W3f — only mount the body (and its TanStack Query hooks)
// when the dialog is open. This keeps the parent <TargetCard>
// test-friendly: existing tests can render the card without
// standing up a QueryClientProvider, and the hooks only execute
// once the user actually clicks the cog.
// Only mount the body (and its TanStack Query hooks) when the dialog
// is open. This keeps the parent reference panel test-friendly:
// callers can render the panel without standing up a
// QueryClientProvider, and the hooks only execute once the user
// actually opens the editor.
//
// v1.4.27 R4 RC2 — migrated raw <Dialog> → <ResponsiveSheet> so the
// bottom-sheet branch sticky-pins reset + cancel + save above the
@@ -124,6 +121,24 @@ function TargetEditSheetBody({
const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null;
const isDerivedMetric = !isBp && metric == null;
// Glucose is the one metric whose display unit can differ from the
// canonical storage unit: HealthLog stores mg/dL, but a user on the
// mmol/L preference sees — and types — mmol/L. The parent already
// hands us `unit="mmol/L"` and a `initialRange` pre-converted to that
// unit. We must therefore (a) seed the mg/dL persisted override into
// the display unit, (b) validate the typed value against bounds
// expressed in the display unit, and (c) convert the typed value back
// to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target
// is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5.
const isGlucoseMmol =
metric != null &&
metric.startsWith("BLOOD_GLUCOSE") &&
unit === "mmol/L";
const toDisplay = (mgdl: number) =>
isGlucoseMmol ? convertGlucose(mgdl, "mmol/L") : mgdl;
const toCanonical = (displayValue: number) =>
isGlucoseMmol ? toCanonicalMgdl(displayValue, "mmol/L") : displayValue;
// Lazy-load the thresholds payload so the dialog also catches any
// already-persisted override even when the seeded `initialRange`
// came from the targets API (which can paint a different in-band
@@ -146,13 +161,18 @@ function TargetEditSheetBody({
m: ThresholdMetric | null,
fallback: CurrentRange | null,
) => {
// The persisted override is canonical (mg/dL for glucose); the
// parent-provided `fallback` (initialRange) is already in the
// display unit. Convert the override into the display unit so the
// seeded inputs always read in the unit the label announces.
if (m && thresholdsData?.overrides?.[m]) {
return thresholdsData.overrides[m]!;
const override = thresholdsData.overrides[m]!;
return { min: toDisplay(override.min), max: toDisplay(override.max) };
}
if (fallback) return fallback;
if (m) {
const bounds = METRIC_BOUNDS[m];
return { min: bounds.min, max: bounds.max };
return { min: toDisplay(bounds.min), max: toDisplay(bounds.max) };
}
return { min: 0, max: 100 };
};
@@ -264,13 +284,24 @@ function TargetEditSheetBody({
const diaMinNum = parseFloat(displayDiaMin);
const diaMaxNum = parseFloat(displayDiaMax);
const primaryBounds = isBp
const canonicalPrimaryBounds = isBp
? METRIC_BOUNDS.BLOOD_PRESSURE_SYS
: metric
? METRIC_BOUNDS[metric]
: null;
const secondaryBounds = isBp ? METRIC_BOUNDS.BLOOD_PRESSURE_DIA : null;
// Bounds the user is validated/clamped against — expressed in the
// display unit so a mmol/L glucose entry is checked against the
// mmol/L-projected 40–400 mg/dL window, not the raw mg/dL numbers.
const primaryBounds = canonicalPrimaryBounds
? {
min: toDisplay(canonicalPrimaryBounds.min),
max: toDisplay(canonicalPrimaryBounds.max),
unit,
}
: null;
const primaryValid =
primaryBounds != null &&
Number.isFinite(minNum) &&
@@ -300,7 +331,11 @@ function TargetEditSheetBody({
return;
}
if (!metric) return;
updateMutation.mutate({ [metric]: { min: minNum, max: maxNum } });
// Convert the typed display-unit value back to canonical mg/dL for
// glucose on the mmol/L preference; a no-op for every other metric.
updateMutation.mutate({
[metric]: { min: toCanonical(minNum), max: toCanonical(maxNum) },
});
};
const hasOverride = isBp
@@ -1,91 +0,0 @@
"use client";
import { CheckCircle2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W3e — page-level summary line.
*
* "4 of 6 targets met this week" (DE: "4 von 6 Zielwerten diese Woche
* erreicht"). When every target hits the bar we surface a calm check
* mark + "All N targets met this week" copy — no animation, no
* celebration. Marc directive: stay quieter than the v1.4.20 Insights
* hero.
*
* Optional streak highlight chip when one metric has a current ≥ 3 day
* streak; the metric label is resolved from the targets.label.* i18n
* map so it renders in the user's locale.
*/
export interface TargetsSummaryHeaderProps {
targetsMetThisWeek: number;
totalTargets: number;
streakHighlight: { metric: string; days: number } | null;
className?: string;
}
export function TargetsSummaryHeader({
targetsMetThisWeek,
totalTargets,
streakHighlight,
className,
}: TargetsSummaryHeaderProps) {
const { t } = useTranslations();
if (totalTargets === 0) return null;
const allMet = targetsMetThisWeek === totalTargets;
const headline = allMet
? t("targets.summary.weekTitleAll", { total: String(totalTargets) })
: t("targets.summary.weekTitle", {
met: String(targetsMetThisWeek),
total: String(totalTargets),
});
let highlightCopy: string | null = null;
if (streakHighlight && streakHighlight.days >= 3) {
const metricLabel = t(`targets.label.${streakHighlight.metric}`);
const resolvedMetric =
metricLabel === `targets.label.${streakHighlight.metric}`
? streakHighlight.metric
: metricLabel;
highlightCopy = t("targets.summary.streakHighlight", {
count: String(streakHighlight.days),
metric: resolvedMetric,
});
}
return (
<section
data-slot="targets-summary-header"
className={cn(
"flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3",
className,
)}
>
<div className="flex items-center gap-2">
{allMet && (
<CheckCircle2
className="size-4 shrink-0 text-[var(--dracula-green)]"
aria-hidden="true"
/>
)}
<p
className="text-foreground text-sm font-medium"
data-slot="targets-summary-title"
>
{headline}
</p>
</div>
{highlightCopy && (
<span
className="text-muted-foreground text-xs sm:before:mx-1 sm:before:content-['·']"
data-slot="targets-summary-streak"
>
{highlightCopy}
</span>
)}
</section>
);
}
@@ -20,6 +20,9 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => {
// A terminal `no-provider` / settled payload never carries preparing,
// so it stops immediately regardless of attempt count.
expect(nextStatusPollInterval(false, 0)).toBe(false);
// A settled payload with neither preparing nor revalidating is terminal.
expect(nextStatusPollInterval(false, 0, false)).toBe(false);
expect(nextStatusPollInterval(undefined, 0, undefined)).toBe(false);
});
it("keeps polling while preparing and below the cap", () => {
@@ -42,4 +45,24 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => {
expect(interval).not.toBe(false);
expect(interval as number).toBeGreaterThan(0);
});
// v1.9.0 — stale-while-revalidate: a terminal payload that serves last-good
// text sets `revalidating` so the open card keeps polling until the
// freshly-warmed assessment lands, without a remount.
it("keeps polling on revalidating even when not preparing", () => {
expect(nextStatusPollInterval(false, 0, true)).toBeTypeOf("number");
expect(nextStatusPollInterval(undefined, 1, true)).toBeTypeOf("number");
expect(
nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS - 1, true),
).toBeTypeOf("number");
});
it("honours the same attempt cap for revalidating", () => {
expect(
nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS, true),
).toBe(false);
expect(
nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS + 5, true),
).toBe(false);
});
});
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import type { AnalyticsRange } from "@/lib/analytics/range-shared";
/**
* v1.9.0 — client reader for the single-metric period-over-period range
* read (`GET /api/analytics/range`). One cache slot per `(type, range)` via
* the centralised query-key factory, so switching the time-range pills is a
* cheap cache hit after first fetch and never collides with the shared
* `["analytics", "summaries"]` slot.
*/
export interface AnalyticsWindowAggregate {
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
}
export interface AnalyticsRangeData {
range: AnalyticsRange;
windowDays: number;
granularity: string;
current: AnalyticsWindowAggregate;
previous: AnalyticsWindowAggregate;
delta: number | null;
deltaPct: number | null;
}
export function useAnalyticsRange(
type: string,
range: AnalyticsRange,
enabled = true,
) {
const { isAuthenticated } = useAuth();
return useQuery({
queryKey: queryKeys.analyticsRange(type, range),
queryFn: async (): Promise<AnalyticsRangeData> => {
const res = await fetch(
`/api/analytics/range?type=${encodeURIComponent(
type,
)}&range=${encodeURIComponent(range)}`,
);
if (!res.ok) throw new Error("Failed to load range");
const json = (await res.json()) as { data: AnalyticsRangeData };
return json.data;
},
enabled: isAuthenticated && enabled && type.length > 0,
staleTime: 60 * 1000,
});
}
-52
View File
@@ -1,52 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import type { CoachScope } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — controlled state for the Coach drawer when mounted
* outside the `/insights` page (currently `/targets`, future
* `/insights/<metric>` sub-pages).
*
* Encapsulates the open / prefill / scope triple so each new surface
* only has to mount `<CoachDrawer>` and wire the page-level CTAs to
* the returned `askCoach()` callback. The drawer itself owns no
* cross-surface state; this hook is the binding point.
*
* Reset behaviour mirrors the v1.4.20 Insights pattern: closing the
* drawer clears prefill so the next open starts blank unless a CTA
* supplies a fresh prefill.
*/
export interface CoachHandoffApi {
coachOpen: boolean;
setCoachOpen: (next: boolean) => void;
coachPrefill: string | null;
coachScope: CoachScope | null;
askCoach: (payload: { prefill: string; scope: CoachScope }) => void;
}
export function useCoachHandoff(): CoachHandoffApi {
const [coachOpen, setOpen] = useState(false);
const [coachPrefill, setPrefill] = useState<string | null>(null);
const [coachScope, setScope] = useState<CoachScope | null>(null);
const setCoachOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
setPrefill(null);
setScope(null);
}
}, []);
const askCoach = useCallback(
({ prefill, scope }: { prefill: string; scope: CoachScope }) => {
setPrefill(prefill);
setScope(scope);
setOpen(true);
},
[],
);
return { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach };
}
+24 -6
View File
@@ -31,6 +31,15 @@ export interface InsightStatusData {
* warms the cache. Absent / false means the payload is terminal.
*/
preparing?: boolean;
/**
* v1.9.0 — true when the route serves last-good (stale) text AND a fresh
* generation is in flight. The served payload would otherwise be terminal
* (`preparing` false), so the open card would stop polling and never pick
* up the warmed assessment until a remount. The hook keeps polling on
* `preparing || revalidating` (bounded by the same attempt ceiling) so the
* card upgrades to the fresh text in the same session.
*/
revalidating?: boolean;
}
/**
@@ -64,17 +73,24 @@ const STATUS_POLL_MS = 4_000;
export const STATUS_POLL_MAX_ATTEMPTS = 12;
/**
* Decide whether a `preparing` card should schedule its next poll.
* Pure and shared between the `useInsightStatus` hook and the inline
* medication-compliance query so both sites enforce the identical
* ceiling. Returns the interval in ms while polling is warranted, or
* `false` once the payload is terminal OR the attempt cap is reached.
* Decide whether a card should schedule its next poll. Pure and shared
* between the `useInsightStatus` hook and the inline medication-compliance
* query so both sites enforce the identical ceiling. Returns the interval
* in ms while polling is warranted, or `false` once the payload is terminal
* OR the attempt cap is reached.
*
* v1.9.0 — `revalidating` joins `preparing` as a "keep polling" signal: when
* the route serves last-good (stale) text it would otherwise be terminal and
* stop the poll, so the freshly-warmed assessment never reaches the open card
* until a remount. The attempt ceiling bounds both signals identically, so a
* persistently failing generation still cannot poll an open page forever.
*/
export function nextStatusPollInterval(
preparing: boolean | undefined,
dataUpdateCount: number,
revalidating?: boolean | undefined,
): number | false {
if (!preparing) return false;
if (!preparing && !revalidating) return false;
if (dataUpdateCount >= STATUS_POLL_MAX_ATTEMPTS) return false;
return STATUS_POLL_MS;
}
@@ -161,6 +177,7 @@ export function useInsightStatus(metric: InsightStatusMetric) {
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
query.state.data?.revalidating,
),
});
}
@@ -225,6 +242,7 @@ export function useInsightMetricStatus(
nextStatusPollInterval(
query.state.data?.preparing,
query.state.dataUpdateCount,
query.state.data?.revalidating,
),
});
}
+55
View File
@@ -1,5 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
@@ -7,6 +9,59 @@ import {
resolveDashboardLayout,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import {
ANALYTICS_RANGES,
type AnalyticsRange,
} from "@/lib/analytics/range-shared";
const RANGE_STORAGE_KEY = "healthlog.insights.range";
const DEFAULT_RANGE: AnalyticsRange = "30d";
function isAnalyticsRange(value: string | null): value is AnalyticsRange {
return (
value !== null && (ANALYTICS_RANGES as readonly string[]).includes(value)
);
}
/**
* Read the persisted range once, lazily. Guarded by `typeof window` so the
* server render (where `localStorage` is absent) returns the default rather
* than throwing; the first client render then hydrates with the stored value
* via the lazy `useState` initializer — no `setState`-in-effect needed.
*/
function readStoredRange(): AnalyticsRange {
if (typeof window === "undefined") return DEFAULT_RANGE;
try {
const stored = window.localStorage.getItem(RANGE_STORAGE_KEY);
return isAnalyticsRange(stored) ? stored : DEFAULT_RANGE;
} catch {
return DEFAULT_RANGE;
}
}
/**
* v1.9.0 — persisted time-range choice for the Insights metric pages.
*
* The choice sticks across metrics (a user who picks `90d` on weight sees
* `90d` on HRV) via `localStorage` — the same lightweight client-pref seam
* the comparison toggle conceptually lives alongside, without a server
* round-trip or a dashboard-layout schema change. Defaults to `30d` so the
* existing fixed-window behaviour is the default and nothing regresses.
*/
export function useInsightsRangePref() {
const [range, setRangeState] = useState<AnalyticsRange>(readStoredRange);
const setRange = useCallback((next: AnalyticsRange) => {
setRangeState(next);
try {
window.localStorage.setItem(RANGE_STORAGE_KEY, next);
} catch {
// Best-effort persistence; the in-memory state still drives the UI.
}
}, []);
return { range, setRange } as const;
}
/**
* v1.4.25 W4 — shared reader for the dashboard-layout payload across
+23
View File
@@ -1,7 +1,9 @@
import { describe, it, expect } from "vitest";
import {
mgdlToMmol,
mmolToMgdl,
convertGlucose,
toCanonicalMgdl,
resolveGlucoseUnit,
thresholdMetricForContext,
} from "../glucose";
@@ -50,4 +52,25 @@ describe("glucose conversion", () => {
expect(convertGlucose(fastingMax, "mmol/L")).toBe(5.5);
expect(convertGlucose(ada126, "mmol/L")).toBe(7);
});
// The target-editor write path: a value the user typed in their
// display unit must persist as canonical mg/dL. Before the fix the
// editor saved the mmol/L number verbatim (a 5.5 mmol/L target stored
// as 5.5 mg/dL, or rejected outright by the 40–400 mg/dL bounds).
it("converts a display-unit value back to canonical mg/dL", () => {
expect(mmolToMgdl(5.5)).toBe(99);
expect(mmolToMgdl(7.0)).toBe(126); // ADA diabetes threshold
expect(mmolToMgdl(3.9)).toBe(70); // hypoglycemia threshold
});
it("toCanonicalMgdl is the inverse of convertGlucose at the editor boundary", () => {
// mg/dL display unit is already canonical — only rounds.
expect(toCanonicalMgdl(126, "mg/dL")).toBe(126);
expect(toCanonicalMgdl(99.4, "mg/dL")).toBe(99);
// mmol/L display unit converts back; the round-trip is stable to the
// 1-decimal mmol/L the UI shows (≤1 mg/dL drift is unavoidable).
expect(toCanonicalMgdl(7.0, "mmol/L")).toBe(126);
expect(toCanonicalMgdl(mgdlToMmol(126), "mmol/L")).toBeCloseTo(126, -0.5);
expect(toCanonicalMgdl(mgdlToMmol(70), "mmol/L")).toBe(70);
});
});
-1
View File
@@ -309,7 +309,6 @@ describe("queryKey factory enforcement", () => {
join(repoRoot, "src", "hooks"),
join(repoRoot, "src", "app", "medications", "page.tsx"),
join(repoRoot, "src", "app", "medications", "[id]", "history", "page.tsx"),
join(repoRoot, "src", "app", "targets", "page.tsx"),
];
function collect(): string[] {
@@ -1,129 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildTargetPrompt } from "../target-prompts";
import { coachScopeForTarget } from "../target-scope";
/**
* v1.4.25 W3e — per-card Coach prefill builder.
*
* Pin the per-metric template shape + the locale switch so a
* refactor cannot silently flatten the prompts to a single fallback.
*/
describe("buildTargetPrompt", () => {
const base = {
current: 124,
range: { min: 110, max: 130 },
unit: "mmHg",
status: "On target",
streakDays: 5,
daysInRange7d: 6,
};
it("returns the EN BP template with live values", () => {
const out = buildTargetPrompt({
type: "BLOOD_PRESSURE",
locale: "en",
...base,
});
expect(out).toContain("blood pressure");
expect(out).toContain("124 mmHg");
expect(out).toContain("110–130 mmHg");
expect(out).toContain("6 days");
expect(out).toContain("5 days"); // streak
});
it("returns the DE BP template with live values", () => {
const out = buildTargetPrompt({
type: "BLOOD_PRESSURE",
locale: "de",
...base,
});
expect(out).toContain("Blutdruck-Wert");
expect(out).toContain("124 mmHg");
expect(out).toContain("110–130 mmHg");
expect(out).toContain("6 Tagen");
});
it("omits the streak fragment when streakDays < 3", () => {
const out = buildTargetPrompt({
type: "BLOOD_PRESSURE",
locale: "en",
...base,
streakDays: 2,
});
expect(out).not.toContain("current streak");
});
it("renders the MOOD_STABILITY verbal label inline", () => {
const stableOut = buildTargetPrompt({
type: "MOOD_STABILITY",
locale: "en",
...base,
current: 0.5,
unit: "σ",
range: { min: 0, max: 0.5 },
});
expect(stableOut).toContain("stable");
const variableOut = buildTargetPrompt({
type: "MOOD_STABILITY",
locale: "en",
...base,
current: 1.5,
unit: "σ",
range: { min: 0, max: 0.5 },
});
expect(variableOut).toContain("variable");
const highOut = buildTargetPrompt({
type: "MOOD_STABILITY",
locale: "en",
...base,
current: 2.5,
unit: "σ",
range: { min: 0, max: 0.5 },
});
expect(highOut).toContain("highly variable");
});
it("falls back to the general template for unknown target types", () => {
const out = buildTargetPrompt({
type: "FUTURE_METRIC_X",
locale: "en",
...base,
});
expect(out).toContain("how I'm doing on");
expect(out).toContain("future metric x");
});
it("DE fallback localises the question shape", () => {
const out = buildTargetPrompt({
type: "FUTURE_METRIC_X",
locale: "de",
...base,
});
expect(out).toContain("Wie steht es um meine");
});
});
describe("coachScopeForTarget", () => {
it("maps WEIGHT + BMI to the weight source", () => {
expect(coachScopeForTarget("WEIGHT")).toEqual(["weight"]);
expect(coachScopeForTarget("BMI")).toEqual(["weight"]);
});
it("maps BLOOD_PRESSURE and BLOOD_PRESSURE_IN_TARGET to bp", () => {
expect(coachScopeForTarget("BLOOD_PRESSURE")).toEqual(["bp"]);
expect(coachScopeForTarget("BLOOD_PRESSURE_IN_TARGET")).toEqual(["bp"]);
});
it("maps mood + stability to the mood source", () => {
expect(coachScopeForTarget("MOOD_SCORE")).toEqual(["mood"]);
expect(coachScopeForTarget("MOOD_STABILITY")).toEqual(["mood"]);
});
it("returns an empty array for unmapped targets (use defaults)", () => {
expect(coachScopeForTarget("BLOOD_GLUCOSE_FASTING")).toEqual([]);
expect(coachScopeForTarget("BODY_FAT")).toEqual([]);
expect(coachScopeForTarget("FUTURE_X")).toEqual([]);
});
});
-117
View File
@@ -1,117 +0,0 @@
import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
import type { Locale } from "@/lib/i18n/config";
/**
* v1.4.25 W3e — per-target Coach prompt builder.
*
* Returns a locale-appropriate pre-filled question that seeds the
* Coach drawer's composer when the user clicks "Ask Coach about this"
* on a target card. The prompt mentions the live current value, the
* target range, the user's last-7-day cadence, and the streak (when
* present) so the model has enough context to give a grounded answer
* even before the snapshot lands.
*
* Prompts are deliberately question-shaped (not instructions). The
* user can rewrite freely; the prefill exists to remove the
* cold-start friction of typing the same question every time.
*
* Six metric templates × two locales = 12 prompts. Glucose contexts
* fall back to the "general" template so we don't ship a four-way
* context-aware glucose prompt before the iOS rollout decides whether
* to surface glucose-by-context in the iOS Targets view.
*
* NOTE on PROMPT_VERSION: the Coach prompt itself (the system prompt
* the model sees) does NOT change with this addition. The pre-fill is
* just text the user can edit before sending. PROMPT_VERSION ratchets
* when the assistant-facing prompt shifts; pre-fills do not, so we
* leave PROMPT_VERSION at 4.23.0. Confirm with Marc if a future
* change makes this prefill the canonical first turn.
*/
export interface TargetPromptInput {
type: string;
/**
* v1.4.25 W9e — accepts every shipped locale. Templates exist for
* DE and EN today; non-DE locales fall through to the EN body until
* proper FR/ES/IT/PL pre-fills land.
*/
locale: Locale;
current: number | null;
range: { min: number; max: number } | null;
unit: string;
status: string | null;
streakDays: number;
daysInRange7d: number;
}
type Template = (input: TargetPromptInput) => string;
function fmt(value: number | null, decimals = 1): string {
if (value == null) return "—";
return Number(value.toFixed(decimals)).toString();
}
const TEMPLATES_EN: Record<string, Template> = {
BLOOD_PRESSURE: (i) =>
`My latest blood pressure reading is ${fmt(i.current, 0)} mmHg systolic and my target band is ${i.range ? `${i.range.min}–${i.range.max} mmHg` : "not set"}. Over the last 7 days I've hit the band on ${i.daysInRange7d} days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days)` : ""}. What should I focus on this week?`,
BLOOD_PRESSURE_IN_TARGET: (i) =>
`My blood pressure has been in target ${i.daysInRange7d} of the last 7 days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days)` : ""}. Walk me through what's driving the trend and what I should change.`,
WEIGHT: (i) =>
`My current weight is ${fmt(i.current)} ${i.unit} and my healthy range is ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "not set"}. Over the last 7 days I've been in range on ${i.daysInRange7d} days. What should I focus on?`,
PULSE: (i) =>
`My resting pulse is ${fmt(i.current, 0)} ${i.unit} (target band ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "not set"}). How does this compare to a healthy pattern for me and what should I watch for?`,
BMI: (i) =>
`My current BMI is ${fmt(i.current)} ${i.unit} and the healthy range is ${i.range ? `${i.range.min}–${i.range.max}` : "18.5–24.9"}. How am I trending and what should I focus on?`,
MOOD_SCORE: (i) =>
`My latest mood score is ${fmt(i.current)} / 5 and my target is ${i.range ? `${i.range.min}+ / 5` : "3.5+ / 5"}. How does my mood compare to recent weeks and what could I try?`,
MOOD_STABILITY: (i) => {
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
const verbal =
label === "stable"
? "stable"
: label === "variable"
? "variable"
: "highly variable";
return `My mood pattern is currently ${verbal}. What does this mean for me and what should I keep an eye on?`;
},
MEDICATION_COMPLIANCE: (i) =>
`My medication adherence is ${fmt(i.current, 0)}% over the last 7 days${i.streakDays >= 3 ? ` (current streak: ${i.streakDays} days at goal)` : ""}. What can I change in my routine?`,
};
const TEMPLATES_DE: Record<string, Template> = {
BLOOD_PRESSURE: (i) =>
`Mein letzter Blutdruck-Wert liegt bei ${fmt(i.current, 0)} mmHg systolisch, mein Zielbereich ist ${i.range ? `${i.range.min}–${i.range.max} mmHg` : "nicht festgelegt"}. In den letzten 7 Tagen war ich an ${i.daysInRange7d} Tagen im Bereich${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage)` : ""}. Worauf sollte ich diese Woche achten?`,
BLOOD_PRESSURE_IN_TARGET: (i) =>
`Mein Blutdruck war an ${i.daysInRange7d} von 7 Tagen im Zielbereich${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage)` : ""}. Erklär mir bitte, was den Trend treibt und was ich ändern sollte.`,
WEIGHT: (i) =>
`Mein aktuelles Gewicht ist ${fmt(i.current)} ${i.unit}, mein gesunder Bereich liegt bei ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "nicht festgelegt"}. In den letzten 7 Tagen war ich an ${i.daysInRange7d} Tagen im Bereich. Worauf sollte ich mich konzentrieren?`,
PULSE: (i) =>
`Mein Ruhepuls ist ${fmt(i.current, 0)} ${i.unit} (Zielbereich ${i.range ? `${i.range.min}–${i.range.max} ${i.unit}` : "nicht festgelegt"}). Wie passt das zu einem gesunden Muster für mich und worauf sollte ich achten?`,
BMI: (i) =>
`Mein aktueller BMI liegt bei ${fmt(i.current)} ${i.unit}, der gesunde Bereich ist ${i.range ? `${i.range.min}–${i.range.max}` : "18,5–24,9"}. Wie ist mein Trend und worauf sollte ich achten?`,
MOOD_SCORE: (i) =>
`Mein letzter Stimmungs-Wert ist ${fmt(i.current)} / 5, mein Ziel liegt bei ${i.range ? `${i.range.min}+ / 5` : "3,5+ / 5"}. Wie ist meine Stimmung im Vergleich zu den letzten Wochen, und was könnte ich ausprobieren?`,
MOOD_STABILITY: (i) => {
const label = i.current != null ? moodStabilityLabel(i.current) : "stable";
const verbal =
label === "stable"
? "stabil"
: label === "variable"
? "schwankend"
: "sehr schwankend";
return `Mein Stimmungsmuster ist aktuell ${verbal}. Was bedeutet das für mich und worauf sollte ich achten?`;
},
MEDICATION_COMPLIANCE: (i) =>
`Meine Einnahmetreue liegt in den letzten 7 Tagen bei ${fmt(i.current, 0)}%${i.streakDays >= 3 ? ` (aktuelle Serie: ${i.streakDays} Tage im Ziel)` : ""}. Was könnte ich an meiner Routine ändern?`,
};
const GENERAL_EN: Template = (i) =>
`I'd like to understand how I'm doing on ${i.type.toLowerCase().replace(/_/g, " ")} this week. Where am I doing well and where should I focus?`;
const GENERAL_DE: Template = (i) =>
`Wie steht es um meine ${i.type.toLowerCase().replace(/_/g, " ")} diese Woche? Wo läuft es gut und worauf sollte ich achten?`;
export function buildTargetPrompt(input: TargetPromptInput): string {
const table = input.locale === "de" ? TEMPLATES_DE : TEMPLATES_EN;
const fallback = input.locale === "de" ? GENERAL_DE : GENERAL_EN;
const template = table[input.type] ?? fallback;
return template(input);
}
-48
View File
@@ -1,48 +0,0 @@
import type { CoachScopeSource } from "@/lib/ai/coach/types";
/**
* v1.4.25 W3e — per-target Coach scope source mapping.
*
* The per-card "Ask Coach about this" CTA narrows the snapshot to the
* single source the user is asking about. For derived metrics (BMI
* derives from weight; mood stability derives from mood) we map to the
* underlying source — the Coach prompt-builder knows how to talk
* about the derived view but the data it needs lives in the source.
*
* Targets without a CoachScopeSource counterpart (glucose contexts,
* steps, sleep, body-fat as of v1.4.23) fall back to an empty array,
* which the drawer reads as "do not narrow — use defaults". The
* Coach defaults already include the five-source set, so the snapshot
* stays useful even when this map has no opinion.
*/
export function coachScopeForTarget(
targetType: string,
): ReadonlyArray<CoachScopeSource> {
switch (targetType) {
case "WEIGHT":
case "BMI":
return ["weight"];
case "BLOOD_PRESSURE":
case "BLOOD_PRESSURE_IN_TARGET":
return ["bp"];
case "PULSE":
return ["pulse"];
case "MOOD_SCORE":
case "MOOD_STABILITY":
return ["mood"];
case "MEDICATION_COMPLIANCE":
return ["compliance"];
case "SLEEP_DURATION":
return ["sleep"];
case "ACTIVITY_STEPS":
return ["steps"];
case "BODY_FAT":
// No 1:1 source — the snapshot's weight + general blocks carry
// the surrounding context. Empty scope = "use defaults".
return [];
default:
// Glucose contexts + future targets — defaults will surface the
// available scopes in the drawer.
return [];
}
}
@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import {
composeDelta,
rangeWindowDays,
sliceWindowDelta,
type WindowAggregate,
} from "@/lib/analytics/range-delta";
import type { RollupBucketRow } from "@/lib/rollups/measurement-read-wmy";
/**
* v1.9.0 — the period-over-period range delta is two reads composed into one
* caption. The composition (slice the buckets into the current vs previous
* halves, aggregate each, derive the delta) is the load-bearing logic and is
* pure over the bucket rows — pinned here without a DB.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
function bucket(daysAgo: number, count: number, mean: number): RollupBucketRow {
return {
bucketStart: new Date(Date.now() - daysAgo * DAY_MS),
count,
mean,
sd: null,
slope: null,
r2: null,
sumValue: count * mean,
minValue: mean,
maxValue: mean,
};
}
describe("rangeWindowDays", () => {
it("maps each range onto its trailing-window length", () => {
expect(rangeWindowDays("7d")).toBe(7);
expect(rangeWindowDays("30d")).toBe(30);
expect(rangeWindowDays("90d")).toBe(90);
expect(rangeWindowDays("1y")).toBe(365);
});
});
describe("sliceWindowDelta", () => {
it("splits buckets into the current and previous halves by bucketStart", () => {
const now = Date.now();
// 30d window: current = [now-30, now), previous = [now-60, now-30).
const rows = [
bucket(5, 2, 80), // current
bucket(20, 3, 82), // current
bucket(40, 2, 70), // previous
bucket(55, 2, 74), // previous
];
const { current, previous } = sliceWindowDelta(rows, 30, now);
expect(current.count).toBe(5);
expect(previous.count).toBe(4);
// Count-weighted means compose linearly across buckets.
expect(current.mean).toBeCloseTo((2 * 80 + 3 * 82) / 5, 6);
expect(previous.mean).toBeCloseTo((2 * 70 + 2 * 74) / 4, 6);
});
it("excludes buckets outside the 2N span", () => {
const now = Date.now();
const rows = [
bucket(5, 1, 90), // current
bucket(40, 1, 80), // previous
bucket(200, 1, 50), // outside both halves
];
const { current, previous } = sliceWindowDelta(rows, 30, now);
expect(current.count).toBe(1);
expect(previous.count).toBe(1);
});
it("yields empty aggregates when no bucket falls in a half", () => {
const now = Date.now();
const rows = [bucket(5, 4, 100)]; // only current half populated
const { current, previous } = sliceWindowDelta(rows, 30, now);
expect(current.count).toBe(4);
expect(previous.count).toBe(0);
expect(previous.mean).toBeNull();
});
});
describe("composeDelta", () => {
const win = (count: number, mean: number | null): WindowAggregate => ({
count,
min: mean,
max: mean,
mean,
sum: mean === null ? null : count * mean,
});
it("computes the signed delta and percentage", () => {
const { delta, deltaPct } = composeDelta(win(5, 103), win(5, 100));
expect(delta).toBeCloseTo(3, 6);
expect(deltaPct).toBeCloseTo(0.03, 6);
});
it("returns null delta when either window is empty", () => {
expect(composeDelta(win(0, null), win(5, 100))).toEqual({
delta: null,
deltaPct: null,
});
expect(composeDelta(win(5, 100), win(0, null))).toEqual({
delta: null,
deltaPct: null,
});
});
it("returns null deltaPct (not Infinity) when the prior mean is zero", () => {
const { delta, deltaPct } = composeDelta(win(5, 4), win(3, 0));
expect(delta).toBeCloseTo(4, 6);
expect(deltaPct).toBeNull();
});
it("captures a negative move", () => {
const { delta, deltaPct } = composeDelta(win(5, 90), win(5, 100));
expect(delta).toBeCloseTo(-10, 6);
expect(deltaPct).toBeCloseTo(-0.1, 6);
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* v1.9.0 — period-over-period range deltas for a single metric.
*
* Background
* ----------
* The Insights metric pages render fixed windows (`avg7`, `avg30`,
* `slope7/30/90`). A user-selectable time-range with a "vs prior period"
* delta needs two reads of the same metric: the current window and the
* previous comparable window, composed into a delta. This is exactly the
* two-window pattern `computeAvg30LastYearForType` already uses for the
* year-ago baseline (`summaries-slice.ts`) — current = the trailing window,
* previous = the equally-sized window immediately before it.
*
* Single metric, two reads, no fan-out
* ------------------------------------
* The route this backs is single-metric (the metric page is single-metric),
* so the cost is one `readBestGranularityRollups` call covering the trailing
* `2N` days, sliced into the current and previous halves. No per-type fan-out,
* no Prisma-pool burst — the opposite of the deleted 15-way live walk.
*
* Compositional contract
* ----------------------
* `count / min / max / mean / sum` are linearly composable across buckets
* (the rollup tier's compositional contract — `measurement-read-wmy.ts`), so
* a window aggregate built from WEEK / MONTH buckets equals the per-row
* aggregate over the same span. SD / slope / r² are intentionally NOT part of
* the delta — they do not compose, matching the rest of the WMY reader tier.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
aggregateWmyBuckets,
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
import {
ANALYTICS_RANGES,
rangeWindowDays,
type AnalyticsRange,
type RangeDeltaResult,
type WindowAggregate,
} from "@/lib/analytics/range-shared";
// The range constants + result shapes live in the client-safe
// `range-shared` module so the insights client bundle never pulls the
// server-only rollup readers (and `pg`) through a range import. Re-exported
// here so existing server-side callers keep importing from `range-delta`.
export {
ANALYTICS_RANGES,
rangeWindowDays,
type AnalyticsRange,
type RangeDeltaResult,
type WindowAggregate,
};
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Slice the resolved buckets into the current half (`[now-N, now)`) and the
* previous half (`[now-2N, now-N)`) and compose each. Pure over the bucket
* rows + the window boundaries so the composition is unit-testable without a
* DB. `bucketStart` is the slice key — the same conservative overlap filter
* `computeAvg30LastYearForType` uses (a bucket counts toward the half its
* `bucketStart` falls into).
*/
export function sliceWindowDelta(
rows: RollupBucketRow[],
windowDays: number,
now: number,
): { current: WindowAggregate; previous: WindowAggregate } {
const currentStart = now - windowDays * DAY_MS;
const previousStart = now - 2 * windowDays * DAY_MS;
const currentRows: RollupBucketRow[] = [];
const previousRows: RollupBucketRow[] = [];
for (const row of rows) {
const t = row.bucketStart.getTime();
if (t >= currentStart && t < now) {
currentRows.push(row);
} else if (t >= previousStart && t < currentStart) {
previousRows.push(row);
}
}
return {
current: aggregateWmyBuckets(currentRows),
previous: aggregateWmyBuckets(previousRows),
};
}
/**
* Compose the period-over-period delta from the two window aggregates.
* Pure — pinned by unit test. Guards both the missing-data case (either
* window empty → delta null) and the divide-by-zero case (prior mean zero or
* null → deltaPct null) so the caption never paints a misleading 0 %.
*/
export function composeDelta(
current: WindowAggregate,
previous: WindowAggregate,
): { delta: number | null; deltaPct: number | null } {
if (current.mean === null || previous.mean === null) {
return { delta: null, deltaPct: null };
}
const delta = current.mean - previous.mean;
const deltaPct = previous.mean !== 0 ? delta / previous.mean : null;
return { delta, deltaPct };
}
/**
* Read the current vs previous window for one `(userId, type)` and compose
* the delta. Reads a single `2N`-day window through the granularity router so
* both halves resolve at the same granularity (the comparison stays
* apples-to-apples). Returns a zeroed result on a coverage miss — the route
* still answers 200 with empty windows so the UI shows "no prior-period
* data" rather than erroring.
*/
export async function computeRangeDelta(
userId: string,
type: MeasurementType,
range: AnalyticsRange,
now: number = Date.now(),
): Promise<RangeDeltaResult> {
const windowDays = rangeWindowDays(range);
// Read the full 2N span in one go so the current and previous halves share
// a granularity; the router picks the coarsest tier that resolves 2N.
const resolved = await readBestGranularityRollups(userId, type, windowDays * 2);
if (!resolved) {
const empty: WindowAggregate = {
count: 0,
min: null,
max: null,
mean: null,
sum: null,
};
return {
range,
windowDays,
granularity: "none",
current: empty,
previous: empty,
delta: null,
deltaPct: null,
};
}
const { current, previous } = sliceWindowDelta(resolved.rows, windowDays, now);
const { delta, deltaPct } = composeDelta(current, previous);
return {
range,
windowDays,
granularity: resolved.granularity,
current,
previous,
delta,
deltaPct,
};
}
+58
View File
@@ -0,0 +1,58 @@
/**
* v1.9.0 — client-safe range constants and types for the Insights metric
* pages.
*
* The user-selectable range list, its type, the per-range window length, and
* the period-over-period result shapes are shared between the client (the
* pills, the delta caption, the range hook, the layout-prefs hook) and the
* server (`/api/analytics/range` + `range-delta.ts`). This module imports
* NOTHING server-side — no rollup readers, no `db`, no Prisma client — so the
* client bundle never transitively pulls `pg` (dns/fs/net/tls) through a range
* import. The server-only window-composition logic lives in `range-delta.ts`,
* which re-exports these names for its own callers.
*/
/** The user-selectable ranges. */
export const ANALYTICS_RANGES = ["7d", "30d", "90d", "1y"] as const;
export type AnalyticsRange = (typeof ANALYTICS_RANGES)[number];
/** Trailing-window length, in days, for each range. */
const RANGE_WINDOW_DAYS: Record<AnalyticsRange, number> = {
"7d": 7,
"30d": 30,
"90d": 90,
"1y": 365,
};
export function rangeWindowDays(range: AnalyticsRange): number {
return RANGE_WINDOW_DAYS[range];
}
/** A composed window aggregate. `mean`/etc. are null when the window is empty. */
export interface WindowAggregate {
count: number;
min: number | null;
max: number | null;
mean: number | null;
sum: number | null;
}
export interface RangeDeltaResult {
range: AnalyticsRange;
windowDays: number;
/** The granularity the underlying rollup read resolved against. */
granularity: string;
current: WindowAggregate;
previous: WindowAggregate;
/**
* `current.mean - previous.mean`, or null when either window has no mean
* (no data in the current or prior window) — never a misleading 0.
*/
delta: number | null;
/**
* `delta / previous.mean` as a fraction (0.03 = +3 %), or null when the
* prior window has no mean / a zero mean (no divide-by-zero, no misleading
* 0 %). The client renders "no prior-period data" in that case.
*/
deltaPct: number | null;
}
+171 -1
View File
@@ -88,12 +88,59 @@ export interface DoctorReportData {
medications: Array<{
name: string;
dose: string;
// v1.9.0 — optional user/clinician-asserted drug-classification
// codes. Carried here so the FHIR exporter can emit a coded
// `medicationCodeableConcept` (ATC primary, RxNorm secondary). NULL
// when no code was captured — the exporter then emits the pre-v1.9.0
// text-only concept. Optional in the type so pre-v1.9.0 fixtures
// still typecheck.
atcCode?: string | null;
rxNormCode?: string | null;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
}>;
}>;
/**
* v1.9.0 — acted medication-intake events over the report window, fed
* to the FHIR `MedicationAdministration` builder. One entry per intake
* row the user actually actioned (taken OR explicitly skipped);
* pending / missed / soft-deleted rows are excluded upstream so the
* exporter never asserts an administration that did not happen. The
* `dose` is the medication's structured dose-in-effect at
* `effectiveAt` (from `MedicationDoseChange`) when one exists, else
* NULL. Optional so pre-v1.9.0 fixtures still typecheck.
*/
medicationAdministrations?: Array<{
medicationName: string;
/** ISO timestamp: `takenAt` for a taken dose, `scheduledFor` for a skip. */
effectiveAt: string;
status: "completed" | "not-done";
/** Free-text dose label (the medication's `dose`), mirrors the statement. */
doseText: string | null;
/** Structured dose-in-effect at `effectiveAt`, when a dose-change history exists. */
dose: { value: number; unit: string } | null;
/** Injection site, when the row recorded one. */
injectionSite: string | null;
/** ATC / RxNorm codes, mirrored from the medication for the self-describing concept. */
atcCode: string | null;
rxNormCode: string | null;
/** Delivery form — ORAL / INJECTION / OTHER — for the route mapping. */
deliveryForm: string | null;
}>;
/**
* v1.9.0 — set when {@link MAX_MEDICATION_ADMINISTRATIONS} trimmed the
* administration set. `total` is the full acted-intake count over the
* window; `included` is how many (the most-recent) survived the cap.
* Null/absent when nothing was trimmed. The FHIR builder discloses
* this in the document narrative so the export is honest about the
* omitted (oldest) rows. Optional so pre-v1.9.0 fixtures still typecheck.
*/
medicationAdministrationsTruncation?: {
total: number;
included: number;
} | null;
mood: DoctorReportMood | null;
/**
* v1.4.25 W4d — GLP-1 therapy section. Populated when the user has at
@@ -159,6 +206,21 @@ const MIN_RANGE_DAYS = 1;
const MAX_RANGE_DAYS = 730;
const DEFAULT_RANGE_DAYS = 90;
/**
* v1.9.0 — hard ceiling on the number of `MedicationAdministration`
* source rows materialised per export. The report window is already
* bounded at {@link MAX_RANGE_DAYS} (730 days), but a chronic medication
* dosed several times a day across that window — multiplied by several
* such medications — can still produce thousands of administration
* resources in a single Bundle (e.g. 4×/day × 730 days ≈ 2 920 per med).
* Cap at the MOST-RECENT 1 000 acted intakes overall: that preserves the
* recent-adherence picture a clinician cares about while bounding the
* in-memory array and the serialised Bundle. When the cap trims rows the
* aggregator flags it so the FHIR narrative can disclose the truncation
* rather than silently dropping history.
*/
const MAX_MEDICATION_ADMINISTRATIONS = 1000;
export interface DoctorReportRange {
/** Start of the reporting window (UTC, inclusive). */
start: Date;
@@ -310,7 +372,21 @@ export async function collectDoctorReportData(
prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the doctor report.
where: { userId, deletedAt: null, scheduledFor: { gte: start, lte: end } },
include: { medication: { select: { name: true } } },
include: {
// v1.9.0 — carry the medication identity + codes + delivery
// form so the FHIR MedicationAdministration builder can emit a
// self-describing `medicationCodeableConcept` and a route.
medication: {
select: {
id: true,
name: true,
dose: true,
atcCode: true,
rxNormCode: true,
deliveryForm: true,
},
},
},
orderBy: { scheduledFor: "asc" },
}),
// Mood data: zero DB read when the user opted out. This is the
@@ -386,6 +462,93 @@ export async function collectDoctorReportData(
}
}
// v1.9.0 — MedicationAdministration source rows. One entry per acted
// intake (taken OR explicitly skipped); pending / missed rows are
// dropped so the FHIR export never asserts an administration that did
// not happen, and tombstoned rows are already excluded by the query
// `deletedAt: null` predicate. The structured `dose` is resolved from
// the medication's `MedicationDoseChange` history — the latest change
// effective at or before the administration instant — when one exists.
//
// Dose-change history is loaded on the `medications` array (active
// meds only); a per-medicationId index lets the resolver run in O(1)
// lookups + a short linear scan over the (typically small) change list.
const doseChangesByMedId = new Map<
string,
Array<{ effectiveFrom: Date; doseValue: number; doseUnit: string }>
>();
for (const m of medications) {
doseChangesByMedId.set(
m.id,
m.doseChanges.map((dc) => ({
effectiveFrom: dc.effectiveFrom,
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
})),
);
}
const resolveDoseInEffect = (
medicationId: string,
at: Date,
): { value: number; unit: string } | null => {
const changes = doseChangesByMedId.get(medicationId);
if (!changes || changes.length === 0) return null;
// `doseChanges` are loaded ordered by `effectiveFrom asc`; take the
// last one whose effectiveFrom is <= the administration instant.
let inEffect: { value: number; unit: string } | null = null;
for (const c of changes) {
if (c.effectiveFrom.getTime() <= at.getTime()) {
inEffect = { value: c.doseValue, unit: c.doseUnit };
} else {
break;
}
}
return inEffect;
};
const medicationAdministrations: NonNullable<
DoctorReportData["medicationAdministrations"]
> = [];
for (const event of intakeEvents) {
// Only acted rows: a taken dose (completed) or an explicit skip
// (not-done). A scheduled-but-unconfirmed ("missed") slot is not an
// administration event and is omitted entirely.
const isTaken = event.takenAt !== null;
if (!isTaken && !event.skipped) continue;
const effectiveAt = isTaken
? (event.takenAt as Date)
: event.scheduledFor;
medicationAdministrations.push({
medicationName: event.medication.name,
effectiveAt: effectiveAt.toISOString(),
status: isTaken ? "completed" : "not-done",
doseText: event.medication.dose || null,
// Structured dose only meaningful for a taken dose with a
// dose-change history; a skip records no dose consumed.
dose: isTaken
? resolveDoseInEffect(event.medication.id, effectiveAt)
: null,
injectionSite: event.injectionSite ?? null,
atcCode: event.medication.atcCode ?? null,
rxNormCode: event.medication.rxNormCode ?? null,
deliveryForm: event.medication.deliveryForm ?? null,
});
}
// v1.9.0 — bound the administration set. `intakeEvents` is ordered
// `scheduledFor: asc`, so the most-recent acted rows are at the tail;
// keep the last N and flag the trim so the FHIR narrative can disclose
// it. The omitted rows are the OLDEST in the window — recent adherence
// is preserved intact.
const totalAdministrations = medicationAdministrations.length;
const medicationAdministrationsTruncated =
totalAdministrations > MAX_MEDICATION_ADMINISTRATIONS;
const cappedAdministrations = medicationAdministrationsTruncated
? medicationAdministrations.slice(
totalAdministrations - MAX_MEDICATION_ADMINISTRATIONS,
)
: medicationAdministrations;
// Mood summary.
const moodScores = moodEntries.map((e) => e.score);
const mood: DoctorReportMood | null =
@@ -593,12 +756,19 @@ export async function collectDoctorReportData(
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
// v1.9.0 — drug-classification codes for the coded FHIR concept.
atcCode: m.atcCode,
rxNormCode: m.rxNormCode,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
})),
})),
medicationAdministrations: cappedAdministrations,
medicationAdministrationsTruncation: medicationAdministrationsTruncated
? { total: totalAdministrations, included: cappedAdministrations.length }
: null,
mood,
glp1,
};
@@ -258,12 +258,9 @@ describe("Coach disable cascade invariant", () => {
// call site in the codebase.
//
// The fixture-count check below pins the surfaces the fixture mounts
// directly. Cross-cut gates on sub-pages (`/targets` page drawer,
// `<TargetCard>` per-card CTA) sit OUTSIDE the fixture and are owned
// by sibling invariants. A future contributor who lands a new Coach
// mount on (e.g.) `/insights/blood-pressure/page.tsx` would not trip the
// surface-count check at all — the new site is on a sub-page and
// would silently leak.
// directly. A future contributor who lands a new Coach mount on (e.g.)
// `/insights/blood-pressure/page.tsx` would not trip the surface-count
// check at all — the new site is on a sub-page and would silently leak.
//
// The walk below scans `src/` for every `flags.coach` occurrence and
// requires each path to appear in the explicit `KNOWN_COACH_GATE_SITES`
@@ -286,10 +283,6 @@ describe("Coach disable cascade invariant", () => {
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
// Cross-cut gates owned by sibling invariants
// (`targets-coach-mount.test.tsx`, `target-card.test.tsx`).
"src/app/targets/page.tsx",
"src/components/targets/target-card.tsx",
];
/**
@@ -389,11 +382,8 @@ describe("Coach disable cascade invariant", () => {
// contributor adds a new `useFeatureFlags()` + `flags.coach` gate
// to a render path, the count below must move and this assertion
// forces them to revisit the fixture. The number tracks the
// surfaces the fixture mounts directly; cross-cut gates on
// sub-pages (target-card, /targets page) are owned by other
// invariants (`targets-coach-mount.test.tsx`,
// `target-card.test.tsx`) and by the grep-based discovery test
// above.
// surfaces the fixture mounts directly; the grep-based discovery
// test above pins every other `flags.coach` call site.
expect(COACH_SURFACES.length).toBe(6);
});
});
@@ -19,9 +19,6 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
import { HeroStrip } from "@/components/insights/hero-strip";
import { TargetCard, type TargetCardData } from "@/components/targets/target-card";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
/**
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
@@ -182,61 +179,8 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
),
proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
},
{
// v1.4.48 L2 — `<TargetCard>` carries the per-card "Ask the coach"
// CTA on the targets page. The card itself only consults
// `flags.coach`; the per-user disable gate is computed by the parent
// (`src/app/targets/page.tsx`) which does
// `aiEnabled = flags.coach && !user?.disableCoach && providerActive`.
// The driver below mirrors that wiring so the negative assertion
// (CTA absent when `disableCoach` is true) pins the chain end-to-end
// and trips a regression in either the card-level gate or the
// page-level computation.
name: "TargetCard per-card 'Ask the coach' CTA",
mount: () => <TargetCardDriver />,
proofWhenVisible: 'data-slot="target-coach-cta"',
},
];
/**
* Inline driver that mirrors the `src/app/targets/page.tsx` wiring of
* `aiEnabled` to the per-card CTA. Reads the same hooks the page does,
* threads the resolved boolean into `<TargetCard>`. Keeps the user-
* disable fixture honest about the cascade — a future contributor who
* "simplifies" the page's `coachEnabled` computation and drops the
* `user?.disableCoach` branch fails this surface's negative assertion.
*/
const TARGET_CARD_FIXTURE: TargetCardData = {
type: "WEIGHT",
label: "Weight",
current: 78.2,
average30: 79.1,
trend: "down",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: "2026-05-10",
streakDays: 4,
insufficientData: false,
consistency7d: ["in", "in", "near", "in", "out", "in", "in"],
};
function TargetCardDriver() {
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const aiEnabled = flags.coach && !disableCoach;
return (
<TargetCard
target={TARGET_CARD_FIXTURE}
aiEnabled={aiEnabled}
onAskCoach={() => undefined}
/>
);
}
describe("Coach per-user disableCoach invariant", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -307,10 +251,9 @@ describe("Coach per-user disableCoach invariant", () => {
//
// Mirrors the `flags.coach` discovery walk in `coach-cascade.test.tsx`:
// the surface fixture above pins the surfaces this file mounts
// directly. Cross-cut gates on sub-pages (`/targets` page parent
// computation, Settings → Insights toggle, `/api/auth/me` response
// shape, the disable-coach PATCH route) sit OUTSIDE the fixture and
// are owned by sibling invariants or by route-level tests.
// directly. Cross-cut gates (Settings → Insights toggle, `/api/auth/me`
// response shape, the disable-coach PATCH route) sit OUTSIDE the
// fixture and are owned by sibling invariants or by route-level tests.
//
// The walk below scans `src/` for every `useDisableCoach` /
// `user.disableCoach` / `user?.disableCoach` occurrence and requires
@@ -346,11 +289,6 @@ describe("Coach per-user disableCoach invariant", () => {
// writes the flag does NOT read `user.disableCoach` (it reads
// `body.disableCoach`) and is intentionally not listed here.
"src/app/api/auth/me/route.ts",
// - The targets page's parent-level cascade: computes
// `coachEnabled = flags.coach && !user?.disableCoach` and threads
// `aiEnabled` down to `<TargetCard>`. The disable-cascade fixture
// covers the end-to-end chain via `<TargetCardDriver>`.
"src/app/targets/page.tsx",
// - Settings → Insights toggle reads + writes the flag.
"src/components/settings/ai-section.tsx",
// v1.7.0 W6 — the unified dashboard snapshot builder reads
+310 -3
View File
@@ -4,11 +4,23 @@ import type { DoctorReportData } from "@/lib/doctor-report-data";
import type {
FhirObservation,
FhirMedicationStatement,
FhirMedicationAdministration,
FhirPatient,
FhirCoverage,
FhirDiagnosticReport,
} from "../types";
function administrationsOf(
bundle: ReturnType<typeof buildFhirDocumentBundle>,
): FhirMedicationAdministration[] {
return bundle.entry
.map((e) => e.resource)
.filter(
(r): r is FhirMedicationAdministration =>
r.resourceType === "MedicationAdministration",
);
}
function coverageOf(
bundle: ReturnType<typeof buildFhirDocumentBundle>,
): FhirCoverage | undefined {
@@ -229,7 +241,7 @@ describe("buildFhirDocumentBundle", () => {
expect(coverage?.contained?.[0].identifier?.[0].value).toBe("101234567");
});
it("omits the Coverage entirely when neither insurer name nor IKNR is present", () => {
it("omits the Coverage entirely when there is no payor signal at all", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
@@ -241,17 +253,98 @@ describe("buildFhirDocumentBundle", () => {
// neither insurerName nor insurerIkNumber
},
}),
{ insuranceNumber: "A123456780" },
// ...and no KVNR.
{ insuranceNumber: null },
FIXED_NOW,
);
expect(coverageOf(bundle)).toBeUndefined();
// KVNR still rides on Patient.identifier.
});
it("emits a KVNR-only Coverage (no insurer) to align with the iOS exporter", () => {
const bundle = buildFhirDocumentBundle(
makeData({
patient: {
username: "sample-user",
dateOfBirth: null,
gender: null,
heightCm: null,
fullName: "Sample Patient",
// neither insurerName nor insurerIkNumber — just a bare KVNR.
},
}),
{ insuranceNumber: "A123456780" },
FIXED_NOW,
);
const coverage = coverageOf(bundle);
expect(coverage).toBeDefined();
expect(coverage?.status).toBe("active");
expect(coverage?.beneficiary.reference).toBe("Patient/patient-1");
expect(coverage?.subscriberId).toBe("A123456780");
// No insurer known → no contained payor Organization, no payor[].
expect(coverage?.contained).toBeUndefined();
expect(coverage?.payor).toBeUndefined();
// KVNR still rides on Patient.identifier too.
const patient = bundle.entry.find(
(e) => e.resource.resourceType === "Patient",
)?.resource as FhirPatient;
expect(patient.identifier?.[0].value).toBe("A123456780");
});
it("emits a top-level Composition.text narrative", () => {
const bundle = buildFhirDocumentBundle(
makeData(),
{ insuranceNumber: null },
FIXED_NOW,
);
const composition = bundle.entry[0].resource;
expect(composition.resourceType).toBe("Composition");
if (composition.resourceType === "Composition") {
expect(composition.text?.status).toBe("generated");
expect(composition.text?.div).toContain("Health record for");
}
});
it("discloses an administration truncation in the Composition narrative", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Mounjaro",
effectiveAt: "2026-04-30T08:00:00.000Z",
status: "completed",
doseText: "10mg",
dose: { value: 10, unit: "mg" },
injectionSite: null,
atcCode: null,
rxNormCode: null,
deliveryForm: "INJECTION",
},
],
medicationAdministrationsTruncation: { total: 2920, included: 1000 },
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const composition = bundle.entry[0].resource;
if (composition.resourceType === "Composition") {
expect(composition.text?.div).toContain(
"Medication administrations truncated: showing the most recent 1000 of 2920",
);
}
});
it("omits the truncation sentence when nothing was capped", () => {
const bundle = buildFhirDocumentBundle(
makeData({ medicationAdministrationsTruncation: null }),
{ insuranceNumber: null },
FIXED_NOW,
);
const composition = bundle.entry[0].resource;
if (composition.resourceType === "Composition") {
expect(composition.text?.div).not.toContain("truncated");
}
});
it("maps weight to LOINC 29463-7 / UCUM kg with the latest reading", () => {
const bundle = buildFhirDocumentBundle(
makeData(),
@@ -336,6 +429,220 @@ describe("buildFhirDocumentBundle", () => {
expect(meds[0].dosage?.[0].text).toBe("5mg");
});
it("keeps a text-only medicationCodeableConcept when no codes are stored", () => {
const bundle = buildFhirDocumentBundle(
makeData(),
{ insuranceNumber: null },
FIXED_NOW,
);
const stmt = bundle.entry
.map((e) => e.resource)
.find(
(r): r is FhirMedicationStatement =>
r.resourceType === "MedicationStatement",
);
// Pre-v1.9.0 shape: text anchor, no coding[].
expect(stmt?.medicationCodeableConcept.text).toBe("Example Drug");
expect(stmt?.medicationCodeableConcept.coding).toBeUndefined();
});
it("emits ATC (primary) + RxNorm (secondary) codings when both are stored", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medications: [
{
name: "Mounjaro",
dose: "10mg",
atcCode: "A10BX10",
rxNormCode: "2601723",
schedules: [],
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const stmt = bundle.entry
.map((e) => e.resource)
.find(
(r): r is FhirMedicationStatement =>
r.resourceType === "MedicationStatement",
);
const coding = stmt?.medicationCodeableConcept.coding;
expect(stmt?.medicationCodeableConcept.text).toBe("Mounjaro");
expect(coding).toHaveLength(2);
// ATC is primary (first), with the name as display.
expect(coding?.[0]).toEqual({
system: "http://www.whocc.no/atc",
code: "A10BX10",
display: "Mounjaro",
});
// RxNorm is secondary, no display.
expect(coding?.[1]).toEqual({
system: "http://www.nlm.nih.gov/research/umls/rxnorm",
code: "2601723",
});
});
it("emits only the ATC coding when RxNorm is absent", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medications: [
{ name: "Ramipril", dose: "5mg", atcCode: "C09AA05", schedules: [] },
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const stmt = bundle.entry
.map((e) => e.resource)
.find(
(r): r is FhirMedicationStatement =>
r.resourceType === "MedicationStatement",
);
const coding = stmt?.medicationCodeableConcept.coding;
expect(coding).toHaveLength(1);
expect(coding?.[0].system).toBe("http://www.whocc.no/atc");
expect(coding?.[0].code).toBe("C09AA05");
});
it("maps a taken intake to a completed MedicationAdministration with a structured dose", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Mounjaro",
effectiveAt: "2026-04-30T08:12:00.000Z",
status: "completed",
doseText: "10mg",
dose: { value: 10, unit: "mg" },
injectionSite: "abdomen-left",
atcCode: "A10BX10",
rxNormCode: "2601723",
deliveryForm: "INJECTION",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const admins = administrationsOf(bundle);
expect(admins).toHaveLength(1);
const a = admins[0];
expect(a.status).toBe("completed");
expect(a.effectiveDateTime).toBe("2026-04-30T08:12:00.000Z");
// Self-describing concept reuses the ATC/RxNorm coding + text anchor.
expect(a.medicationCodeableConcept.text).toBe("Mounjaro");
expect(a.medicationCodeableConcept.coding?.[0].code).toBe("A10BX10");
// Structured dose satisfies the R4 dose-or-rate invariant.
expect(a.dosage?.dose).toEqual({
value: 10,
unit: "mg",
system: "http://unitsofmeasure.org",
code: "mg",
});
expect(a.dosage?.text).toBe("10mg");
expect(a.dosage?.route?.text).toBe("Injection");
expect(a.dosage?.site?.text).toBe("abdomen-left");
});
it("maps a skipped intake to a not-done administration with no dosage", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Ramipril",
effectiveAt: "2026-04-29T08:00:00.000Z",
status: "not-done",
doseText: "5mg",
dose: null,
injectionSite: null,
atcCode: null,
rxNormCode: null,
deliveryForm: "ORAL",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const admins = administrationsOf(bundle);
expect(admins).toHaveLength(1);
expect(admins[0].status).toBe("not-done");
expect(admins[0].effectiveDateTime).toBe("2026-04-29T08:00:00.000Z");
// No structured dose → no dosage block (R4 dose-or-rate invariant).
expect(admins[0].dosage).toBeUndefined();
// Text-only concept when the medication has no codes.
expect(admins[0].medicationCodeableConcept.coding).toBeUndefined();
expect(admins[0].medicationCodeableConcept.text).toBe("Ramipril");
});
it("omits the dosage when a taken dose has no structured dose Quantity", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Vitamin D3",
effectiveAt: "2026-04-30T20:00:00.000Z",
status: "completed",
doseText: "1000 IU",
dose: null,
injectionSite: null,
atcCode: null,
rxNormCode: null,
deliveryForm: "ORAL",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const a = administrationsOf(bundle)[0];
expect(a.status).toBe("completed");
// A dosage with only `.text` would violate the dose-or-rate
// invariant; the builder omits the dosage entirely instead.
expect(a.dosage).toBeUndefined();
});
it("emits no MedicationAdministration when there are no acted intakes", () => {
const bundle = buildFhirDocumentBundle(
makeData({ medicationAdministrations: [] }),
{ insuranceNumber: null },
FIXED_NOW,
);
expect(administrationsOf(bundle)).toHaveLength(0);
});
it("references the administrations from the Medications composition section", () => {
const bundle = buildFhirDocumentBundle(
makeData({
medicationAdministrations: [
{
medicationName: "Mounjaro",
effectiveAt: "2026-04-30T08:12:00.000Z",
status: "completed",
doseText: "10mg",
dose: { value: 10, unit: "mg" },
injectionSite: null,
atcCode: null,
rxNormCode: null,
deliveryForm: "INJECTION",
},
],
}),
{ insuranceNumber: null },
FIXED_NOW,
);
const composition = bundle.entry[0].resource;
expect(composition.resourceType).toBe("Composition");
const medSection =
composition.resourceType === "Composition"
? composition.section?.find((s) => s.title === "Medications")
: undefined;
const refs = medSection?.entry?.map((e) => e.reference) ?? [];
expect(refs).toContain("MedicationAdministration/medadmin-1");
});
it("omits the mood Observation when mood is null (privacy default)", () => {
const bundle = buildFhirDocumentBundle(
makeData({ mood: null }),
+184 -23
View File
@@ -39,6 +39,8 @@ import type {
FhirCodeableConcept,
FhirObservation,
FhirMedicationStatement,
FhirMedicationAdministration,
FhirDosage,
FhirPatient,
FhirCoverage,
FhirOrganization,
@@ -60,6 +62,84 @@ const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10";
/** German insurer institution-number (IKNR) identifier namespace. */
const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
/**
* v1.9.0 — drug-coding system URIs. ATC is the portable WHO default
* (the iOS export emits the identical URI); RxNorm is the secondary US
* coding. Both are additive `coding[]` entries on the same concept; the
* free-text `.text` (the user's medication name) stays the anchor.
*/
const ATC_SYSTEM = "http://www.whocc.no/atc";
const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
/**
* Build a `medicationCodeableConcept` from a medication's free-text name
* plus its optional user-asserted codes. ATC is emitted first (primary),
* RxNorm second (secondary); both are omitted when NULL, collapsing to
* exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code.
*/
function medicationConcept(
name: string,
atcCode: string | null | undefined,
rxNormCode: string | null | undefined,
): FhirCodeableConcept {
const coding: NonNullable<FhirCodeableConcept["coding"]> = [];
if (atcCode) {
coding.push({ system: ATC_SYSTEM, code: atcCode, display: name });
}
if (rxNormCode) {
coding.push({ system: RXNORM_SYSTEM, code: rxNormCode });
}
return coding.length > 0 ? { coding, text: name } : { text: name };
}
/**
* v1.9.0 — UCUM codes for the dose units HealthLog stores. The display
* `unit` is always the user's original string; the UCUM `code` is set
* only for an unambiguous mapping so a consumer that resolves UCUM never
* sees a guessed code. An unmapped unit drops `code` (and `system`) and
* keeps just the human-readable `unit` — conformant, just not coded.
*/
const UCUM_DOSE_CODES: Record<string, string> = {
mg: "mg",
g: "g",
mcg: "ug",
µg: "ug",
ug: "ug",
ml: "mL",
mL: "mL",
};
function doseQuantity(value: number, unit: string): {
value: number;
unit: string;
system?: string;
code?: string;
} {
const ucum = UCUM_DOSE_CODES[unit];
return ucum
? { value, unit, system: UCUM_SYSTEM, code: ucum }
: { value, unit };
}
/**
* v1.9.0 — text-only route of administration derived from the
* medication's delivery form. Text-only (no SNOMED licence concern); the
* design doc upgrades to coded routes jointly with iOS later. Returns
* `undefined` for an unknown / absent form so no empty route is emitted.
*/
function routeConcept(
deliveryForm: string | null,
): FhirCodeableConcept | undefined {
switch (deliveryForm) {
case "ORAL":
return { text: "Oral" };
case "INJECTION":
return { text: "Injection" };
default:
return undefined;
}
}
/** Escape the five XML-significant characters for the xhtml narrative. */
function escapeXml(value: string): string {
return value
@@ -146,33 +226,36 @@ export function buildFhirDocumentBundle(
entries.push({ fullUrl: `urn:uuid:${patientId}`, resource: patient });
// --- Coverage (insurer; sits right after the Patient) ------------------
// Emitted whenever ANY payor info is present (insurer name and/or IKNR).
// The payor is a CONTAINED Organization referenced by a local `#`-ref.
// The KVNR stays on `Patient.identifier`; here it doubles as the
// Coverage `subscriberId` (the member id) when present. When neither an
// insurer name nor an IKNR is known, omit Coverage entirely rather than
// emit an empty payor.
// v1.9.0 — emitted whenever ANY payor signal is present: an insurer
// name, an IKNR, OR a bare KVNR. The KVNR-only case aligns the server
// with the iOS exporter (which emits a Coverage on a bare member id);
// it carries the `subscriberId` with no contained payor Organization.
// When an insurer name and/or IKNR is known, the payor is a CONTAINED
// Organization referenced by a local `#`-ref. The KVNR stays on
// `Patient.identifier` and doubles as the `subscriberId` (member id).
const insurerName = data.patient.insurerName ?? null;
const insurerIkNumber = data.patient.insurerIkNumber ?? null;
if (insurerName || insurerIkNumber) {
const orgId = "insurer-org-1";
const payorOrg: FhirOrganization = {
resourceType: "Organization",
id: orgId,
};
if (insurerIkNumber) {
payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }];
}
if (insurerName) payorOrg.name = insurerName;
const hasPayorOrg = Boolean(insurerName || insurerIkNumber);
if (hasPayorOrg || identity.insuranceNumber) {
const coverage: FhirCoverage = {
resourceType: "Coverage",
id: "coverage-1",
status: "active",
contained: [payorOrg],
beneficiary: patientRef,
payor: [{ reference: `#${orgId}` }],
};
if (hasPayorOrg) {
const orgId = "insurer-org-1";
const payorOrg: FhirOrganization = {
resourceType: "Organization",
id: orgId,
};
if (insurerIkNumber) {
payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }];
}
if (insurerName) payorOrg.name = insurerName;
coverage.contained = [payorOrg];
coverage.payor = [{ reference: `#${orgId}` }];
}
if (identity.insuranceNumber) {
coverage.subscriberId = identity.insuranceNumber;
}
@@ -389,7 +472,14 @@ export function buildFhirDocumentBundle(
resourceType: "MedicationStatement",
id,
status: "active",
medicationCodeableConcept: { text: med.name },
// v1.9.0 — additive ATC (primary) / RxNorm (secondary) codings when
// the user stored them; falls back to the text-only concept when
// neither code is present (the pre-v1.9.0 shape).
medicationCodeableConcept: medicationConcept(
med.name,
med.atcCode,
med.rxNormCode,
),
subject: patientRef,
};
if (med.dose) stmt.dosage = [{ text: med.dose }];
@@ -397,11 +487,66 @@ export function buildFhirDocumentBundle(
medicationRefs.push({ reference: `MedicationStatement/${id}` });
}
// --- MedicationAdministration per acted intake -------------------------
// One resource per intake the user actually actioned: `completed`
// (taken) or `not-done` (explicitly skipped). Pending / missed slots and
// soft-deleted tombstones are excluded upstream by the aggregator. The
// concept reuses the same ATC/RxNorm coding as the statement so each
// administration is self-describing without resolving a reference (no
// `partOf` / `request` coupling — see the v1.9.0 design doc). A `dosage`
// is emitted ONLY when a structured `dose` Quantity is available; a
// dosage with only `.text` would violate the R4 dose-or-rate invariant.
const administrationRefs: FhirReference[] = [];
let adminSeq = 0;
for (const admin of data.medicationAdministrations ?? []) {
adminSeq += 1;
const id = `medadmin-${adminSeq}`;
const resource: FhirMedicationAdministration = {
resourceType: "MedicationAdministration",
id,
status: admin.status,
medicationCodeableConcept: medicationConcept(
admin.medicationName,
admin.atcCode,
admin.rxNormCode,
),
subject: patientRef,
effectiveDateTime: admin.effectiveAt,
};
// Dosage: only when a structured dose Quantity exists. Carry the
// free-text dose + route + site alongside it. Text-only route/site
// (no SNOMED licence concern); the design doc upgrades to coded
// routes jointly with iOS later.
if (admin.dose) {
const dosage: FhirDosage = {
dose: doseQuantity(admin.dose.value, admin.dose.unit),
};
if (admin.doseText) dosage.text = admin.doseText;
const route = routeConcept(admin.deliveryForm);
if (route) dosage.route = route;
if (admin.injectionSite) dosage.site = { text: admin.injectionSite };
resource.dosage = dosage;
}
entries.push({ fullUrl: `urn:uuid:${id}`, resource });
administrationRefs.push({ reference: `MedicationAdministration/${id}` });
}
// --- Composition (leading "cover" resource) ----------------------------
// v1.9.0 — when the aggregator capped the administration set, disclose
// it in the narrative so the export is honest: it carries the
// most-recent N of M acted intakes, the oldest having been omitted.
const truncation = data.medicationAdministrationsTruncation;
const narrativeText = [
`Health record for ${escapeXml(displayName ?? "patient")}.`,
`Reporting period ${data.period.start.slice(0, 10)} to ${data.period.end.slice(0, 10)}.`,
`${observationRefs.length} observation(s), ${medicationRefs.length} medication(s).`,
`${observationRefs.length} observation(s), ${medicationRefs.length} medication(s), ${administrationRefs.length} administration(s).`,
...(truncation
? [
`Medication administrations truncated: showing the most recent ${truncation.included} of ${truncation.total} recorded; older entries omitted.`,
]
: []),
].join(" ");
const composition: FhirComposition = {
@@ -422,6 +567,13 @@ export function buildFhirDocumentBundle(
date: now.toISOString(),
author: [{ reference: "Device/healthlog", display: "HealthLog" }],
title: "Health Record",
// v1.9.0 — top-level document narrative (Coverage nit 2). Reuses the
// same escaped plain-text summary the Vital-signs section carries, so
// a strict US-Core-style validator sees a `Composition.text`.
text: {
status: "generated",
div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>`,
},
section: [
{
// Vital-signs section carries the narrative + every Observation ref,
@@ -430,8 +582,17 @@ export function buildFhirDocumentBundle(
text: { status: "generated", div: `<div xmlns="http://www.w3.org/1999/xhtml">${escapeXml(narrativeText)}</div>` },
entry: [patientRef, ...observationRefs],
},
...(medicationRefs.length > 0
? [{ title: "Medications", entry: medicationRefs }]
// Medications section carries both the active-medication
// statements and the per-dose administration records (v1.9.0), so
// the iOS two-section graph ("Vital signs" + "Medications") is
// preserved without adding a third section.
...(medicationRefs.length > 0 || administrationRefs.length > 0
? [
{
title: "Medications",
entry: [...medicationRefs, ...administrationRefs],
},
]
: []),
],
};
+39
View File
@@ -79,6 +79,17 @@ export interface FhirObservation {
export interface FhirDosage {
text?: string;
/**
* v1.9.0 — structured dose `Quantity`. On a `MedicationAdministration`
* a `dosage` SHALL carry at least one of `dose` or `rate` (R4
* invariant), so the builder only emits a `dosage` block when a `dose`
* is available; a text-only dose stays on the `MedicationStatement`.
*/
dose?: FhirQuantity;
/** v1.9.0 — route of administration. Text-only by default (no SNOMED licence concern). */
route?: FhirCodeableConcept;
/** v1.9.0 — administration body-site (e.g. injection site). Text-only by default. */
site?: FhirCodeableConcept;
}
export interface FhirPeriod {
@@ -96,6 +107,27 @@ export interface FhirMedicationStatement {
dosage?: FhirDosage[];
}
export interface FhirMedicationAdministration {
resourceType: "MedicationAdministration";
id: string;
/**
* v1.9.0 — only `completed` (a taken dose) or `not-done` (an explicit
* skip) are emitted; the exporter never asserts an administration for a
* pending / missed slot or a soft-deleted tombstone.
*/
status: "completed" | "not-done";
/** Self-describing concept — the same ATC/RxNorm + text the statement carries. */
medicationCodeableConcept: FhirCodeableConcept;
subject: FhirReference;
/**
* `takenAt` for a `completed` administration, `scheduledFor` (the
* intended instant) for a `not-done` skip.
*/
effectiveDateTime: string;
/** Single dosage (R4 `0..1`). Present only when a structured `dose` exists. */
dosage?: FhirDosage;
}
export interface FhirDiagnosticReport {
resourceType: "DiagnosticReport";
id: string;
@@ -143,6 +175,12 @@ export interface FhirComposition {
date: string;
author: FhirReference[];
title: string;
/**
* v1.9.0 — top-level document narrative. Section narrative is already
* present; a strict US-Core-style validator additionally expects a
* `Composition.text`. Reuses the same escaped plain-text summary.
*/
text?: FhirNarrative;
section?: FhirCompositionSection[];
}
@@ -152,6 +190,7 @@ export type FhirResource =
| FhirCoverage
| FhirObservation
| FhirMedicationStatement
| FhirMedicationAdministration
| FhirDiagnosticReport;
export interface FhirBundleEntry {
+15
View File
@@ -15,10 +15,25 @@ export function mgdlToMmol(mgdl: number): number {
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
}
export function mmolToMgdl(mmol: number): number {
return Math.round(mmol * MGDL_PER_MMOL);
}
export function convertGlucose(value: number, to: GlucoseUnit): number {
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
}
/**
* Convert a value the user typed in their display unit back to the
* canonical mg/dL HealthLog stores. The inverse of {@link convertGlucose}
* for the editor write path: a `5.5 mmol/L` target the user enters must
* persist as `99 mg/dL`, not as the literal `5.5`. A `mg/dL` display unit
* is already canonical, so it only rounds.
*/
export function toCanonicalMgdl(value: number, from: GlucoseUnit): number {
return from === "mmol/L" ? mmolToMgdl(value) : Math.round(value);
}
export function resolveGlucoseUnit(
userPreference: string | null | undefined,
): GlucoseUnit {
@@ -11,12 +11,16 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const deleteMany = vi.fn();
const auditFindMany = vi.fn();
const enqueueStatusGeneration = vi.fn();
const userFindUnique = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) },
auditLog: {
deleteMany: (...a: unknown[]) => deleteMany(...a),
findMany: (...a: unknown[]) => auditFindMany(...a),
},
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
},
}));
@@ -30,6 +34,9 @@ import { invalidateStatusInsightsForTypes } from "../comprehensive-generate";
beforeEach(() => {
vi.clearAllMocks();
deleteMany.mockResolvedValue({ count: 0 });
// Default: no recently-warmed cache rows, so every dirtied scope is stale
// and refreshes — the pre-debounce contract the bulk of these tests pin.
auditFindMany.mockResolvedValue([]);
enqueueStatusGeneration.mockResolvedValue(undefined);
userFindUnique.mockResolvedValue({ locale: "de" });
});
@@ -176,4 +183,66 @@ describe("invalidateStatusInsightsForTypes", () => {
enqueuedScopes().some((s) => s.startsWith("metric:")),
).toBe(false);
});
describe("ingest-invalidation debounce (v1.9.0)", () => {
/** Build a recent (within-window) real assessment cache row. */
function freshRow(scope: string, locale = "de") {
return {
action: `insights.${scope}-status.${locale}`,
details: JSON.stringify({ text: "fresh assessment", model: "gpt" }),
};
}
it("skips a scope whose assessment was warmed within the window", async () => {
// `general` was regenerated minutes ago; a fresh WEIGHT sample must not
// delete or re-enqueue it. weight + bmi are still stale, so they refresh.
auditFindMany.mockResolvedValue([freshRow("general")]);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(deletedScopes()).toEqual(["bmi", "weight"]);
expect(enqueuedScopes()).toEqual(["bmi", "weight"]);
});
it("is a complete no-op (no delete, no enqueue) when every scope is fresh", async () => {
auditFindMany.mockResolvedValue([
freshRow("weight"),
freshRow("bmi"),
freshRow("general"),
]);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(deleteMany).not.toHaveBeenCalled();
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("treats a recent timeout stub as not-fresh and still refreshes the scope", async () => {
// A stub carries no real assessment, so a scope that recently stalled
// must retry rather than be debounced into staying cold.
auditFindMany.mockResolvedValue([
{
action: "insights.general-status.de",
details: JSON.stringify({ model: "timeout-stub", text: "stub" }),
},
]);
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
expect(deletedScopes()).toEqual(["bmi", "general", "weight"]);
expect(enqueuedScopes()).toEqual(["bmi", "general", "weight"]);
});
it("scopes the freshness probe to the user's resolved locale and the window cutoff", async () => {
userFindUnique.mockResolvedValue({ locale: "en" });
await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]);
const where = auditFindMany.mock.calls[0][0].where;
expect(where.userId).toBe("u1");
// Only the en cache actions for the dirtied scopes are probed.
expect(new Set(where.action.in)).toEqual(
new Set([
"insights.weight-status.en",
"insights.bmi-status.en",
"insights.general-status.en",
]),
);
// A recency floor is applied (createdAt >= cutoff in the past).
expect(where.createdAt.gte).toBeInstanceOf(Date);
expect(where.createdAt.gte.getTime()).toBeLessThan(Date.now());
});
});
});
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest";
import {
STABILITY_MIN_DAYS,
STABILITY_SD_FULL_SCALE,
TIME_OF_DAY_MIN_BUCKET_SAMPLES,
bucketForHour,
computeDistribution,
computeHeatmapCells,
computeInTargetPct,
computeMoodAggregates,
computeMoodMetricCorrelation,
computeMoodStability,
computeStructuredTagSummary,
computeTagSummary,
computeTimeOfDayAverages,
computeWeekdayAverages,
selectHeatmapWindow,
type CrossMetricMeasurement,
@@ -300,4 +306,159 @@ describe("computeMoodAggregates", () => {
expect(agg.heatmap.cells).toEqual([]);
expect(agg.distribution.every((d) => d.count === 0)).toBe(true);
});
it("carries the time-of-day pattern and stability fields", () => {
const agg = computeMoodAggregates({
entries: [],
measurements: [],
now: NOW,
});
expect(agg.timeOfDay.reliable).toBe(false);
expect(agg.timeOfDay.buckets.map((b) => b.bucket)).toEqual([
"morning",
"afternoon",
"evening",
"night",
]);
expect(agg.stability).toBeNull();
});
});
describe("bucketForHour", () => {
it("maps each hour to its part of day with night wrapping midnight", () => {
expect(bucketForHour(0)).toBe("night");
expect(bucketForHour(4)).toBe("night");
expect(bucketForHour(5)).toBe("morning");
expect(bucketForHour(11)).toBe("morning");
expect(bucketForHour(12)).toBe("afternoon");
expect(bucketForHour(16)).toBe("afternoon");
expect(bucketForHour(17)).toBe("evening");
expect(bucketForHour(20)).toBe("evening");
expect(bucketForHour(21)).toBe("night");
expect(bucketForHour(23)).toBe("night");
});
});
describe("computeTimeOfDayAverages", () => {
/** Build an entry at a fixed UTC instant + hour, in a given tz. */
function tzEntry(
daysAgo: number,
utcHour: number,
score: number,
tz: string | null = "UTC",
): MoodAggregateEntry {
const base = new Date(NOW.getTime() - daysAgo * dayMs);
base.setUTCHours(utcHour, 0, 0, 0);
return { date: dayKey(daysAgo), score, tags: null, moodLoggedAt: base, tz };
}
it("buckets entries by part of day and averages per bucket", () => {
const entries: MoodAggregateEntry[] = [
tzEntry(1, 8, 5),
tzEntry(2, 9, 3),
tzEntry(3, 14, 4),
tzEntry(4, 14, 2),
];
const pattern = computeTimeOfDayAverages(entries);
const morning = pattern.buckets.find((b) => b.bucket === "morning");
const afternoon = pattern.buckets.find((b) => b.bucket === "afternoon");
expect(morning).toEqual({ bucket: "morning", avgScore: 4, count: 2 });
expect(afternoon).toEqual({ bucket: "afternoon", avgScore: 3, count: 2 });
});
it("honours the per-row timezone when bucketing the local hour", () => {
// 06:00 UTC is 08:00 in Berlin (summer, UTC+2) → morning, but stays
// in the night bucket (00:00–04:59) under a UTC-6 zone.
const berlin = computeTimeOfDayAverages([
tzEntry(1, 6, 5, "Europe/Berlin"),
]);
expect(berlin.buckets.find((b) => b.bucket === "morning")?.count).toBe(1);
const chicago = computeTimeOfDayAverages([
tzEntry(1, 6, 5, "America/Chicago"),
]);
expect(chicago.buckets.find((b) => b.bucket === "night")?.count).toBe(1);
});
it("falls back to UTC for legacy rows without a tz", () => {
const pattern = computeTimeOfDayAverages([tzEntry(1, 14, 5, null)]);
expect(pattern.buckets.find((b) => b.bucket === "afternoon")?.count).toBe(1);
});
it("is unreliable for a once-a-day logger clustered in one bucket", () => {
// Ten nightly logs, all in the evening bucket → single-bucket spread.
const entries = Array.from({ length: 10 }, (_, i) => tzEntry(i, 19, 4));
const pattern = computeTimeOfDayAverages(entries);
expect(pattern.reliable).toBe(false);
expect(pattern.best).toBeNull();
expect(pattern.worst).toBeNull();
});
it("is unreliable when a second bucket is below the sample floor", () => {
const entries = [
...Array.from({ length: 5 }, (_, i) => tzEntry(i, 8, 5)), // morning ≥ floor
tzEntry(5, 14, 3), // a single afternoon log, below the floor
];
expect(TIME_OF_DAY_MIN_BUCKET_SAMPLES).toBeGreaterThan(1);
const pattern = computeTimeOfDayAverages(entries);
expect(pattern.reliable).toBe(false);
});
it("surfaces best/worst once two buckets clear the sample floor", () => {
const entries = [
...Array.from({ length: 4 }, (_, i) => tzEntry(i, 8, 5)), // morning avg 5
...Array.from({ length: 4 }, (_, i) => tzEntry(i + 4, 22, 2)), // night avg 2
];
const pattern = computeTimeOfDayAverages(entries);
expect(pattern.reliable).toBe(true);
expect(pattern.best).toBe("morning");
expect(pattern.worst).toBe("night");
});
});
describe("computeMoodStability", () => {
function points(values: number[]) {
return values.map((value, i) => ({ dayOffset: i, value }));
}
it("returns null below the minimum-days floor", () => {
const sparse = points(Array(STABILITY_MIN_DAYS - 1).fill(3));
expect(computeMoodStability(sparse)).toBeNull();
});
it("scores a perfectly flat mood at 100 (very steady)", () => {
const flat = points(Array(STABILITY_MIN_DAYS).fill(3));
const stability = computeMoodStability(flat);
expect(stability).not.toBeNull();
expect(stability?.score).toBe(100);
expect(stability?.stdDev).toBe(0);
expect(stability?.band).toBe("verySteady");
expect(stability?.days).toBe(STABILITY_MIN_DAYS);
});
it("scores an sd at or beyond the full-scale cap at 0 (very variable)", () => {
// Alternating 1 / 5 → sd 2.0 > FULL_SCALE → clamped to 0.
const swing = points(
Array.from({ length: 10 }, (_, i) => (i % 2 === 0 ? 1 : 5)),
);
const stability = computeMoodStability(swing);
expect(stability?.score).toBe(0);
expect(stability?.band).toBe("veryVariable");
expect(stability?.stdDev).toBeGreaterThanOrEqual(STABILITY_SD_FULL_SCALE);
});
it("scales linearly between the bounds", () => {
// A balanced series alternating ±halfFull around its mean has a
// population sd of exactly halfFull → score 50.
const halfFull = STABILITY_SD_FULL_SCALE / 2;
const balanced = points(
Array.from({ length: 8 }, (_, i) =>
i % 2 === 0 ? 3 - halfFull : 3 + halfFull,
),
);
const stability = computeMoodStability(balanced);
expect(stability?.stdDev).toBe(halfFull);
expect(stability?.score).toBe(50);
expect(stability?.band).toBe("variable");
});
});
@@ -25,6 +25,7 @@ function emptyInput(): MoodNarrativeInput {
return {
daily: [],
weekday: [],
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
tags: [],
structuredTags: [],
inTargetPct: null,
@@ -355,6 +356,7 @@ describe("computeMoodNarratives — ranking and capping", () => {
const input: MoodNarrativeInput = {
daily: [...recent, ...prior],
weekday,
timeOfDay: { buckets: [], reliable: false, best: null, worst: null },
tags: [
{ tag: "great", count: 10, avgScore: 5 },
{ tag: "bad", count: 10, avgScore: 1 },
@@ -376,4 +378,69 @@ describe("computeMoodNarratives — ranking and capping", () => {
it("exposes a stable minimum-days constant", () => {
expect(MOOD_NARRATIVE_MIN_DAYS).toBeGreaterThan(0);
});
it("stays silent on time-of-day when the pattern is unreliable", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
timeOfDay: {
// Spread is fine but the effect is computed from best/worst below.
buckets: [
{ bucket: "morning", avgScore: 4, count: 5 },
{ bucket: "afternoon", avgScore: 4, count: 5 },
{ bucket: "evening", avgScore: null, count: 0 },
{ bucket: "night", avgScore: null, count: 0 },
],
reliable: false,
best: null,
worst: null,
},
};
expect(
computeMoodNarratives(input).find((n) => n.kind === "time-of-day"),
).toBeUndefined();
});
it("fires the time-of-day takeaway when reliable and the effect clears the bar", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
timeOfDay: {
buckets: [
{ bucket: "morning", avgScore: 4.5, count: 6 },
{ bucket: "afternoon", avgScore: 3.0, count: 6 },
{ bucket: "evening", avgScore: null, count: 0 },
{ bucket: "night", avgScore: null, count: 0 },
],
reliable: true,
best: "morning",
worst: "afternoon",
},
};
const item = computeMoodNarratives(input).find(
(n) => n.kind === "time-of-day",
);
expect(item).toBeDefined();
expect(item?.messageKey).toBe("insights.mood.narrative.timeOfDay");
expect(item?.vars.bucketKey).toBe("insights.mood.timeOfDay.morning");
expect(item?.vars.value).toBe("4.5");
});
it("stays silent on time-of-day when best/worst are within the effect threshold", () => {
const input: MoodNarrativeInput = {
...emptyInput(),
timeOfDay: {
buckets: [
{ bucket: "morning", avgScore: 3.6, count: 6 },
{ bucket: "afternoon", avgScore: 3.5, count: 6 },
{ bucket: "evening", avgScore: null, count: 0 },
{ bucket: "night", avgScore: null, count: 0 },
],
reliable: true,
best: "morning",
worst: "afternoon",
},
};
expect(
computeMoodNarratives(input).find((n) => n.kind === "time-of-day"),
).toBeUndefined();
});
});
@@ -163,7 +163,13 @@ describe("resolveReadOnlyStatusMiss", () => {
locale: "de",
});
expect(outcome.kind).toBe("preparing");
expect(outcome).toEqual({ kind: "preparing", lastGood: null });
// No last-good text to show, so nothing to revalidate against — the card
// polls on `preparing` alone.
expect(outcome).toEqual({
kind: "preparing",
lastGood: null,
revalidating: false,
});
expect(enqueueStatusGeneration).toHaveBeenCalledWith({
userId: "u1",
metric: "pulse",
@@ -189,6 +195,9 @@ describe("resolveReadOnlyStatusMiss", () => {
expect(outcome.kind).toBe("preparing");
if (outcome.kind !== "preparing") throw new Error("expected preparing");
expect(outcome.lastGood?.text).toBe("Steady upward trend.");
// v1.9.0 — stale text served AND a refresh enqueued → revalidating so the
// open card keeps polling until the fresh assessment lands.
expect(outcome.revalidating).toBe(true);
// A refresh is still enqueued behind the stale serve.
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
});
@@ -209,6 +218,9 @@ describe("resolveReadOnlyStatusMiss", () => {
locale: "en",
});
expect(outcome.kind).toBe("preparing");
if (outcome.kind !== "preparing") throw new Error("expected preparing");
// No enqueue on the suppressed branch → nothing in flight to revalidate.
expect(outcome.revalidating).toBe(false);
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
+5 -1
View File
@@ -45,6 +45,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -106,11 +107,13 @@ export async function generateBloodPressureStatusForUser(
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = `insights.blood-pressure-status.${locale}`;
const cacheAction = statusCacheAction("blood-pressure", locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
@@ -151,6 +154,7 @@ export async function generateBloodPressureStatusForUser(
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
+5 -1
View File
@@ -25,6 +25,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -44,11 +45,13 @@ export async function generateBmiStatusForUser(
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = `insights.bmi-status.${locale}`;
const cacheAction = statusCacheAction("bmi", locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
@@ -89,6 +92,7 @@ export async function generateBmiStatusForUser(
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
+176 -45
View File
@@ -47,15 +47,46 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { invalidateUserInsights } from "@/lib/cache/invalidate";
import { enqueueStatusGeneration } from "@/lib/jobs/insight-status-generate-shared";
import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { normalizeLocale } from "@/lib/insights/status-shared";
import {
isTimeoutStub,
statusCacheAction,
statusCacheActionPrefix,
} from "@/lib/insights/status-cache";
import {
metricIdForMeasurementType,
metricStatusScope,
} from "@/lib/insights/metric-status-registry";
import { annotate } from "@/lib/logging/context";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Debounce window for ingest-driven status invalidation. A scope whose
* cached assessment was (re)generated within this window is left intact on
* a fresh measurement ingest — neither evicted nor re-enqueued.
*
* A constantly-syncing client (Apple Health drips batches every few
* minutes) used to delete + re-enqueue every dirtied scope on every batch,
* and because each delete dropped the cache row the per-(user,scope,locale)
* enqueue singleton (120 s) could not coalesce across batches that arrive
* minutes apart — so STEPS / general / a metric card regenerated several
* times an hour and the assessment felt "regenerated on every visit". This
* window coalesces the storm: a genuinely stale scope (no fresh assessment)
* still refreshes immediately, but a scope refreshed inside the window is
* skipped, so a fresh assessment survives the day's sync drip and a burst
* of batches costs at most one regeneration per scope per window.
*
* 30 min is comfortably longer than a typical sync cadence yet far shorter
* than the 24 h assessment TTL, so a card still tracks new data within the
* same session without thrashing the provider.
*/
const INGEST_INVALIDATE_DEBOUNCE_MS = 30 * 60 * 1000;
const MAX_DOWNGRADE_TOKENS: ReadonlyArray<string> = [
"anthropometrics",
"medications",
@@ -221,52 +252,26 @@ export async function invalidateStatusInsightsForTypes(
userId: string,
types: Iterable<MeasurementType>,
): Promise<void> {
const scopes = new Set<PerStatusScope>();
// v1.8.7.1 — a fresh measurement of a type that backs a generic
// HealthKit assessment card (the ~30 registry metrics) also dirties that
// card's `metric:<ID>` scope. Collect those alongside the seven
// specialised scopes so the generic tier is invalidated + re-warmed on
// ingest with the same debounced enqueue, rather than lagging until the
// nightly cron. Only registered, data-bearing types map here; the seven
// specialised metrics and any unregistered type resolve to null and are
// skipped, so the constant sync cannot fan out to unwanted scopes.
const metricScopes = new Set<`metric:${string}`>();
// One ordered set of every scope the batch dirties. The seven specialised
// scopes are bare slugs (`weight`, `general`, …); the generic HealthKit
// cards (v1.8.7.1) carry a `metric:<ID>` prefix. Both share the cache-key
// shape `insights.<scope>-status.<locale>`, so a single set covers the
// eviction + the debounce filter + the enqueue uniformly. Only registered,
// data-bearing types contribute a generic scope; the seven specialised
// metrics and any unregistered type resolve to null and are skipped, so
// the constant sync cannot fan out to unwanted scopes.
const scopes = new Set<InsightStatusScope>();
for (const type of types) {
for (const scope of statusScopesForMeasurementType(type)) {
scopes.add(scope);
}
const metricId = metricIdForMeasurementType(type);
if (metricId) {
metricScopes.add(metricStatusScope(metricId));
scopes.add(metricStatusScope(metricId));
}
}
if (scopes.size === 0 && metricScopes.size === 0) return;
if (scopes.size === 0) return;
await prisma.auditLog.deleteMany({
where: {
userId,
OR: [
...Array.from(scopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
...Array.from(metricScopes, (scope) => ({
action: { startsWith: `insights.${scope}-status.` },
})),
],
},
});
// v1.8.7 — regenerate-on-invalidate. Deleting the today-row alone left
// the card to re-warm only on the user's next category open (a miss → a
// worker round-trip while they wait) or the nightly cron. Instead,
// proactively enqueue a debounced regenerate for each dirtied scope so
// the cache is re-warmed in the background. The enqueue is coalesced per
// `(user, metric, locale)` via the queue's `singletonKey` (120 s window),
// so the constant Apple-Health sync cannot fan out a storm of jobs — a
// burst of samples collapses into one regenerate per scope.
// The stale-while-revalidate read keeps the previous assessment visible
// until the fresh one lands, so the user never waits on a card.
//
// v1.8.7 — regenerate only the user's resolved locale, matching the
// read-path (every `*-status` GET serves `normalizeLocale(user.locale)`).
// Warming both locales doubled provider spend on every sync, half of it
@@ -277,16 +282,121 @@ export async function invalidateStatusInsightsForTypes(
select: { locale: true },
});
const locale = normalizeLocale(localeRow?.locale);
// v1.9.0 — debounce the ingest-invalidation storm. A constantly-syncing
// client drips batches every few minutes; deleting + re-enqueuing every
// dirtied scope on every batch regenerated the same card several times an
// hour (the per-(user,scope,locale) enqueue singleton is only 120 s, so it
// could not coalesce across batches arriving minutes apart, and each
// delete dropped the row a fresh enqueue would otherwise have found warm).
// Skip any scope whose cached assessment was (re)generated within the
// debounce window — leave its row intact and do NOT re-enqueue. A
// genuinely stale or missing scope still refreshes immediately, so
// correctness holds; only the redundant churn is removed.
const freshScopes = await findRecentlyWarmedScopes(userId, locale, scopes);
const staleScopes = Array.from(scopes).filter(
(scope) => !freshScopes.has(scope),
);
if (staleScopes.length === 0) {
annotate({
action: { name: "insights.status.invalidate.debounced" },
meta: { skipped: scopes.size, refreshed: 0 },
});
return;
}
// Drop the cached per-metric assessment rows that are actually stale, so
// the next mount / nightly warm pass regenerates them against the new
// data. The `-status.` substring guard keeps the sweep off the
// comprehensive cache and any unrelated `insights.*` audit row.
await prisma.auditLog.deleteMany({
where: {
userId,
OR: staleScopes.map((scope) => ({
action: { startsWith: statusCacheActionPrefix(scope) },
})),
},
});
// v1.8.7 — regenerate-on-invalidate. Deleting the today-row alone left
// the card to re-warm only on the user's next category open (a miss → a
// worker round-trip while they wait) or the nightly cron. Instead,
// proactively enqueue a debounced regenerate for each dirtied scope so
// the cache is re-warmed in the background. The enqueue is coalesced per
// `(user, metric, locale)` via the queue's `singletonKey` (120 s window);
// the debounce above is the second, wider coalescing layer that survives
// across the sync drip. The stale-while-revalidate read keeps the
// previous assessment visible until the fresh one lands.
for (const scope of staleScopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
}
annotate({
action: { name: "insights.status.invalidate.debounced" },
meta: { skipped: freshScopes.size, refreshed: staleScopes.length },
});
}
/**
* v1.9.0 — return the subset of `scopes` whose cached assessment for
* `locale` was generated within `INGEST_INVALIDATE_DEBOUNCE_MS` and is a
* real (non-stub) assessment. Those scopes are skipped by the ingest
* invalidator so a fresh assessment survives the sync drip.
*
* One indexed read per user (a single `findMany` over this user's recent
* status-cache rows, newest-first) answers it for every candidate scope at
* once — cheaper than a per-scope probe and bounded by `take`. A timeout
* stub never counts as fresh (it carries no real assessment), so a scope
* that recently stalled still gets a retry enqueued.
*/
async function findRecentlyWarmedScopes(
userId: string,
locale: "de" | "en",
scopes: ReadonlySet<InsightStatusScope>,
): Promise<Set<InsightStatusScope>> {
const cutoff = new Date(Date.now() - INGEST_INVALIDATE_DEBOUNCE_MS);
// Match exactly the cache actions for the candidate scopes in this locale.
const candidateActions = Array.from(scopes, (scope) =>
statusCacheAction(scope, locale),
);
const actionToScope = new Map<string, InsightStatusScope>();
for (const scope of scopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
actionToScope.set(statusCacheAction(scope, locale), scope);
}
// v1.8.7.1 — re-warm the matching generic metric scopes the same way, so
// a freshly-synced HealthKit metric's assessment card refreshes in the
// background instead of lagging until the nightly warm pass. Same
// single-locale, singleton-debounced enqueue as the seven scopes above.
for (const scope of metricScopes) {
void enqueueStatusGeneration({ userId, metric: scope, locale });
const rows = await prisma.auditLog.findMany({
where: {
userId,
action: { in: candidateActions },
createdAt: { gte: cutoff },
},
orderBy: { createdAt: "desc" },
select: { action: true, details: true },
});
const fresh = new Set<InsightStatusScope>();
for (const row of rows) {
const scope = actionToScope.get(row.action);
if (!scope || fresh.has(scope)) continue;
if (!row.details) continue;
try {
const parsed = JSON.parse(row.details) as {
model?: string;
timeout?: boolean;
text?: string;
};
// A stub is not a real assessment — let a stalled scope retry.
if (isTimeoutStub(parsed)) continue;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
continue;
}
fresh.add(scope);
} catch {
// Malformed payload — treat as not-fresh so the scope refreshes.
continue;
}
}
return fresh;
}
interface GenerateOptions {
@@ -294,6 +404,17 @@ interface GenerateOptions {
locale: "de" | "en";
/** Skip the 24 h cache short-circuit and force a fresh generation. */
force?: boolean;
/**
* v1.9.0 — abort the generation when the caller has given up on it.
*
* The on-demand `forceWarmUser` path bounds the comprehensive step with a
* timeout and then proceeds to warm the per-status caches itself. Without
* this signal a comprehensive that resolves AFTER the timeout would still
* reach `evictPerStatusInsightCache` and delete the rows the warm passes
* had just written — undoing its own work. The signal lets the caller cut
* the generation off before the eviction so the warmed rows survive.
*/
signal?: AbortSignal;
}
/**
@@ -313,6 +434,7 @@ export async function generateComprehensiveInsight(
): Promise<GenerateOutcome> {
const { locale } = options;
const force = options.force === true;
const signal = options.signal;
const dbUser = await prisma.user.findUnique({
where: { id: userId },
@@ -420,6 +542,15 @@ export async function generateComprehensiveInsight(
return { status: "failed", reason: "invalid-json" };
}
// v1.9.0 — the caller abandoned this generation (its bounded timeout
// fired and it has moved on to warm the per-status caches itself). Bail
// out before the write + evict so a late-resolving comprehensive can never
// delete the rows the warm passes have since written. The provider call
// already cost what it cost; what we must NOT do now is touch the cache.
if (signal?.aborted) {
return { status: "failed", reason: "aborted" };
}
await prisma.user.update({
where: { id: userId },
data: {
+5 -1
View File
@@ -28,6 +28,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -59,11 +60,13 @@ export async function generateGeneralStatusForUser(
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = `insights.general-status.${locale}`;
const cacheAction = statusCacheAction("general", locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
@@ -104,6 +107,7 @@ export async function generateGeneralStatusForUser(
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
@@ -30,6 +30,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
isTimeoutStub,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -59,11 +60,13 @@ export async function generateMedicationComplianceStatusForUser(
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = `insights.medication-compliance-status.${locale}`;
const cacheAction = statusCacheAction("medication-compliance", locale);
const todayKey = toBerlinDayKey(new Date());
// This route carries a richer cached envelope (`summary` +
@@ -140,6 +143,7 @@ export async function generateMedicationComplianceStatusForUser(
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
+9 -1
View File
@@ -55,6 +55,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -66,6 +67,12 @@ export interface MetricStatusResult {
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/**
* v1.9.0 — true when last-good text is served while a fresh generation is
* in flight. The card keeps polling (bounded) so the open page upgrades to
* the warmed assessment without a remount.
*/
revalidating?: boolean;
/**
* v1.8.7.1 — true when the metric has no data at all. The route maps
* this onto the card's insufficient-data state; no LLM was called.
@@ -118,7 +125,7 @@ export async function generateMetricStatus(args: {
const force = args.force === true;
const readOnly = args.readOnly === true;
const scope = metricStatusScope(meta.id);
const cacheAction = `insights.${scope}-status.${locale}`;
const cacheAction = statusCacheAction(scope, locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
@@ -178,6 +185,7 @@ export async function generateMetricStatus(args: {
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
+225
View File
@@ -25,6 +25,7 @@ import {
type DailyBucket,
} from "@/lib/insights/bucket-series";
import { round, summarizeSeries } from "@/lib/insights/status-shared";
import { getLocalDateParts } from "@/lib/timezone";
import {
computeMoodNarratives,
type MoodNarrative,
@@ -85,6 +86,14 @@ export interface MoodAggregateEntry {
tags: unknown;
/** Exact log timestamp — carries time-of-day. */
moodLoggedAt: Date;
/**
* v1.9.0 — per-row IANA timezone the entry was logged under. Used to
* bucket `moodLoggedAt` into a part of day in the user's own local
* time. Optional: legacy rows (`tz IS NULL`) fall back to UTC, mirroring
* the rollup-tier convention; the legacy aggregate tests keep their
* narrow fixtures.
*/
tz?: string | null;
/**
* v1.8.5 — structured tags from the taxonomy join. Additive next to
* the flat `tags`: an entry can carry both. Optional so the legacy
@@ -342,6 +351,202 @@ export function computeWeekdayAverages(
return rows;
}
// --- Time-of-day pattern (tz-aware part-of-day buckets) ---
/** Part-of-day bucket key. Order is morning → night for stable rendering. */
export type TimeOfDayBucket = "morning" | "afternoon" | "evening" | "night";
/** Ordered bucket list — drives both the chart x-axis and the iteration. */
export const TIME_OF_DAY_BUCKETS: readonly TimeOfDayBucket[] = [
"morning",
"afternoon",
"evening",
"night",
] as const;
/**
* Minimum entries a single bucket must hold before it counts toward the
* pattern. A lone log in a bucket is noise, not a daypart preference.
*/
export const TIME_OF_DAY_MIN_BUCKET_SAMPLES = 3;
/**
* Minimum distinct populated buckets (each at or above the per-bucket
* sample floor) before the pattern surfaces at all. The guard against the
* once-a-day logger: a nightly Telegram check-in clusters in a single
* bucket, which can never clear a two-bucket spread, so the "you feel
* best in the morning" takeaway never fires misleadingly.
*/
export const TIME_OF_DAY_MIN_SPREAD = 2;
/**
* Map a local hour-of-day (0..23) to its part-of-day bucket.
*
* morning 05:00–11:59, afternoon 12:00–16:59, evening 17:00–20:59,
* night 21:00–04:59. The boundaries follow the common Daylio / consumer
* convention; night wraps midnight so a 02:00 log lands in `night`.
*/
export function bucketForHour(hour: number): TimeOfDayBucket {
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 17) return "afternoon";
if (hour >= 17 && hour < 21) return "evening";
return "night";
}
export interface TimeOfDayRow {
bucket: TimeOfDayBucket;
avgScore: number | null;
count: number;
}
export interface TimeOfDayPattern {
/** All four buckets, in canonical order; unpopulated buckets carry null. */
buckets: TimeOfDayRow[];
/**
* Whether the spread + sample floors are cleared — i.e. the pattern is
* trustworthy enough to surface a chart and a takeaway. False for the
* once-a-day logger (everything in one bucket) or a sparse history.
*/
reliable: boolean;
/** Best / worst bucket keys when `reliable`; null otherwise. */
best: TimeOfDayBucket | null;
worst: TimeOfDayBucket | null;
}
/**
* Average mood per part of day, bucketed in each entry's own timezone.
*
* `moodLoggedAt` carries the exact instant; `tz` (per-row IANA) anchors
* the local hour. Legacy rows without a `tz` fall back to UTC — the same
* convention the rollup tier uses for `tz IS NULL` mood rows. Every entry
* (not the daily mean) feeds its bucket, because the question is "what
* time of day do I feel best", which a daily collapse would erase.
*
* The `reliable` flag is the once-a-day-logger guard: it only trips when
* at least `TIME_OF_DAY_MIN_SPREAD` buckets each carry
* `TIME_OF_DAY_MIN_BUCKET_SAMPLES` entries. `best`/`worst` are computed
* over the populated-and-sufficient buckets only.
*/
export function computeTimeOfDayAverages(
entries: MoodAggregateEntry[],
): TimeOfDayPattern {
const sums = new Map<TimeOfDayBucket, { sum: number; count: number }>();
for (const entry of entries) {
const tz = entry.tz ?? "UTC";
const { hour } = getLocalDateParts(entry.moodLoggedAt, tz);
const bucket = bucketForHour(hour);
const cur = sums.get(bucket) ?? { sum: 0, count: 0 };
cur.sum += entry.score;
cur.count += 1;
sums.set(bucket, cur);
}
const buckets: TimeOfDayRow[] = TIME_OF_DAY_BUCKETS.map((bucket) => {
const agg = sums.get(bucket);
return {
bucket,
avgScore: agg ? round(agg.sum / agg.count, 2) : null,
count: agg?.count ?? 0,
};
});
const sufficient = buckets.filter(
(row): row is TimeOfDayRow & { avgScore: number } =>
row.avgScore != null && row.count >= TIME_OF_DAY_MIN_BUCKET_SAMPLES,
);
const reliable = sufficient.length >= TIME_OF_DAY_MIN_SPREAD;
let best: TimeOfDayBucket | null = null;
let worst: TimeOfDayBucket | null = null;
if (reliable) {
let bestRow = sufficient[0];
let worstRow = sufficient[0];
for (const row of sufficient) {
if (row.avgScore > bestRow.avgScore) bestRow = row;
if (row.avgScore < worstRow.avgScore) worstRow = row;
}
best = bestRow.bucket;
worst = worstRow.bucket;
}
return { buckets, reliable, best, worst };
}
// --- Mood stability score (variance of daily means → 0..100) ---
/**
* Minimum distinct daily points before a stability score is computed. A
* handful of days has no meaningful variance signal, so a sparse logger
* gets `null` (no tile, no sentence) rather than a noisy number.
*/
export const STABILITY_MIN_DAYS = 7;
/**
* The full-scale standard deviation that maps to a 0 stability score. The
* mood scale spans 1..5, so the widest day-to-day swing is 4 points; a
* population SD at or above this is treated as maximally unstable. Below
* it, the score scales linearly toward 100 (perfectly steady).
*/
export const STABILITY_SD_FULL_SCALE = 1.5;
export type StabilityBand = "verySteady" | "steady" | "variable" | "veryVariable";
export interface MoodStability {
/** 0..100; higher = steadier (lower day-to-day variance). */
score: number;
/** Population standard deviation of the daily means (raw, for tests). */
stdDev: number;
/** Descriptive, non-judgemental band. */
band: StabilityBand;
/** Daily points the score was computed over. */
days: number;
}
/**
* Map a 0..100 stability score to a four-band descriptive label.
* Descriptive, not judgemental — some variation is healthy (Oura framing),
* so the bands read "steady" / "variable", never "good" / "bad".
*/
function stabilityBand(score: number): StabilityBand {
if (score >= 80) return "verySteady";
if (score >= 60) return "steady";
if (score >= 40) return "variable";
return "veryVariable";
}
/**
* Mood-stability score from the population standard deviation of the
* daily means.
*
* Formula:
* sd = sqrt( mean( (x_i - mean(x))^2 ) ) // population SD
* score = round( 100 * (1 - min(sd, FULL) / FULL) ) // clamped 0..100
*
* A flat mood (sd = 0) scores 100; an sd at or beyond
* `STABILITY_SD_FULL_SCALE` scores 0. Returns `null` below
* `STABILITY_MIN_DAYS` distinct daily points so a sparse logger never
* gets a meaningless score.
*/
export function computeMoodStability(daily: DailyPoint[]): MoodStability | null {
if (daily.length < STABILITY_MIN_DAYS) return null;
const values = daily.map((bucket) => bucket.value);
const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
const variance =
values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length;
const stdDev = Math.sqrt(variance);
const normalised = Math.min(stdDev, STABILITY_SD_FULL_SCALE) / STABILITY_SD_FULL_SCALE;
const score = Math.round(100 * (1 - normalised));
return {
score,
stdDev: round(stdDev, 3),
band: stabilityBand(score),
days: daily.length,
};
}
// --- Heatmap (one cell per day, score band per cell) ---
export type HeatmapWindowDays = 30 | 90 | 365;
@@ -472,6 +677,17 @@ export interface MoodAggregates {
};
distribution: DistributionRow[];
weekday: WeekdayRow[];
/**
* v1.9.0 — average mood per part of day, bucketed in each entry's own
* timezone. `reliable` gates the chart + takeaway against the once-a-day
* logger (everything in one bucket).
*/
timeOfDay: TimeOfDayPattern;
/**
* v1.9.0 — day-to-day stability score (0..100, higher = steadier) plus a
* descriptive band. `null` for a sparse logger (< STABILITY_MIN_DAYS).
*/
stability: MoodStability | null;
tags: TagSummaryRow[];
/** v1.8.5 — structured-tag breakdown from the taxonomy join. */
structuredTags: StructuredTagRow[];
@@ -533,6 +749,8 @@ export function computeMoodAggregates(args: {
const windowDays = selectHeatmapWindow(totalSpanDays);
const weekday = computeWeekdayAverages(moodDaily, now);
const timeOfDay = computeTimeOfDayAverages(entries);
const stability = computeMoodStability(moodDaily);
const tags = computeTagSummary(entries, now);
const structuredTags = computeStructuredTagSummary(entries, now);
const inTargetPct = computeInTargetPct(moodDaily);
@@ -560,11 +778,14 @@ export function computeMoodAggregates(args: {
},
distribution: computeDistribution(moodDaily),
weekday,
timeOfDay,
stability,
tags,
structuredTags,
narratives: computeMoodNarratives({
daily: moodDaily,
weekday,
timeOfDay,
tags,
structuredTags,
inTargetPct,
@@ -604,6 +825,9 @@ export async function fetchMoodAggregates(
score: true,
tags: true,
moodLoggedAt: true,
// v1.9.0 — per-row IANA tz anchors the part-of-day bucketing in
// the user's local time (legacy null rows fall back to UTC).
tz: true,
// v1.8.5 — pull the structured-tag links + their catalog rows so
// the breakdown can fold structured tags next to the flat ones.
tagLinks: {
@@ -626,6 +850,7 @@ export async function fetchMoodAggregates(
score: row.score,
tags: parseTags(row.tags),
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
structuredTags: row.tagLinks.map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
+48 -1
View File
@@ -19,6 +19,7 @@ import { round } from "@/lib/insights/status-shared";
import type {
StructuredTagRow,
TagSummaryRow,
TimeOfDayPattern,
WeekdayRow,
} from "@/lib/insights/mood-aggregates";
@@ -49,7 +50,8 @@ export type MoodNarrativeKind =
| "tag-lift"
| "tag-drop"
| "in-target"
| "streak";
| "streak"
| "time-of-day";
/**
* A single takeaway. `messageKey` resolves to an i18n template; `vars`
@@ -70,6 +72,8 @@ type DailyPoint = { dayOffset: number; value: number };
export interface MoodNarrativeInput {
daily: DailyPoint[];
weekday: WeekdayRow[];
/** v1.9.0 — tz-aware part-of-day pattern (gated on its own spread floor). */
timeOfDay: TimeOfDayPattern;
tags: TagSummaryRow[];
structuredTags: StructuredTagRow[];
inTargetPct: number | null;
@@ -78,6 +82,14 @@ export interface MoodNarrativeInput {
now: Date;
}
/** i18n label keys for each part-of-day bucket. */
const TIME_OF_DAY_LABEL_KEYS = {
morning: "insights.mood.timeOfDay.morning",
afternoon: "insights.mood.timeOfDay.afternoon",
evening: "insights.mood.timeOfDay.evening",
night: "insights.mood.timeOfDay.night",
} as const;
const WEEKDAY_LABEL_KEYS = [
"charts.weekdaysFull.mon",
"charts.weekdaysFull.tue",
@@ -364,6 +376,38 @@ function streakNarrative(input: MoodNarrativeInput): MoodNarrative | null {
};
}
/**
* Time-of-day takeaway: the part of day the user feels best.
*
* Rides the `TimeOfDayPattern.reliable` flag — already the once-a-day-
* logger guard (≥2 buckets with ≥3 entries each) — so this builder adds
* only the effect-magnitude gate on top: the best bucket must clear
* `MOOD_NARRATIVE_MIN_EFFECT` over the worst before it counts as a
* pattern rather than noise.
*/
function timeOfDayNarrative(input: MoodNarrativeInput): MoodNarrative | null {
const { reliable, best, worst, buckets } = input.timeOfDay;
if (!reliable || best == null || worst == null) return null;
const byBucket = new Map(buckets.map((row) => [row.bucket, row]));
const bestRow = byBucket.get(best);
const worstRow = byBucket.get(worst);
if (bestRow?.avgScore == null || worstRow?.avgScore == null) return null;
const delta = round(bestRow.avgScore - worstRow.avgScore, 2);
if (delta < MOOD_NARRATIVE_MIN_EFFECT) return null;
return {
kind: "time-of-day",
messageKey: "insights.mood.narrative.timeOfDay",
vars: {
bucketKey: TIME_OF_DAY_LABEL_KEYS[best],
value: bestRow.avgScore.toFixed(1),
},
strength: delta,
};
}
/**
* Build the ranked, threshold-gated narrative feed. Pure over the
* supplied aggregates; emits only takeaways whose named thresholds are
@@ -383,6 +427,9 @@ export function computeMoodNarratives(
const weekend = weekendNarrative(input);
if (weekend) all.push(weekend);
const timeOfDay = timeOfDayNarrative(input);
if (timeOfDay) all.push(timeOfDay);
const inTarget = inTargetNarrative(input);
if (inTarget) all.push(inTarget);
+29 -1
View File
@@ -13,8 +13,10 @@ import {
MOOD_ORANGE_MAX,
MOOD_ORANGE_MIN,
computeInTargetPct,
computeMoodStability,
computeStructuredTagSummary,
computeTagSummary,
computeTimeOfDayAverages,
computeWeekdayAverages,
pairDailyBuckets,
} from "@/lib/insights/mood-aggregates";
@@ -36,6 +38,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -55,11 +58,13 @@ export async function generateMoodStatusForUser(
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = `insights.mood-status.${locale}`;
const cacheAction = statusCacheAction("mood", locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
@@ -100,6 +105,7 @@ export async function generateMoodStatusForUser(
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
@@ -119,6 +125,9 @@ export async function generateMoodStatusForUser(
score: true,
tags: true,
moodLoggedAt: true,
// v1.9.0 — per-row tz anchors the part-of-day bucketing so the
// snapshot's time-of-day signal matches the visible mood page.
tz: true,
// v1.8.6 — pull the structured-tag links so the snapshot feeds
// `computeMoodNarratives` the same structured-tag pool the visible
// mood page does (shown == sent for the tag→mood takeaways).
@@ -142,6 +151,7 @@ export async function generateMoodStatusForUser(
score: row.score,
tags: row.tags,
moodLoggedAt: row.moodLoggedAt,
tz: row.tz,
structuredTags: (row.tagLinks ?? []).map((link) => ({
key: link.moodTag.key,
categoryKey: link.moodTag.category.key,
@@ -263,9 +273,16 @@ export async function generateMoodStatusForUser(
// model writes never contradicts the takeaways on screen (shown ==
// sent). Both the flat free-text tags and the structured taxonomy tags
// feed the tag→mood pool, matching the visible feed exactly.
// v1.9.0 — tz-aware part-of-day pattern + day-to-day stability. Computed
// here so the snapshot carries the same signals the visible mood page
// surfaces (shown == sent), and the narrative feed can cite the daypart.
const timeOfDay = computeTimeOfDayAverages(entries);
const stability = computeMoodStability(moodSeries.daily);
const narratives = computeMoodNarratives({
daily: moodSeries.daily,
weekday: computeWeekdayAverages(moodSeries.daily, now),
timeOfDay,
tags: tagSummary,
structuredTags: structuredTagSummary,
inTargetPct: inTargetPctLast30DailyPoints,
@@ -304,6 +321,17 @@ export async function generateMoodStatusForUser(
},
tags: tagSummary.length > 0 ? tagSummary : null,
narratives: narratives.length > 0 ? narratives : null,
// v1.9.0 — only emit the daypart pattern when it cleared its
// spread/sample floors, so the model never reasons over a once-a-day
// logger's single-bucket artefact.
timeOfDay: timeOfDay.reliable
? {
buckets: timeOfDay.buckets,
best: timeOfDay.best,
worst: timeOfDay.worst,
}
: null,
stability,
},
crossMetricContext:
weightSeries.daily.length >= 3 ||
+5 -1
View File
@@ -31,6 +31,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -50,11 +51,13 @@ export async function generatePulseStatusForUser(
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = `insights.pulse-status.${locale}`;
const cacheAction = statusCacheAction("pulse", locale);
const todayKey = toBerlinDayKey(new Date());
const cached = await readFreshStatusText({
@@ -95,6 +98,7 @@ export async function generatePulseStatusForUser(
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
+43 -4
View File
@@ -28,6 +28,26 @@ import { annotate } from "@/lib/logging/context";
* the fallback for the day, and a fresh generation is attempted instead.
*/
/**
* The single source of truth for the per-status cache-action shape.
*
* Every per-metric assessment is persisted as an `auditLog` row whose
* `action` is `insights.<scope>-status.<locale>`. The shape IS the cache
* key, so building it by hand in multiple places risks the same silent
* drift the queryKey factory guards against on the client. `statusCacheAction`
* is the one builder; `statusCacheActionPrefix` is its locale-agnostic
* sibling for the `startsWith` eviction that drops every locale variant of a
* scope at once.
*/
export function statusCacheAction(scope: string, locale: string): string {
return `insights.${scope}-status.${locale}`;
}
/** Locale-agnostic prefix for `startsWith`-eviction of every locale of a scope. */
export function statusCacheActionPrefix(scope: string): string {
return `insights.${scope}-status.`;
}
interface ParsedStatusCache {
dateKey?: string;
locale?: string;
@@ -153,10 +173,22 @@ export async function readLastGoodStatusText(args: {
* v1.8.7 — `preparing` now carries the last good assessment (if any) so
* the card renders the previous text immediately (stale-while-revalidate)
* instead of a skeleton while the worker re-warms the cache.
*
* v1.9.0 — `revalidating` is true only when last-good text is served AND a
* fresh generation was actually enqueued (the open-card refresh case). When
* the served text is terminal the card otherwise stops polling and never
* sees the freshly-warmed assessment until a remount; the flag keeps the
* bounded poll alive until the new row lands. It is false on the
* suppressed-stub branch (no enqueue, so nothing is in flight) and whenever
* there is no last-good text (that path already polls via `preparing`).
*/
export type ReadOnlyMissOutcome =
| { kind: "no-provider" }
| { kind: "preparing"; lastGood: LastGoodStatusHit | null };
| {
kind: "preparing";
lastGood: LastGoodStatusHit | null;
revalidating: boolean;
};
/**
* v1.8.3 — resolve what a read-only status generation should return on a
@@ -182,7 +214,7 @@ export async function resolveReadOnlyStatusMiss(args: {
const hasProvider = await hasUsableStatusProvider(args.userId);
if (!hasProvider) return { kind: "no-provider" };
const cacheAction = `insights.${args.metric}-status.${args.locale}`;
const cacheAction = statusCacheAction(args.metric, args.locale);
// v1.8.7 — stale-while-revalidate. Surface the last good (non-stub)
// assessment for this scope so the card renders the previous text
@@ -204,7 +236,10 @@ export async function resolveReadOnlyStatusMiss(args: {
action: { name: "insights.status.preparing" },
meta: { metric: args.metric, suppressed_enqueue: true },
});
return { kind: "preparing", lastGood };
// No enqueue on this branch — nothing is in flight, so the open card has
// nothing to revalidate against. It still polls via `preparing` when
// there is no last-good text to show.
return { kind: "preparing", lastGood, revalidating: false };
}
// Enqueue out of band — do NOT await an LLM here. The enqueue itself is
@@ -218,7 +253,11 @@ export async function resolveReadOnlyStatusMiss(args: {
action: { name: "insights.status.preparing" },
meta: { metric: args.metric, stale_served: lastGood !== null },
});
return { kind: "preparing", lastGood };
// A fresh generation is now in flight. When last-good text is served the
// payload is otherwise terminal (`preparing` is false), so the open card
// would stop polling and never pick up the warmed assessment. Signal
// `revalidating` so the bounded poll stays alive until the new row lands.
return { kind: "preparing", lastGood, revalidating: lastGood !== null };
}
/**
+66
View File
@@ -0,0 +1,66 @@
/**
* v1.9.0 — shared metric-aware trend sentiment helpers.
*
* Extracted from `trend-card.tsx` so the range-delta caption (the time-range
* pills feature) paints the exact same colour for the same signal as the
* dashboard tiles. The mapping is the v1.5 phase-5 audit's: an upward move is
* good for some metrics (mood, HRV, steps), bad for others (resting HR,
* weight, body fat), and value-judgement-free for the rest.
*/
/**
* Maps a metric's "up means" direction to colour sentiment.
*
* - `up-good` — higher value is better (mood, HRV, steps). ↑ green, ↓ orange.
* - `up-bad` — higher value is worse (resting HR, weight, body fat).
* ↑ orange, ↓ green.
* - `neutral` — direction carries no value judgement (the change renders
* muted).
*/
export type TrendDirectionSentiment = "up-good" | "up-bad" | "neutral";
/**
* The resolved sentiment of a signed change under a metric's direction.
*
* - `positive` — the change moves the metric toward its goal. Renders green.
* - `negative` — the change moves away from the goal. Renders orange.
* - `neutral` — the metric's direction is neutral, or the change is below
* the noise floor (|change| < 0.05). Renders muted.
*/
export type TrendSentimentDirection = "positive" | "negative" | "neutral";
export 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";
}
export function sentimentColorClass(
direction: TrendSentimentDirection,
): string {
if (direction === "positive") return "text-dracula-green";
if (direction === "negative") return "text-dracula-orange";
return "text-muted-foreground";
}
/**
* v1.9.0 — map a `MetricDirection` (the metric-status registry's
* `higher-better` / `lower-better` / `target-band`) onto a
* `TrendDirectionSentiment` for the range-delta caption. `target-band`
* metrics (body temperature, respiratory rate, …) have no single good
* direction, so a period-over-period delta carries no sentiment colour —
* `neutral`, matching Oura's deviation framing for baseline metrics.
*/
export function sentimentFromMetricDirection(
direction: "higher-better" | "lower-better" | "target-band",
): TrendDirectionSentiment {
if (direction === "higher-better") return "up-good";
if (direction === "lower-better") return "up-bad";
return "neutral";
}
+5 -1
View File
@@ -35,6 +35,7 @@ import { runStatusCompletion } from "@/lib/insights/status-provider";
import {
readFreshStatusText,
resolveReadOnlyStatusMiss,
statusCacheAction,
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
@@ -99,11 +100,13 @@ export async function generateWeightStatusForUser(
cached: boolean;
updatedAt: string | null;
preparing?: boolean;
/** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */
revalidating?: boolean;
}> {
const locale = normalizeLocale(options?.locale);
const force = options?.force === true;
const readOnly = options?.readOnly === true;
const cacheAction = `insights.weight-status.${locale}`;
const cacheAction = statusCacheAction("weight", locale);
const todayKey = toBerlinDayKey(new Date());
// Serve today's real assessment when present. The shared reader
@@ -149,6 +152,7 @@ export async function generateWeightStatusForUser(
cached: outcome.lastGood !== null,
updatedAt: outcome.lastGood?.updatedAt ?? null,
preparing: outcome.lastGood === null,
revalidating: outcome.revalidating,
};
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more