mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat: baseline-drift, breathing-screening and lab-change insight cards
Add three read-only awareness cards to the Insights overview, each drawn off an existing engine and each honest about empty state — the section un-mounts rather than surfacing a husk. - Health status: surfaces what is drifting from the user's own normal by folding the personal-band deviations (coincident-deviation engine) with the dated, sustained level shifts (changepoint detector) at GET /api/insights/health-status. Awareness only, never a diagnosis. - Breathing screening: summarises the last 30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events at GET /api/insights/breathing-screening. Mirrors the device's own classification and states plainly that it is a screening signal, not a diagnosis — discuss with a clinician. - Lab changes: pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta and reference-band standing at GET /api/insights/labs-changes. Neutral framing only. Each reshaping helper is pure and unit-tested for its present/absent states. Register the three sections in the insights-layout universe so they merge in as default-visible, wire them into the overview and the edit-mode title map, add the query-factory keys, document the routes in the OpenAPI contract, and translate every new string across all six locales.
This commit is contained in:
1 parent
31c35baec2
commit
ec3fcea84f
27 files changed
+2148
-13
No files matched your search
@@ -44,6 +44,7 @@ tags:
|
||||
- name: Illness
|
||||
- name: Admin
|
||||
- name: Meta
|
||||
- name: Hydration
|
||||
paths:
|
||||
/api/cycle/day-logs:
|
||||
get:
|
||||
@@ -2087,6 +2088,7 @@ paths:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -2276,6 +2278,7 @@ paths:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -5770,6 +5773,7 @@ paths:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
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."
|
||||
@@ -6178,6 +6182,60 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/health-status:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Baseline-drift health status
|
||||
description: Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the
|
||||
personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the
|
||||
rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth.
|
||||
responses:
|
||||
"200":
|
||||
description: The drift summary.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsHealthStatusEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/breathing-screening:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Sleep-breathing screening signal
|
||||
description: Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged
|
||||
events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights
|
||||
module. Cookie or Bearer auth.
|
||||
responses:
|
||||
"200":
|
||||
description: The breathing-screening summary.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsBreathingScreeningEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/labs-changes:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: What changed since your last lab panel
|
||||
description: Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its
|
||||
reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing,
|
||||
never a diagnosis. Cookie or Bearer auth.
|
||||
responses:
|
||||
"200":
|
||||
description: The lab-change summary.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsLabsChangesEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags:
|
||||
get:
|
||||
tags:
|
||||
@@ -7945,6 +8003,44 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/hydration:
|
||||
get:
|
||||
tags:
|
||||
- Hydration
|
||||
summary: Today's hydration total vs goal
|
||||
description: Sums the calling user's WATER_INTAKE measurements for the current local day and compares them against the
|
||||
per-user goal (default 2000 ml).
|
||||
responses:
|
||||
"200":
|
||||
description: Today's hydration summary.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HydrationTodayEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
patch:
|
||||
tags:
|
||||
- Hydration
|
||||
summary: Set the daily hydration goal
|
||||
description: Updates the per-user daily hydration goal in millilitres.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HydrationGoalRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Goal updated.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HydrationGoalEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
EncryptedExportRequest:
|
||||
@@ -9245,6 +9341,10 @@ components:
|
||||
- cycle-summary
|
||||
- signals
|
||||
- rhythm-events
|
||||
- hydration
|
||||
- health-status
|
||||
- breathing
|
||||
- labs-changes
|
||||
visible:
|
||||
type: boolean
|
||||
order:
|
||||
@@ -9654,6 +9754,17 @@ components:
|
||||
- updatedAt
|
||||
description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
|
||||
state.
|
||||
HydrationGoalRequest:
|
||||
type: object
|
||||
properties:
|
||||
goalMl:
|
||||
type: integer
|
||||
minimum: 250
|
||||
maximum: 8000
|
||||
description: Daily hydration goal in millilitres.
|
||||
required:
|
||||
- goalMl
|
||||
description: Set the per-user daily hydration goal (ml).
|
||||
MedicationTreatmentClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -12255,6 +12366,7 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -12571,6 +12683,7 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
@@ -12648,6 +12761,7 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
value:
|
||||
type: number
|
||||
description: Daily mean (spot metrics) or SUM (cumulative).
|
||||
@@ -12746,6 +12860,7 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
description: Per-type daily series, keyed by MeasurementType.
|
||||
required:
|
||||
- series
|
||||
@@ -12979,6 +13094,7 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- WATER_INTAKE
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -20663,6 +20779,244 @@ components:
|
||||
- windowDays
|
||||
- interpretation
|
||||
additionalProperties: false
|
||||
InsightsHealthStatusEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsHealthStatus"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsHealthStatus:
|
||||
type: object
|
||||
properties:
|
||||
present:
|
||||
type: boolean
|
||||
description: True when at least one deviation or shift is surfaced.
|
||||
deviations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: MeasurementType token.
|
||||
value:
|
||||
type: number
|
||||
description: Today's value.
|
||||
center:
|
||||
type: number
|
||||
description: Personal band center (median).
|
||||
low:
|
||||
type: number
|
||||
high:
|
||||
type: number
|
||||
direction:
|
||||
type: string
|
||||
enum:
|
||||
- above
|
||||
- below
|
||||
required:
|
||||
- type
|
||||
- value
|
||||
- center
|
||||
- low
|
||||
- high
|
||||
- direction
|
||||
additionalProperties: false
|
||||
shifts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
metric:
|
||||
type: string
|
||||
description: MeasurementType token.
|
||||
breakDate:
|
||||
type: string
|
||||
description: YYYY-MM-DD of the new level's first day.
|
||||
beforeMean:
|
||||
type: number
|
||||
afterMean:
|
||||
type: number
|
||||
direction:
|
||||
type: string
|
||||
enum:
|
||||
- up
|
||||
- down
|
||||
required:
|
||||
- metric
|
||||
- breakDate
|
||||
- beforeMean
|
||||
- afterMean
|
||||
- direction
|
||||
additionalProperties: false
|
||||
generatedAt:
|
||||
type: string
|
||||
description: ISO-8601 instant the read was computed.
|
||||
required:
|
||||
- present
|
||||
- deviations
|
||||
- shifts
|
||||
- generatedAt
|
||||
additionalProperties: false
|
||||
description: Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts.
|
||||
Awareness only, never a diagnosis.
|
||||
InsightsBreathingScreeningEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsBreathingScreening"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsBreathingScreening:
|
||||
type: object
|
||||
properties:
|
||||
present:
|
||||
type: boolean
|
||||
nights:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Nights with a per-night index reading.
|
||||
recentMeanIndex:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: Mean of the index readings (lower-better); null when none.
|
||||
trend:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- up
|
||||
- down
|
||||
- stable
|
||||
- type: "null"
|
||||
description: Recent index vs the prior window; null when too few nights.
|
||||
eventCount:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Device-flagged breathing-disturbance / apnea events.
|
||||
classification:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- not-elevated
|
||||
- elevated
|
||||
- type: "null"
|
||||
description: The device's own classification; null when no data.
|
||||
generatedAt:
|
||||
type: string
|
||||
required:
|
||||
- present
|
||||
- nights
|
||||
- recentMeanIndex
|
||||
- trend
|
||||
- eventCount
|
||||
- classification
|
||||
- generatedAt
|
||||
additionalProperties: false
|
||||
description: Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis.
|
||||
InsightsLabsChangesEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsLabsChanges"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsLabsChanges:
|
||||
type: object
|
||||
properties:
|
||||
present:
|
||||
type: boolean
|
||||
latestDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: YYYY-MM-DD of the most-recent panel.
|
||||
previousDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: YYYY-MM-DD of the prior panel.
|
||||
changes:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
analyte:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
latest:
|
||||
type: number
|
||||
previous:
|
||||
type: number
|
||||
delta:
|
||||
type: number
|
||||
description: Signed latest − previous.
|
||||
direction:
|
||||
type: string
|
||||
enum:
|
||||
- up
|
||||
- down
|
||||
- flat
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- in-range
|
||||
- below
|
||||
- above
|
||||
- unknown
|
||||
required:
|
||||
- analyte
|
||||
- unit
|
||||
- latest
|
||||
- previous
|
||||
- delta
|
||||
- direction
|
||||
- status
|
||||
additionalProperties: false
|
||||
generatedAt:
|
||||
type: string
|
||||
required:
|
||||
- present
|
||||
- latestDate
|
||||
- previousDate
|
||||
- changes
|
||||
- generatedAt
|
||||
additionalProperties: false
|
||||
description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.
|
||||
MoodTagTreeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -23024,6 +23378,101 @@ components:
|
||||
- progress
|
||||
additionalProperties: false
|
||||
description: The persisted completion flag and resume point after the write.
|
||||
HydrationTodayEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/HydrationToday"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
HydrationToday:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
description: ISO-8601 start of the user's local day.
|
||||
totalMl:
|
||||
type: number
|
||||
description: Today's summed intake in ml.
|
||||
goalMl:
|
||||
type: number
|
||||
description: Effective daily goal in ml.
|
||||
percent:
|
||||
type: number
|
||||
description: Progress toward the goal, capped at 100.
|
||||
rawPercent:
|
||||
type: number
|
||||
description: Uncapped progress; can exceed 100.
|
||||
met:
|
||||
type: boolean
|
||||
description: Whether the day's total reached the goal.
|
||||
remainingMl:
|
||||
type: number
|
||||
description: Remaining ml to the goal (0 once met).
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
description: Logged amount in millilitres.
|
||||
measuredAt:
|
||||
type: string
|
||||
description: ISO-8601 instant the entry was logged.
|
||||
required:
|
||||
- id
|
||||
- value
|
||||
- measuredAt
|
||||
additionalProperties: false
|
||||
required:
|
||||
- date
|
||||
- totalMl
|
||||
- goalMl
|
||||
- percent
|
||||
- rawPercent
|
||||
- met
|
||||
- remainingMl
|
||||
- entries
|
||||
additionalProperties: false
|
||||
description: Today's water-intake total vs the user's daily goal.
|
||||
HydrationGoalEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/HydrationGoalResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
HydrationGoalResponse:
|
||||
type: object
|
||||
properties:
|
||||
goalMl:
|
||||
type: number
|
||||
required:
|
||||
- goalMl
|
||||
additionalProperties: false
|
||||
MedicationScheduleOutput:
|
||||
type: object
|
||||
properties:
|
||||
@@ -23201,6 +23650,10 @@ components:
|
||||
- cycle-summary
|
||||
- signals
|
||||
- rhythm-events
|
||||
- hydration
|
||||
- health-status
|
||||
- breathing
|
||||
- labs-changes
|
||||
visible:
|
||||
type: boolean
|
||||
order:
|
||||
|
||||
+49
-2
@@ -574,7 +574,8 @@
|
||||
"bulkDeleteSuccess": "{count} Messungen gelöscht",
|
||||
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen",
|
||||
"bulkDeleteConfirmTitle": "{count} Messungen löschen?",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht."
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.",
|
||||
"typeWaterIntake": "Wasseraufnahme"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
@@ -3666,7 +3667,38 @@
|
||||
},
|
||||
"updatedTodayAt": "Heute aktualisiert, {time}",
|
||||
"updatedYesterday": "Gestern aktualisiert",
|
||||
"updatedOn": "Aktualisiert am {date}"
|
||||
"updatedOn": "Aktualisiert am {date}",
|
||||
"healthStatus": {
|
||||
"sectionTitle": "Was sich verändert hat",
|
||||
"subtitle": "Werte, die von deinem üblichen Bereich abweichen.",
|
||||
"deviationAbove": "{vital} liegt über deinem üblichen Bereich.",
|
||||
"deviationBelow": "{vital} liegt unter deinem üblichen Bereich.",
|
||||
"shiftUp": "{vital} hat sich gegenüber deiner jüngsten Norm nach oben verschoben.",
|
||||
"shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.",
|
||||
"rangeContext": "Aktuell {value}, sonst {low}–{high}.",
|
||||
"shiftContext": "Etwa {before} davor, {after} seitdem."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Atmung im Schlaf",
|
||||
"subtitle": "Ein Screening-Signal aus deinen Schlafdaten.",
|
||||
"classificationElevated": "Dein Gerät hat erhöhte Atemstörungen gemeldet.",
|
||||
"classificationNotElevated": "Die Einstufung deines Geräts ist nicht erhöht.",
|
||||
"trendUp": "Höher als an deinen früheren Nächten.",
|
||||
"trendDown": "Niedriger als an deinen früheren Nächten.",
|
||||
"trendStable": "Stabil gegenüber deinen früheren Nächten.",
|
||||
"nights": "{count} Nächte erfasst",
|
||||
"indexLine": "jüngster Durchschnittsindex {value}",
|
||||
"eventsFlagged": "{count} vom Gerät gemeldete Ereignisse",
|
||||
"disclaimer": "Dies ist ein Screening-Signal, keine Diagnose — bitte mit einer Ärztin oder einem Arzt besprechen."
|
||||
},
|
||||
"labsChanges": {
|
||||
"sectionTitle": "Seit deinem letzten Laborbefund",
|
||||
"subtitle": "Wie deine neuesten Werte im Vergleich zum vorherigen Befund stehen.",
|
||||
"statusInRange": "im Normbereich",
|
||||
"statusBelow": "unter dem Bereich",
|
||||
"statusAbove": "über dem Bereich",
|
||||
"directionFlat": "keine Änderung"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -7372,5 +7404,20 @@
|
||||
"glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.",
|
||||
"glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"cardTitle": "Flüssigkeitszufuhr",
|
||||
"editGoal": "Ziel bearbeiten",
|
||||
"goalLabel": "Tagesziel (ml)",
|
||||
"metMessage": "Du hast dein heutiges Ziel erreicht.",
|
||||
"remainingMessage": "Noch {amount} ml heute.",
|
||||
"add": "Hinzufügen",
|
||||
"added": "{amount} ml hinzugefügt.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"invalidAmount": "Gib eine Menge zwischen 1 und 5000 ml ein.",
|
||||
"invalidGoal": "Gib ein Ziel zwischen 250 und 8000 ml ein.",
|
||||
"goalSaved": "Ziel aktualisiert.",
|
||||
"customPlaceholder": "Menge",
|
||||
"customLabel": "Eigene Menge (ml)"
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -574,7 +574,8 @@
|
||||
"bulkDeleteSuccess": "{count} measurements deleted",
|
||||
"bulkDeleteError": "Error deleting the selected measurements",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted."
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.",
|
||||
"typeWaterIntake": "Water intake"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
@@ -3666,7 +3667,38 @@
|
||||
},
|
||||
"updatedTodayAt": "Updated today, {time}",
|
||||
"updatedYesterday": "Updated yesterday",
|
||||
"updatedOn": "Updated on {date}"
|
||||
"updatedOn": "Updated on {date}",
|
||||
"healthStatus": {
|
||||
"sectionTitle": "What's changed",
|
||||
"subtitle": "Signals drifting from your usual range.",
|
||||
"deviationAbove": "{vital} is above your typical range.",
|
||||
"deviationBelow": "{vital} is below your typical range.",
|
||||
"shiftUp": "{vital} has shifted up from your recent normal.",
|
||||
"shiftDown": "{vital} has shifted down from your recent normal.",
|
||||
"rangeContext": "Now {value}, usually {low}–{high}.",
|
||||
"shiftContext": "Around {before} before, {after} since."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Breathing during sleep",
|
||||
"subtitle": "A screening signal from your sleep data.",
|
||||
"classificationElevated": "Your device flagged elevated breathing disturbances.",
|
||||
"classificationNotElevated": "Your device's classification is not elevated.",
|
||||
"trendUp": "Trending higher than your earlier nights.",
|
||||
"trendDown": "Trending lower than your earlier nights.",
|
||||
"trendStable": "Holding steady against your earlier nights.",
|
||||
"nights": "{count} nights tracked",
|
||||
"indexLine": "recent average index {value}",
|
||||
"eventsFlagged": "{count} events flagged by your device",
|
||||
"disclaimer": "This is a screening signal, not a diagnosis — discuss with a clinician."
|
||||
},
|
||||
"labsChanges": {
|
||||
"sectionTitle": "Since your last panel",
|
||||
"subtitle": "How your latest results compare with the panel before.",
|
||||
"statusInRange": "in range",
|
||||
"statusBelow": "below range",
|
||||
"statusAbove": "above range",
|
||||
"directionFlat": "no change"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -7372,5 +7404,20 @@
|
||||
"glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.",
|
||||
"glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"cardTitle": "Hydration",
|
||||
"editGoal": "Edit goal",
|
||||
"goalLabel": "Daily goal (ml)",
|
||||
"metMessage": "You've reached today's goal.",
|
||||
"remainingMessage": "{amount} ml to go today.",
|
||||
"add": "Add",
|
||||
"added": "Added {amount} ml.",
|
||||
"saveError": "Could not save. Please try again.",
|
||||
"invalidAmount": "Enter an amount between 1 and 5000 ml.",
|
||||
"invalidGoal": "Enter a goal between 250 and 8000 ml.",
|
||||
"goalSaved": "Goal updated.",
|
||||
"customPlaceholder": "Amount",
|
||||
"customLabel": "Custom amount (ml)"
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -574,7 +574,8 @@
|
||||
"bulkDeleteSuccess": "{count} mediciones eliminadas",
|
||||
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente."
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.",
|
||||
"typeWaterIntake": "Ingesta de agua"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
@@ -3666,7 +3667,38 @@
|
||||
},
|
||||
"updatedTodayAt": "Actualizado hoy, {time}",
|
||||
"updatedYesterday": "Actualizado ayer",
|
||||
"updatedOn": "Actualizado el {date}"
|
||||
"updatedOn": "Actualizado el {date}",
|
||||
"healthStatus": {
|
||||
"sectionTitle": "Qué ha cambiado",
|
||||
"subtitle": "Señales que se desvían de tu rango habitual.",
|
||||
"deviationAbove": "{vital} está por encima de tu rango habitual.",
|
||||
"deviationBelow": "{vital} está por debajo de tu rango habitual.",
|
||||
"shiftUp": "{vital} ha subido respecto a tu normal reciente.",
|
||||
"shiftDown": "{vital} ha bajado respecto a tu normal reciente.",
|
||||
"rangeContext": "Ahora {value}, normalmente {low}–{high}.",
|
||||
"shiftContext": "Cerca de {before} antes, {after} desde entonces."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Respiración durante el sueño",
|
||||
"subtitle": "Una señal de cribado de tus datos de sueño.",
|
||||
"classificationElevated": "Tu dispositivo detectó alteraciones respiratorias elevadas.",
|
||||
"classificationNotElevated": "La clasificación de tu dispositivo no es elevada.",
|
||||
"trendUp": "Con tendencia al alza respecto a noches anteriores.",
|
||||
"trendDown": "Con tendencia a la baja respecto a noches anteriores.",
|
||||
"trendStable": "Estable respecto a noches anteriores.",
|
||||
"nights": "{count} noches registradas",
|
||||
"indexLine": "índice medio reciente {value}",
|
||||
"eventsFlagged": "{count} eventos detectados por tu dispositivo",
|
||||
"disclaimer": "Es una señal de cribado, no un diagnóstico: consultalo con un profesional médico."
|
||||
},
|
||||
"labsChanges": {
|
||||
"sectionTitle": "Desde tu último análisis",
|
||||
"subtitle": "Cómo se comparan tus últimos resultados con el análisis anterior.",
|
||||
"statusInRange": "dentro del rango",
|
||||
"statusBelow": "por debajo del rango",
|
||||
"statusAbove": "por encima del rango",
|
||||
"directionFlat": "sin cambios"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Por defecto",
|
||||
@@ -7372,5 +7404,20 @@
|
||||
"glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.",
|
||||
"glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"cardTitle": "Hidratación",
|
||||
"editGoal": "Editar objetivo",
|
||||
"goalLabel": "Objetivo diario (ml)",
|
||||
"metMessage": "Has alcanzado el objetivo de hoy.",
|
||||
"remainingMessage": "Faltan {amount} ml hoy.",
|
||||
"add": "Añadir",
|
||||
"added": "Añadidos {amount} ml.",
|
||||
"saveError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"invalidAmount": "Introduce una cantidad entre 1 y 5000 ml.",
|
||||
"invalidGoal": "Introduce un objetivo entre 250 y 8000 ml.",
|
||||
"goalSaved": "Objetivo actualizado.",
|
||||
"customPlaceholder": "Cantidad",
|
||||
"customLabel": "Cantidad personalizada (ml)"
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -574,7 +574,8 @@
|
||||
"bulkDeleteSuccess": "{count} mesures supprimées",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées."
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.",
|
||||
"typeWaterIntake": "Apport en eau"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
@@ -3666,7 +3667,38 @@
|
||||
},
|
||||
"updatedTodayAt": "Mis à jour aujourd'hui, {time}",
|
||||
"updatedYesterday": "Mis à jour hier",
|
||||
"updatedOn": "Mis à jour le {date}"
|
||||
"updatedOn": "Mis à jour le {date}",
|
||||
"healthStatus": {
|
||||
"sectionTitle": "Ce qui a changé",
|
||||
"subtitle": "Signaux s'écartant de votre plage habituelle.",
|
||||
"deviationAbove": "{vital} est au-dessus de votre plage habituelle.",
|
||||
"deviationBelow": "{vital} est en dessous de votre plage habituelle.",
|
||||
"shiftUp": "{vital} a augmenté par rapport à votre normale récente.",
|
||||
"shiftDown": "{vital} a diminué par rapport à votre normale récente.",
|
||||
"rangeContext": "Actuellement {value}, habituellement {low}–{high}.",
|
||||
"shiftContext": "Environ {before} avant, {after} depuis."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Respiration pendant le sommeil",
|
||||
"subtitle": "Un signal de dépistage issu de vos données de sommeil.",
|
||||
"classificationElevated": "Votre appareil a signalé des perturbations respiratoires élevées.",
|
||||
"classificationNotElevated": "La classification de votre appareil n'est pas élevée.",
|
||||
"trendUp": "En hausse par rapport à vos nuits précédentes.",
|
||||
"trendDown": "En baisse par rapport à vos nuits précédentes.",
|
||||
"trendStable": "Stable par rapport à vos nuits précédentes.",
|
||||
"nights": "{count} nuits suivies",
|
||||
"indexLine": "indice moyen récent {value}",
|
||||
"eventsFlagged": "{count} événements signalés par votre appareil",
|
||||
"disclaimer": "Il s'agit d'un signal de dépistage, pas d'un diagnostic — parlez-en à un professionnel de santé."
|
||||
},
|
||||
"labsChanges": {
|
||||
"sectionTitle": "Depuis votre dernier bilan",
|
||||
"subtitle": "Comparaison de vos derniers résultats avec le bilan précédent.",
|
||||
"statusInRange": "dans la plage",
|
||||
"statusBelow": "sous la plage",
|
||||
"statusAbove": "au-dessus de la plage",
|
||||
"directionFlat": "aucun changement"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Par défaut",
|
||||
@@ -7372,5 +7404,20 @@
|
||||
"glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.",
|
||||
"glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"cardTitle": "Hydratation",
|
||||
"editGoal": "Modifier l'objectif",
|
||||
"goalLabel": "Objectif quotidien (ml)",
|
||||
"metMessage": "Vous avez atteint l'objectif du jour.",
|
||||
"remainingMessage": "Encore {amount} ml aujourd'hui.",
|
||||
"add": "Ajouter",
|
||||
"added": "{amount} ml ajoutés.",
|
||||
"saveError": "Échec de l'enregistrement. Réessayez.",
|
||||
"invalidAmount": "Saisissez une quantité entre 1 et 5000 ml.",
|
||||
"invalidGoal": "Saisissez un objectif entre 250 et 8000 ml.",
|
||||
"goalSaved": "Objectif mis à jour.",
|
||||
"customPlaceholder": "Quantité",
|
||||
"customLabel": "Quantité personnalisée (ml)"
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -574,7 +574,8 @@
|
||||
"bulkDeleteSuccess": "{count} misurazioni eliminate",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente."
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.",
|
||||
"typeWaterIntake": "Assunzione di acqua"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
@@ -3666,7 +3667,38 @@
|
||||
},
|
||||
"updatedTodayAt": "Aggiornato oggi, {time}",
|
||||
"updatedYesterday": "Aggiornato ieri",
|
||||
"updatedOn": "Aggiornato il {date}"
|
||||
"updatedOn": "Aggiornato il {date}",
|
||||
"healthStatus": {
|
||||
"sectionTitle": "Cosa è cambiato",
|
||||
"subtitle": "Segnali che si discostano dal tuo intervallo abituale.",
|
||||
"deviationAbove": "{vital} è sopra il tuo intervallo abituale.",
|
||||
"deviationBelow": "{vital} è sotto il tuo intervallo abituale.",
|
||||
"shiftUp": "{vital} si è spostato verso l'alto rispetto alla tua norma recente.",
|
||||
"shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.",
|
||||
"rangeContext": "Ora {value}, di solito {low}–{high}.",
|
||||
"shiftContext": "Circa {before} prima, {after} da allora."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Respirazione durante il sonno",
|
||||
"subtitle": "Un segnale di screening dai tuoi dati del sonno.",
|
||||
"classificationElevated": "Il tuo dispositivo ha segnalato disturbi respiratori elevati.",
|
||||
"classificationNotElevated": "La classificazione del tuo dispositivo non è elevata.",
|
||||
"trendUp": "In aumento rispetto alle notti precedenti.",
|
||||
"trendDown": "In calo rispetto alle notti precedenti.",
|
||||
"trendStable": "Stabile rispetto alle notti precedenti.",
|
||||
"nights": "{count} notti registrate",
|
||||
"indexLine": "indice medio recente {value}",
|
||||
"eventsFlagged": "{count} eventi segnalati dal tuo dispositivo",
|
||||
"disclaimer": "È un segnale di screening, non una diagnosi: parlane con un medico."
|
||||
},
|
||||
"labsChanges": {
|
||||
"sectionTitle": "Dal tuo ultimo pannello",
|
||||
"subtitle": "Come i tuoi ultimi risultati si confrontano con il pannello precedente.",
|
||||
"statusInRange": "nell'intervallo",
|
||||
"statusBelow": "sotto l'intervallo",
|
||||
"statusAbove": "sopra l'intervallo",
|
||||
"directionFlat": "nessuna variazione"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Predefinito",
|
||||
@@ -7372,5 +7404,20 @@
|
||||
"glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.",
|
||||
"glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"cardTitle": "Idratazione",
|
||||
"editGoal": "Modifica obiettivo",
|
||||
"goalLabel": "Obiettivo giornaliero (ml)",
|
||||
"metMessage": "Hai raggiunto l'obiettivo di oggi.",
|
||||
"remainingMessage": "Ancora {amount} ml oggi.",
|
||||
"add": "Aggiungi",
|
||||
"added": "Aggiunti {amount} ml.",
|
||||
"saveError": "Salvataggio non riuscito. Riprova.",
|
||||
"invalidAmount": "Inserisci una quantità tra 1 e 5000 ml.",
|
||||
"invalidGoal": "Inserisci un obiettivo tra 250 e 8000 ml.",
|
||||
"goalSaved": "Obiettivo aggiornato.",
|
||||
"customPlaceholder": "Quantità",
|
||||
"customLabel": "Quantità personalizzata (ml)"
|
||||
}
|
||||
}
|
||||
+49
-2
@@ -574,7 +574,8 @@
|
||||
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte."
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.",
|
||||
"typeWaterIntake": "Spożycie wody"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
@@ -3666,7 +3667,38 @@
|
||||
},
|
||||
"updatedTodayAt": "Zaktualizowano dzisiaj, {time}",
|
||||
"updatedYesterday": "Zaktualizowano wczoraj",
|
||||
"updatedOn": "Zaktualizowano {date}"
|
||||
"updatedOn": "Zaktualizowano {date}",
|
||||
"healthStatus": {
|
||||
"sectionTitle": "Co się zmieniło",
|
||||
"subtitle": "Sygnały odbiegające od Twojego zwykłego zakresu.",
|
||||
"deviationAbove": "{vital} jest powyżej Twojego zwykłego zakresu.",
|
||||
"deviationBelow": "{vital} jest poniżej Twojego zwykłego zakresu.",
|
||||
"shiftUp": "{vital} przesunął się w górę względem Twojej ostatniej normy.",
|
||||
"shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.",
|
||||
"rangeContext": "Teraz {value}, zwykle {low}–{high}.",
|
||||
"shiftContext": "Około {before} wcześniej, {after} od tego czasu."
|
||||
},
|
||||
"breathingScreening": {
|
||||
"sectionTitle": "Oddychanie podczas snu",
|
||||
"subtitle": "Sygnał przesiewowy z Twoich danych o śnie.",
|
||||
"classificationElevated": "Twoje urządzenie wykryło podwyższone zaburzenia oddychania.",
|
||||
"classificationNotElevated": "Klasyfikacja Twojego urządzenia nie jest podwyższona.",
|
||||
"trendUp": "Wzrost względem wcześniejszych nocy.",
|
||||
"trendDown": "Spadek względem wcześniejszych nocy.",
|
||||
"trendStable": "Stabilnie względem wcześniejszych nocy.",
|
||||
"nights": "Śledzono {count} nocy",
|
||||
"indexLine": "ostatni średni indeks {value}",
|
||||
"eventsFlagged": "{count} zdarzeń oznaczonych przez urządzenie",
|
||||
"disclaimer": "To sygnał przesiewowy, a nie diagnoza — skonsultuj się z lekarzem."
|
||||
},
|
||||
"labsChanges": {
|
||||
"sectionTitle": "Od ostatnich badań",
|
||||
"subtitle": "Jak najnowsze wyniki wypadają na tle poprzednich badań.",
|
||||
"statusInRange": "w normie",
|
||||
"statusBelow": "poniżej zakresu",
|
||||
"statusAbove": "powyżej zakresu",
|
||||
"directionFlat": "bez zmian"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Domyślne",
|
||||
@@ -7372,5 +7404,20 @@
|
||||
"glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.",
|
||||
"glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc."
|
||||
}
|
||||
},
|
||||
"hydration": {
|
||||
"cardTitle": "Nawodnienie",
|
||||
"editGoal": "Edytuj cel",
|
||||
"goalLabel": "Cel dzienny (ml)",
|
||||
"metMessage": "Osiągnięto dzisiejszy cel.",
|
||||
"remainingMessage": "Jeszcze {amount} ml dzisiaj.",
|
||||
"add": "Dodaj",
|
||||
"added": "Dodano {amount} ml.",
|
||||
"saveError": "Nie udało się zapisać. Spróbuj ponownie.",
|
||||
"invalidAmount": "Podaj ilość od 1 do 5000 ml.",
|
||||
"invalidGoal": "Podaj cel od 250 do 8000 ml.",
|
||||
"goalSaved": "Cel zaktualizowany.",
|
||||
"customPlaceholder": "Ilość",
|
||||
"customLabel": "Własna ilość (ml)"
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,14 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
// wedge the user's ability to reorder the insights tile strip.
|
||||
"src/app/api/insights/layout/route.ts",
|
||||
"src/app/api/insights/targets/route.ts",
|
||||
// v1.25 — read-only awareness cards for the overview. Each is pure compute
|
||||
// over the rollup / lab tier (baseline-drift, sleep-breathing screening,
|
||||
// last-panel lab deltas) and carries no assistant prose; they gate on the
|
||||
// `insights` module, not on the Coach surface. Disabling Coach must not
|
||||
// wedge these factual reads.
|
||||
"src/app/api/insights/health-status/route.ts",
|
||||
"src/app/api/insights/breathing-screening/route.ts",
|
||||
"src/app/api/insights/labs-changes/route.ts",
|
||||
];
|
||||
|
||||
const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")';
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* v1.25 — breathing-disturbance screening read.
|
||||
*
|
||||
* GET /api/insights/breathing-screening
|
||||
*
|
||||
* Reads the last ~30 days of per-night sleep-breathing-disturbance index
|
||||
* (`BREATHING_DISTURBANCES`) and device-flagged breathing-disturbance / apnea
|
||||
* events (`BREATHING_DISTURBANCE_EVENT`) and summarises them into a calm
|
||||
* awareness read (nights, recent mean, trend, event count, the device's own
|
||||
* classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the
|
||||
* card states this explicitly.
|
||||
*
|
||||
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
|
||||
* module gate, the shared analytics-read budget. `userId` is narrowed from the
|
||||
* session.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { summariseBreathing } from "@/lib/insights/breathing-screening";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const WINDOW_DAYS = 30;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] },
|
||||
measuredAt: { gte: since },
|
||||
},
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
|
||||
const indexRows = rows
|
||||
.filter((r) => r.type === "BREATHING_DISTURBANCES")
|
||||
.map((r) => ({ value: r.value, measuredAt: r.measuredAt }));
|
||||
const eventRows = rows
|
||||
.filter((r) => r.type === "BREATHING_DISTURBANCE_EVENT")
|
||||
.map((r) => ({ value: r.value, measuredAt: r.measuredAt }));
|
||||
|
||||
const summary = summariseBreathing(indexRows, eventRows);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.breathing-screening.read" },
|
||||
meta: {
|
||||
present: summary.present,
|
||||
nights: summary.nights,
|
||||
events: summary.eventCount,
|
||||
classification: summary.classification ?? "none",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ ...summary, generatedAt: now.toISOString() });
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* v1.25 — health-status / baseline-drift read.
|
||||
*
|
||||
* GET /api/insights/health-status
|
||||
*
|
||||
* Surfaces what is drifting from the user's own normal, drawn off two existing
|
||||
* read-only engines — the personal-band deviations from the coincident-
|
||||
* deviation flag (`coincident-deviation.ts`) and the dated, sustained level
|
||||
* shifts from the changepoint detector (`changepoint.ts`). Pure compute over
|
||||
* the rollup tier: no provider call, no fabricated value, never a diagnosis.
|
||||
*
|
||||
* Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the
|
||||
* `insights` module gate, the shared analytics-read budget. `userId` is always
|
||||
* narrowed from the session. The labels are mapped client-side from the
|
||||
* MeasurementType, so the wire carries the type tokens only.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import { buildChangepointSignals } from "@/lib/insights/derived/changepoint";
|
||||
import { summariseHealthStatus } from "@/lib/insights/health-status";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
|
||||
const coincident = await computeCoincidentDeviation(user.id, profile, {
|
||||
tz: user.timezone,
|
||||
now,
|
||||
});
|
||||
const shifts = await buildChangepointSignals(user.id, now);
|
||||
|
||||
const vitals = coincident.status === "ok" ? coincident.value.vitals : [];
|
||||
const summary = summariseHealthStatus(vitals, shifts);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.health-status.read" },
|
||||
meta: {
|
||||
deviations: summary.deviations.length,
|
||||
shifts: summary.shifts.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
present: summary.present,
|
||||
deviations: summary.deviations,
|
||||
shifts: summary.shifts,
|
||||
generatedAt: now.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" read.
|
||||
*
|
||||
* GET /api/insights/labs-changes
|
||||
*
|
||||
* Groups the user's live numeric lab results by panel date, pairs the two
|
||||
* most-recent panels, and reports the per-analyte delta + reference-band
|
||||
* standing (`summariseLabChanges`). Absent when there are fewer than two
|
||||
* panels or no analyte is shared. Neutral framing only — a delta is not a
|
||||
* diagnosis.
|
||||
*
|
||||
* Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights`
|
||||
* module gate, the shared analytics-read budget. `userId` is narrowed from the
|
||||
* session; soft-deleted rows (`deletedAt`) are filtered out.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { summariseLabChanges } from "@/lib/insights/labs-changes";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: { userId: user.id, deletedAt: null, value: { not: null } },
|
||||
select: {
|
||||
analyte: true,
|
||||
unit: true,
|
||||
value: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
takenAt: true,
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
});
|
||||
|
||||
const summary = summariseLabChanges(
|
||||
rows.map((r) => ({
|
||||
analyte: r.analyte,
|
||||
unit: r.unit,
|
||||
value: r.value as number,
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
takenAt: r.takenAt,
|
||||
})),
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.labs-changes.read" },
|
||||
meta: { present: summary.present, changes: summary.changes.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ ...summary, generatedAt: new Date().toISOString() });
|
||||
});
|
||||
@@ -127,6 +127,42 @@ const RhythmEventsCard = dynamic(
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — hydration daily-goal ring. Always present (a goal ring at 0 / goal is
|
||||
// still useful); it owns its own today-total query and the goal editor.
|
||||
const HydrationCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/hydration-card").then((mod) => ({
|
||||
default: mod.HydrationCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own
|
||||
// read-only `/api/insights/health-status` query.
|
||||
const HealthStatusCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/health-status-card").then((mod) => ({
|
||||
default: mod.HealthStatusCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening
|
||||
// signal only, never a diagnosis.
|
||||
const BreathingScreeningCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/breathing-screening-card").then((mod) => ({
|
||||
default: mod.BreathingScreeningCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer
|
||||
// than two panels or no shared analyte.
|
||||
const LabsChangesCard = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/labs-changes-card").then((mod) => ({
|
||||
default: mod.LabsChangesCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from
|
||||
// a buried below-the-fold tile to the top-of-overview daily read (the
|
||||
// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it
|
||||
@@ -472,6 +508,10 @@ export default function InsightsPage() {
|
||||
) : null,
|
||||
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
|
||||
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
|
||||
hydration: <HydrationCard enabled={isAuthenticated} />,
|
||||
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
|
||||
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
|
||||
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
|
||||
};
|
||||
|
||||
const orderedSectionIds = orderedVisibleSectionIds(layout);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Wind } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import type {
|
||||
BreathingClassification,
|
||||
BreathingTrend,
|
||||
} from "@/lib/insights/breathing-screening";
|
||||
|
||||
/**
|
||||
* v1.25 — sleep-breathing screening card.
|
||||
*
|
||||
* A calm read off the read-only `/api/insights/breathing-screening` route: the
|
||||
* device's own classification, the recent index trend, and the device-flagged
|
||||
* event count. It un-mounts when there is no data (`present === false`). Every
|
||||
* render carries the mandatory screening-only disclaimer — this is a SCREENING
|
||||
* SIGNAL, not a diagnosis. Neutral tone, no chart, no red.
|
||||
*/
|
||||
|
||||
interface BreathingScreeningResponse {
|
||||
present: boolean;
|
||||
nights: number;
|
||||
recentMeanIndex: number | null;
|
||||
trend: BreathingTrend;
|
||||
eventCount: number;
|
||||
classification: BreathingClassification;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
interface BreathingScreeningCardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BreathingScreeningCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: BreathingScreeningCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsBreathingScreening(),
|
||||
queryFn: () =>
|
||||
apiGet<BreathingScreeningResponse>("/api/insights/breathing-screening"),
|
||||
enabled: enabled && isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!data || data.present === false) return null;
|
||||
|
||||
const classificationLabel =
|
||||
data.classification === "elevated"
|
||||
? t("insights.breathingScreening.classificationElevated")
|
||||
: data.classification === "not-elevated"
|
||||
? t("insights.breathingScreening.classificationNotElevated")
|
||||
: null;
|
||||
|
||||
const trendLabel =
|
||||
data.trend === "up"
|
||||
? t("insights.breathingScreening.trendUp")
|
||||
: data.trend === "down"
|
||||
? t("insights.breathingScreening.trendDown")
|
||||
: data.trend === "stable"
|
||||
? t("insights.breathingScreening.trendStable")
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="breathing-screening-section"
|
||||
aria-label={t("insights.breathingScreening.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Wind}
|
||||
title={t("insights.breathingScreening.sectionTitle")}
|
||||
subtitle={t("insights.breathingScreening.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="breathing-screening-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
{classificationLabel ? (
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="breathing-classification"
|
||||
>
|
||||
{classificationLabel}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.breathingScreening.nights", { count: data.nights })}
|
||||
{data.recentMeanIndex != null
|
||||
? ` · ${t("insights.breathingScreening.indexLine", {
|
||||
value: data.recentMeanIndex,
|
||||
})}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{trendLabel ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="breathing-trend"
|
||||
>
|
||||
{trendLabel}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{data.eventCount > 0 ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="breathing-events"
|
||||
>
|
||||
{t("insights.breathingScreening.eventsFlagged", {
|
||||
count: data.eventCount,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p
|
||||
className="text-muted-foreground mt-1 text-xs leading-snug"
|
||||
data-slot="breathing-disclaimer"
|
||||
>
|
||||
{t("insights.breathingScreening.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import type {
|
||||
HealthStatusDeviation,
|
||||
HealthStatusShift,
|
||||
} from "@/lib/insights/health-status";
|
||||
|
||||
/**
|
||||
* v1.25 — baseline-drift card.
|
||||
*
|
||||
* A calm read of what is drifting from the user's own normal, drawn off the
|
||||
* read-only `/api/insights/health-status` route (which folds the personal-band
|
||||
* deviations from the coincident engine with the dated changepoint shifts). The
|
||||
* card un-mounts entirely when nothing is drifting (`present === false`) rather
|
||||
* than surfacing an empty header. Awareness framing only — at most a neutral
|
||||
* tone, never a red alert, never a diagnosis.
|
||||
*/
|
||||
|
||||
interface HealthStatusResponse {
|
||||
present: boolean;
|
||||
deviations: HealthStatusDeviation[];
|
||||
shifts: HealthStatusShift[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
interface HealthStatusCardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HealthStatusCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: HealthStatusCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsHealthStatus(),
|
||||
queryFn: () => apiGet<HealthStatusResponse>("/api/insights/health-status"),
|
||||
enabled: enabled && isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!data || data.present === false) return null;
|
||||
|
||||
const vitalName = (type: string): string => {
|
||||
const key = MEASUREMENT_TYPE_LABEL_KEYS[type];
|
||||
return key ? t(key) : type;
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="health-status-section"
|
||||
aria-label={t("insights.healthStatus.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.healthStatus.sectionTitle")}
|
||||
subtitle={t("insights.healthStatus.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="health-status-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
{data.deviations.map((d) => (
|
||||
<div
|
||||
key={`dev-${d.type}`}
|
||||
className="flex items-start gap-2"
|
||||
data-slot="health-status-deviation"
|
||||
>
|
||||
{d.direction === "above" ? (
|
||||
<ArrowUpRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ArrowDownRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-foreground text-sm">
|
||||
{t(
|
||||
d.direction === "above"
|
||||
? "insights.healthStatus.deviationAbove"
|
||||
: "insights.healthStatus.deviationBelow",
|
||||
{ vital: vitalName(d.type) },
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.healthStatus.rangeContext", {
|
||||
value: d.value,
|
||||
low: d.low,
|
||||
high: d.high,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{data.shifts.map((s) => (
|
||||
<div
|
||||
key={`shift-${s.metric}`}
|
||||
className="flex items-start gap-2"
|
||||
data-slot="health-status-shift"
|
||||
>
|
||||
{s.direction === "up" ? (
|
||||
<ArrowUpRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ArrowDownRight
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-foreground text-sm">
|
||||
{t(
|
||||
s.direction === "up"
|
||||
? "insights.healthStatus.shiftUp"
|
||||
: "insights.healthStatus.shiftDown",
|
||||
{ vital: vitalName(s.metric) },
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.healthStatus.shiftContext", {
|
||||
before: s.beforeMean,
|
||||
after: s.afterMean,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +63,10 @@ const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
|
||||
"cycle-summary": "cycle.insightsSummary.title",
|
||||
signals: "insights.derived.coincident.cardTitle",
|
||||
"rhythm-events": "insights.rhythmEvents.sectionTitle",
|
||||
hydration: "hydration.cardTitle",
|
||||
"health-status": "insights.healthStatus.sectionTitle",
|
||||
breathing: "insights.breathingScreening.sectionTitle",
|
||||
"labs-changes": "insights.labsChanges.sectionTitle",
|
||||
};
|
||||
|
||||
interface InsightsEditModeProps {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
*
|
||||
* A neutral read off the read-only `/api/insights/labs-changes` route: for each
|
||||
* analyte present in both the latest and the previous panel, the latest value,
|
||||
* the signed delta, and where the latest value sits against its reference band.
|
||||
* It un-mounts when there are fewer than two panels or no shared analyte
|
||||
* (`present === false`). Never a diagnosis — neutral framing only.
|
||||
*/
|
||||
|
||||
interface LabsChangesResponse {
|
||||
present: boolean;
|
||||
latestDate: string | null;
|
||||
previousDate: string | null;
|
||||
changes: LabChange[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
interface LabsChangesCardProps {
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function statusLabelKey(status: LabChange["status"]): string | null {
|
||||
switch (status) {
|
||||
case "in-range":
|
||||
return "insights.labsChanges.statusInRange";
|
||||
case "below":
|
||||
return "insights.labsChanges.statusBelow";
|
||||
case "above":
|
||||
return "insights.labsChanges.statusAbove";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function LabsChangesCard({
|
||||
enabled = true,
|
||||
className,
|
||||
}: LabsChangesCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
queryFn: () => apiGet<LabsChangesResponse>("/api/insights/labs-changes"),
|
||||
enabled: enabled && isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!data || data.present === false) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="labs-changes-section"
|
||||
aria-label={t("insights.labsChanges.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={FlaskConical}
|
||||
title={t("insights.labsChanges.sectionTitle")}
|
||||
subtitle={t("insights.labsChanges.subtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="labs-changes-card"
|
||||
className="bg-card flex w-full min-w-0 flex-col divide-y rounded-xl border"
|
||||
>
|
||||
{data.changes.map((c) => {
|
||||
const labelKey = statusLabelKey(c.status);
|
||||
const signedDelta =
|
||||
c.direction === "flat"
|
||||
? t("insights.labsChanges.directionFlat")
|
||||
: `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim();
|
||||
return (
|
||||
<div
|
||||
key={c.analyte}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
data-slot="labs-change-row"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground truncate text-sm font-medium">
|
||||
{c.analyte}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{`${c.latest} ${c.unit}`.trim()}
|
||||
{labelKey ? ` · ${t(labelKey)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className="text-muted-foreground shrink-0 text-xs tabular-nums"
|
||||
data-slot="labs-change-delta"
|
||||
>
|
||||
{signedDelta}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "@/lib/insights-layout";
|
||||
|
||||
describe("insights-layout v2 — section id universe", () => {
|
||||
it("carries exactly the eight default sections in render order", () => {
|
||||
it("carries exactly the default sections in render order", () => {
|
||||
expect([...INSIGHTS_SECTION_IDS]).toEqual([
|
||||
"wellness-scores",
|
||||
"daily-briefing",
|
||||
@@ -30,6 +30,10 @@ describe("insights-layout v2 — section id universe", () => {
|
||||
"cycle-summary",
|
||||
"signals",
|
||||
"rhythm-events",
|
||||
"hydration",
|
||||
"health-status",
|
||||
"breathing",
|
||||
"labs-changes",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -113,6 +113,10 @@ export const INSIGHTS_SECTION_IDS = [
|
||||
"cycle-summary",
|
||||
"signals",
|
||||
"rhythm-events",
|
||||
"hydration",
|
||||
"health-status",
|
||||
"breathing",
|
||||
"labs-changes",
|
||||
] as const;
|
||||
|
||||
export type InsightsSectionId = (typeof INSIGHTS_SECTION_IDS)[number];
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
summariseBreathing,
|
||||
type BreathingRow,
|
||||
} from "@/lib/insights/breathing-screening";
|
||||
|
||||
function night(daysAgo: number, value: number): BreathingRow {
|
||||
return {
|
||||
value,
|
||||
measuredAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
describe("summariseBreathing", () => {
|
||||
it("is absent with no rows at all", () => {
|
||||
const s = summariseBreathing([], []);
|
||||
expect(s.present).toBe(false);
|
||||
expect(s.nights).toBe(0);
|
||||
expect(s.classification).toBeNull();
|
||||
expect(s.trend).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies not-elevated with index nights and no events", () => {
|
||||
const s = summariseBreathing([night(2, 3), night(1, 4)], []);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.nights).toBe(2);
|
||||
expect(s.recentMeanIndex).toBe(3.5);
|
||||
expect(s.classification).toBe("not-elevated");
|
||||
});
|
||||
|
||||
it("classifies elevated when the device flagged events", () => {
|
||||
const s = summariseBreathing([night(1, 5)], [night(1, 1)]);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.eventCount).toBe(1);
|
||||
expect(s.classification).toBe("elevated");
|
||||
});
|
||||
|
||||
it("computes an upward trend over enough nights", () => {
|
||||
const rows = [
|
||||
night(8, 2),
|
||||
night(7, 2),
|
||||
night(6, 2),
|
||||
night(5, 8),
|
||||
night(4, 8),
|
||||
night(3, 8),
|
||||
night(2, 8),
|
||||
night(1, 8),
|
||||
];
|
||||
const s = summariseBreathing(rows, []);
|
||||
expect(s.trend).toBe("up");
|
||||
});
|
||||
|
||||
it("leaves the trend null with too few nights", () => {
|
||||
const s = summariseBreathing([night(2, 4), night(1, 5)], []);
|
||||
expect(s.trend).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { summariseHealthStatus } from "@/lib/insights/health-status";
|
||||
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
|
||||
|
||||
function vital(
|
||||
outside: boolean,
|
||||
direction: VitalDeviation["direction"],
|
||||
): VitalDeviation {
|
||||
return {
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: 70,
|
||||
center: 60,
|
||||
low: 55,
|
||||
high: 65,
|
||||
outside,
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
function shift(): ChangepointSignal {
|
||||
return {
|
||||
metric: "WEIGHT",
|
||||
breakDate: "2026-06-10",
|
||||
beforeMean: 80,
|
||||
afterMean: 82,
|
||||
direction: "up",
|
||||
magnitude: 1.6,
|
||||
};
|
||||
}
|
||||
|
||||
describe("summariseHealthStatus", () => {
|
||||
it("is absent when nothing is outside and no shift fired", () => {
|
||||
const s = summariseHealthStatus([vital(false, "in")], []);
|
||||
expect(s.present).toBe(false);
|
||||
expect(s.deviations).toHaveLength(0);
|
||||
expect(s.shifts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces an out-of-band deviation", () => {
|
||||
const s = summariseHealthStatus([vital(true, "above")], []);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.deviations).toHaveLength(1);
|
||||
expect(s.deviations[0]).toMatchObject({
|
||||
type: "RESTING_HEART_RATE",
|
||||
direction: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a changepoint shift even with no deviation", () => {
|
||||
const s = summariseHealthStatus([vital(false, "in")], [shift()]);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.shifts).toHaveLength(1);
|
||||
expect(s.shifts[0]).toMatchObject({ metric: "WEIGHT", direction: "up" });
|
||||
});
|
||||
|
||||
it("drops a vital flagged outside but with an 'in' direction", () => {
|
||||
const s = summariseHealthStatus([vital(true, "in")], []);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
summariseLabChanges,
|
||||
type LabChangeRow,
|
||||
} from "@/lib/insights/labs-changes";
|
||||
|
||||
function row(
|
||||
analyte: string,
|
||||
value: number,
|
||||
day: string,
|
||||
opts: { unit?: string; low?: number | null; high?: number | null } = {},
|
||||
): LabChangeRow {
|
||||
return {
|
||||
analyte,
|
||||
unit: opts.unit ?? "mg/dL",
|
||||
value,
|
||||
referenceLow: opts.low ?? null,
|
||||
referenceHigh: opts.high ?? null,
|
||||
takenAt: new Date(`${day}T08:00:00.000Z`),
|
||||
};
|
||||
}
|
||||
|
||||
describe("summariseLabChanges", () => {
|
||||
it("is absent with no rows", () => {
|
||||
expect(summariseLabChanges([])).toMatchObject({ present: false });
|
||||
});
|
||||
|
||||
it("is absent with a single panel", () => {
|
||||
const s = summariseLabChanges([row("LDL", 130, "2026-06-01")]);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
|
||||
it("is absent when no analyte is shared across panels", () => {
|
||||
const s = summariseLabChanges([
|
||||
row("LDL", 130, "2026-06-01"),
|
||||
row("HbA1c", 5.4, "2026-05-01"),
|
||||
]);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
|
||||
it("pairs the two most-recent panels for a shared analyte", () => {
|
||||
const s = summariseLabChanges([
|
||||
row("LDL", 120, "2026-06-01", { high: 116 }),
|
||||
row("LDL", 140, "2026-05-01"),
|
||||
row("LDL", 150, "2026-04-01"),
|
||||
]);
|
||||
expect(s.present).toBe(true);
|
||||
expect(s.latestDate).toBe("2026-06-01");
|
||||
expect(s.previousDate).toBe("2026-05-01");
|
||||
expect(s.changes).toHaveLength(1);
|
||||
const c = s.changes[0];
|
||||
expect(c.latest).toBe(120);
|
||||
expect(c.previous).toBe(140);
|
||||
expect(c.delta).toBe(-20);
|
||||
expect(c.direction).toBe("down");
|
||||
expect(c.status).toBe("above");
|
||||
});
|
||||
|
||||
it("skips qualitative (non-finite) values", () => {
|
||||
const s = summariseLabChanges([
|
||||
row("LDL", Number.NaN, "2026-06-01"),
|
||||
row("LDL", 140, "2026-05-01"),
|
||||
]);
|
||||
expect(s.present).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* v1.25 — breathing-disturbance screening shaping.
|
||||
*
|
||||
* Reads the per-night sleep-breathing-disturbance index
|
||||
* (`BREATHING_DISTURBANCES`, lower-better) and the device-flagged
|
||||
* breathing-disturbance / possible-apnea events (`BREATHING_DISTURBANCE_EVENT`)
|
||||
* a sync writes, and folds them into a calm awareness summary: how many nights
|
||||
* are covered, the recent mean index, the short-window trend, and the device's
|
||||
* own classification.
|
||||
*
|
||||
* This is a SCREENING SIGNAL ONLY — never a HealthLog diagnosis. The card built
|
||||
* on top of this states that explicitly. The classification mirrors the device:
|
||||
* a night the device flagged with one or more elevated-breathing events reads
|
||||
* as "elevated"; with index nights and no flagged event it reads as
|
||||
* "not-elevated". HealthLog invents no numeric threshold of its own.
|
||||
*
|
||||
* The math lives here, free of Prisma, so the present / absent / trend states
|
||||
* are unit-testable in isolation.
|
||||
*/
|
||||
|
||||
/** A single per-night reading (index) or a device-flagged event row. */
|
||||
export interface BreathingRow {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
export type BreathingTrend = "up" | "down" | "stable" | null;
|
||||
export type BreathingClassification = "not-elevated" | "elevated" | null;
|
||||
|
||||
export interface BreathingScreeningSummary {
|
||||
/** True when there is any index night or any flagged event to surface. */
|
||||
present: boolean;
|
||||
/** Number of nights with a per-night index reading. */
|
||||
nights: number;
|
||||
/** Mean of the index readings (lower-better), rounded to 1dp; null when none. */
|
||||
recentMeanIndex: number | null;
|
||||
/**
|
||||
* Direction of the recent index vs the prior window. "up" is worse (more
|
||||
* disturbance). Null when there are too few nights to compare.
|
||||
*/
|
||||
trend: BreathingTrend;
|
||||
/** Count of device-flagged breathing-disturbance / apnea events. */
|
||||
eventCount: number;
|
||||
/**
|
||||
* The device's own classification: "elevated" when it flagged events,
|
||||
* "not-elevated" when there are index nights and no flagged event, null when
|
||||
* there is no data at all.
|
||||
*/
|
||||
classification: BreathingClassification;
|
||||
}
|
||||
|
||||
/** Window (most-recent nights) the trend compares against the prior block. */
|
||||
const TREND_WINDOW_NIGHTS = 7;
|
||||
/** Need at least this many nights total before a trend is meaningful. */
|
||||
const MIN_TREND_NIGHTS = 6;
|
||||
/** Relative band within which the recent mean reads as "stable". */
|
||||
const STABLE_BAND = 0.1;
|
||||
|
||||
function mean(xs: readonly number[]): number {
|
||||
if (xs.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const x of xs) sum += x;
|
||||
return sum / xs.length;
|
||||
}
|
||||
|
||||
const round1 = (n: number): number => Math.round(n * 10) / 10;
|
||||
|
||||
/**
|
||||
* Summarise the breathing-screening signal. `indexRows` are the per-night
|
||||
* index readings; `eventRows` are the device-flagged events. Order-independent
|
||||
* — the helper sorts by `measuredAt` for the trend.
|
||||
*/
|
||||
export function summariseBreathing(
|
||||
indexRows: readonly BreathingRow[],
|
||||
eventRows: readonly BreathingRow[],
|
||||
): BreathingScreeningSummary {
|
||||
const sorted = [...indexRows]
|
||||
.filter((r) => Number.isFinite(r.value))
|
||||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
|
||||
const nights = sorted.length;
|
||||
const eventCount = eventRows.length;
|
||||
|
||||
if (nights === 0 && eventCount === 0) {
|
||||
return {
|
||||
present: false,
|
||||
nights: 0,
|
||||
recentMeanIndex: null,
|
||||
trend: null,
|
||||
eventCount: 0,
|
||||
classification: null,
|
||||
};
|
||||
}
|
||||
|
||||
const values = sorted.map((r) => r.value);
|
||||
const recentMeanIndex = nights > 0 ? round1(mean(values)) : null;
|
||||
|
||||
let trend: BreathingTrend = null;
|
||||
if (nights >= MIN_TREND_NIGHTS) {
|
||||
const recent = values.slice(-TREND_WINDOW_NIGHTS);
|
||||
const prior = values.slice(0, -TREND_WINDOW_NIGHTS);
|
||||
if (recent.length > 0 && prior.length > 0) {
|
||||
const recentMean = mean(recent);
|
||||
const priorMean = mean(prior);
|
||||
const band = Math.abs(priorMean) * STABLE_BAND;
|
||||
const delta = recentMean - priorMean;
|
||||
trend = Math.abs(delta) <= band ? "stable" : delta > 0 ? "up" : "down";
|
||||
}
|
||||
}
|
||||
|
||||
const classification: BreathingClassification =
|
||||
eventCount > 0 ? "elevated" : nights > 0 ? "not-elevated" : null;
|
||||
|
||||
return {
|
||||
present: true,
|
||||
nights,
|
||||
recentMeanIndex,
|
||||
trend,
|
||||
eventCount,
|
||||
classification,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* v1.25 — health-status / baseline-drift shaping.
|
||||
*
|
||||
* Surfaces what is drifting from the user's own normal by combining two
|
||||
* existing read-only engines:
|
||||
* - the personal-band deviations from the coincident-deviation flag
|
||||
* (`coincident-deviation.ts`, which fans `VITALS_BASELINE` across vitals),
|
||||
* keeping only the vitals that sit OUTSIDE their band today, and
|
||||
* - the dated, sustained level shifts from the changepoint detector
|
||||
* (`changepoint.ts`).
|
||||
*
|
||||
* This module owns only the pure reshaping — taking the engine outputs and
|
||||
* folding them into the flat response the card reads — so the present / absent
|
||||
* states are unit-testable without Prisma. It never labels a cause and never
|
||||
* diagnoses; it lists what changed and frames it as awareness only.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
|
||||
|
||||
/** One vital sitting outside its personal band today. */
|
||||
export interface HealthStatusDeviation {
|
||||
type: MeasurementType;
|
||||
/** Today's value. */
|
||||
value: number;
|
||||
/** Band center (median). */
|
||||
center: number;
|
||||
low: number;
|
||||
high: number;
|
||||
/** Which side of the band the value falls on. */
|
||||
direction: "above" | "below";
|
||||
}
|
||||
|
||||
/** One dated, sustained level shift from the changepoint detector. */
|
||||
export interface HealthStatusShift {
|
||||
metric: MeasurementType;
|
||||
/** YYYY-MM-DD of the first day of the new level. */
|
||||
breakDate: string;
|
||||
beforeMean: number;
|
||||
afterMean: number;
|
||||
direction: "up" | "down";
|
||||
}
|
||||
|
||||
export interface HealthStatusSummary {
|
||||
/** True when there is at least one deviation or one shift to surface. */
|
||||
present: boolean;
|
||||
deviations: HealthStatusDeviation[];
|
||||
shifts: HealthStatusShift[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the band deviations + level shifts into the response shape. `vitals` is
|
||||
* the full banded set from the coincident engine (any standing); only the
|
||||
* out-of-band ones become deviations. `shifts` is the changepoint output as-is.
|
||||
*/
|
||||
export function summariseHealthStatus(
|
||||
vitals: readonly VitalDeviation[],
|
||||
shifts: readonly ChangepointSignal[],
|
||||
): HealthStatusSummary {
|
||||
const deviations: HealthStatusDeviation[] = vitals
|
||||
.filter((v) => v.outside && v.direction !== "in")
|
||||
.map((v) => ({
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
center: v.center,
|
||||
low: v.low,
|
||||
high: v.high,
|
||||
direction: v.direction === "above" ? "above" : "below",
|
||||
}));
|
||||
|
||||
const mappedShifts: HealthStatusShift[] = shifts.map((s) => ({
|
||||
metric: s.metric,
|
||||
breakDate: s.breakDate,
|
||||
beforeMean: s.beforeMean,
|
||||
afterMean: s.afterMean,
|
||||
direction: s.direction,
|
||||
}));
|
||||
|
||||
return {
|
||||
present: deviations.length + mappedShifts.length > 0,
|
||||
deviations,
|
||||
shifts: mappedShifts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" shaping.
|
||||
*
|
||||
* Groups the user's numeric lab results by panel DATE (the calendar day of
|
||||
* `takenAt`, UTC), finds the two most-recent dates, and for every analyte that
|
||||
* appears in BOTH the latest and the previous panel reports the signed delta,
|
||||
* the direction, and where the latest value sits against its reference band
|
||||
* (`classifyReferenceRange`). Qualitative (valueText-only) results carry no
|
||||
* numeric value, so they are skipped — a delta is meaningless for them.
|
||||
*
|
||||
* Pure + Prisma-free so the present / absent states are unit-testable: it is
|
||||
* absent when there are fewer than two panel dates or no analyte is shared.
|
||||
* Neutral framing only — a delta is not a diagnosis.
|
||||
*/
|
||||
import {
|
||||
classifyReferenceRange,
|
||||
type ReferenceRangeStatus,
|
||||
} from "@/lib/labs/reference-range";
|
||||
|
||||
/** A single numeric lab reading. */
|
||||
export interface LabChangeRow {
|
||||
analyte: string;
|
||||
unit: string;
|
||||
value: number;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
takenAt: Date;
|
||||
}
|
||||
|
||||
export interface LabChange {
|
||||
analyte: string;
|
||||
unit: string;
|
||||
latest: number;
|
||||
previous: number;
|
||||
/** Signed latest − previous, rounded to 2dp. */
|
||||
delta: number;
|
||||
direction: "up" | "down" | "flat";
|
||||
status: ReferenceRangeStatus;
|
||||
}
|
||||
|
||||
export interface LabChangesSummary {
|
||||
present: boolean;
|
||||
/** YYYY-MM-DD of the most-recent panel, or null when absent. */
|
||||
latestDate: string | null;
|
||||
/** YYYY-MM-DD of the prior panel, or null when absent. */
|
||||
previousDate: string | null;
|
||||
changes: LabChange[];
|
||||
}
|
||||
|
||||
const ABSENT: LabChangesSummary = {
|
||||
present: false,
|
||||
latestDate: null,
|
||||
previousDate: null,
|
||||
changes: [],
|
||||
};
|
||||
|
||||
/** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */
|
||||
function dayKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Normalised analyte name for cross-panel matching (free-text tolerant). */
|
||||
function analyteKey(analyte: string): string {
|
||||
return analyte.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Summarise the change between the two most-recent numeric lab panels. `rows`
|
||||
* should already be the user's live (non-deleted) numeric results; this helper
|
||||
* does the grouping + pairing.
|
||||
*/
|
||||
export function summariseLabChanges(
|
||||
rows: readonly LabChangeRow[],
|
||||
): LabChangesSummary {
|
||||
const numeric = rows.filter((r) => Number.isFinite(r.value));
|
||||
if (numeric.length === 0) return ABSENT;
|
||||
|
||||
// Group by panel day, keeping the latest reading per analyte within a day.
|
||||
const byDay = new Map<string, Map<string, LabChangeRow>>();
|
||||
for (const row of numeric) {
|
||||
const day = dayKey(row.takenAt);
|
||||
let analytes = byDay.get(day);
|
||||
if (!analytes) {
|
||||
analytes = new Map();
|
||||
byDay.set(day, analytes);
|
||||
}
|
||||
const key = analyteKey(row.analyte);
|
||||
const existing = analytes.get(key);
|
||||
if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) {
|
||||
analytes.set(key, row);
|
||||
}
|
||||
}
|
||||
|
||||
const days = [...byDay.keys()].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
|
||||
if (days.length < 2) return ABSENT;
|
||||
|
||||
const latestDate = days[0];
|
||||
const previousDate = days[1];
|
||||
const latest = byDay.get(latestDate)!;
|
||||
const previous = byDay.get(previousDate)!;
|
||||
|
||||
const changes: LabChange[] = [];
|
||||
for (const [key, latestRow] of latest) {
|
||||
const previousRow = previous.get(key);
|
||||
if (!previousRow) continue;
|
||||
const delta = round2(latestRow.value - previousRow.value);
|
||||
const direction: LabChange["direction"] =
|
||||
delta > 0 ? "up" : delta < 0 ? "down" : "flat";
|
||||
changes.push({
|
||||
analyte: latestRow.analyte,
|
||||
unit: latestRow.unit,
|
||||
latest: latestRow.value,
|
||||
previous: previousRow.value,
|
||||
delta,
|
||||
direction,
|
||||
status: classifyReferenceRange(
|
||||
latestRow.value,
|
||||
latestRow.referenceLow,
|
||||
latestRow.referenceHigh,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (changes.length === 0) return ABSENT;
|
||||
|
||||
changes.sort((a, b) => a.analyte.localeCompare(b.analyte));
|
||||
|
||||
return { present: true, latestDate, previousDate, changes };
|
||||
}
|
||||
@@ -36,9 +36,11 @@ import { biomarkerPaths } from "./biomarkers";
|
||||
import { devicePaths } from "./devices";
|
||||
import { exportPaths } from "./export";
|
||||
import { healthRecordPaths } from "./health-record";
|
||||
import { hydrationPaths } from "./hydration";
|
||||
import { illnessPaths } from "./illness";
|
||||
import { importPaths } from "./import";
|
||||
import { insightsPaths } from "./insights";
|
||||
import { insightsSignalPaths } from "./insights-signals";
|
||||
import { labsPaths } from "./labs";
|
||||
import { ocrPaths } from "./ocr";
|
||||
import { measurementPaths } from "./measurements";
|
||||
@@ -72,6 +74,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...coachFeedbackPaths,
|
||||
...adminDiagnosticPaths,
|
||||
...insightsPaths,
|
||||
...insightsSignalPaths,
|
||||
...moodPaths,
|
||||
...settingsPaths,
|
||||
...consentPaths,
|
||||
@@ -82,6 +85,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...biomarkerPaths,
|
||||
...illnessPaths,
|
||||
...onboardingPaths,
|
||||
...hydrationPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* OpenAPI route module — v1.25 read-only insight signals.
|
||||
*
|
||||
* Three calm awareness reads built on existing engines:
|
||||
* - GET /api/insights/health-status baseline-drift (bands + changepoints)
|
||||
* - GET /api/insights/breathing-screening sleep-breathing screening signal
|
||||
* - GET /api/insights/labs-changes "what changed since your last panel"
|
||||
*
|
||||
* Each is pure compute over the rollup / lab tier — no provider call. Labels
|
||||
* are resolved client-side from the type tokens, so the wire carries tokens
|
||||
* only. Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
const healthStatusResponse = z
|
||||
.object({
|
||||
present: z
|
||||
.boolean()
|
||||
.describe("True when at least one deviation or shift is surfaced."),
|
||||
deviations: z.array(
|
||||
z.object({
|
||||
type: z.string().describe("MeasurementType token."),
|
||||
value: z.number().describe("Today's value."),
|
||||
center: z.number().describe("Personal band center (median)."),
|
||||
low: z.number(),
|
||||
high: z.number(),
|
||||
direction: z.enum(["above", "below"]),
|
||||
}),
|
||||
),
|
||||
shifts: z.array(
|
||||
z.object({
|
||||
metric: z.string().describe("MeasurementType token."),
|
||||
breakDate: z
|
||||
.string()
|
||||
.describe("YYYY-MM-DD of the new level's first day."),
|
||||
beforeMean: z.number(),
|
||||
afterMean: z.number(),
|
||||
direction: z.enum(["up", "down"]),
|
||||
}),
|
||||
),
|
||||
generatedAt: z.string().describe("ISO-8601 instant the read was computed."),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsHealthStatus",
|
||||
description:
|
||||
"Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. Awareness only, never a diagnosis.",
|
||||
});
|
||||
|
||||
const breathingScreeningResponse = z
|
||||
.object({
|
||||
present: z.boolean(),
|
||||
nights: z.number().int().describe("Nights with a per-night index reading."),
|
||||
recentMeanIndex: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("Mean of the index readings (lower-better); null when none."),
|
||||
trend: z
|
||||
.enum(["up", "down", "stable"])
|
||||
.nullable()
|
||||
.describe("Recent index vs the prior window; null when too few nights."),
|
||||
eventCount: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Device-flagged breathing-disturbance / apnea events."),
|
||||
classification: z
|
||||
.enum(["not-elevated", "elevated"])
|
||||
.nullable()
|
||||
.describe("The device's own classification; null when no data."),
|
||||
generatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsBreathingScreening",
|
||||
description:
|
||||
"Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis.",
|
||||
});
|
||||
|
||||
const labsChangesResponse = z
|
||||
.object({
|
||||
present: z.boolean(),
|
||||
latestDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("YYYY-MM-DD of the most-recent panel."),
|
||||
previousDate: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("YYYY-MM-DD of the prior panel."),
|
||||
changes: z.array(
|
||||
z.object({
|
||||
analyte: z.string(),
|
||||
unit: z.string(),
|
||||
latest: z.number(),
|
||||
previous: z.number(),
|
||||
delta: z.number().describe("Signed latest − previous."),
|
||||
direction: z.enum(["up", "down", "flat"]),
|
||||
status: z.enum(["in-range", "below", "above", "unknown"]),
|
||||
}),
|
||||
),
|
||||
generatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsLabsChanges",
|
||||
description:
|
||||
"Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.",
|
||||
});
|
||||
|
||||
export const insightsSignalPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/insights/health-status": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Baseline-drift health status",
|
||||
description:
|
||||
"Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The drift summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthStatusResponse,
|
||||
"InsightsHealthStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/breathing-screening": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Sleep-breathing screening signal",
|
||||
description:
|
||||
"Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights module. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The breathing-screening summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
breathingScreeningResponse,
|
||||
"InsightsBreathingScreeningEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/labs-changes": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "What changed since your last lab panel",
|
||||
description:
|
||||
"Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, never a diagnosis. Cookie or Bearer auth.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The lab-change summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
labsChangesResponse,
|
||||
"InsightsLabsChangesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -137,4 +137,29 @@ export const insightsKeys = {
|
||||
*/
|
||||
insightsCoachRead: (metric: string) =>
|
||||
["insights", "coach-read", metric] as const,
|
||||
|
||||
/**
|
||||
* v1.25 — baseline-drift read (`/api/insights/health-status`). Combines the
|
||||
* personal-band deviations + the dated changepoint shifts into one awareness
|
||||
* card. Pure compute over the rollup tier; the `["insights"]` prefix keeps it
|
||||
* in the standard invalidation fan-out (a measurement write busts it).
|
||||
*/
|
||||
insightsHealthStatus: () => ["insights", "health-status"] as const,
|
||||
|
||||
/**
|
||||
* v1.25 — breathing-disturbance screening read
|
||||
* (`/api/insights/breathing-screening`). Last ~30 nights of the per-night
|
||||
* index + device-flagged events. The `["insights"]` prefix carries the
|
||||
* invalidation fan-out from an Apple Health sleep batch write.
|
||||
*/
|
||||
insightsBreathingScreening: () =>
|
||||
["insights", "breathing-screening"] as const,
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" read
|
||||
* (`/api/insights/labs-changes`). The two most-recent numeric lab panels'
|
||||
* shared-analyte deltas. The `["insights"]` prefix keeps it in the standard
|
||||
* invalidation fan-out.
|
||||
*/
|
||||
insightsLabsChanges: () => ["insights", "labs-changes"] as const,
|
||||
};
|
||||
Reference in new issue
Block a user