mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'feat/v125-clinical-signals' into release/v1.25.0
This commit is contained in:
commit
d5f31b5b5f
35 files changed
+2810
-20
No files matched your search
@@ -2087,6 +2087,12 @@ paths:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
- in: query
|
||||
name: from
|
||||
schema:
|
||||
@@ -2276,6 +2282,12 @@ paths:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
value:
|
||||
type: number
|
||||
measuredAt:
|
||||
@@ -5770,6 +5782,12 @@ paths:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
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."
|
||||
@@ -7992,6 +8010,95 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mental-health/assessments:
|
||||
get:
|
||||
tags:
|
||||
- Mental health
|
||||
summary: List the caller's screener history
|
||||
description: Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are
|
||||
never returned.
|
||||
parameters:
|
||||
- in: query
|
||||
name: instrument
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
responses:
|
||||
"200":
|
||||
description: Screener history.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
tags:
|
||||
- Mental health
|
||||
summary: Record a completed PHQ-9 / GAD-7 screener
|
||||
description: Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE /
|
||||
GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
instrument:
|
||||
type: string
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
items:
|
||||
minItems: 1
|
||||
maxItems: 12
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 3
|
||||
functionalDifficulty:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 3
|
||||
takenAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
|
||||
tz:
|
||||
type: string
|
||||
maxLength: 64
|
||||
locale:
|
||||
type: string
|
||||
maxLength: 16
|
||||
required:
|
||||
- instrument
|
||||
- items
|
||||
responses:
|
||||
"201":
|
||||
description: Assessment recorded.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/onboarding/disclaimer:
|
||||
post:
|
||||
tags:
|
||||
@@ -12353,6 +12460,12 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -12669,6 +12782,12 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
additionalProperties:
|
||||
type: array
|
||||
items:
|
||||
@@ -12746,6 +12865,12 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
value:
|
||||
type: number
|
||||
description: Daily mean (spot metrics) or SUM (cumulative).
|
||||
@@ -12844,6 +12969,12 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
description: Per-type daily series, keyed by MeasurementType.
|
||||
required:
|
||||
- series
|
||||
@@ -13077,6 +13208,12 @@ components:
|
||||
- SLEEP_SCORE
|
||||
- BODY_TEMPERATURE_DEVIATION
|
||||
- RESILIENCE
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
- GRIP_STRENGTH
|
||||
- PAIN_NRS
|
||||
- WAIST_CIRCUMFERENCE
|
||||
- WAIST_TO_HEIGHT
|
||||
value:
|
||||
type: number
|
||||
unit:
|
||||
@@ -23363,6 +23500,137 @@ components:
|
||||
(median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, a
|
||||
per-type breakdown, and the dominant driving vital (`gapDriverType`, null when no gap is surfaced).
|
||||
Retrospective only."
|
||||
ListMentalHealthAssessmentsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/ListMentalHealthAssessmentsResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ListMentalHealthAssessmentsResponse:
|
||||
type: object
|
||||
properties:
|
||||
assessments:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MentalHealthAssessment"
|
||||
required:
|
||||
- assessments
|
||||
additionalProperties: false
|
||||
MentalHealthAssessment:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
instrument:
|
||||
type: string
|
||||
enum:
|
||||
- PHQ9
|
||||
- GAD7
|
||||
locale:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
totalScore:
|
||||
type: number
|
||||
severityBand:
|
||||
type: string
|
||||
item9Flagged:
|
||||
type: boolean
|
||||
crisisShownAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
takenAt:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- instrument
|
||||
- locale
|
||||
- version
|
||||
- totalScore
|
||||
- severityBand
|
||||
- item9Flagged
|
||||
- crisisShownAt
|
||||
- takenAt
|
||||
- createdAt
|
||||
additionalProperties: false
|
||||
description: One completed screener administration. `severityBand` is a descriptive label for the screen (never a
|
||||
diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis
|
||||
resources when it is. Raw per-item answers are intentionally absent.
|
||||
CreateMentalHealthAssessmentEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CreateMentalHealthAssessmentResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CreateMentalHealthAssessmentResponse:
|
||||
type: object
|
||||
properties:
|
||||
assessment:
|
||||
$ref: "#/components/schemas/MentalHealthAssessment"
|
||||
actionThreshold:
|
||||
type: number
|
||||
crisis:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/CrisisResourceSet"
|
||||
- type: "null"
|
||||
required:
|
||||
- assessment
|
||||
- actionThreshold
|
||||
- crisis
|
||||
additionalProperties: false
|
||||
CrisisResourceSet:
|
||||
type: object
|
||||
properties:
|
||||
emergencyNumber:
|
||||
type: string
|
||||
resources:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CrisisResource"
|
||||
required:
|
||||
- emergencyNumber
|
||||
- resources
|
||||
additionalProperties: false
|
||||
description: Locale-aware crisis-resource signposting, present only when item-9 is flagged. Names resolve via i18n on
|
||||
the client; contacts are literal.
|
||||
CrisisResource:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
contacts:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- contacts
|
||||
additionalProperties: false
|
||||
DisclaimerAckEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+130
-3
@@ -575,7 +575,13 @@
|
||||
"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.",
|
||||
"typePhq9Score": "PHQ-9-Wert",
|
||||
"typeGad7Score": "GAD-7-Wert",
|
||||
"typeGripStrength": "Griffstärke",
|
||||
"typePainNrs": "Schmerz (0–10)",
|
||||
"typeWaistCircumference": "Taillenumfang",
|
||||
"typeWaistToHeight": "Taille-Größe-Verhältnis"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
@@ -7090,8 +7096,16 @@
|
||||
"hemoglobin": "Das sauerstofftransportierende Protein in den roten Blutkörperchen.",
|
||||
"hematocrit": "Der Anteil der roten Blutkörperchen am Blutvolumen.",
|
||||
"wbc": "Gesamtzahl der weißen Blutkörperchen, der zirkulierenden Abwehr des Immunsystems.",
|
||||
"platelets": "Zellfragmente, die sich zusammenlagern und die Blutgerinnung bewirken."
|
||||
}
|
||||
"platelets": "Zellfragmente, die sich zusammenlagern und die Blutgerinnung bewirken.",
|
||||
"apob": "Zählt atherogene Partikel direkt; oft ein besserer Risikomarker als LDL-C, wenn beide auseinandergehen.",
|
||||
"lp-a": "Ein weitgehend vererbter, lebenslanger Risikofaktor — meist einmal gemessen. In molaren Einheiten (nmol/L) angeben.",
|
||||
"fasting-insulin": "Ergänzt den Nüchtern-Blutzucker zur Abschätzung der Insulinresistenz (HOMA-IR).",
|
||||
"omega-3-index": "Anteil von EPA+DHA in den roten Blutkörperchen; 8–12 % gelten als kardioprotektiver Bereich."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
"fasting-insulin": "Nüchtern-Insulin",
|
||||
"omega-3-index": "Omega-3-Index"
|
||||
},
|
||||
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
|
||||
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
|
||||
@@ -7426,5 +7440,118 @@
|
||||
"reload": "Neu laden",
|
||||
"dismiss": "Schließen"
|
||||
}
|
||||
},
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Check-in zum seelischen Wohlbefinden",
|
||||
"pageDescription": "Zwei validierte, freiwillige Selbsttests — PHQ-9 (Stimmung) und GAD-7 (Sorgen) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
|
||||
"disclaimer": "Dies ist ein Screening-Selbsttest, keine Diagnose. Ein hoher oder niedriger Wert bedeutet nicht, dass eine Erkrankung vorliegt oder nicht. Nur eine qualifizierte Fachperson kann eine Diagnose stellen. Bei Sorgen sprich bitte mit deiner Ärztin oder deinem Arzt.",
|
||||
"optInNote": "Freiwillig und privat: Deine einzelnen Antworten sind verschlüsselt, bleiben aus dem KI-Coach und aus Exporten heraus und werden nie geteilt.",
|
||||
"attributionLabel": "Quelle",
|
||||
"choosePrompt": "Check-in auswählen",
|
||||
"start": "Starten",
|
||||
"back": "Zurück",
|
||||
"stem": "Wie oft fühltest du dich im Verlauf der letzten 2 Wochen durch die folgenden Beschwerden beeinträchtigt?",
|
||||
"submit": "Ergebnis ansehen",
|
||||
"submitting": "Wird gespeichert…",
|
||||
"saved": "Gespeichert",
|
||||
"error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
|
||||
"considerProfessional": "Dein Wert liegt in einem Bereich, in dem ein Gespräch mit einer Fachperson helfen kann. Das ist ein behutsamer Hinweis, keine Diagnose.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 — Stimmung",
|
||||
"gad7": "GAD-7 — Sorgen"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Depressions-Screening, 9 Fragen",
|
||||
"gad7": "Angst-Screening, 7 Fragen"
|
||||
},
|
||||
"options": {
|
||||
"0": "Überhaupt nicht",
|
||||
"1": "An einzelnen Tagen",
|
||||
"2": "An mehr als der Hälfte der Tage",
|
||||
"3": "Beinahe jeden Tag"
|
||||
},
|
||||
"functionalTitle": "Wenn du eine oder mehrere Beschwerden angegeben hast: Wie sehr haben diese deine Arbeit, deinen Haushalt oder den Umgang mit anderen Menschen erschwert?",
|
||||
"functional": {
|
||||
"0": "Überhaupt nicht erschwert",
|
||||
"1": "Etwas erschwert",
|
||||
"2": "Sehr erschwert",
|
||||
"3": "Extrem erschwert"
|
||||
},
|
||||
"result": {
|
||||
"title": "Dein Ergebnis",
|
||||
"totalLabel": "Gesamtwert",
|
||||
"bandLabel": "Bereich für dieses Screening",
|
||||
"takeAnother": "Weiteren Check-in machen"
|
||||
},
|
||||
"band": {
|
||||
"PHQ9": {
|
||||
"minimal": "Keine / minimal",
|
||||
"mild": "Leicht",
|
||||
"moderate": "Mittelgradig",
|
||||
"modSevere": "Mittelschwer",
|
||||
"severe": "Schwer"
|
||||
},
|
||||
"GAD7": {
|
||||
"minimal": "Keine / minimal",
|
||||
"mild": "Leicht",
|
||||
"moderate": "Mittelgradig",
|
||||
"severe": "Schwer"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Dein Verlauf",
|
||||
"empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Du musst das nicht allein durchstehen",
|
||||
"intro": "Du hast Gedanken angegeben, lieber tot zu sein oder dir etwas anzutun. Es gibt jetzt sofort Unterstützung — sich zu melden ist ein Zeichen von Stärke.",
|
||||
"ifDanger": "Wenn du in unmittelbarer Gefahr bist, ruf jetzt {emergency} an.",
|
||||
"resourcesTitle": "Menschen, die helfen können"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Wenig Interesse oder Freude an Ihren Tätigkeiten",
|
||||
"2": "Niedergeschlagenheit, Schwermut oder Hoffnungslosigkeit",
|
||||
"3": "Schwierigkeiten, ein- oder durchzuschlafen, oder vermehrter Schlaf",
|
||||
"4": "Müdigkeit oder Gefühl, keine Energie zu haben",
|
||||
"5": "Verminderter Appetit oder übermäßiges Bedürfnis zu essen",
|
||||
"6": "Schlechte Meinung von sich selbst; Gefühl, ein Versager zu sein oder die Familie enttäuscht zu haben",
|
||||
"7": "Schwierigkeiten, sich auf etwas zu konzentrieren, z. B. beim Zeitunglesen oder Fernsehen",
|
||||
"8": "Waren Ihre Bewegungen oder Ihre Sprache so verlangsamt, dass es auch anderen auffallen würde? Oder waren Sie im Gegenteil „zappelig“ oder ruhelos und hatten einen stärkeren Bewegungsdrang als sonst?",
|
||||
"9": "Gedanken, dass Sie lieber tot wären oder sich Leid zufügen möchten"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Nervosität, Ängstlichkeit oder Anspannung",
|
||||
"2": "Nicht in der Lage sein, Sorgen zu stoppen oder zu kontrollieren",
|
||||
"3": "Übermäßige Sorgen bezüglich verschiedener Angelegenheiten",
|
||||
"4": "Schwierigkeiten zu entspannen",
|
||||
"5": "Rastlosigkeit, sodass Stillsitzen schwerfällt",
|
||||
"6": "Schnelle Verärgerung oder Gereiztheit",
|
||||
"7": "Gefühl der Angst, als würde etwas Schlimmes passieren"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (rund um die Uhr, kostenlos, vertraulich)"
|
||||
},
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (für junge Menschen)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7-Chat, junge Menschen)"
|
||||
},
|
||||
"findahelpline": {
|
||||
"name": "Find a Helpline (weltweites Verzeichnis)"
|
||||
},
|
||||
"euEmotionalSupport": {
|
||||
"name": "Telefonseelsorge-Hotline"
|
||||
},
|
||||
"lifeline988": {
|
||||
"name": "988 Suicide & Crisis Lifeline"
|
||||
},
|
||||
"crisisTextLine": {
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-3
@@ -575,7 +575,13 @@
|
||||
"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.",
|
||||
"typePhq9Score": "PHQ-9 score",
|
||||
"typeGad7Score": "GAD-7 score",
|
||||
"typeGripStrength": "Grip strength",
|
||||
"typePainNrs": "Pain (0–10)",
|
||||
"typeWaistCircumference": "Waist circumference",
|
||||
"typeWaistToHeight": "Waist-to-height ratio"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
@@ -7090,8 +7096,16 @@
|
||||
"hemoglobin": "The oxygen-carrying protein in red blood cells.",
|
||||
"hematocrit": "The fraction of blood volume made up of red blood cells.",
|
||||
"wbc": "Total count of white blood cells, the immune system's circulating defence.",
|
||||
"platelets": "Cell fragments that clump together to form blood clots."
|
||||
}
|
||||
"platelets": "Cell fragments that clump together to form blood clots.",
|
||||
"apob": "Counts atherogenic particles directly; often a better risk marker than LDL-C when the two disagree.",
|
||||
"lp-a": "A largely inherited, lifelong risk factor — usually measured once. Report molar units (nmol/L).",
|
||||
"fasting-insulin": "Pairs with fasting glucose to estimate insulin resistance (HOMA-IR).",
|
||||
"omega-3-index": "Red-blood-cell EPA+DHA share; 8–12% is the commonly cited cardioprotective range."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
"fasting-insulin": "Fasting insulin",
|
||||
"omega-3-index": "Omega-3 index"
|
||||
},
|
||||
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
|
||||
"showingLatestOf": "Showing the latest {shown} of {total} readings",
|
||||
@@ -7426,5 +7440,118 @@
|
||||
"reload": "Reload",
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
},
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Mental wellbeing check-in",
|
||||
"pageDescription": "Two validated, opt-in self-assessments — PHQ-9 (mood) and GAD-7 (worry) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
|
||||
"disclaimer": "This is a screening self-assessment, not a diagnosis. A high or low score does not mean you do or do not have a condition. Only a qualified health professional can diagnose. If you have concerns, please talk to your doctor.",
|
||||
"optInNote": "Opt-in and private: your individual answers are encrypted, kept out of the AI Coach and exports, and never shared.",
|
||||
"attributionLabel": "Attribution",
|
||||
"choosePrompt": "Choose a check-in",
|
||||
"start": "Start",
|
||||
"back": "Back",
|
||||
"stem": "Over the last 2 weeks, how often have you been bothered by the following problems?",
|
||||
"submit": "See my result",
|
||||
"submitting": "Saving…",
|
||||
"saved": "Saved",
|
||||
"error": "Something went wrong. Please try again.",
|
||||
"considerProfessional": "Your score is in a range where talking to a health professional may help. This is a gentle suggestion, not a diagnosis.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 — mood",
|
||||
"gad7": "GAD-7 — worry"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Depression screen, 9 questions",
|
||||
"gad7": "Anxiety screen, 7 questions"
|
||||
},
|
||||
"options": {
|
||||
"0": "Not at all",
|
||||
"1": "Several days",
|
||||
"2": "More than half the days",
|
||||
"3": "Nearly every day"
|
||||
},
|
||||
"functionalTitle": "If you checked off any problems, how difficult have these made it to do your work, take care of things at home, or get along with other people?",
|
||||
"functional": {
|
||||
"0": "Not difficult at all",
|
||||
"1": "Somewhat difficult",
|
||||
"2": "Very difficult",
|
||||
"3": "Extremely difficult"
|
||||
},
|
||||
"result": {
|
||||
"title": "Your result",
|
||||
"totalLabel": "Total score",
|
||||
"bandLabel": "Range for this screen",
|
||||
"takeAnother": "Take another check-in"
|
||||
},
|
||||
"band": {
|
||||
"PHQ9": {
|
||||
"minimal": "None / minimal",
|
||||
"mild": "Mild",
|
||||
"moderate": "Moderate",
|
||||
"modSevere": "Moderately severe",
|
||||
"severe": "Severe"
|
||||
},
|
||||
"GAD7": {
|
||||
"minimal": "None / minimal",
|
||||
"mild": "Mild",
|
||||
"moderate": "Moderate",
|
||||
"severe": "Severe"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Your history",
|
||||
"empty": "No check-ins yet. Your trend will appear here once you complete one."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "You don't have to face this alone",
|
||||
"intro": "You indicated thoughts of being better off dead or of hurting yourself. Support is available right now, and reaching out is a sign of strength.",
|
||||
"ifDanger": "If you are in immediate danger, call {emergency} now.",
|
||||
"resourcesTitle": "People who can help"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Little interest or pleasure in doing things",
|
||||
"2": "Feeling down, depressed, or hopeless",
|
||||
"3": "Trouble falling or staying asleep, or sleeping too much",
|
||||
"4": "Feeling tired or having little energy",
|
||||
"5": "Poor appetite or overeating",
|
||||
"6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
|
||||
"7": "Trouble concentrating on things, such as reading the newspaper or watching television",
|
||||
"8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
|
||||
"9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Feeling nervous, anxious, or on edge",
|
||||
"2": "Not being able to stop or control worrying",
|
||||
"3": "Worrying too much about different things",
|
||||
"4": "Trouble relaxing",
|
||||
"5": "Being so restless that it is hard to sit still",
|
||||
"6": "Becoming easily annoyed or irritable",
|
||||
"7": "Feeling afraid as if something awful might happen"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, free, confidential)"
|
||||
},
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (for young people)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7 chat, young people)"
|
||||
},
|
||||
"findahelpline": {
|
||||
"name": "Find a helpline (worldwide directory)"
|
||||
},
|
||||
"euEmotionalSupport": {
|
||||
"name": "Emotional-support helpline"
|
||||
},
|
||||
"lifeline988": {
|
||||
"name": "988 Suicide & Crisis Lifeline"
|
||||
},
|
||||
"crisisTextLine": {
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-3
@@ -575,7 +575,13 @@
|
||||
"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.",
|
||||
"typePhq9Score": "Puntuación PHQ-9",
|
||||
"typeGad7Score": "Puntuación GAD-7",
|
||||
"typeGripStrength": "Fuerza de agarre",
|
||||
"typePainNrs": "Dolor (0–10)",
|
||||
"typeWaistCircumference": "Perímetro de cintura",
|
||||
"typeWaistToHeight": "Índice cintura-altura"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
@@ -7090,8 +7096,16 @@
|
||||
"hemoglobin": "La proteína de los glóbulos rojos que transporta el oxígeno.",
|
||||
"hematocrit": "La fracción del volumen sanguíneo formada por glóbulos rojos.",
|
||||
"wbc": "Recuento total de glóbulos blancos, la defensa circulante del sistema inmunitario.",
|
||||
"platelets": "Fragmentos celulares que se agregan para formar coágulos sanguíneos."
|
||||
}
|
||||
"platelets": "Fragmentos celulares que se agregan para formar coágulos sanguíneos.",
|
||||
"apob": "Cuenta directamente las partículas aterogénicas; a menudo mejor marcador de riesgo que el LDL-C cuando discrepan.",
|
||||
"lp-a": "Factor de riesgo en gran parte hereditario y de por vida — suele medirse una vez. Informa en unidades molares (nmol/L).",
|
||||
"fasting-insulin": "Se combina con la glucosa en ayunas para estimar la resistencia a la insulina (HOMA-IR).",
|
||||
"omega-3-index": "Proporción de EPA+DHA en glóbulos rojos; 8–12 % es el rango cardioprotector citado habitualmente."
|
||||
},
|
||||
"apob": "Apolipoproteína B (ApoB)",
|
||||
"lp-a": "Lipoproteína(a)",
|
||||
"fasting-insulin": "Insulina en ayunas",
|
||||
"omega-3-index": "Índice omega-3"
|
||||
},
|
||||
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
|
||||
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
|
||||
@@ -7426,5 +7440,118 @@
|
||||
"reload": "Recargar",
|
||||
"dismiss": "Descartar"
|
||||
}
|
||||
},
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Revisión de bienestar mental",
|
||||
"pageDescription": "Dos autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo) y GAD-7 (preocupación) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
|
||||
"disclaimer": "Esto es una autoevaluación de cribado, no un diagnóstico. Una puntuación alta o baja no significa que tengas o no una afección. Solo un profesional de salud cualificado puede diagnosticar. Si tienes dudas, habla con tu médico.",
|
||||
"optInNote": "Opcional y privado: tus respuestas individuales están cifradas, se mantienen fuera del Coach de IA y de las exportaciones, y nunca se comparten.",
|
||||
"attributionLabel": "Fuente",
|
||||
"choosePrompt": "Elige una revisión",
|
||||
"start": "Empezar",
|
||||
"back": "Atrás",
|
||||
"stem": "Durante las últimas 2 semanas, ¿con qué frecuencia te han molestado los siguientes problemas?",
|
||||
"submit": "Ver mi resultado",
|
||||
"submitting": "Guardando…",
|
||||
"saved": "Guardado",
|
||||
"error": "Algo salió mal. Inténtalo de nuevo.",
|
||||
"considerProfessional": "Tu puntuación está en un rango en el que hablar con un profesional de salud puede ayudar. Es una sugerencia amable, no un diagnóstico.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 — ánimo",
|
||||
"gad7": "GAD-7 — preocupación"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Cribado de depresión, 9 preguntas",
|
||||
"gad7": "Cribado de ansiedad, 7 preguntas"
|
||||
},
|
||||
"options": {
|
||||
"0": "Nunca",
|
||||
"1": "Varios días",
|
||||
"2": "Más de la mitad de los días",
|
||||
"3": "Casi todos los días"
|
||||
},
|
||||
"functionalTitle": "Si marcaste algún problema, ¿cuánta dificultad te han causado para trabajar, ocuparte de la casa o relacionarte con otras personas?",
|
||||
"functional": {
|
||||
"0": "Ninguna dificultad",
|
||||
"1": "Algo de dificultad",
|
||||
"2": "Mucha dificultad",
|
||||
"3": "Dificultad extrema"
|
||||
},
|
||||
"result": {
|
||||
"title": "Tu resultado",
|
||||
"totalLabel": "Puntuación total",
|
||||
"bandLabel": "Rango para este cribado",
|
||||
"takeAnother": "Hacer otra revisión"
|
||||
},
|
||||
"band": {
|
||||
"PHQ9": {
|
||||
"minimal": "Ninguna / mínima",
|
||||
"mild": "Leve",
|
||||
"moderate": "Moderada",
|
||||
"modSevere": "Moderadamente grave",
|
||||
"severe": "Grave"
|
||||
},
|
||||
"GAD7": {
|
||||
"minimal": "Ninguna / mínima",
|
||||
"mild": "Leve",
|
||||
"moderate": "Moderada",
|
||||
"severe": "Grave"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Tu historial",
|
||||
"empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "No tienes que afrontar esto a solas",
|
||||
"intro": "Indicaste pensamientos de estar mejor muerto o de hacerte daño. Hay apoyo disponible ahora mismo, y pedir ayuda es una señal de fortaleza.",
|
||||
"ifDanger": "Si estás en peligro inmediato, llama ahora al {emergency}.",
|
||||
"resourcesTitle": "Personas que pueden ayudar"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Little interest or pleasure in doing things",
|
||||
"2": "Feeling down, depressed, or hopeless",
|
||||
"3": "Trouble falling or staying asleep, or sleeping too much",
|
||||
"4": "Feeling tired or having little energy",
|
||||
"5": "Poor appetite or overeating",
|
||||
"6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
|
||||
"7": "Trouble concentrating on things, such as reading the newspaper or watching television",
|
||||
"8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
|
||||
"9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Feeling nervous, anxious, or on edge",
|
||||
"2": "Not being able to stop or control worrying",
|
||||
"3": "Worrying too much about different things",
|
||||
"4": "Trouble relaxing",
|
||||
"5": "Being so restless that it is hard to sit still",
|
||||
"6": "Becoming easily annoyed or irritable",
|
||||
"7": "Feeling afraid as if something awful might happen"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, free, confidential)"
|
||||
},
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (for young people)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7 chat, young people)"
|
||||
},
|
||||
"findahelpline": {
|
||||
"name": "Find a helpline (worldwide directory)"
|
||||
},
|
||||
"euEmotionalSupport": {
|
||||
"name": "Emotional-support helpline"
|
||||
},
|
||||
"lifeline988": {
|
||||
"name": "988 Suicide & Crisis Lifeline"
|
||||
},
|
||||
"crisisTextLine": {
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-3
@@ -575,7 +575,13 @@
|
||||
"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.",
|
||||
"typePhq9Score": "Score PHQ-9",
|
||||
"typeGad7Score": "Score GAD-7",
|
||||
"typeGripStrength": "Force de préhension",
|
||||
"typePainNrs": "Douleur (0–10)",
|
||||
"typeWaistCircumference": "Tour de taille",
|
||||
"typeWaistToHeight": "Rapport taille-taille"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
@@ -7090,8 +7096,16 @@
|
||||
"hemoglobin": "La protéine des globules rouges qui transporte l'oxygène.",
|
||||
"hematocrit": "La fraction du volume sanguin constituée de globules rouges.",
|
||||
"wbc": "Nombre total de globules blancs, la défense circulante du système immunitaire.",
|
||||
"platelets": "Fragments cellulaires qui s'agrègent pour former des caillots sanguins."
|
||||
}
|
||||
"platelets": "Fragments cellulaires qui s'agrègent pour former des caillots sanguins.",
|
||||
"apob": "Compte directement les particules athérogènes ; souvent un meilleur marqueur de risque que le LDL-C en cas de discordance.",
|
||||
"lp-a": "Facteur de risque en grande partie héréditaire et permanent — mesuré une seule fois en général. À exprimer en unités molaires (nmol/L).",
|
||||
"fasting-insulin": "Se combine à la glycémie à jeun pour estimer la résistance à l'insuline (HOMA-IR).",
|
||||
"omega-3-index": "Part d'EPA+DHA dans les globules rouges ; 8–12 % est la plage cardioprotectrice habituellement citée."
|
||||
},
|
||||
"apob": "Apolipoprotéine B (ApoB)",
|
||||
"lp-a": "Lipoprotéine(a)",
|
||||
"fasting-insulin": "Insuline à jeun",
|
||||
"omega-3-index": "Indice oméga-3"
|
||||
},
|
||||
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
|
||||
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
|
||||
@@ -7426,5 +7440,118 @@
|
||||
"reload": "Recharger",
|
||||
"dismiss": "Ignorer"
|
||||
}
|
||||
},
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Point sur le bien-être mental",
|
||||
"pageDescription": "Deux auto-évaluations validées et facultatives — PHQ-9 (humeur) et GAD-7 (inquiétude) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
|
||||
"disclaimer": "Il s'agit d'une auto-évaluation de dépistage, pas d'un diagnostic. Un score élevé ou bas ne signifie pas que tu as ou n'as pas un trouble. Seul un professionnel de santé qualifié peut poser un diagnostic. En cas de doute, parles-en à ton médecin.",
|
||||
"optInNote": "Facultatif et privé : tes réponses individuelles sont chiffrées, tenues à l'écart du Coach IA et des exports, et ne sont jamais partagées.",
|
||||
"attributionLabel": "Source",
|
||||
"choosePrompt": "Choisis un bilan",
|
||||
"start": "Commencer",
|
||||
"back": "Retour",
|
||||
"stem": "Au cours des 2 dernières semaines, à quelle fréquence as-tu été gêné(e) par les problèmes suivants ?",
|
||||
"submit": "Voir mon résultat",
|
||||
"submitting": "Enregistrement…",
|
||||
"saved": "Enregistré",
|
||||
"error": "Une erreur est survenue. Réessaie.",
|
||||
"considerProfessional": "Ton score se situe dans une plage où parler à un professionnel de santé peut aider. C'est une suggestion bienveillante, pas un diagnostic.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 — humeur",
|
||||
"gad7": "GAD-7 — inquiétude"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Dépistage de la dépression, 9 questions",
|
||||
"gad7": "Dépistage de l'anxiété, 7 questions"
|
||||
},
|
||||
"options": {
|
||||
"0": "Jamais",
|
||||
"1": "Plusieurs jours",
|
||||
"2": "Plus de la moitié des jours",
|
||||
"3": "Presque tous les jours"
|
||||
},
|
||||
"functionalTitle": "Si tu as coché des problèmes, à quel point t'ont-ils gêné(e) dans ton travail, à la maison ou dans tes relations avec les autres ?",
|
||||
"functional": {
|
||||
"0": "Pas du tout gênant",
|
||||
"1": "Un peu gênant",
|
||||
"2": "Très gênant",
|
||||
"3": "Extrêmement gênant"
|
||||
},
|
||||
"result": {
|
||||
"title": "Ton résultat",
|
||||
"totalLabel": "Score total",
|
||||
"bandLabel": "Plage pour ce dépistage",
|
||||
"takeAnother": "Faire un autre bilan"
|
||||
},
|
||||
"band": {
|
||||
"PHQ9": {
|
||||
"minimal": "Aucune / minimale",
|
||||
"mild": "Légère",
|
||||
"moderate": "Modérée",
|
||||
"modSevere": "Modérément sévère",
|
||||
"severe": "Sévère"
|
||||
},
|
||||
"GAD7": {
|
||||
"minimal": "Aucune / minimale",
|
||||
"mild": "Légère",
|
||||
"moderate": "Modérée",
|
||||
"severe": "Sévère"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Ton historique",
|
||||
"empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Tu n'as pas à affronter cela seul(e)",
|
||||
"intro": "Tu as indiqué des pensées qu'il vaudrait mieux être mort(e) ou de te faire du mal. De l'aide est disponible dès maintenant, et demander de l'aide est un signe de force.",
|
||||
"ifDanger": "Si tu es en danger immédiat, appelle le {emergency} maintenant.",
|
||||
"resourcesTitle": "Des personnes qui peuvent aider"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Little interest or pleasure in doing things",
|
||||
"2": "Feeling down, depressed, or hopeless",
|
||||
"3": "Trouble falling or staying asleep, or sleeping too much",
|
||||
"4": "Feeling tired or having little energy",
|
||||
"5": "Poor appetite or overeating",
|
||||
"6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
|
||||
"7": "Trouble concentrating on things, such as reading the newspaper or watching television",
|
||||
"8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
|
||||
"9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Feeling nervous, anxious, or on edge",
|
||||
"2": "Not being able to stop or control worrying",
|
||||
"3": "Worrying too much about different things",
|
||||
"4": "Trouble relaxing",
|
||||
"5": "Being so restless that it is hard to sit still",
|
||||
"6": "Becoming easily annoyed or irritable",
|
||||
"7": "Feeling afraid as if something awful might happen"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, free, confidential)"
|
||||
},
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (for young people)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7 chat, young people)"
|
||||
},
|
||||
"findahelpline": {
|
||||
"name": "Find a helpline (worldwide directory)"
|
||||
},
|
||||
"euEmotionalSupport": {
|
||||
"name": "Emotional-support helpline"
|
||||
},
|
||||
"lifeline988": {
|
||||
"name": "988 Suicide & Crisis Lifeline"
|
||||
},
|
||||
"crisisTextLine": {
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-3
@@ -575,7 +575,13 @@
|
||||
"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.",
|
||||
"typePhq9Score": "Punteggio PHQ-9",
|
||||
"typeGad7Score": "Punteggio GAD-7",
|
||||
"typeGripStrength": "Forza di presa",
|
||||
"typePainNrs": "Dolore (0–10)",
|
||||
"typeWaistCircumference": "Circonferenza vita",
|
||||
"typeWaistToHeight": "Rapporto vita-altezza"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
@@ -7090,8 +7096,16 @@
|
||||
"hemoglobin": "La proteina dei globuli rossi che trasporta l'ossigeno.",
|
||||
"hematocrit": "La frazione del volume sanguigno costituita da globuli rossi.",
|
||||
"wbc": "Conteggio totale dei globuli bianchi, la difesa circolante del sistema immunitario.",
|
||||
"platelets": "Frammenti cellulari che si aggregano per formare coaguli di sangue."
|
||||
}
|
||||
"platelets": "Frammenti cellulari che si aggregano per formare coaguli di sangue.",
|
||||
"apob": "Conta direttamente le particelle aterogene; spesso un marcatore di rischio migliore del LDL-C quando i due discordano.",
|
||||
"lp-a": "Fattore di rischio in gran parte ereditario e permanente — di solito misurato una volta. Riportare in unità molari (nmol/L).",
|
||||
"fasting-insulin": "Si abbina alla glicemia a digiuno per stimare la resistenza insulinica (HOMA-IR).",
|
||||
"omega-3-index": "Quota di EPA+DHA nei globuli rossi; 8–12% è l'intervallo cardioprotettivo comunemente citato."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
"fasting-insulin": "Insulina a digiuno",
|
||||
"omega-3-index": "Indice omega-3"
|
||||
},
|
||||
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
|
||||
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
|
||||
@@ -7426,5 +7440,118 @@
|
||||
"reload": "Ricarica",
|
||||
"dismiss": "Ignora"
|
||||
}
|
||||
},
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Check-in sul benessere mentale",
|
||||
"pageDescription": "Due autovalutazioni validate e facoltative — PHQ-9 (umore) e GAD-7 (preoccupazione) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
|
||||
"disclaimer": "Questa è un'autovalutazione di screening, non una diagnosi. Un punteggio alto o basso non significa che tu abbia o non abbia un disturbo. Solo un professionista sanitario qualificato può fare una diagnosi. In caso di dubbi, parlane con il tuo medico.",
|
||||
"optInNote": "Facoltativo e privato: le tue risposte individuali sono cifrate, tenute fuori dal Coach IA e dalle esportazioni, e non vengono mai condivise.",
|
||||
"attributionLabel": "Fonte",
|
||||
"choosePrompt": "Scegli un check-in",
|
||||
"start": "Inizia",
|
||||
"back": "Indietro",
|
||||
"stem": "Nelle ultime 2 settimane, con quale frequenza ti hanno disturbato i seguenti problemi?",
|
||||
"submit": "Vedi il mio risultato",
|
||||
"submitting": "Salvataggio…",
|
||||
"saved": "Salvato",
|
||||
"error": "Qualcosa è andato storto. Riprova.",
|
||||
"considerProfessional": "Il tuo punteggio è in un intervallo in cui parlare con un professionista sanitario può aiutare. È un suggerimento gentile, non una diagnosi.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 — umore",
|
||||
"gad7": "GAD-7 — preoccupazione"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Screening della depressione, 9 domande",
|
||||
"gad7": "Screening dell'ansia, 7 domande"
|
||||
},
|
||||
"options": {
|
||||
"0": "Per niente",
|
||||
"1": "Alcuni giorni",
|
||||
"2": "Più della metà dei giorni",
|
||||
"3": "Quasi ogni giorno"
|
||||
},
|
||||
"functionalTitle": "Se hai indicato dei problemi, quanto ti hanno reso difficile lavorare, occuparti della casa o andare d'accordo con gli altri?",
|
||||
"functional": {
|
||||
"0": "Per niente difficile",
|
||||
"1": "Un po' difficile",
|
||||
"2": "Molto difficile",
|
||||
"3": "Estremamente difficile"
|
||||
},
|
||||
"result": {
|
||||
"title": "Il tuo risultato",
|
||||
"totalLabel": "Punteggio totale",
|
||||
"bandLabel": "Intervallo per questo screening",
|
||||
"takeAnother": "Fai un altro check-in"
|
||||
},
|
||||
"band": {
|
||||
"PHQ9": {
|
||||
"minimal": "Nessuna / minima",
|
||||
"mild": "Lieve",
|
||||
"moderate": "Moderata",
|
||||
"modSevere": "Moderatamente grave",
|
||||
"severe": "Grave"
|
||||
},
|
||||
"GAD7": {
|
||||
"minimal": "Nessuna / minima",
|
||||
"mild": "Lieve",
|
||||
"moderate": "Moderata",
|
||||
"severe": "Grave"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "La tua cronologia",
|
||||
"empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Non devi affrontare tutto questo da solo/a",
|
||||
"intro": "Hai indicato pensieri che sarebbe meglio essere morto/a o di farti del male. Un supporto è disponibile proprio ora, e chiedere aiuto è un segno di forza.",
|
||||
"ifDanger": "Se sei in pericolo immediato, chiama subito il {emergency}.",
|
||||
"resourcesTitle": "Persone che possono aiutare"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Little interest or pleasure in doing things",
|
||||
"2": "Feeling down, depressed, or hopeless",
|
||||
"3": "Trouble falling or staying asleep, or sleeping too much",
|
||||
"4": "Feeling tired or having little energy",
|
||||
"5": "Poor appetite or overeating",
|
||||
"6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
|
||||
"7": "Trouble concentrating on things, such as reading the newspaper or watching television",
|
||||
"8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
|
||||
"9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Feeling nervous, anxious, or on edge",
|
||||
"2": "Not being able to stop or control worrying",
|
||||
"3": "Worrying too much about different things",
|
||||
"4": "Trouble relaxing",
|
||||
"5": "Being so restless that it is hard to sit still",
|
||||
"6": "Becoming easily annoyed or irritable",
|
||||
"7": "Feeling afraid as if something awful might happen"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, free, confidential)"
|
||||
},
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (for young people)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7 chat, young people)"
|
||||
},
|
||||
"findahelpline": {
|
||||
"name": "Find a helpline (worldwide directory)"
|
||||
},
|
||||
"euEmotionalSupport": {
|
||||
"name": "Emotional-support helpline"
|
||||
},
|
||||
"lifeline988": {
|
||||
"name": "988 Suicide & Crisis Lifeline"
|
||||
},
|
||||
"crisisTextLine": {
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-3
@@ -575,7 +575,13 @@
|
||||
"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.",
|
||||
"typePhq9Score": "Wynik PHQ-9",
|
||||
"typeGad7Score": "Wynik GAD-7",
|
||||
"typeGripStrength": "Siła chwytu",
|
||||
"typePainNrs": "Ból (0–10)",
|
||||
"typeWaistCircumference": "Obwód talii",
|
||||
"typeWaistToHeight": "Stosunek talii do wzrostu"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
@@ -7090,8 +7096,16 @@
|
||||
"hemoglobin": "Białko czerwonych krwinek transportujące tlen.",
|
||||
"hematocrit": "Udział czerwonych krwinek w objętości krwi.",
|
||||
"wbc": "Całkowita liczba białych krwinek, krążącej obrony układu odpornościowego.",
|
||||
"platelets": "Fragmenty komórkowe, które zlepiają się, tworząc skrzepy krwi."
|
||||
}
|
||||
"platelets": "Fragmenty komórkowe, które zlepiają się, tworząc skrzepy krwi.",
|
||||
"apob": "Bezpośrednio liczy cząstki aterogenne; często lepszy marker ryzyka niż LDL-C, gdy się różnią.",
|
||||
"lp-a": "W dużej mierze dziedziczny, dożywotni czynnik ryzyka — zwykle mierzony raz. Podawaj w jednostkach molowych (nmol/L).",
|
||||
"fasting-insulin": "Łączy się z glukozą na czczo do oszacowania insulinooporności (HOMA-IR).",
|
||||
"omega-3-index": "Udział EPA+DHA w czerwonych krwinkach; 8–12% to powszechnie podawany zakres kardioprotekcyjny."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
"fasting-insulin": "Insulina na czczo",
|
||||
"omega-3-index": "Indeks omega-3"
|
||||
},
|
||||
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
|
||||
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
|
||||
@@ -7426,5 +7440,118 @@
|
||||
"reload": "Załaduj ponownie",
|
||||
"dismiss": "Odrzuć"
|
||||
}
|
||||
},
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Sprawdzenie dobrostanu psychicznego",
|
||||
"pageDescription": "Dwa zwalidowane, dobrowolne testy — PHQ-9 (nastrój) i GAD-7 (lęk) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
|
||||
"disclaimer": "To przesiewowa samoocena, a nie diagnoza. Wysoki lub niski wynik nie oznacza, że masz lub nie masz danego schorzenia. Tylko wykwalifikowany pracownik ochrony zdrowia może postawić diagnozę. W razie wątpliwości porozmawiaj z lekarzem.",
|
||||
"optInNote": "Dobrowolne i prywatne: Twoje poszczególne odpowiedzi są szyfrowane, pozostają poza Coachem AI i eksportami i nigdy nie są udostępniane.",
|
||||
"attributionLabel": "Źródło",
|
||||
"choosePrompt": "Wybierz sprawdzenie",
|
||||
"start": "Rozpocznij",
|
||||
"back": "Wstecz",
|
||||
"stem": "Jak często w ciągu ostatnich 2 tygodni dokuczały Ci następujące problemy?",
|
||||
"submit": "Zobacz mój wynik",
|
||||
"submitting": "Zapisywanie…",
|
||||
"saved": "Zapisano",
|
||||
"error": "Coś poszło nie tak. Spróbuj ponownie.",
|
||||
"considerProfessional": "Twój wynik mieści się w zakresie, w którym rozmowa ze specjalistą może pomóc. To życzliwa sugestia, a nie diagnoza.",
|
||||
"instrument": {
|
||||
"phq9": "PHQ-9 — nastrój",
|
||||
"gad7": "GAD-7 — lęk"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Przesiew depresji, 9 pytań",
|
||||
"gad7": "Przesiew lęku, 7 pytań"
|
||||
},
|
||||
"options": {
|
||||
"0": "Wcale",
|
||||
"1": "Kilka dni",
|
||||
"2": "Więcej niż połowę dni",
|
||||
"3": "Niemal codziennie"
|
||||
},
|
||||
"functionalTitle": "Jeśli zaznaczyłeś(-aś) jakiekolwiek problemy, na ile utrudniły Ci one pracę, prowadzenie domu lub kontakty z innymi ludźmi?",
|
||||
"functional": {
|
||||
"0": "Wcale nie utrudniły",
|
||||
"1": "Trochę utrudniły",
|
||||
"2": "Bardzo utrudniły",
|
||||
"3": "Skrajnie utrudniły"
|
||||
},
|
||||
"result": {
|
||||
"title": "Twój wynik",
|
||||
"totalLabel": "Wynik łączny",
|
||||
"bandLabel": "Zakres dla tego przesiewu",
|
||||
"takeAnother": "Wykonaj kolejne sprawdzenie"
|
||||
},
|
||||
"band": {
|
||||
"PHQ9": {
|
||||
"minimal": "Brak / minimalny",
|
||||
"mild": "Łagodny",
|
||||
"moderate": "Umiarkowany",
|
||||
"modSevere": "Umiarkowanie ciężki",
|
||||
"severe": "Ciężki"
|
||||
},
|
||||
"GAD7": {
|
||||
"minimal": "Brak / minimalny",
|
||||
"mild": "Łagodny",
|
||||
"moderate": "Umiarkowany",
|
||||
"severe": "Ciężki"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "Twoja historia",
|
||||
"empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Nie musisz mierzyć się z tym sam(a)",
|
||||
"intro": "Wskazałeś(-aś) myśli, że lepiej byłoby nie żyć lub zrobić sobie krzywdę. Wsparcie jest dostępne już teraz, a sięgnięcie po pomoc to oznaka siły.",
|
||||
"ifDanger": "Jeśli grozi Ci bezpośrednie niebezpieczeństwo, zadzwoń teraz pod {emergency}.",
|
||||
"resourcesTitle": "Osoby, które mogą pomóc"
|
||||
},
|
||||
"items": {
|
||||
"phq9": {
|
||||
"1": "Little interest or pleasure in doing things",
|
||||
"2": "Feeling down, depressed, or hopeless",
|
||||
"3": "Trouble falling or staying asleep, or sleeping too much",
|
||||
"4": "Feeling tired or having little energy",
|
||||
"5": "Poor appetite or overeating",
|
||||
"6": "Feeling bad about yourself — or that you are a failure or have let yourself or your family down",
|
||||
"7": "Trouble concentrating on things, such as reading the newspaper or watching television",
|
||||
"8": "Moving or speaking so slowly that other people could have noticed. Or the opposite — being so fidgety or restless that you have been moving around a lot more than usual",
|
||||
"9": "Thoughts that you would be better off dead, or of hurting yourself in some way"
|
||||
},
|
||||
"gad7": {
|
||||
"1": "Feeling nervous, anxious, or on edge",
|
||||
"2": "Not being able to stop or control worrying",
|
||||
"3": "Worrying too much about different things",
|
||||
"4": "Trouble relaxing",
|
||||
"5": "Being so restless that it is hard to sit still",
|
||||
"6": "Becoming easily annoyed or irritable",
|
||||
"7": "Feeling afraid as if something awful might happen"
|
||||
}
|
||||
},
|
||||
"crisisResource": {
|
||||
"telefonSeelsorge": {
|
||||
"name": "TelefonSeelsorge (24/7, free, confidential)"
|
||||
},
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (for young people)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7 chat, young people)"
|
||||
},
|
||||
"findahelpline": {
|
||||
"name": "Find a helpline (worldwide directory)"
|
||||
},
|
||||
"euEmotionalSupport": {
|
||||
"name": "Emotional-support helpline"
|
||||
},
|
||||
"lifeline988": {
|
||||
"name": "988 Suicide & Crisis Lifeline"
|
||||
},
|
||||
"crisisTextLine": {
|
||||
"name": "Crisis Text Line"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
-- v1.25 — clinical-signals wave: validated mental-health screeners (PHQ-9 + GAD-7).
|
||||
--
|
||||
-- Adds the two derived total-score MeasurementType members and the
|
||||
-- `mental_health_assessments` table that holds one completed administration per
|
||||
-- row. The per-item answers ride a single AES-256-GCM `bytea` blob
|
||||
-- (`responses_encrypted`); only the derived total / band / item-9 flag are
|
||||
-- stored in the clear, for cheap history reads. Opt-in, beside mood tracking.
|
||||
|
||||
-- New MeasurementType members for the derived scores. ADD VALUE IF NOT EXISTS is
|
||||
-- idempotent and not used within this migration, so the in-transaction guard
|
||||
-- does not apply.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'PHQ9_SCORE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'GAD7_SCORE';
|
||||
|
||||
-- Instrument discriminator.
|
||||
CREATE TYPE "assessment_instrument" AS ENUM ('PHQ9', 'GAD7');
|
||||
|
||||
CREATE TABLE "mental_health_assessments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"instrument" "assessment_instrument" NOT NULL,
|
||||
"locale" TEXT NOT NULL,
|
||||
"version" TEXT NOT NULL DEFAULT 'standard',
|
||||
"responses_encrypted" BYTEA NOT NULL,
|
||||
"total_score" INTEGER NOT NULL,
|
||||
"severity_band" TEXT NOT NULL,
|
||||
"item9_flagged" BOOLEAN NOT NULL DEFAULT false,
|
||||
"crisis_shown_at" TIMESTAMP(3),
|
||||
"taken_at" TIMESTAMP(3) NOT NULL,
|
||||
"tz" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'WEB',
|
||||
"external_id" TEXT,
|
||||
"sync_version" INTEGER NOT NULL DEFAULT 0,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "mental_health_assessments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "mental_health_assessments_user_id_source_external_id_key" ON "mental_health_assessments"("user_id", "source", "external_id");
|
||||
CREATE INDEX "mental_health_assessments_user_id_instrument_taken_at_idx" ON "mental_health_assessments"("user_id", "instrument", "taken_at");
|
||||
CREATE INDEX "mental_health_assessments_user_id_updated_at_id_idx" ON "mental_health_assessments"("user_id", "updated_at", "id");
|
||||
|
||||
ALTER TABLE "mental_health_assessments" ADD CONSTRAINT "mental_health_assessments_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- v1.25 — clinical-signals wave: physical / clinical measurement types.
|
||||
--
|
||||
-- Grip strength (EWGSOP2 sarcopenia limb), pain Numeric Rating Scale (0–10),
|
||||
-- waist circumference (WHO STEPS), and the waist-to-height ratio (WHtR). Each
|
||||
-- is a first-class numeric Measurement type; bands/units are applied at the
|
||||
-- display edge from the signal registry. ADD VALUE IF NOT EXISTS is idempotent.
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'GRIP_STRENGTH';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'PAIN_NRS';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WAIST_CIRCUMFERENCE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WAIST_TO_HEIGHT';
|
||||
@@ -608,6 +608,7 @@ model User {
|
||||
fitbitConnection FitbitConnection?
|
||||
fitbitOAuthStates FitbitOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
mentalHealthAssessments MentalHealthAssessment[]
|
||||
moodTagsCustom MoodTag[] @relation("UserMoodTags")
|
||||
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
|
||||
moodTagsHidden MoodTagHidden[]
|
||||
@@ -1107,10 +1108,90 @@ enum MeasurementType {
|
||||
// The 5-level → ordinal mapping is the single source of truth in
|
||||
// `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`.
|
||||
RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5).
|
||||
// ── v1.25 — clinical-signals wave (manual / instrument-derived) ──
|
||||
// PHQ-9 / GAD-7 total scores. Server-derived from a completed
|
||||
// `MentalHealthAssessment` (one measurement row per administration, value =
|
||||
// total). Unit `score`. The raw item answers live encrypted on the
|
||||
// assessment row, never here — these rows carry only the numeric trend so
|
||||
// the existing chart / rollup infra reads them with no item exposure.
|
||||
PHQ9_SCORE // score (0–27) — PHQ-9 depression screen total. Opt-in. See MentalHealthAssessment.
|
||||
GAD7_SCORE // score (0–21) — GAD-7 anxiety screen total. Opt-in. See MentalHealthAssessment.
|
||||
// Maximal isometric handgrip force (best of all trials, either hand). The
|
||||
// EWGSOP2 sarcopenia floor is sex-specific (men < 27 kg, women < 16 kg); the
|
||||
// band is applied at display time, the column stores the raw kg.
|
||||
GRIP_STRENGTH // kg — hand-dynamometer maximal grip (EWGSOP2 sarcopenia limb).
|
||||
// Patient-reported pain intensity on the 0–10 Numeric Rating Scale
|
||||
// (0 = no pain, 10 = worst imaginable). LOINC 72514-3.
|
||||
PAIN_NRS // score (0–10) — pain Numeric Rating Scale.
|
||||
// Waist circumference in cm (WHO STEPS protocol) + the ancestry-robust
|
||||
// waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5).
|
||||
WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display).
|
||||
WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk).
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
/// v1.25 — validated mental-health self-assessment instruments: PHQ-9
|
||||
/// (depression) + GAD-7 (anxiety). The discriminator on
|
||||
/// `MentalHealthAssessment`; extensible (WHO-5 / PSS-10 are natural future
|
||||
/// siblings) without a schema change.
|
||||
enum AssessmentInstrument {
|
||||
PHQ9
|
||||
GAD7
|
||||
|
||||
@@map("assessment_instrument")
|
||||
}
|
||||
|
||||
/// v1.25 — one completed administration of a validated mental-health screener
|
||||
/// (PHQ-9 / GAD-7). Opt-in, beside the existing `MoodEntry` (never replaces it).
|
||||
/// The per-item answers are the most sensitive payload, so they live in a
|
||||
/// single AES-256-GCM `Bytes` blob (`responsesEncrypted`) and are never indexed
|
||||
/// nor logged. The derived total / band / item-9 flag are denormalised
|
||||
/// (server-authoritative) for cheap history reads without decrypting. A
|
||||
/// matching `PHQ9_SCORE` / `GAD7_SCORE` Measurement row is written per
|
||||
/// administration (linked by `externalId = assessment:<id>`) so the score rides
|
||||
/// the existing chart / rollup infra — the trend, never the item content.
|
||||
model MentalHealthAssessment {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
instrument AssessmentInstrument
|
||||
/// The validated translation actually presented (officially-validated wording
|
||||
/// per locale, English fallback otherwise — recorded for the audit trail).
|
||||
locale String
|
||||
/// Instrument variant identifier (e.g. "standard"); future-proofs revisions.
|
||||
version String @default("standard")
|
||||
/// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }.
|
||||
/// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS +
|
||||
/// the rotation script. The item-9 raw value lives ONLY here.
|
||||
responsesEncrypted Bytes @map("responses_encrypted")
|
||||
/// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads.
|
||||
totalScore Int @map("total_score")
|
||||
/// Derived severity band label (server-authoritative; descriptive, never a diagnosis).
|
||||
severityBand String @map("severity_band")
|
||||
/// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so
|
||||
/// the history view can re-surface crisis resources without decrypting.
|
||||
item9Flagged Boolean @default(false) @map("item9_flagged")
|
||||
/// Audit stamp: the supportive crisis-resource signpost was shown to the user.
|
||||
/// Never triggers any third-party alert — private, on-device-only signposting.
|
||||
crisisShownAt DateTime? @map("crisis_shown_at")
|
||||
/// Recall-window anchor (when the screener was taken).
|
||||
takenAt DateTime @map("taken_at")
|
||||
tz String?
|
||||
source String @default("WEB")
|
||||
externalId String? @map("external_id")
|
||||
syncVersion Int @default(0) @map("sync_version")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, source, externalId])
|
||||
@@index([userId, instrument, takenAt])
|
||||
@@index([userId, updatedAt, id])
|
||||
@@map("mental_health_assessments")
|
||||
}
|
||||
|
||||
/// v1.10.0 — categorical events (WX-B)
|
||||
/// The device's own classification RESULT / severity for an EVENT row.
|
||||
/// Non-NULL only for the categorical EVENT `MeasurementType`s
|
||||
|
||||
@@ -448,6 +448,17 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.25 mental-health screener item answers (Bytes column) ─────
|
||||
// The PHQ-9 / GAD-7 encrypted per-item blob. Always present (NOT NULL), so
|
||||
// every row rotates.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"MentalHealthAssessment",
|
||||
"responsesEncrypted",
|
||||
prisma.mentalHealthAssessment,
|
||||
),
|
||||
);
|
||||
|
||||
console.log("\n=== Rotation summary ===");
|
||||
let totalRotated = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* v1.25 — opt-in mental-health screener assessments (PHQ-9 / GAD-7).
|
||||
*
|
||||
* POST records one completed administration: the per-item answers are encrypted
|
||||
* into a single AES-256-GCM Bytes blob (never logged, never returned by
|
||||
* default), and the server-authoritative total + severity band + item-9 safety
|
||||
* flag are denormalised for cheap history reads. A matching PHQ9_SCORE /
|
||||
* GAD7_SCORE Measurement row is written so the score trend rides the existing
|
||||
* chart/rollup infra — the trend, never the item content.
|
||||
*
|
||||
* SAFETY: on any non-zero PHQ-9 item 9, the response carries the calm,
|
||||
* locale-aware crisis-resource set and stamps `crisisShownAt`. No clinical
|
||||
* interpretation, no AI ingestion of item content, no third-party alerting.
|
||||
*
|
||||
* GET lists the caller's assessments (totals + bands + flags only — item-level
|
||||
* answers are excluded from this surface by construction).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
createAssessmentSchema,
|
||||
listAssessmentsSchema,
|
||||
} from "@/lib/validations/mental-health";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
INSTRUMENT_MEASUREMENT_TYPE,
|
||||
isSafetyFlagged,
|
||||
scoreTotal,
|
||||
severityBand,
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
|
||||
import type {
|
||||
MeasurementType,
|
||||
MeasurementSource,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
function shapeRow(row: {
|
||||
id: string;
|
||||
instrument: string;
|
||||
locale: string;
|
||||
version: string;
|
||||
totalScore: number;
|
||||
severityBand: string;
|
||||
item9Flagged: boolean;
|
||||
crisisShownAt: Date | null;
|
||||
takenAt: Date;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
instrument: row.instrument,
|
||||
locale: row.locale,
|
||||
version: row.version,
|
||||
totalScore: row.totalScore,
|
||||
severityBand: row.severityBand,
|
||||
item9Flagged: row.item9Flagged,
|
||||
crisisShownAt: row.crisisShownAt?.toISOString() ?? null,
|
||||
takenAt: row.takenAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listAssessmentsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "mental-health.list.validation-failed" },
|
||||
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { instrument, limit, offset } = parsed.data;
|
||||
const rows = await prisma.mentalHealthAssessment.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
...(instrument ? { instrument } : {}),
|
||||
},
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: limit ?? 100,
|
||||
skip: offset ?? 0,
|
||||
select: {
|
||||
id: true,
|
||||
instrument: true,
|
||||
locale: true,
|
||||
version: true,
|
||||
totalScore: true,
|
||||
severityBand: true,
|
||||
item9Flagged: true,
|
||||
crisisShownAt: true,
|
||||
takenAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "mental-health.list" },
|
||||
meta: { count: rows.length },
|
||||
});
|
||||
return apiSuccess({ assessments: rows.map(shapeRow) });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const body = await safeJson(request);
|
||||
const parsed = createAssessmentSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "mental-health.create.validation-failed" },
|
||||
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { instrument, items, functionalDifficulty, takenAt, tz, locale } =
|
||||
parsed.data;
|
||||
const id = instrument as InstrumentId;
|
||||
const def = INSTRUMENTS[id];
|
||||
|
||||
// Server-authoritative scoring (the client never computes these).
|
||||
const total = scoreTotal(items);
|
||||
const band = severityBand(id, total);
|
||||
const flagged = isSafetyFlagged(id, items);
|
||||
const presentedLocale = locale ?? user.locale ?? "en";
|
||||
const when = takenAt ? new Date(takenAt) : new Date();
|
||||
|
||||
// Encrypt the per-item answers + the (unscored) functional-impairment follow-up.
|
||||
const blob = encryptToBytes(
|
||||
JSON.stringify({
|
||||
items,
|
||||
...(functionalDifficulty !== undefined ? { functionalDifficulty } : {}),
|
||||
schema: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const assessment = await prisma.mentalHealthAssessment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
instrument: id,
|
||||
locale: presentedLocale,
|
||||
version: "standard",
|
||||
responsesEncrypted: blob,
|
||||
totalScore: total,
|
||||
severityBand: band,
|
||||
item9Flagged: flagged,
|
||||
crisisShownAt: flagged ? new Date() : null,
|
||||
takenAt: when,
|
||||
tz: tz ?? null,
|
||||
source: "WEB",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
instrument: true,
|
||||
locale: true,
|
||||
version: true,
|
||||
totalScore: true,
|
||||
severityBand: true,
|
||||
item9Flagged: true,
|
||||
crisisShownAt: true,
|
||||
takenAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Read-optimised projection: the total rides a Measurement row so the existing
|
||||
// chart/rollup infra reads the trend without ever touching item content.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
|
||||
value: total,
|
||||
unit: "score",
|
||||
source: "MANUAL" as MeasurementSource,
|
||||
measuredAt: when,
|
||||
notes: null,
|
||||
externalId: `assessment:${assessment.id}`,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("mental-health.create", {
|
||||
userId: user.id,
|
||||
// The score band is non-sensitive; item answers are NEVER logged.
|
||||
details: { instrument: id, severityBand: band },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "mental-health.create" },
|
||||
meta: { instrument: id, item9_flagged: flagged },
|
||||
});
|
||||
|
||||
// SAFETY: surface the locale-aware crisis resources on a positive item-9.
|
||||
const crisis = flagged ? crisisResourcesForLocale(presentedLocale) : null;
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
assessment: shapeRow(assessment),
|
||||
actionThreshold: def.actionThreshold,
|
||||
crisis,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
|
||||
/**
|
||||
* v1.25 — `/insights/mental-wellbeing`.
|
||||
*
|
||||
* Opt-in PHQ-9 / GAD-7 screener surface, beside mood tracking. The screen owns
|
||||
* its own questionnaire + result + crisis-resource flow; it deliberately does
|
||||
* NOT mount the shared Coach-launch assessment card (mental-health item content
|
||||
* is kept out of the AI Coach by construction).
|
||||
*/
|
||||
export default function MentalWellbeingPage() {
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<MentalWellbeing />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,6 +122,13 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation",
|
||||
// ── v1.19.0 — Oura resilience ──
|
||||
RESILIENCE: "measurements.typeResilience",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "measurements.typePhq9Score",
|
||||
GAD7_SCORE: "measurements.typeGad7Score",
|
||||
GRIP_STRENGTH: "measurements.typeGripStrength",
|
||||
PAIN_NRS: "measurements.typePainNrs",
|
||||
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
|
||||
WAIST_TO_HEIGHT: "measurements.typeWaistToHeight",
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -245,6 +252,15 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
// Gauge reads as the composite "index / level" dial, matching the other
|
||||
// recovery / score composites (RECOVERY_SCORE, DAY_STRAIN).
|
||||
RESILIENCE: Gauge,
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// Gauge reads as the screener-score dial; Dumbbell carries grip strength;
|
||||
// Activity the pain trace; Scale the anthropometric waist pair.
|
||||
PHQ9_SCORE: Gauge,
|
||||
GAD7_SCORE: Gauge,
|
||||
GRIP_STRENGTH: Dumbbell,
|
||||
PAIN_NRS: Activity,
|
||||
WAIST_CIRCUMFERENCE: Scale,
|
||||
WAIST_TO_HEIGHT: Scale,
|
||||
};
|
||||
|
||||
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
@@ -359,4 +375,13 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
|
||||
// chart-1 (Dracula purple) marks the derived recovery / score composites as
|
||||
// their own visual group, matching RECOVERY_SCORE / DAY_STRAIN.
|
||||
RESILIENCE: "bg-chart-1/20 text-chart-1",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// chart-1 (composite/score family) for the screener totals + grip strength;
|
||||
// chart-3 (cardio/alert family) for pain; chart-4 (body family) for waist.
|
||||
PHQ9_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GAD7_SCORE: "bg-chart-1/20 text-chart-1",
|
||||
GRIP_STRENGTH: "bg-chart-1/20 text-chart-1",
|
||||
PAIN_NRS: "bg-chart-3/20 text-chart-3",
|
||||
WAIST_CIRCUMFERENCE: "bg-chart-4/20 text-chart-4",
|
||||
WAIST_TO_HEIGHT: "bg-chart-4/20 text-chart-4",
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25 — opt-in mental-health screener surface (PHQ-9 / GAD-7).
|
||||
*
|
||||
* Beside mood tracking, never replacing it; framed as a screening self-check,
|
||||
* not a diagnosis. The per-item answers stay on the device until submit, the
|
||||
* server encrypts them, and only the total trend is persisted as a measurement.
|
||||
*
|
||||
* SAFETY: this surface is deliberately kept OFF the AI Coach — the shared
|
||||
* Coach-launch assessment card is intentionally NOT used here, so a depression /
|
||||
* anxiety screen never invites an AI conversation about item content. On a
|
||||
* positive PHQ-9 item-9 the server returns a calm, locale-aware crisis-resource
|
||||
* set which is rendered immediately below the result.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
PHQ_GAD_ATTRIBUTION,
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
type Phase = "choose" | "form" | "result";
|
||||
|
||||
interface AssessmentRow {
|
||||
id: string;
|
||||
instrument: InstrumentId;
|
||||
totalScore: number;
|
||||
severityBand: string;
|
||||
item9Flagged: boolean;
|
||||
takenAt: string;
|
||||
}
|
||||
|
||||
interface CrisisSet {
|
||||
emergencyNumber: string;
|
||||
resources: { id: string; contacts: string[] }[];
|
||||
}
|
||||
|
||||
interface CreateResponse {
|
||||
assessment: AssessmentRow;
|
||||
actionThreshold: number;
|
||||
crisis: CrisisSet | null;
|
||||
}
|
||||
|
||||
const SCALE = [0, 1, 2, 3] as const;
|
||||
const FUNCTIONAL = [0, 1, 2, 3] as const;
|
||||
|
||||
export function MentalWellbeing() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [phase, setPhase] = useState<Phase>("choose");
|
||||
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
|
||||
const [items, setItems] = useState<number[]>([]);
|
||||
const [functional, setFunctional] = useState<number | null>(null);
|
||||
const [result, setResult] = useState<CreateResponse | null>(null);
|
||||
|
||||
const lower = (id: InstrumentId) => (id === "PHQ9" ? "phq9" : "gad7");
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
queryFn: () =>
|
||||
apiGet<{ assessments: AssessmentRow[] }>(
|
||||
"/api/mental-health/assessments",
|
||||
),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: unknown) =>
|
||||
apiPost<CreateResponse>("/api/mental-health/assessments", body),
|
||||
onSuccess: (data) => {
|
||||
setResult(data);
|
||||
setPhase("result");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function begin(id: InstrumentId) {
|
||||
setInstrument(id);
|
||||
setItems(Array(INSTRUMENTS[id].itemCount).fill(-1));
|
||||
setFunctional(null);
|
||||
setResult(null);
|
||||
setPhase("form");
|
||||
}
|
||||
|
||||
const complete = items.length > 0 && items.every((v) => v >= 0);
|
||||
|
||||
function submit() {
|
||||
if (!complete) return;
|
||||
mutation.mutate({
|
||||
instrument,
|
||||
items,
|
||||
...(functional !== null ? { functionalDifficulty: functional } : {}),
|
||||
locale,
|
||||
});
|
||||
}
|
||||
|
||||
const itemCount = INSTRUMENTS[instrument].itemCount;
|
||||
const itemIndexes = useMemo(
|
||||
() => Array.from({ length: itemCount }, (_, i) => i),
|
||||
[itemCount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-2xl flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t("mentalHealth.pageTitle")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.pageDescription")}
|
||||
</p>
|
||||
<p className="text-muted-foreground border-border/60 bg-muted/40 rounded-md border p-3 text-xs">
|
||||
{t("mentalHealth.disclaimer")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mentalHealth.optInNote")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{phase === "choose" && (
|
||||
<section
|
||||
className="flex flex-col gap-3"
|
||||
aria-label={t("mentalHealth.choosePrompt")}
|
||||
>
|
||||
<h2 className="text-sm font-medium">
|
||||
{t("mentalHealth.choosePrompt")}
|
||||
</h2>
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<Card key={id}>
|
||||
<CardContent className="flex items-center justify-between gap-4 p-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.instrument.${lower(id)}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${lower(id)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => begin(id)}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{phase === "form" && (
|
||||
<section className="flex flex-col gap-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhase("choose")}
|
||||
>
|
||||
{t("mentalHealth.back")}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">
|
||||
{t(`mentalHealth.instrument.${lower(instrument)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{t("mentalHealth.stem")}</p>
|
||||
|
||||
<ol className="flex flex-col gap-5">
|
||||
{itemIndexes.map((i) => (
|
||||
<li key={i} className="flex flex-col gap-2">
|
||||
<span className="text-sm">
|
||||
{i + 1}.{" "}
|
||||
{t(`mentalHealth.items.${lower(instrument)}.${i + 1}`)}
|
||||
</span>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t(
|
||||
`mentalHealth.items.${lower(instrument)}.${i + 1}`,
|
||||
)}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={items[i] === v ? "default" : "outline"}
|
||||
aria-pressed={items[i] === v}
|
||||
onClick={() =>
|
||||
setItems((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = v;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t(`mentalHealth.options.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="border-border/60 flex flex-col gap-2 border-t pt-4">
|
||||
<span className="text-sm">{t("mentalHealth.functionalTitle")}</span>
|
||||
<div
|
||||
className="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label={t("mentalHealth.functionalTitle")}
|
||||
>
|
||||
{FUNCTIONAL.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={functional === v ? "default" : "outline"}
|
||||
aria-pressed={functional === v}
|
||||
onClick={() => setFunctional(v)}
|
||||
>
|
||||
{t(`mentalHealth.functional.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("mentalHealth.error")}
|
||||
</p>
|
||||
)}
|
||||
<Button onClick={submit} disabled={!complete || mutation.isPending}>
|
||||
{mutation.isPending
|
||||
? t("mentalHealth.submitting")
|
||||
: t("mentalHealth.submit")}
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{phase === "result" && result && (
|
||||
<section className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-xs" role="status">
|
||||
{t("mentalHealth.saved")}
|
||||
</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("mentalHealth.result.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.totalLabel")}
|
||||
</span>
|
||||
<span className="text-2xl font-semibold">
|
||||
{result.assessment.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("mentalHealth.result.bandLabel")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{t(
|
||||
`mentalHealth.band.${result.assessment.instrument}.${result.assessment.severityBand}`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{result.assessment.totalScore >= result.actionThreshold && (
|
||||
<p className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs">
|
||||
{t("mentalHealth.considerProfessional")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result.crisis && (
|
||||
<Card className="border-amber-400/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.crisis.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 text-sm">
|
||||
<p>{t("mentalHealth.crisis.intro")}</p>
|
||||
<p className="font-medium">
|
||||
{t("mentalHealth.crisis.ifDanger", {
|
||||
emergency: result.crisis.emergencyNumber,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("mentalHealth.crisis.resourcesTitle")}
|
||||
</span>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{result.crisis.resources.map((r) => (
|
||||
<li key={r.id} className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{t(`mentalHealth.crisisResource.${r.id}.name`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{r.contacts.join(" · ")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-[11px] leading-snug">
|
||||
<span className="font-medium">
|
||||
{t("mentalHealth.attributionLabel")}:
|
||||
</span>{" "}
|
||||
{PHQ_GAD_ATTRIBUTION}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={() => setPhase("choose")}>
|
||||
{t("mentalHealth.result.takeAnother")}
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="border-border/60 flex flex-col gap-2 border-t pt-4">
|
||||
<h2 className="text-sm font-medium">
|
||||
{t("mentalHealth.history.title")}
|
||||
</h2>
|
||||
{!history || history.assessments.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{history.assessments.map((row) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span>
|
||||
{t(`mentalHealth.instrument.${lower(row.instrument)}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t(`mentalHealth.band.${row.instrument}.${row.severityBand}`)}{" "}
|
||||
· {formatDate(row.takenAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,10 +98,17 @@ const EXPECTED_TYPES = [
|
||||
"BODY_TEMPERATURE_DEVIATION",
|
||||
// ── v1.19.0 — Oura resilience (ordinal-encoded level) ──
|
||||
"RESILIENCE",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 67 canonical measurement types", () => {
|
||||
it("exposes the 73 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
@@ -244,6 +251,19 @@ describe("measurementTypeEnum coverage", () => {
|
||||
// Insights / measurement-list surfaces and never belongs in the clinical
|
||||
// vitals PDF. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"RESILIENCE",
|
||||
// v1.25 — clinical-signals wave. The mental-health screener totals are
|
||||
// opt-in, derived, and deliberately excluded from any export/share/AI
|
||||
// surface by default (item answers are encrypted). The physical clinical
|
||||
// signals (grip strength, pain NRS, waist circumference + WHtR) surface on
|
||||
// their own measurement/insights surfaces; they join the doctor PDF once a
|
||||
// clinical layout + reference ranges land alongside the other v1.5+
|
||||
// additions. See doctor-report-pdf-core.ts for the matching rationale.
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
|
||||
@@ -156,6 +156,16 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" },
|
||||
{ model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" },
|
||||
{ model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.25 mental-health screener item answers (Bytes column) ─────
|
||||
// The PHQ-9 / GAD-7 per-item responses (incl. the safety-critical item 9)
|
||||
// ride a single AES-256-GCM blob. The most sensitive payload in the wave;
|
||||
// never indexed, never logged, never in wide-event meta.
|
||||
{
|
||||
model: "MentalHealthAssessment",
|
||||
field: "responsesEncrypted",
|
||||
kind: "bytes",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Stable `Model.field` key for a registry entry. */
|
||||
|
||||
@@ -139,6 +139,36 @@ const LAB_LOINC_BY_KEY: Record<string, LabLoincMapping> = {
|
||||
display: "Hemoglobin [Mass/volume] in Blood",
|
||||
ucum: "g/dL",
|
||||
},
|
||||
// ── v1.25 — longevity lab panel additions (loinc.org confirmed this pass) ──
|
||||
apob: {
|
||||
loinc: "1884-6",
|
||||
display: "Apolipoprotein B [Mass/volume] in Serum or Plasma",
|
||||
ucum: "mg/dL",
|
||||
},
|
||||
lpa: {
|
||||
loinc: "43583-4",
|
||||
display: "Lipoprotein a [Moles/volume] in Serum or Plasma",
|
||||
ucum: "nmol/L",
|
||||
},
|
||||
hscrp: {
|
||||
loinc: "30522-7",
|
||||
display:
|
||||
"C reactive protein [Mass/volume] in Serum or Plasma by High sensitivity method",
|
||||
ucum: "mg/L",
|
||||
},
|
||||
ggt: {
|
||||
loinc: "2324-2",
|
||||
display:
|
||||
"Gamma glutamyl transferase [Enzymatic activity/volume] in Serum or Plasma",
|
||||
ucum: "U/L",
|
||||
},
|
||||
fastinginsulin: {
|
||||
loinc: "27873-9",
|
||||
display: "Insulin [Units/volume] in Serum or Plasma --fasting",
|
||||
ucum: "u[IU]/mL",
|
||||
},
|
||||
// NOTE(NEEDS-VERIFY): the RBC omega-3 index has no confirmed LOINC this pass
|
||||
// — it intentionally falls back to a local text-only concept on export.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -206,6 +236,20 @@ const LAB_ALIASES: Record<string, string> = {
|
||||
hb: "hemoglobin",
|
||||
hgb: "hemoglobin",
|
||||
hamoglobin: "hemoglobin",
|
||||
// ── v1.25 — longevity lab panel aliases ──
|
||||
apolipoproteinb: "apob",
|
||||
apolipoprotein: "apob",
|
||||
lipoproteina: "lpa",
|
||||
lpkleina: "lpa",
|
||||
hscrp: "hscrp",
|
||||
highsensitivitycrp: "hscrp",
|
||||
hochsensitivescrp: "hscrp",
|
||||
gammagt: "ggt",
|
||||
gammaglutamyltransferase: "ggt",
|
||||
ggtp: "ggt",
|
||||
fastinginsulin: "fastinginsulin",
|
||||
insulinfasting: "fastinginsulin",
|
||||
nuchterninsulin: "fastinginsulin",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,14 @@ export const UCUM_SYSTEM = "http://unitsofmeasure.org";
|
||||
export const HEALTHKIT_CODESYSTEM =
|
||||
"https://healthlog.dev/fhir/CodeSystem/healthkit";
|
||||
|
||||
export type FhirObservationCategory = "vital-signs" | "laboratory" | "activity";
|
||||
export type FhirObservationCategory =
|
||||
| "vital-signs"
|
||||
| "laboratory"
|
||||
| "activity"
|
||||
// v1.25 — patient-reported screener / questionnaire observations (PHQ-9 /
|
||||
// GAD-7 totals, pain NRS). The builder already emits a `survey` category for
|
||||
// the descriptive wellness composites; `categoryConcept` takes any string.
|
||||
| "survey";
|
||||
|
||||
export interface LoincMapping {
|
||||
/** LOINC code, or null when no stable LOINC applies (local text fallback). */
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeHomaIr, classifyHomaIr } from "@/lib/labs/homa-ir";
|
||||
|
||||
describe("computeHomaIr", () => {
|
||||
it("computes the conventional-unit formula (glucose × insulin ÷ 405)", () => {
|
||||
// 90 mg/dL × 5 µIU/mL ÷ 405 = 1.111…
|
||||
expect(computeHomaIr(90, 5)).toBeCloseTo(1.1111, 3);
|
||||
});
|
||||
|
||||
it("matches the SI form within rounding (mmol/L × insulin ÷ 22.5)", () => {
|
||||
// 100 mg/dL = 5.556 mmol/L; SI: 5.556 × 8 ÷ 22.5 ≈ 1.975; conv: 100×8÷405 ≈ 1.975
|
||||
const conv = computeHomaIr(100, 8)!;
|
||||
const si = (5.5556 * 8) / 22.5;
|
||||
expect(conv).toBeCloseTo(si, 2);
|
||||
});
|
||||
|
||||
it("returns null for missing or non-positive inputs", () => {
|
||||
expect(computeHomaIr(null, 5)).toBeNull();
|
||||
expect(computeHomaIr(90, undefined)).toBeNull();
|
||||
expect(computeHomaIr(0, 5)).toBeNull();
|
||||
expect(computeHomaIr(90, -1)).toBeNull();
|
||||
expect(computeHomaIr(Number.NaN, 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyHomaIr", () => {
|
||||
it("places values in the documented bands", () => {
|
||||
expect(classifyHomaIr(0.8)).toBe("optimal");
|
||||
expect(classifyHomaIr(1.5)).toBe("intermediate");
|
||||
expect(classifyHomaIr(2.5)).toBe("elevated");
|
||||
expect(classifyHomaIr(3.5)).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,33 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
|
||||
lowerBound: null,
|
||||
upperBound: 150,
|
||||
},
|
||||
// v1.25 — longevity panel: atherogenic-particle burden + genetic + RBC EPA/DHA.
|
||||
// ApoB is the primary atherogenic-burden number (ESC/EAS 2019); the upper
|
||||
// bound is the population-desirable line, lower goals are care-context-specific.
|
||||
{
|
||||
slug: "apob",
|
||||
panel: "lipids",
|
||||
unit: "mg/dL",
|
||||
lowerBound: null,
|
||||
upperBound: 90,
|
||||
},
|
||||
// Lp(a) is largely genetically fixed (measure once, flag elevated); molar
|
||||
// (nmol/L) reporting preferred — never interconvert mass↔molar by a factor.
|
||||
{
|
||||
slug: "lp-a",
|
||||
panel: "lipids",
|
||||
unit: "nmol/L",
|
||||
lowerBound: null,
|
||||
upperBound: 125,
|
||||
},
|
||||
// Omega-3 index (RBC EPA+DHA %): cardioprotective/optimal 8–12%.
|
||||
{
|
||||
slug: "omega-3-index",
|
||||
panel: "lipids",
|
||||
unit: "%",
|
||||
lowerBound: 8,
|
||||
upperBound: null,
|
||||
},
|
||||
// Glucose metabolism
|
||||
{
|
||||
slug: "fasting-glucose",
|
||||
@@ -76,6 +103,16 @@ export const BIOMARKER_CATALOG: readonly BiomarkerSeed[] = [
|
||||
lowerBound: null,
|
||||
upperBound: 5.7,
|
||||
},
|
||||
// v1.25 — longevity panel: fasting insulin (metabolically-optimal often
|
||||
// cited < 8 µIU/mL; typical reference ~2.5–13). HOMA-IR is derived from
|
||||
// fasting glucose × insulin (see `homa-ir.ts`), not a stored catalog marker.
|
||||
{
|
||||
slug: "fasting-insulin",
|
||||
panel: "glucose",
|
||||
unit: "µIU/mL",
|
||||
lowerBound: 2.5,
|
||||
upperBound: 13,
|
||||
},
|
||||
// Thyroid
|
||||
{
|
||||
slug: "tsh",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* v1.25 — HOMA-IR (Homeostatic Model Assessment of Insulin Resistance).
|
||||
*
|
||||
* A computed longevity marker derived from fasting glucose + fasting insulin —
|
||||
* NOT a stored biomarker catalog entry (BMI's derived-value precedent). Server-
|
||||
* authoritative per the parity rule: compute it here, never recompute on the
|
||||
* client. Pure + side-effect-free so it is trivially testable.
|
||||
*
|
||||
* Conventional-unit formula (fasting glucose mg/dL × fasting insulin µIU/mL ÷
|
||||
* 405) and the SI form (mmol/L × µIU/mL ÷ 22.5) are equivalent
|
||||
* (mg/dL ÷ 18 = mmol/L; 405 = 22.5 × 18). Population-dependent interpretive
|
||||
* bands (optimal < 1.0, early IR > 1.9, significant IR > 2.9) are applied at the
|
||||
* display edge, never asserted here. [HOMA-IR, PMC5033570].
|
||||
*/
|
||||
|
||||
/** Interpretive band for a HOMA-IR index value (population-dependent guidance). */
|
||||
export type HomaIrBand = "optimal" | "intermediate" | "elevated" | "high";
|
||||
|
||||
/**
|
||||
* Compute HOMA-IR from fasting glucose (mg/dL) and fasting insulin (µIU/mL).
|
||||
* Returns `null` when either input is missing or non-positive (the index is
|
||||
* undefined for a zero/negative reading).
|
||||
*/
|
||||
export function computeHomaIr(
|
||||
fastingGlucoseMgDl: number | null | undefined,
|
||||
fastingInsulinUiuMl: number | null | undefined,
|
||||
): number | null {
|
||||
if (
|
||||
fastingGlucoseMgDl == null ||
|
||||
fastingInsulinUiuMl == null ||
|
||||
!Number.isFinite(fastingGlucoseMgDl) ||
|
||||
!Number.isFinite(fastingInsulinUiuMl) ||
|
||||
fastingGlucoseMgDl <= 0 ||
|
||||
fastingInsulinUiuMl <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (fastingGlucoseMgDl * fastingInsulinUiuMl) / 405;
|
||||
}
|
||||
|
||||
/** Place a HOMA-IR value in its descriptive band (general guidance, not a diagnosis). */
|
||||
export function classifyHomaIr(value: number): HomaIrBand {
|
||||
if (value < 1.0) return "optimal";
|
||||
if (value <= 1.9) return "intermediate";
|
||||
if (value <= 2.9) return "elevated";
|
||||
return "high";
|
||||
}
|
||||
@@ -77,6 +77,17 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
|
||||
// the Oura API (source = OURA), never from HealthKit: Apple ships no
|
||||
// resilience metric. No HK mapping by design.
|
||||
"RESILIENCE",
|
||||
// v1.25 — clinical-signals wave. The mental-health screener totals are
|
||||
// derived from an in-app questionnaire, and grip strength / pain NRS / waist
|
||||
// circumference + waist-to-height are manual / instrument readings — none
|
||||
// ships a first-class HealthKit quantity we ingest, so they have no HK
|
||||
// mapping by design.
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
]);
|
||||
|
||||
describe("APPLE_HEALTH_TYPE_MAP", () => {
|
||||
|
||||
@@ -204,6 +204,18 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
|
||||
// The resilience level is a derived recovery composite (ordinal-encoded
|
||||
// band) → scores cluster alongside RECOVERY_SCORE / ANS_CHARGE.
|
||||
["RESILIENCE", "scores"],
|
||||
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// PHQ-9 / GAD-7 totals are composite screener indices → scores cluster.
|
||||
["PHQ9_SCORE", "scores"],
|
||||
["GAD7_SCORE", "scores"],
|
||||
// Grip strength is a strength/fitness reading → activity cluster.
|
||||
["GRIP_STRENGTH", "activity"],
|
||||
// Pain NRS is a patient-reported clinical point-in-time signal → vitals.
|
||||
["PAIN_NRS", "vitals"],
|
||||
// Waist circumference + waist-to-height are anthropometric → body cluster.
|
||||
["WAIST_CIRCUMFERENCE", "body"],
|
||||
["WAIST_TO_HEIGHT", "body"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* v1.25 — locale-aware crisis-resource signposting for the PHQ-9 item-9 safety
|
||||
* path. This is SUPPORT, not interpretation: when item 9 is answered with any
|
||||
* non-zero value the UI shows a calm, non-alarmist card pointing to real help.
|
||||
*
|
||||
* The list is maintained config (not hardcoded in a component) so a self-hoster
|
||||
* can localise it. Names resolve via the `mentalHealth.crisisResource.<id>.name`
|
||||
* i18n key; the contact lines (phone numbers / URLs) are literal data, never
|
||||
* translated. The emergency number is region-specific and always shown first.
|
||||
*
|
||||
* HARD RULE: showing these resources NEVER triggers any third-party alert,
|
||||
* never notifies a contact, never emails. It is private, on-device-only
|
||||
* signposting on a self-host.
|
||||
*/
|
||||
|
||||
export interface CrisisResource {
|
||||
/** Stable id; the `mentalHealth.crisisResource.<id>.name` i18n key resolves the label. */
|
||||
id: string;
|
||||
/** Contact lines (phone / SMS / chat URL) — literal, not translated. */
|
||||
contacts: readonly string[];
|
||||
}
|
||||
|
||||
export interface CrisisResourceSet {
|
||||
/** Region's universal emergency number, shown first ("if in immediate danger…"). */
|
||||
emergencyNumber: string;
|
||||
resources: readonly CrisisResource[];
|
||||
}
|
||||
|
||||
/** International fallback set (used when a region has no dedicated entry). */
|
||||
const INTERNATIONAL: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
{ id: "euEmotionalSupport", contacts: ["116 123"] },
|
||||
],
|
||||
};
|
||||
|
||||
/** Germany (de) — TelefonSeelsorge + youth lines + acute emergency. */
|
||||
const DE: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
{
|
||||
id: "telefonSeelsorge",
|
||||
contacts: ["0800 111 0 111", "0800 111 0 222", "telefonseelsorge.de"],
|
||||
},
|
||||
{ id: "nummerGegenKummer", contacts: ["116 111"] },
|
||||
{ id: "krisenchat", contacts: ["krisenchat.de"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/** United States (en-US region hint) — 988 + Crisis Text Line. */
|
||||
const US: CrisisResourceSet = {
|
||||
emergencyNumber: "911",
|
||||
resources: [
|
||||
{ id: "lifeline988", contacts: ["988"] },
|
||||
{ id: "crisisTextLine", contacts: ["Text HOME to 741741"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a crisis-resource set from the user's locale. Region detection is
|
||||
* coarse (locale prefix) and defaults to the international set — the goal is to
|
||||
* always surface SOMETHING actionable, never to be exhaustive.
|
||||
*/
|
||||
export function crisisResourcesForLocale(
|
||||
locale: string | null | undefined,
|
||||
): CrisisResourceSet {
|
||||
const lc = (locale ?? "").toLowerCase();
|
||||
if (lc.startsWith("de")) return DE;
|
||||
if (lc === "en-us" || lc.startsWith("en_us") || lc === "us") return US;
|
||||
return INTERNATIONAL;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* v1.25 — validated mental-health screener definitions (PHQ-9 + GAD-7).
|
||||
*
|
||||
* Pure, server-authoritative scoring. The instrument item wording, 0–3 scale,
|
||||
* totals, and severity bands are taken from the primary sources (Kroenke 2001
|
||||
* for PHQ-9; Spitzer 2006 for GAD-7). This module owns the canonical NUMERIC
|
||||
* contract; the human wording lives in `messages/*.json` under
|
||||
* `mentalHealth.items.*` (officially-validated translations per locale, English
|
||||
* fallback otherwise — re-wording invalidates the score).
|
||||
*
|
||||
* PHQ-9 item 9 (index 8) is the passive self-harm ideation screen and is the
|
||||
* safety-critical field (see `crisis-resources.ts` + the API handler): any
|
||||
* non-zero answer triggers a calm, non-alarmist crisis-resource signpost. The
|
||||
* raw item answers are NEVER exposed to the Coach / Insights / export surfaces —
|
||||
* only the derived total score rides a `*_SCORE` Measurement row.
|
||||
*/
|
||||
|
||||
export type InstrumentId = "PHQ9" | "GAD7";
|
||||
|
||||
/**
|
||||
* Required attribution — render VERBATIM on every screen displaying the
|
||||
* instrument and in the PDF/FHIR export. The PHQ family is public-domain (Pfizer
|
||||
* 2010); this is the canonical footer text. By convention it stays in English
|
||||
* (the developers' names are not translated).
|
||||
*/
|
||||
export const PHQ_GAD_ATTRIBUTION =
|
||||
"Developed by Drs. Robert L. Spitzer, Janet B.W. Williams, Kurt Kroenke and " +
|
||||
"colleagues, with an educational grant from Pfizer Inc. No permission " +
|
||||
"required to reproduce, translate, display or distribute.";
|
||||
|
||||
export interface InstrumentDefinition {
|
||||
id: InstrumentId;
|
||||
/** Number of scored items (PHQ-9 = 9, GAD-7 = 7). */
|
||||
itemCount: number;
|
||||
/** Inclusive total range. */
|
||||
minScore: number;
|
||||
maxScore: number;
|
||||
/** Total-score LOINC for the FHIR/export contract. */
|
||||
totalLoinc: string;
|
||||
/** Severity bands, ordered low→high. `min`/`max` inclusive over the total. */
|
||||
bands: ReadonlyArray<{ key: string; min: number; max: number }>;
|
||||
/** Optimal screening cut-off (≥ this total = consider professional follow-up). */
|
||||
actionThreshold: number;
|
||||
/** Item index (0-based) of the safety-critical self-harm item, or null. */
|
||||
safetyItemIndex: number | null;
|
||||
}
|
||||
|
||||
const PHQ9: InstrumentDefinition = {
|
||||
id: "PHQ9",
|
||||
itemCount: 9,
|
||||
minScore: 0,
|
||||
maxScore: 27,
|
||||
totalLoinc: "44261-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
{ key: "mild", min: 5, max: 9 },
|
||||
{ key: "moderate", min: 10, max: 14 },
|
||||
{ key: "modSevere", min: 15, max: 19 },
|
||||
{ key: "severe", min: 20, max: 27 },
|
||||
],
|
||||
actionThreshold: 10,
|
||||
// Item 9 ("Thoughts that you would be better off dead…") — the safety field.
|
||||
safetyItemIndex: 8,
|
||||
};
|
||||
|
||||
const GAD7: InstrumentDefinition = {
|
||||
id: "GAD7",
|
||||
itemCount: 7,
|
||||
minScore: 0,
|
||||
maxScore: 21,
|
||||
totalLoinc: "70274-6",
|
||||
bands: [
|
||||
{ key: "minimal", min: 0, max: 4 },
|
||||
{ key: "mild", min: 5, max: 9 },
|
||||
{ key: "moderate", min: 10, max: 14 },
|
||||
{ key: "severe", min: 15, max: 21 },
|
||||
],
|
||||
actionThreshold: 10,
|
||||
safetyItemIndex: null,
|
||||
};
|
||||
|
||||
export const INSTRUMENTS: Record<InstrumentId, InstrumentDefinition> = {
|
||||
PHQ9,
|
||||
GAD7,
|
||||
};
|
||||
|
||||
/** The DB MeasurementType the instrument's total score projects to. */
|
||||
export const INSTRUMENT_MEASUREMENT_TYPE: Record<InstrumentId, string> = {
|
||||
PHQ9: "PHQ9_SCORE",
|
||||
GAD7: "GAD7_SCORE",
|
||||
};
|
||||
|
||||
/** Sum the item answers into a total (server-authoritative). */
|
||||
export function scoreTotal(items: readonly number[]): number {
|
||||
return items.reduce((sum, v) => sum + v, 0);
|
||||
}
|
||||
|
||||
/** Resolve the severity-band key for a total score. */
|
||||
export function severityBand(id: InstrumentId, total: number): string {
|
||||
const def = INSTRUMENTS[id];
|
||||
const band = def.bands.find((b) => total >= b.min && total <= b.max);
|
||||
// Defensive clamp — the API validates the total is in-range before this runs.
|
||||
return band?.key ?? def.bands[def.bands.length - 1].key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the safety signpost must be shown for these answers. True iff the
|
||||
* instrument has a safety item and that item was answered with any non-zero
|
||||
* value — independent of the total score (item 9 is never gated by the band).
|
||||
*/
|
||||
export function isSafetyFlagged(
|
||||
id: InstrumentId,
|
||||
items: readonly number[],
|
||||
): boolean {
|
||||
const idx = INSTRUMENTS[id].safetyItemIndex;
|
||||
if (idx === null) return false;
|
||||
return (items[idx] ?? 0) > 0;
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { labsPaths } from "./labs";
|
||||
import { ocrPaths } from "./ocr";
|
||||
import { measurementPaths } from "./measurements";
|
||||
import { measurementReminderPaths } from "./measurement-reminders";
|
||||
import { mentalHealthPaths } from "./mental-health";
|
||||
import { medicationPaths, medicationResource } from "./medications";
|
||||
import { metaPaths } from "./meta";
|
||||
import { moodPaths } from "./mood";
|
||||
@@ -83,6 +84,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...ocrPaths,
|
||||
...biomarkerPaths,
|
||||
...illnessPaths,
|
||||
...mentalHealthPaths,
|
||||
...onboardingPaths,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* OpenAPI route table for the opt-in mental-health screeners
|
||||
* (`/api/mental-health/assessments`). PHQ-9 / GAD-7.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request body
|
||||
* reuses the runtime Zod schema from `@/lib/validations/mental-health` so the
|
||||
* wire contract stays single-source. The response deliberately excludes the
|
||||
* raw item answers — only the server-authoritative total / band / safety flag
|
||||
* are exposed. The iOS client mirrors this DTO and MUST surface its own
|
||||
* crisis-resource card on a positive item-9 flag (never rely on server text
|
||||
* alone).
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
createAssessmentSchema,
|
||||
listAssessmentsSchema,
|
||||
} from "@/lib/validations/mental-health";
|
||||
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
createAssessmentSchema.meta({
|
||||
id: "CreateMentalHealthAssessmentRequest",
|
||||
description:
|
||||
"Record one completed PHQ-9 (9 items) / GAD-7 (7 items) administration. Each item answer is 0–3; the array length must match the instrument. The item answers are encrypted at rest and never returned. The server computes the total + severity band + item-9 safety flag. Opt-in, beside mood tracking — this is a screening surface, not a diagnosis.",
|
||||
});
|
||||
|
||||
listAssessmentsSchema.meta({
|
||||
id: "ListMentalHealthAssessmentsQuery",
|
||||
description:
|
||||
"Filter the caller's screener history by instrument; paginate with limit/offset.",
|
||||
});
|
||||
|
||||
const assessmentRow = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
instrument: z.enum(["PHQ9", "GAD7"]),
|
||||
locale: z.string(),
|
||||
version: z.string(),
|
||||
totalScore: z.number(),
|
||||
severityBand: z.string(),
|
||||
item9Flagged: z.boolean(),
|
||||
crisisShownAt: z.string().nullable(),
|
||||
takenAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "MentalHealthAssessment",
|
||||
description:
|
||||
"One completed screener administration. `severityBand` is a descriptive label for the screen (never a diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis resources when it is. Raw per-item answers are intentionally absent.",
|
||||
});
|
||||
|
||||
const crisisResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
contacts: z.array(z.string()),
|
||||
})
|
||||
.meta({ id: "CrisisResource" });
|
||||
|
||||
const crisisSet = z
|
||||
.object({
|
||||
emergencyNumber: z.string(),
|
||||
resources: z.array(crisisResource),
|
||||
})
|
||||
.meta({
|
||||
id: "CrisisResourceSet",
|
||||
description:
|
||||
"Locale-aware crisis-resource signposting, present only when item-9 is flagged. Names resolve via i18n on the client; contacts are literal.",
|
||||
});
|
||||
|
||||
const createResponse = z
|
||||
.object({
|
||||
assessment: assessmentRow,
|
||||
actionThreshold: z.number(),
|
||||
crisis: crisisSet.nullable(),
|
||||
})
|
||||
.meta({ id: "CreateMentalHealthAssessmentResponse" });
|
||||
|
||||
const listResponse = z
|
||||
.object({ assessments: z.array(assessmentRow) })
|
||||
.meta({ id: "ListMentalHealthAssessmentsResponse" });
|
||||
|
||||
export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/mental-health/assessments": {
|
||||
get: {
|
||||
tags: ["Mental health"],
|
||||
summary: "List the caller's screener history",
|
||||
description:
|
||||
"Returns PHQ-9 / GAD-7 administrations (newest first). Totals + bands + flags only; raw item answers are never returned.",
|
||||
requestParams: { query: listAssessmentsSchema },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Screener history.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
listResponse,
|
||||
"ListMentalHealthAssessmentsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Mental health"],
|
||||
summary: "Record a completed PHQ-9 / GAD-7 screener",
|
||||
description:
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a PHQ9_SCORE / GAD7_SCORE measurement. On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: createAssessmentSchema },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Assessment recorded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
createResponse,
|
||||
"CreateMentalHealthAssessmentEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -77,6 +77,9 @@ export function getPRDirection(
|
||||
case "SIX_MINUTE_WALK_DISTANCE":
|
||||
case "STAIR_ASCENT_SPEED":
|
||||
case "STAIR_DESCENT_SPEED":
|
||||
// v1.25 — grip strength is a clean strength-achievement axis (higher is the
|
||||
// record), the strength limb of the EWGSOP2 sarcopenia algorithm.
|
||||
case "GRIP_STRENGTH":
|
||||
return PersonalRecordDirection.MAX;
|
||||
|
||||
// MIN direction — lower value is the record.
|
||||
@@ -194,6 +197,17 @@ export function getPRDirection(
|
||||
// inputs — a derived composite with no clean goal axis, so it stays null
|
||||
// like the other device-derived scores.
|
||||
case "RESILIENCE":
|
||||
// v1.25 — clinical-signals wave. The mental-health screener totals are
|
||||
// deliberately NOT gamified (no streaks / "best score" framing around a
|
||||
// depression / anxiety screen — research safety rule). Pain NRS is a
|
||||
// symptom, not an achievement axis. Waist circumference + waist-to-height
|
||||
// are goal-dependent like WEIGHT (a lower waist is a goal for some, not
|
||||
// all). All defer to a null PR direction.
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
case "PAIN_NRS":
|
||||
case "WAIST_CIRCUMFERENCE":
|
||||
case "WAIST_TO_HEIGHT":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { labKeys } from "./labs";
|
||||
import { measurementKeys } from "./measurements";
|
||||
import { measurementReminderKeys } from "./measurement-reminders";
|
||||
import { medicationKeys } from "./medications";
|
||||
import { mentalHealthKeys } from "./mental-health";
|
||||
import { moodKeys } from "./mood";
|
||||
import { settingsKeys } from "./settings";
|
||||
import { workoutKeys } from "./workouts";
|
||||
@@ -46,6 +47,7 @@ export const queryKeys = {
|
||||
...measurementReminderKeys,
|
||||
...labKeys,
|
||||
...illnessKeys,
|
||||
...mentalHealthKeys,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Query keys — opt-in mental-health screeners (PHQ-9 / GAD-7).
|
||||
* Part of the centralized factory; aggregated in `./index.ts`.
|
||||
*/
|
||||
export const mentalHealthKeys = {
|
||||
/** The caller's screener history, optionally filtered by instrument. */
|
||||
mentalHealthAssessments: (instrument?: string) =>
|
||||
["mental-health-assessments", instrument ?? null] as const,
|
||||
};
|
||||
@@ -214,5 +214,41 @@
|
||||
"display": "Sleeping breathing disturbances",
|
||||
"unit": "{count}",
|
||||
"category": "vital-signs"
|
||||
},
|
||||
"PHQ9_SCORE": {
|
||||
"loinc": "44261-6",
|
||||
"display": "Patient Health Questionnaire 9 item (PHQ-9) total score [Reported]",
|
||||
"unit": "{score}",
|
||||
"category": "survey"
|
||||
},
|
||||
"GAD7_SCORE": {
|
||||
"loinc": "70274-6",
|
||||
"display": "Generalized anxiety disorder 7 item (GAD-7) total score [Reported.PHQ]",
|
||||
"unit": "{score}",
|
||||
"category": "survey"
|
||||
},
|
||||
"GRIP_STRENGTH": {
|
||||
"loinc": null,
|
||||
"display": "Hand grip strength",
|
||||
"unit": "kg",
|
||||
"category": "vital-signs"
|
||||
},
|
||||
"PAIN_NRS": {
|
||||
"loinc": "72514-3",
|
||||
"display": "Pain severity - 0-10 verbal numeric rating [Score] - Reported",
|
||||
"unit": "{score}",
|
||||
"category": "survey"
|
||||
},
|
||||
"WAIST_CIRCUMFERENCE": {
|
||||
"loinc": "8280-0",
|
||||
"display": "Waist circumference at umbilicus by Tape measure",
|
||||
"unit": "cm",
|
||||
"category": "vital-signs"
|
||||
},
|
||||
"WAIST_TO_HEIGHT": {
|
||||
"loinc": null,
|
||||
"display": "Waist to height ratio",
|
||||
"unit": "1",
|
||||
"category": "vital-signs"
|
||||
}
|
||||
}
|
||||
@@ -1199,6 +1199,311 @@ export const SIGNALS: Record<string, SignalDefinition> = {
|
||||
mcp: true,
|
||||
},
|
||||
},
|
||||
// ── v1.25 clinical-signals wave — validated mental-health screeners ──────
|
||||
// PHQ-9 / GAD-7 totals. Opt-in, beside mood tracking. Deliberately kept OFF
|
||||
// the Coach snapshot + MCP surfaces (`coachSnapshot:false` ⇒ `mcp:false`):
|
||||
// mental-health item content is excluded from AI by construction, and the
|
||||
// score trend stays a quiet local signal. FHIR rides the `survey` category
|
||||
// with the instrument total-score LOINC.
|
||||
PHQ9_SCORE: {
|
||||
key: "PHQ9_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "PHQ9_SCORE" },
|
||||
displayName: "PHQ-9 depression screen (total)",
|
||||
unit: "score",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
normalRange: { low: 0, high: 4 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
loinc: "44261-6",
|
||||
display:
|
||||
"Patient Health Questionnaire 9 item (PHQ-9) total score [Reported]",
|
||||
unit: "{score}",
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
GAD7_SCORE: {
|
||||
key: "GAD7_SCORE",
|
||||
kind: "score",
|
||||
source: { measurementType: "GAD7_SCORE" },
|
||||
displayName: "GAD-7 anxiety screen (total)",
|
||||
unit: "score",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
normalRange: { low: 0, high: 4 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
loinc: "70274-6",
|
||||
display:
|
||||
"Generalized anxiety disorder 7 item (GAD-7) total score [Reported.PHQ]",
|
||||
unit: "{score}",
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
// ── v1.25 clinical-signals wave — physical / clinical measurements ──────
|
||||
GRIP_STRENGTH: {
|
||||
key: "GRIP_STRENGTH",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "GRIP_STRENGTH" },
|
||||
displayName: "Grip strength",
|
||||
unit: "kg",
|
||||
direction: "higher-better",
|
||||
archetype: "activity-fitness",
|
||||
// Coarse population floor; the EWGSOP2 cut-off is sex-specific (men < 27,
|
||||
// women < 16 kg) and applied at the display edge.
|
||||
normalRange: { low: 16, high: 60 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
// TODO(NEEDS-VERIFY): no hand-grip-strength LOINC confirmed this pass —
|
||||
// emits a local text concept until a stable LOINC is checked on loinc.org.
|
||||
loinc: null,
|
||||
display: "Hand grip strength",
|
||||
unit: "kg",
|
||||
category: "vital-signs",
|
||||
},
|
||||
},
|
||||
PAIN_NRS: {
|
||||
key: "PAIN_NRS",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "PAIN_NRS" },
|
||||
displayName: "Pain (0–10 NRS)",
|
||||
unit: "score",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
normalRange: { low: 0, high: 3 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
loinc: "72514-3",
|
||||
display: "Pain severity - 0-10 verbal numeric rating [Score] - Reported",
|
||||
unit: "{score}",
|
||||
category: "survey",
|
||||
},
|
||||
},
|
||||
WAIST_CIRCUMFERENCE: {
|
||||
key: "WAIST_CIRCUMFERENCE",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "WAIST_CIRCUMFERENCE" },
|
||||
sourcePriorityKey: "waist",
|
||||
displayName: "Waist circumference",
|
||||
unit: "cm",
|
||||
direction: "lower-better",
|
||||
archetype: "body-composition",
|
||||
// WHO European-origin increased-risk threshold (men > 94); ethnicity-aware
|
||||
// bands are applied at the display edge.
|
||||
normalRange: { low: 0, high: 94 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
loinc: "8280-0",
|
||||
display: "Waist circumference at umbilicus by Tape measure",
|
||||
unit: "cm",
|
||||
category: "vital-signs",
|
||||
},
|
||||
},
|
||||
WAIST_TO_HEIGHT: {
|
||||
key: "WAIST_TO_HEIGHT",
|
||||
kind: "measurement",
|
||||
source: { measurementType: "WAIST_TO_HEIGHT" },
|
||||
displayName: "Waist-to-height ratio",
|
||||
unit: "ratio",
|
||||
direction: "lower-better",
|
||||
archetype: "body-composition",
|
||||
// NICE: keep waist under half your height — WHtR ≥ 0.5 flags increased risk.
|
||||
normalRange: { low: 0, high: 0.5 },
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
fhir: {
|
||||
// TODO(NEEDS-VERIFY): WHtR LOINC not confirmed this pass — local text concept.
|
||||
loinc: null,
|
||||
display: "Waist to height ratio",
|
||||
unit: "1",
|
||||
category: "vital-signs",
|
||||
},
|
||||
},
|
||||
// ── v1.25 clinical-signals wave — longevity lab panel (kind:"biomarker") ─
|
||||
// Registered through the registry to prove the labs path under one definition
|
||||
// shape. The lab FHIR/UCUM coding lives in `fhir/lab-loinc.ts`; the
|
||||
// biomarker-detail rail renders these via the labs catalog. Off Coach/MCP
|
||||
// here (the labs surface has its own `get_labs` MCP tool).
|
||||
APOB: {
|
||||
key: "APOB",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "apob" },
|
||||
displayName: "Apolipoprotein B",
|
||||
unit: "mg/dL",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
LPA: {
|
||||
key: "LPA",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "lp-a" },
|
||||
displayName: "Lipoprotein(a)",
|
||||
unit: "nmol/L",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
HS_CRP: {
|
||||
key: "HS_CRP",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "hs-crp" },
|
||||
displayName: "hs-CRP",
|
||||
unit: "mg/L",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
HBA1C: {
|
||||
key: "HBA1C",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "hba1c" },
|
||||
displayName: "HbA1c",
|
||||
unit: "%",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
FASTING_GLUCOSE: {
|
||||
key: "FASTING_GLUCOSE",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "fasting-glucose" },
|
||||
displayName: "Fasting glucose",
|
||||
unit: "mg/dL",
|
||||
direction: "target-band",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
FASTING_INSULIN: {
|
||||
key: "FASTING_INSULIN",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "fasting-insulin" },
|
||||
displayName: "Fasting insulin",
|
||||
unit: "µIU/mL",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
EGFR: {
|
||||
key: "EGFR",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "egfr" },
|
||||
displayName: "eGFR (CKD-EPI 2021)",
|
||||
unit: "mL/min/1.73m²",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
GGT: {
|
||||
key: "GGT",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "ggt" },
|
||||
displayName: "GGT",
|
||||
unit: "U/L",
|
||||
direction: "lower-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
FERRITIN: {
|
||||
key: "FERRITIN",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "ferritin" },
|
||||
displayName: "Ferritin",
|
||||
unit: "ng/mL",
|
||||
direction: "target-band",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
OMEGA3_INDEX: {
|
||||
key: "OMEGA3_INDEX",
|
||||
kind: "biomarker",
|
||||
source: { biomarkerKey: "omega-3-index" },
|
||||
displayName: "Omega-3 index",
|
||||
unit: "%",
|
||||
direction: "higher-better",
|
||||
archetype: "physiological-vital",
|
||||
surfaces: {
|
||||
detailPage: false,
|
||||
correlationEligible: false,
|
||||
coachSnapshot: false,
|
||||
mcp: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Type guard: narrow an arbitrary string to a registered signal key. */
|
||||
|
||||
@@ -122,6 +122,19 @@ export const measurementTypeEnum = z.enum([
|
||||
// into the numeric value (limited=1 … exceptional=5), unit `level`. Ingests
|
||||
// server-side as `source = OURA`. See `RESILIENCE_LEVELS` in `src/lib/oura/client`.
|
||||
"RESILIENCE",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// PHQ-9 / GAD-7 derived total scores (one row per completed administration;
|
||||
// value = total). The raw item answers never ride a Measurement row — they
|
||||
// stay in the encrypted MentalHealthAssessment blob.
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
// Grip strength (kg), pain Numeric Rating Scale (0–10), waist circumference
|
||||
// (cm) + waist-to-height ratio. First-class numeric clinical signals; bands
|
||||
// applied at the display edge from the signal registry.
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -357,6 +370,13 @@ const unitMap: Record<string, string> = {
|
||||
// Ordinal level scale (1=limited … 5=exceptional) — the categorical band
|
||||
// encoded into the numeric value. See RESILIENCE_LEVELS in src/lib/oura/client.
|
||||
RESILIENCE: "level",
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
PHQ9_SCORE: "score",
|
||||
GAD7_SCORE: "score",
|
||||
GRIP_STRENGTH: "kg",
|
||||
PAIN_NRS: "score",
|
||||
WAIST_CIRCUMFERENCE: "cm",
|
||||
WAIST_TO_HEIGHT: "ratio",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
@@ -551,6 +571,20 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
// Ordinal level scale: 1 (limited) … 5 (exceptional). The closed encoding
|
||||
// never falls outside this band.
|
||||
RESILIENCE: { min: 1, max: 5 },
|
||||
// ── v1.25 — clinical-signals wave ──
|
||||
// PHQ-9 total 0–27, GAD-7 total 0–21 — server-derived from the screener.
|
||||
PHQ9_SCORE: { min: 0, max: 27 },
|
||||
GAD7_SCORE: { min: 0, max: 21 },
|
||||
// Grip strength kg — a child can read in the single digits, an elite athlete
|
||||
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
|
||||
GRIP_STRENGTH: { min: 0, max: 120 },
|
||||
// Pain NRS — the validated 0–10 scale; the closed band pins the integer.
|
||||
PAIN_NRS: { min: 0, max: 10 },
|
||||
// Waist circumference cm — paediatric lows ~30, severe obesity ~250.
|
||||
WAIST_CIRCUMFERENCE: { min: 30, max: 250 },
|
||||
// Waist-to-height ratio — dimensionless; healthy ~0.4–0.5, the band is
|
||||
// generous either side to admit any plausible body habitus.
|
||||
WAIST_TO_HEIGHT: { min: 0.2, max: 1.5 },
|
||||
};
|
||||
|
||||
export function validateMeasurementRange(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* v1.25 — request/response validation for the opt-in mental-health screeners
|
||||
* (PHQ-9 / GAD-7). The item answers are 0–3 integers; the array length must
|
||||
* match the instrument (PHQ-9 = 9, GAD-7 = 7). `userId` is NEVER a body field —
|
||||
* it is narrowed from the session in the handler (CLAUDE.md convention).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
INSTRUMENTS,
|
||||
type InstrumentId,
|
||||
} from "@/lib/mental-health/instruments";
|
||||
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7"]);
|
||||
|
||||
const itemAnswer = z.number().int().min(0).max(3);
|
||||
|
||||
export const createAssessmentSchema = z
|
||||
.object({
|
||||
instrument: assessmentInstrumentEnum,
|
||||
/** Per-item answers, each 0–3, ordered as the instrument presents them. */
|
||||
items: z.array(itemAnswer).min(1).max(12),
|
||||
/** Optional functional-impairment follow-up (not scored into the total). */
|
||||
functionalDifficulty: z.number().int().min(0).max(3).optional(),
|
||||
/** ISO instant the screener was taken; defaults to now server-side. */
|
||||
takenAt: z.string().datetime().optional(),
|
||||
tz: z.string().max(64).optional(),
|
||||
/** Locale of the validated wording actually presented. */
|
||||
locale: z.string().max(16).optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
const def = INSTRUMENTS[val.instrument as InstrumentId];
|
||||
if (val.items.length !== def.itemCount) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["items"],
|
||||
message: `${val.instrument} expects exactly ${def.itemCount} item answers`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const listAssessmentsSchema = z.object({
|
||||
instrument: assessmentInstrumentEnum.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
offset: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export type CreateAssessmentInput = z.infer<typeof createAssessmentSchema>;
|
||||
Reference in new issue
Block a user