diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b55ba0e06..3b6ddd195 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1837,6 +1837,8 @@ paths: minLength: 1 maxLength: 32 - type: "null" + symptomsPresent: + type: boolean required: - type - value @@ -3170,6 +3172,48 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/diabetes: + get: + tags: + - Auth + summary: Read per-user diabetes opt-in flag + description: Returns the user's per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target + resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general + non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred + from a reading; asserts no diagnosis. + responses: + "200": + description: Resolved flag. + content: + application/json: + schema: + $ref: "#/components/schemas/GetDiabetesResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Auth + summary: Toggle per-user diabetes opt-in flag + description: Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so + the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from + the body. Rate-limit 60/min per user. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DiabetesFlag" + responses: + "200": + description: Resolved next-state echoed back for optimistic-update consumers. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchDiabetesResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/modules: get: tags: @@ -7668,6 +7712,16 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + DiabetesFlag: + type: object + properties: + hasDiabetes: + type: boolean + required: + - hasDiabetes + description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, + postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference + only — never inferred from a value, never a diagnosis. ModulePrefsPatchRequest: type: object properties: @@ -13611,6 +13665,40 @@ components: - data - error additionalProperties: false + GetDiabetesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/DiabetesFlagOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PatchDiabetesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/DiabetesFlagOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetModulesResponse: type: object properties: @@ -19607,6 +19695,17 @@ components: - disableCoach additionalProperties: false description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + DiabetesFlagOutput: + type: object + properties: + hasDiabetes: + type: boolean + required: + - hasDiabetes + additionalProperties: false + description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, + postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference + only — never inferred from a value, never a diagnosis. InsightsLayoutBodyOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index f30923a92..3dcc80763 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4743,7 +4743,10 @@ "savedOff": "Coach ist ausgeblendet.", "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." } - } + }, + "diabetes": "Ich habe Diabetes", + "diabetesDescription": "Nutze ärztliche Glukose-Zielwerte (nüchtern 80–130, nach dem Essen unter 180 mg/dL) statt der allgemeinen Bänder. Das ändert nur den Zielbereich, an dem du gemessen wirst — es ist keine Diagnose.", + "diabetesSaved": "Glukose-Zielwert-Einstellung gespeichert" }, "admin": { "title": "Administration", @@ -6778,5 +6781,24 @@ "reminderSuggestionsLabel": "Kadenz-Vorschläge", "reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen." } + }, + "safety": { + "floor": { + "bpHighTitle": "Sehr hoher Blutdruck", + "bpHighDoctor": "Zwei aktuelle Messungen lagen bei {systolic}/{diastolic} mmHg oder höher. Ruhe dich aus, miss in einigen Minuten erneut und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", + "bpHighEmergency": "Zwei aktuelle Messungen lagen bei {systolic}/{diastolic} mmHg oder höher und du hast Beschwerden angegeben. Bei Brustschmerz, Atemnot, Schwäche oder Seh- oder Sprachstörungen sofort den Notruf wählen.", + "bpLowTitle": "Niedriger Blutdruck", + "bpLowDoctor": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg. Wenn dir schwindelig oder schummrig ist, setz oder leg dich hin und kontaktiere deine Ärztin oder deinen Arzt, wenn es anhält.", + "bpLowEmergency": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg und du hast Beschwerden angegeben. Bei Ohnmachtsgefühl, Verwirrtheit oder kalter, feuchter Haut sofort den Notruf wählen.", + "glucoseLowTitle": "Niedriger Blutzucker", + "glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.", + "glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseLowSevereTitle": "Sehr niedriger Blutzucker", + "glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.", + "glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseHighTitle": "Sehr hoher Blutzucker", + "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", + "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." + } } } diff --git a/messages/en.json b/messages/en.json index 642d77684..a87949920 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4743,7 +4743,10 @@ "savedOff": "Coach hidden.", "saveError": "Could not save the change. Please try again." } - } + }, + "diabetes": "I have diabetes", + "diabetesDescription": "Use clinician glucose targets (fasting 80–130, post-meal under 180 mg/dL) instead of the general bands. This only changes the target you are measured against — it is never a diagnosis.", + "diabetesSaved": "Glucose target preference saved" }, "admin": { "title": "Administration", @@ -6778,5 +6781,24 @@ "reminderSuggestionsLabel": "Cadence suggestions", "reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly." } + }, + "safety": { + "floor": { + "bpHighTitle": "Very high blood pressure", + "bpHighDoctor": "Two recent readings were {systolic}/{diastolic} mmHg or higher. Rest, re-check in a few minutes, and contact your doctor as soon as possible if it stays this high.", + "bpHighEmergency": "Two recent readings were {systolic}/{diastolic} mmHg or higher and you noted symptoms. If you have chest pain, breathlessness, weakness, or vision or speech changes, call emergency services now.", + "bpLowTitle": "Low blood pressure", + "bpLowDoctor": "Two recent readings had a systolic below {systolic} mmHg. If you feel dizzy or faint, sit or lie down, and contact your doctor if it continues.", + "bpLowEmergency": "Two recent readings had a systolic below {systolic} mmHg and you noted symptoms. If you feel faint, confused, or have cold clammy skin, call emergency services now.", + "glucoseLowTitle": "Low blood sugar", + "glucoseLowDoctor": "Two recent readings were below 70 mg/dL ({value} mg/dL). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.", + "glucoseLowEmergency": "Two recent readings were below 70 mg/dL ({value} mg/dL) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.", + "glucoseLowSevereTitle": "Very low blood sugar", + "glucoseLowSevereDoctor": "Two recent readings were below 54 mg/dL ({value} mg/dL). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.", + "glucoseLowSevereEmergency": "Two recent readings were below 54 mg/dL ({value} mg/dL) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.", + "glucoseHighTitle": "Very high blood sugar", + "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", + "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." + } } } diff --git a/messages/es.json b/messages/es.json index fff5381c9..99b6d7782 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4743,7 +4743,10 @@ "savedOff": "Coach oculto.", "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." } - } + }, + "diabetes": "Tengo diabetes", + "diabetesDescription": "Usa objetivos de glucosa clínicos (en ayunas 80–130, tras la comida menos de 180 mg/dL) en lugar de las bandas generales. Esto solo cambia el objetivo con el que te comparas; nunca es un diagnóstico.", + "diabetesSaved": "Preferencia de objetivo de glucosa guardada" }, "admin": { "title": "Administration", @@ -6778,5 +6781,24 @@ "reminderSuggestionsLabel": "Sugerencias de frecuencia", "reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad." } + }, + "safety": { + "floor": { + "bpHighTitle": "Presión arterial muy alta", + "bpHighDoctor": "Dos lecturas recientes fueron de {systolic}/{diastolic} mmHg o más. Descansa, vuelve a medir en unos minutos y contacta a tu médico cuanto antes si sigue así de alta.", + "bpHighEmergency": "Dos lecturas recientes fueron de {systolic}/{diastolic} mmHg o más e indicaste síntomas. Si tienes dolor en el pecho, falta de aire, debilidad o cambios en la visión o el habla, llama a emergencias ya.", + "bpLowTitle": "Presión arterial baja", + "bpLowDoctor": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg. Si te mareas o te sientes débil, siéntate o recuéstate y contacta a tu médico si continúa.", + "bpLowEmergency": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg e indicaste síntomas. Si te sientes a punto de desmayar, confuso o con piel fría y húmeda, llama a emergencias ya.", + "glucoseLowTitle": "Glucosa baja", + "glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.", + "glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.", + "glucoseLowSevereTitle": "Glucosa muy baja", + "glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.", + "glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.", + "glucoseHighTitle": "Glucosa muy alta", + "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", + "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." + } } } diff --git a/messages/fr.json b/messages/fr.json index 4b1daec6e..85cb525e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4743,7 +4743,10 @@ "savedOff": "Coach masqué.", "saveError": "Impossible d enregistrer la modification. Veuillez réessayer." } - } + }, + "diabetes": "Je suis diabétique", + "diabetesDescription": "Utilise des objectifs glycémiques cliniques (à jeun 80–130, après le repas moins de 180 mg/dL) au lieu des plages générales. Cela ne change que l'objectif auquel tu es comparé — ce n'est jamais un diagnostic.", + "diabetesSaved": "Préférence d'objectif glycémique enregistrée" }, "admin": { "title": "Administration", @@ -6778,5 +6781,24 @@ "reminderSuggestionsLabel": "Suggestions de fréquence", "reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement." } + }, + "safety": { + "floor": { + "bpHighTitle": "Tension artérielle très élevée", + "bpHighDoctor": "Deux mesures récentes étaient à {systolic}/{diastolic} mmHg ou plus. Repose-toi, remesure dans quelques minutes et contacte ton médecin dès que possible si cela reste aussi élevé.", + "bpHighEmergency": "Deux mesures récentes étaient à {systolic}/{diastolic} mmHg ou plus et tu as signalé des symptômes. En cas de douleur thoracique, d'essoufflement, de faiblesse ou de troubles de la vision ou de la parole, appelle les secours immédiatement.", + "bpLowTitle": "Tension artérielle basse", + "bpLowDoctor": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg. Si tu te sens étourdi ou faible, assieds-toi ou allonge-toi et contacte ton médecin si cela persiste.", + "bpLowEmergency": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg et tu as signalé des symptômes. Si tu te sens près de t'évanouir, confus ou as la peau froide et moite, appelle les secours immédiatement.", + "glucoseLowTitle": "Glycémie basse", + "glucoseLowDoctor": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.", + "glucoseLowEmergency": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.", + "glucoseLowSevereTitle": "Glycémie très basse", + "glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.", + "glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.", + "glucoseHighTitle": "Glycémie très élevée", + "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", + "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." + } } } diff --git a/messages/it.json b/messages/it.json index 3eadf6c84..48507ed1d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4743,7 +4743,10 @@ "savedOff": "Coach nascosto.", "saveError": "Impossibile salvare la modifica. Riprova." } - } + }, + "diabetes": "Ho il diabete", + "diabetesDescription": "Usa obiettivi glicemici clinici (a digiuno 80–130, dopo i pasti sotto 180 mg/dL) invece delle bande generali. Questo cambia solo l'obiettivo con cui ti confronti — non è mai una diagnosi.", + "diabetesSaved": "Preferenza dell'obiettivo glicemico salvata" }, "admin": { "title": "Administration", @@ -6778,5 +6781,24 @@ "reminderSuggestionsLabel": "Suggerimenti di cadenza", "reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente." } + }, + "safety": { + "floor": { + "bpHighTitle": "Pressione molto alta", + "bpHighDoctor": "Due misurazioni recenti erano di {systolic}/{diastolic} mmHg o superiori. Riposa, rimisura tra qualche minuto e contatta il medico al più presto se resta così alta.", + "bpHighEmergency": "Due misurazioni recenti erano di {systolic}/{diastolic} mmHg o superiori e hai segnalato sintomi. In caso di dolore al petto, affanno, debolezza o disturbi della vista o del linguaggio, chiama subito i soccorsi.", + "bpLowTitle": "Pressione bassa", + "bpLowDoctor": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg. Se ti senti stordito o debole, siediti o sdraiati e contatta il medico se continua.", + "bpLowEmergency": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg e hai segnalato sintomi. Se senti che stai per svenire, sei confuso o hai la pelle fredda e sudata, chiama subito i soccorsi.", + "glucoseLowTitle": "Glicemia bassa", + "glucoseLowDoctor": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.", + "glucoseLowEmergency": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.", + "glucoseLowSevereTitle": "Glicemia molto bassa", + "glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.", + "glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.", + "glucoseHighTitle": "Glicemia molto alta", + "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", + "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." + } } } diff --git a/messages/pl.json b/messages/pl.json index f232aaf25..c60c651f4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4743,7 +4743,10 @@ "savedOff": "Coach ukryty.", "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." } - } + }, + "diabetes": "Mam cukrzycę", + "diabetesDescription": "Użyj klinicznych celów glukozy (na czczo 80–130, po posiłku poniżej 180 mg/dL) zamiast ogólnych zakresów. Zmienia to tylko cel, względem którego jesteś oceniany — to nigdy nie jest diagnoza.", + "diabetesSaved": "Zapisano preferencję celu glukozy" }, "admin": { "title": "Administracja", @@ -6778,5 +6781,24 @@ "reminderSuggestionsLabel": "Sugestie częstotliwości", "reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie." } + }, + "safety": { + "floor": { + "bpHighTitle": "Bardzo wysokie ciśnienie", + "bpHighDoctor": "Dwa ostatnie pomiary wyniosły {systolic}/{diastolic} mmHg lub więcej. Odpocznij, zmierz ponownie za kilka minut i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysokie.", + "bpHighEmergency": "Dwa ostatnie pomiary wyniosły {systolic}/{diastolic} mmHg lub więcej i zgłosiłeś objawy. W razie bólu w klatce piersiowej, duszności, osłabienia lub zaburzeń widzenia czy mowy natychmiast zadzwoń po pomoc.", + "bpLowTitle": "Niskie ciśnienie", + "bpLowDoctor": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg. Jeśli kręci ci się w głowie lub czujesz osłabienie, usiądź lub połóż się i skontaktuj się z lekarzem, jeśli to się utrzymuje.", + "bpLowEmergency": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg i zgłosiłeś objawy. Jeśli czujesz, że możesz zemdleć, jesteś zdezorientowany lub masz zimną, lepką skórę, natychmiast zadzwoń po pomoc.", + "glucoseLowTitle": "Niski poziom cukru", + "glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.", + "glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseLowSevereTitle": "Bardzo niski poziom cukru", + "glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.", + "glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseHighTitle": "Bardzo wysoki poziom cukru", + "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", + "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." + } } } diff --git a/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql new file mode 100644 index 000000000..eaaa0fce8 --- /dev/null +++ b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql @@ -0,0 +1,15 @@ +-- v1.18.6 — diabetes opt-in for clinician glucose targets. +-- +-- One additive, defaulted column on `users` so a user can declare "I have +-- diabetes / clinician glucose targets". When `true`, the glucose target +-- resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, +-- postprandial < 180) instead of the general non-diabetic normal bands. +-- +-- This is NEVER auto-inferred from a reading and is NEVER a diagnosis — it is +-- a user-declared preference that only selects which target band the user is +-- judged against. The default `false` backfills every existing row onto the +-- general bands (the current behaviour), so the migration is a no-op for +-- anyone who never opts in. + +ALTER TABLE "users" + ADD COLUMN "has_diabetes" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 948d0db58..f87097c6e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -193,6 +193,13 @@ model User { moodTagLayoutJson Json? @map("mood_tag_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" + // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. + // When `true`, the glucose target resolver applies the tighter ADA glycemic + // GOAL bands (fasting 80–130, postprandial < 180) instead of the general + // non-diabetic normal bands. NEVER auto-inferred from values, NEVER a + // diagnosis — purely a user-declared preference that selects which target + // band the user is judged against. Default `false`. + hasDiabetes Boolean @default(false) @map("has_diabetes") // v1.7.0 unit pref — global metric/imperial display preference. // Canonical storage stays SI; this only selects the display-time // transform branch (km/h vs mph, km vs mi). Null = default "metric". diff --git a/src/app/api/auth/me/diabetes/route.ts b/src/app/api/auth/me/diabetes/route.ts new file mode 100644 index 000000000..56d72da64 --- /dev/null +++ b/src/app/api/auth/me/diabetes/route.ts @@ -0,0 +1,118 @@ +/** + * v1.18.6 — per-user diabetes opt-in endpoint. + * + * GET /api/auth/me/diabetes — current flag. + * PATCH /api/auth/me/diabetes — body `{ hasDiabetes: boolean }`. + * + * The flag is an explicit, user-declared preference ("I have diabetes / + * clinician glucose targets"). When `true`, the glucose target resolver + * applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial + * < 180) instead of the general non-diabetic normal bands. It is NEVER + * inferred from a reading and asserts NO diagnosis — it only selects which + * target band a glucose reading is judged against. + * + * Mirrors the `disable-coach` / `unit-preference` per-user-scalar pattern: + * 60/min rate limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log + * row, field-by-field write (no mass assignment). Idempotent — the endpoint + * always returns the resolved next state so the client can hard-set the + * optimistic update without an extra round-trip. + */ +import { z } from "zod"; + +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +const patchBodySchema = z.object({ + hasDiabetes: z.boolean(), +}); + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +type DiabetesResponse = { + hasDiabetes: boolean; +}; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.diabetes.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { hasDiabetes: true }, + }); + const payload: DiabetesResponse = { + hasDiabetes: row?.hasDiabetes ?? false, + }; + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `diabetes:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + throw new HttpError(422, "diabetes.body.invalid_json"); + } + + const parsed = patchBodySchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = parsed.data.hasDiabetes; + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { hasDiabetes: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { hasDiabetes: next }, + }); + + await auditLog("user.diabetes.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: previous?.hasDiabetes ?? false, + next, + }, + }); + + annotate({ + action: { name: "auth.me.diabetes.patch" }, + meta: { hasDiabetes: next }, + }); + + const payload: DiabetesResponse = { hasDiabetes: next }; + return apiSuccess(payload); +}); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 77c23833a..0aac4a53c 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -30,6 +30,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check"; import { recomputeBucketsForMeasurement, collapseToTypeDayKeys, @@ -776,6 +777,25 @@ async function postMeasurement(request: NextRequest) { // reminders against the just-landed readings now. Fire-and-forget. void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.18.6 — absolute clinical safety-floor check on the just-written + // readings (confirm-gated, module-gated, never diagnoses). The combined + // BP form lands both arms here, so the BP floor sees a whole reading. + // The transient per-entry `symptomsPresent` flag (ORed across the batch) + // lifts a confirmed breach to the symptom-coupled emergency copy. + // Fire-and-forget — a notification failure never fails the write. + void runSafetyFloorCheck({ + userId: user.id, + written: results.map((r) => ({ + type: r.type, + value: r.value, + measuredAt: r.measuredAt, + glucoseContext: r.glucoseContext, + })), + symptomsPresent: parsed.data.measurements.some( + (m) => m.symptomsPresent === true, + ), + }).catch(() => {}); + // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched so the next analytics / coach read // hits the cache rather than falling through to live aggregation. @@ -836,8 +856,16 @@ async function postMeasurement(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { type, value, measuredAt, notes, source, glucoseContext, deviceType } = - parsed.data; + const { + type, + value, + measuredAt, + notes, + source, + glucoseContext, + deviceType, + symptomsPresent, + } = parsed.data; // Handle unique constraint violation let measurement; @@ -893,6 +921,25 @@ async function postMeasurement(request: NextRequest) { // against the just-landed reading now. Fire-and-forget. void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.18.6 — absolute clinical safety-floor check (confirm-gated, + // module-gated, never diagnoses). A single-entry POST carries only one arm + // of a BP reading, so the BP floor only fires through the combined-form / + // batch path; a lone glucose reading is evaluated here. The transient + // `symptomsPresent` flag lifts a confirmed breach to the emergency copy. + // Fire-and-forget — a notification failure never fails the write. + void runSafetyFloorCheck({ + userId: user.id, + written: [ + { + type: measurement.type, + value: measurement.value, + measuredAt: measurement.measuredAt, + glucoseContext: measurement.glucoseContext, + }, + ], + symptomsPresent, + }).catch(() => {}); + // v1.5.0 — refresh the persistent rollup row for the affected // (type, day) tuple. Runs inline so the next read of the // comprehensive / analytics surface is correct without waiting on diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts new file mode 100644 index 000000000..b16aaa123 --- /dev/null +++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts @@ -0,0 +1,107 @@ +/** + * v1.18.6 — safety-floor escalation push. + * + * Pins: null decision = no-op; a confirmed breach dispatches an URGENT + * SYSTEM_ALERT; asymptomatic → Doctor copy, symptom-coupled → Emergency copy; + * a recent ledger row de-dupes by reason; failures never throw; the ledger + * anchor is stamped before dispatch. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const findFirstMock = vi.fn(); +const createMock = vi.fn(); +const dispatchMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + pushAttempt: { + findFirst: (...a: unknown[]) => findFirstMock(...a), + create: (...a: unknown[]) => createMock(...a), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }), +})); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), +})); + +import { notifySafetyFloor } from "../safety-floor-notify"; +import type { SafetyFloorDecision } from "../safety-floors"; + +const bpHigh: SafetyFloorDecision = { + kind: "BLOOD_PRESSURE", + reason: "bp_hypertensive", + tier: "severe", + symptomCoupled: false, + value: 188, + diastolic: 122, +}; +const glucoseLowSymptomatic: SafetyFloorDecision = { + kind: "GLUCOSE", + reason: "glucose_hypo_severe", + tier: "severe", + symptomCoupled: true, + value: 48, + diastolic: null, +}; + +beforeEach(() => { + findFirstMock.mockReset().mockResolvedValue(null); + createMock.mockReset().mockResolvedValue({}); + dispatchMock.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("notifySafetyFloor", () => { + it("no-op when the decision is null", async () => { + await notifySafetyFloor({ userId: "u1", decision: null }); + expect(dispatchMock).not.toHaveBeenCalled(); + expect(findFirstMock).not.toHaveBeenCalled(); + }); + + it("dispatches an URGENT SYSTEM_ALERT with the asymptomatic Doctor copy", async () => { + await notifySafetyFloor({ userId: "u1", decision: bpHigh }); + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.urgent).toBe(true); + expect(opts.eventType).toBe("SYSTEM_ALERT"); + expect(opts.userId).toBe("u1"); + expect(opts.titleKey).toBe("safety.floor.bpHighTitle"); + expect(opts.messageKey).toBe("safety.floor.bpHighDoctor"); + expect(opts.params).toEqual({ systolic: 188, diastolic: 122 }); + }); + + it("uses the Emergency copy variant when symptom-coupled", async () => { + await notifySafetyFloor({ userId: "u1", decision: glucoseLowSymptomatic }); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.messageKey).toBe("safety.floor.glucoseLowSevereEmergency"); + expect(opts.titleKey).toBe("safety.floor.glucoseLowSevereTitle"); + expect(opts.params).toEqual({ value: 48 }); + }); + + it("de-dupes by reason when a recent ledger row exists", async () => { + findFirstMock.mockResolvedValueOnce({ id: "prior" }); + await notifySafetyFloor({ userId: "u1", decision: bpHigh }); + expect(dispatchMock).not.toHaveBeenCalled(); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("stamps the ledger anchor (keyed by reason) before dispatching", async () => { + await notifySafetyFloor({ userId: "u9", decision: bpHigh }); + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0][0].data.reason).toContain("bp_hypertensive"); + expect(createMock.mock.calls[0][0].data.eventType).toBe("SYSTEM_ALERT"); + }); + + it("never throws when dispatch fails", async () => { + dispatchMock.mockRejectedValueOnce(new Error("boom")); + await expect( + notifySafetyFloor({ userId: "u1", decision: bpHigh }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/illness/__tests__/safety-floors.test.ts b/src/lib/illness/__tests__/safety-floors.test.ts new file mode 100644 index 000000000..4af788946 --- /dev/null +++ b/src/lib/illness/__tests__/safety-floors.test.ts @@ -0,0 +1,227 @@ +/** + * v1.18.6 — absolute clinical safety-floor engine. + * + * Pins each threshold + the confirm-before-alarm gate. The engine must NEVER + * escalate on a single breach reading — a prior same-floor reading inside the + * confirm window is required. Severe tiers keep the confirm gate; symptom + * coupling only lifts the copy tier, never the gate. + */ +import { describe, expect, it } from "vitest"; +import { + evaluateBloodPressure, + evaluateGlucose, + CONFIRM_WINDOW_MS, + BP_SYS_HYPERTENSIVE, + BP_DIA_HYPERTENSIVE, + BP_SYS_HYPOTENSIVE, + GLUCOSE_HYPO, + GLUCOSE_HYPO_SEVERE, + GLUCOSE_HYPER, + type BpSample, + type GlucoseSample, +} from "../safety-floors"; + +const T0 = new Date("2026-06-18T09:00:00Z"); +/** A confirming prior reading 10 minutes before the candidate (in window). */ +const T_PRIOR = new Date(T0.getTime() - 10 * 60 * 1000); +/** A prior reading just outside the confirm window. */ +const T_STALE = new Date(T0.getTime() - CONFIRM_WINDOW_MS - 60 * 1000); + +function bp(measuredAt: Date, systolic: number, diastolic: number): BpSample { + return { measuredAt, systolic, diastolic }; +} +function gl(measuredAt: Date, mgdl: number): GlucoseSample { + return { measuredAt, mgdl }; +} + +describe("safety-floors — blood pressure", () => { + it("does not escalate on a single hypertensive reading (no confirm)", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 185, 110), + recent: [], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("escalates a confirmed hypertensive breach (>=180 systolic) at severe tier", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, BP_SYS_HYPERTENSIVE, 100), + recent: [bp(T_PRIOR, 182, 98)], + symptomCoupled: false, + }); + expect(d).not.toBeNull(); + expect(d!.reason).toBe("bp_hypertensive"); + expect(d!.tier).toBe("severe"); + expect(d!.symptomCoupled).toBe(false); + expect(d!.value).toBe(BP_SYS_HYPERTENSIVE); + expect(d!.diastolic).toBe(100); + }); + + it("escalates when only the diastolic crosses the floor (>=120)", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 150, BP_DIA_HYPERTENSIVE), + recent: [bp(T_PRIOR, 150, 122)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("bp_hypertensive"); + }); + + it("does not confirm with a reading outside the window", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 190, 115), + recent: [bp(T_STALE, 190, 115)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("does not confirm with a prior reading of a DIFFERENT floor family", () => { + // candidate hypertensive, prior hypotensive — not the same floor. + const d = evaluateBloodPressure({ + candidate: bp(T0, 185, 110), + recent: [bp(T_PRIOR, 85, 55)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("treats low BP (<90 systolic) as caution when asymptomatic", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, BP_SYS_HYPOTENSIVE - 1, 55), + recent: [bp(T_PRIOR, 86, 54)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("bp_hypotensive"); + expect(d?.tier).toBe("caution"); + }); + + it("lifts low BP to severe when symptom-coupled", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 85, 55), + recent: [bp(T_PRIOR, 86, 54)], + symptomCoupled: true, + }); + expect(d?.reason).toBe("bp_hypotensive"); + expect(d?.tier).toBe("severe"); + expect(d?.symptomCoupled).toBe(true); + }); + + it("does not escalate a normal reading", () => { + const d = evaluateBloodPressure({ + candidate: bp(T0, 120, 78), + recent: [bp(T_PRIOR, 122, 80)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("ignores a future-dated prior reading (after the candidate)", () => { + const future = new Date(T0.getTime() + 5 * 60 * 1000); + const d = evaluateBloodPressure({ + candidate: bp(T0, 185, 110), + recent: [bp(future, 185, 110)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); +}); + +describe("safety-floors — glucose", () => { + it("does not escalate a single low reading (no confirm)", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 65), + recent: [], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("escalates a confirmed hypo (<70) at caution tier", () => { + const d = evaluateGlucose({ + candidate: gl(T0, GLUCOSE_HYPO - 1), + recent: [gl(T_PRIOR, 66)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hypo"); + expect(d?.tier).toBe("caution"); + expect(d?.value).toBe(GLUCOSE_HYPO - 1); + }); + + it("escalates a confirmed severe-hypo (<54) at severe tier", () => { + const d = evaluateGlucose({ + candidate: gl(T0, GLUCOSE_HYPO_SEVERE - 4), + recent: [gl(T_PRIOR, 52)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hypo_severe"); + expect(d?.tier).toBe("severe"); + }); + + it("confirms a severe-hypo candidate with any low (<70) re-test (same floor family)", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 50), + recent: [gl(T_PRIOR, 68)], // 68 is hypo but not severe — still confirms LOW family + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hypo_severe"); + expect(d?.tier).toBe("severe"); + }); + + it("escalates a confirmed hyper (>=250) at severe tier", () => { + const d = evaluateGlucose({ + candidate: gl(T0, GLUCOSE_HYPER), + recent: [gl(T_PRIOR, 270)], + symptomCoupled: false, + }); + expect(d?.reason).toBe("glucose_hyper"); + expect(d?.tier).toBe("severe"); + }); + + it("does NOT escalate the 200 DKA criterion (euglycemic-DKA conservatism)", () => { + // ADA-2026 lowered the DKA criterion to >=200, but a value alone cannot + // rule DKA in or out, so we never URGENTLY escalate at 200 — only at 250. + const d = evaluateGlucose({ + candidate: gl(T0, 210), + recent: [gl(T_PRIOR, 220)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("does not confirm a low candidate with a high re-test (different family)", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 60), + recent: [gl(T_PRIOR, 260)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("does not confirm outside the window", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 50), + recent: [gl(T_STALE, 50)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); + + it("carries the symptom flag through to the decision", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 300), + recent: [gl(T_PRIOR, 280)], + symptomCoupled: true, + }); + expect(d?.symptomCoupled).toBe(true); + }); + + it("does not escalate an in-range reading", () => { + const d = evaluateGlucose({ + candidate: gl(T0, 110), + recent: [gl(T_PRIOR, 105)], + symptomCoupled: false, + }); + expect(d).toBeNull(); + }); +}); diff --git a/src/lib/illness/safety-floor-check.ts b/src/lib/illness/safety-floor-check.ts new file mode 100644 index 000000000..1b25e1520 --- /dev/null +++ b/src/lib/illness/safety-floor-check.ts @@ -0,0 +1,173 @@ +/** + * Safety-floor write-hook orchestrator (v1.18.6). + * + * Runs AFTER a measurement write lands. Resolves the recent same-kind readings + * the confirm gate needs, runs the absolute-floor engine (`safety-floors.ts`), + * and dispatches an urgent escalation (`safety-floor-notify.ts`) when — and + * only when — a fresh reading breaches a clinical floor AND a prior reading + * inside the confirm window held the same floor. + * + * Module-gated: glucose checks only run when the `glucose` module is enabled; + * BP is a CORE domain (always on) but the BP check is still cheap and only + * fires on an extreme reading. Owner-scoped, fire-and-forget, never throws — + * a notification failure must never fail the user's measurement write. + * + * The caller (the measurement POST route) passes the just-written rows plus + * the transient `symptomsPresent` flag; this helper does the bounded reads. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { + evaluateBloodPressure, + evaluateGlucose, + CONFIRM_WINDOW_MS, + type BpSample, + type GlucoseSample, +} from "@/lib/illness/safety-floors"; +import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify"; + +/** A row the write hook just persisted (the subset the check needs). */ +export interface WrittenReading { + type: string; + value: number; + measuredAt: Date; + glucoseContext?: string | null; +} + +/** + * Evaluate the just-written readings against the absolute safety floors and + * dispatch any confirmed escalation. Best-effort. + * + * `symptomsPresent` is the transient per-write flag — true lifts a confirmed + * breach to the symptom-coupled emergency copy. Defaults to false. + */ +export async function runSafetyFloorCheck(input: { + userId: string; + written: WrittenReading[]; + symptomsPresent?: boolean; +}): Promise { + const { userId, written } = input; + const symptomCoupled = input.symptomsPresent === true; + + try { + await Promise.all([ + checkBloodPressure(userId, written, symptomCoupled), + checkGlucose(userId, written, symptomCoupled), + ]); + } catch (err) { + getEvent()?.addWarning( + `safety-floor check failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} + +/* ── blood pressure ───────────────────────────────────────────────────── */ + +async function checkBloodPressure( + userId: string, + written: WrittenReading[], + symptomCoupled: boolean, +): Promise { + // BP is stored as two rows (SYS + DIA). We only evaluate when BOTH arms of + // the candidate reading are present in this write (the normal combined-form + // and iOS batch path) so we never reason about half a reading. + const sys = written.find((r) => r.type === "BLOOD_PRESSURE_SYS"); + const dia = written.find((r) => r.type === "BLOOD_PRESSURE_DIA"); + if (!sys || !dia) return; + + const candidate: BpSample = { + measuredAt: sys.measuredAt, + systolic: sys.value, + diastolic: dia.value, + }; + + // Pull the recent SYS + DIA rows inside the confirm window and pair them by + // timestamp so the engine sees prior whole readings, not loose arms. The + // candidate's own rows are excluded by the strict `lt: measuredAt` bound. + const windowStart = new Date( + candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS, + ); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, + deletedAt: null, + measuredAt: { gte: windowStart, lt: candidate.measuredAt }, + }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { type: true, value: true, measuredAt: true }, + }); + + const recent = pairBp(rows); + const decision = evaluateBloodPressure({ candidate, recent, symptomCoupled }); + await notifySafetyFloor({ userId, decision }); +} + +/** + * Pair loose SYS/DIA rows into whole BP samples by exact timestamp. A combined + * BP form writes both arms at the same instant, so an exact-instant join is + * the right grain here (the confirm gate only needs prior WHOLE readings). + */ +function pairBp( + rows: ReadonlyArray<{ type: string; value: number; measuredAt: Date }>, +): BpSample[] { + const sysByTs = new Map(); + const diaByTs = new Map(); + for (const r of rows) { + const ts = r.measuredAt.getTime(); + if (r.type === "BLOOD_PRESSURE_SYS") sysByTs.set(ts, r.value); + else if (r.type === "BLOOD_PRESSURE_DIA") diaByTs.set(ts, r.value); + } + const out: BpSample[] = []; + for (const [ts, systolic] of sysByTs) { + const diastolic = diaByTs.get(ts); + if (diastolic === undefined) continue; + out.push({ measuredAt: new Date(ts), systolic, diastolic }); + } + return out; +} + +/* ── glucose ──────────────────────────────────────────────────────────── */ + +async function checkGlucose( + userId: string, + written: WrittenReading[], + symptomCoupled: boolean, +): Promise { + const glucose = written.find((r) => r.type === "BLOOD_GLUCOSE"); + if (!glucose) return; + + // Module gate: only run when the glucose module is enabled for this account. + if (!(await isModuleEnabled(userId, "glucose"))) return; + + const candidate: GlucoseSample = { + measuredAt: glucose.measuredAt, + mgdl: glucose.value, + }; + + const windowStart = new Date( + candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS, + ); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: windowStart, lt: candidate.measuredAt }, + }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { value: true, measuredAt: true }, + }); + + const recent: GlucoseSample[] = rows.map((r) => ({ + measuredAt: r.measuredAt, + mgdl: r.value, + })); + const decision = evaluateGlucose({ candidate, recent, symptomCoupled }); + await notifySafetyFloor({ userId, decision }); +} diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts new file mode 100644 index 000000000..5e206088b --- /dev/null +++ b/src/lib/illness/safety-floor-notify.ts @@ -0,0 +1,123 @@ +/** + * Absolute safety-floor escalation push (v1.18.6). + * + * Bridges a {@link SafetyFloorDecision} (from `safety-floors.ts`, already + * confirm-gated) to the notification dispatcher at the URGENT level, reusing + * the same urgent SYSTEM_ALERT + 24h-dedup mechanics as the illness red-flag + * push (`red-flag-notify.ts`). + * + * Copy framing (firm, safety-reviewed): + * - NEVER diagnoses (no "hypertensive emergency", no "DKA"). + * - ASYMPTOMATIC breach → "re-check and contact your doctor" copy. + * - SYMPTOM-COUPLED breach → emergency copy ("call emergency services"). + * - The body always echoes the (already re-confirmed) reading value. + * + * Dedup: at most one push per (user, reason) per rolling 24h window, anchored + * on a synthetic `pushAttempt` row keyed by reason — so a user re-checking a + * stubbornly-high reading repeatedly doesn't get spammed. Owner-scoped, never + * throws (a notification failure must not break the measurement write). + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; +import type { + SafetyFloorDecision, + SafetyFloorReason, +} from "@/lib/illness/safety-floors"; + +/** One escalation per (user, reason) per this window. */ +const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000; + +/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */ +const LEDGER_REASON_PREFIX = "safety_floor:"; + +/** + * The i18n title/body keys for a decision. Two body variants per reason: the + * asymptomatic "contact your doctor" copy and the symptom-coupled emergency + * copy. Every key below resolves in all six locale bundles (i18n integrity + * guarantee enforced by the locale-integrity test). + */ +function copyKeysFor(decision: SafetyFloorDecision): { + titleKey: string; + messageKey: string; +} { + const variant = decision.symptomCoupled ? "Emergency" : "Doctor"; + const reasonKey: Record = { + bp_hypertensive: "bpHigh", + bp_hypotensive: "bpLow", + glucose_hypo: "glucoseLow", + glucose_hypo_severe: "glucoseLowSevere", + glucose_hyper: "glucoseHigh", + }; + return { + titleKey: `safety.floor.${reasonKey[decision.reason]}Title`, + messageKey: `safety.floor.${reasonKey[decision.reason]}${variant}`, + }; +} + +/** Body params — the re-confirmed reading value(s) echoed into the copy. */ +function paramsFor( + decision: SafetyFloorDecision, +): Record { + if (decision.kind === "BLOOD_PRESSURE") { + return { + systolic: decision.value, + diastolic: decision.diastolic ?? 0, + }; + } + return { value: decision.value }; +} + +/** + * Emit an urgent escalation push for a confirmed safety-floor breach, at most + * once per (user, reason) per `DEDUPE_WINDOW_MS`. No-op when `decision` is + * null. Never throws. + */ +export async function notifySafetyFloor(input: { + userId: string; + decision: SafetyFloorDecision | null; +}): Promise { + const { userId, decision } = input; + if (!decision) return; + + try { + const reason = `${LEDGER_REASON_PREFIX}${decision.reason}`; + const since = new Date(Date.now() - DEDUPE_WINDOW_MS); + const prior = await prisma.pushAttempt.findFirst({ + where: { userId, reason, createdAt: { gte: since } }, + select: { id: true }, + }); + if (prior) return; + + // Stamp the ledger BEFORE dispatching so a concurrent confirm can't + // double-fire. The senders write their own per-channel rows; this + // synthetic row is purely the dedupe anchor. + await prisma.pushAttempt.create({ + data: { + userId, + channel: "WEB_PUSH", + eventType: "SYSTEM_ALERT", + result: "skipped", + reason, + }, + }); + + const { titleKey, messageKey } = copyKeysFor(decision); + await dispatchLocalisedNotification({ + userId, + titleKey, + messageKey, + params: paramsFor(decision), + eventType: "SYSTEM_ALERT", + urgent: true, + }); + + getEvent()?.addMeta("safety_floor_notified", decision.reason); + } catch (err) { + getEvent()?.addWarning( + `safety-floor notify failed (${decision.reason}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +} diff --git a/src/lib/illness/safety-floors.ts b/src/lib/illness/safety-floors.ts new file mode 100644 index 000000000..609e9c38e --- /dev/null +++ b/src/lib/illness/safety-floors.ts @@ -0,0 +1,242 @@ +/** + * Absolute clinical safety floors (v1.18.6) — confirm-before-alarm engine. + * + * The illness *correlation* engine (`correlation.ts`) is retrospective: it + * flags a SUSTAINED multi-day adverse run against a user's OWN baseline. This + * module is its safety-critical complement: it evaluates a SINGLE freshly + * logged reading against ABSOLUTE, guideline-backed clinical floors and, when + * a reading breaches one, decides whether to escalate — but ONLY after a + * second, confirming reading (the AHA "wait and re-measure" discipline), so a + * single oscillometric spike or a one-off finger-stick can never raise a + * false alarm. + * + * Hard design rules (this is health-safety code — conservative by construction): + * + * 1. CONFIRM BEFORE ALARM. A breach reading alone is NEVER enough. We + * require a PRIOR reading of the same kind inside a short re-test window + * that ALSO breached the same floor. One reading → "watch", two → alarm. + * (Severe-tier floors keep the same confirm gate — the differentiator + * between tiers is the copy, not whether we wait.) + * 2. NEVER DIAGNOSE. The copy says "this reading is very high/low — re-check + * and, if it holds, contact your doctor / call emergency services". It + * never names a condition (no "hypertensive emergency", no "DKA"). + * 3. SYMPTOM-COUPLED → emergency copy; ASYMPTOMATIC → contact-doctor copy. + * The caller passes whether the user flagged symptoms with the reading. + * 4. MODULE-GATED. The caller only runs the relevant check when the module + * is enabled (glucose module for glucose, the always-on BP core for BP). + * + * Pure given its inputs — no Prisma, no I/O. The caller resolves the recent + * same-kind readings + the symptom flag and passes them in; the dispatch + + * 24h dedup lives in `safety-floor-notify.ts`. mg/dL + mmHg throughout + * (HealthLog canonical store units). + * + * Citations (general guidance, not medical advice; wide individual variation): + * - BP ≥180/120: ACC/AHA 2017; AHA "Management of Elevated BP in the Acute + * Care Setting" 2024. The number is identical for emergency vs non- + * emergency — symptoms are the differentiator, not the value. Protocol: + * re-measure before acting. + * - Low BP (SBP < 90): NHLBI low-blood-pressure guidance; Hypotension — + * StatPearls/NIH 2024. Cautionary by default; emergency only with self- + * reported shock-type symptoms. + * - Hypoglycemia: ADA Standards of Care §6 — Level 1 alert < 70 mg/dL, + * Level 2 (clinically significant) < 54 mg/dL. Stable 2024–2026. + * - Hyperglycemia / DKA: ADA Standards of Care 2026 — DKA hyperglycemia + * criterion lowered to ≥ 200 mg/dL; ~10% of DKA is euglycemic, so a + * glucose value alone cannot rule it out — we NEVER show "all clear" below + * 200 and reserve the urgent escalation for a sustained very-high band + * (≥ 250 mg/dL) per the practical seek-care trigger. + */ + +/** Reading kinds the absolute-floor engine evaluates. */ +export type SafetyFloorKind = "BLOOD_PRESSURE" | "GLUCOSE"; + +/** Why a reading breached a floor — a stable key the i18n layer renders. */ +export type SafetyFloorReason = + | "bp_hypertensive" // SBP ≥ 180 OR DBP ≥ 120 + | "bp_hypotensive" // SBP < 90 + | "glucose_hypo" // < 70 mg/dL + | "glucose_hypo_severe" // < 54 mg/dL + | "glucose_hyper"; // ≥ 250 mg/dL (sustained-very-high seek-care trigger) + +/** Severity tier — drives copy, never the confirm gate. */ +export type SafetyFloorTier = "caution" | "severe"; + +/* ── absolute floors (documented clinical constants) ─────────────────── */ + +/** Hypertensive-urgency floor: systolic ≥ this OR diastolic ≥ DIA floor. */ +export const BP_SYS_HYPERTENSIVE = 180; +export const BP_DIA_HYPERTENSIVE = 120; +/** Symmetric low-BP cautionary floor: systolic < this. */ +export const BP_SYS_HYPOTENSIVE = 90; + +/** Hypoglycemia Level-1 alert: glucose < this (mg/dL). */ +export const GLUCOSE_HYPO = 70; +/** Hypoglycemia Level-2 (clinically significant): glucose < this (mg/dL). */ +export const GLUCOSE_HYPO_SEVERE = 54; +/** + * Hyperglycemia urgent escalation floor (mg/dL). ADA 2026 lowered the DKA + * hyperglycemia CRITERION to ≥ 200, but a value alone cannot rule DKA in OR + * out (euglycemic DKA exists), so we do NOT urgently escalate at 200. We + * reserve the escalation for the practical "sustained very-high" seek-care + * trigger (≥ 250) and the copy NEVER reads "all clear" below 200. + */ +export const GLUCOSE_HYPER = 250; + +/** + * Confirm window: a breach reading only escalates when a PRIOR same-kind + * reading inside this window ALSO breached the SAME floor. Long enough that a + * deliberate "wait a minute and re-measure" is captured, short enough that two + * unrelated readings days apart don't spuriously confirm each other. + */ +export const CONFIRM_WINDOW_MS = 60 * 60 * 1000; // 1 hour + +/* ── inputs (the caller resolves these and passes them in) ───────────── */ + +/** A blood-pressure reading (systolic + diastolic, mmHg) at an instant. */ +export interface BpSample { + measuredAt: Date; + systolic: number; + diastolic: number; +} + +/** A spot glucose reading (mg/dL canonical) at an instant. */ +export interface GlucoseSample { + measuredAt: Date; + mgdl: number; +} + +/** The escalation decision the engine returns (or null = nothing to do). */ +export interface SafetyFloorDecision { + kind: SafetyFloorKind; + reason: SafetyFloorReason; + tier: SafetyFloorTier; + /** True when the user flagged symptoms → emergency-tier copy. */ + symptomCoupled: boolean; + /** The breaching value(s), for the localised body params. */ + value: number; + /** The diastolic value when `kind === "BLOOD_PRESSURE"` (else null). */ + diastolic: number | null; +} + +/* ── BP evaluation ────────────────────────────────────────────────────── */ + +/** Classify a single BP reading against the absolute floors. */ +function classifyBp( + sys: number, + dia: number, +): { reason: SafetyFloorReason; tier: SafetyFloorTier } | null { + if (sys >= BP_SYS_HYPERTENSIVE || dia >= BP_DIA_HYPERTENSIVE) { + return { reason: "bp_hypertensive", tier: "severe" }; + } + if (sys < BP_SYS_HYPOTENSIVE) { + // Symmetric low-BP is cautionary by default — most hypotension is benign; + // it only becomes urgent when symptom-coupled (handled by the caller's + // symptom flag, which lifts the copy to emergency tier). + return { reason: "bp_hypotensive", tier: "caution" }; + } + return null; +} + +/** + * Evaluate a fresh BP reading against the absolute floors with the confirm + * gate. Returns a decision ONLY when the candidate breaches AND a prior + * reading inside the confirm window breached the SAME reason. `recent` is the + * user's other same-kind readings (excluding the candidate); order-independent. + */ +export function evaluateBloodPressure(input: { + candidate: BpSample; + recent: BpSample[]; + symptomCoupled: boolean; +}): SafetyFloorDecision | null { + const { candidate, recent, symptomCoupled } = input; + const candClass = classifyBp(candidate.systolic, candidate.diastolic); + if (!candClass) return null; + + const windowStart = candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS; + const confirmed = recent.some((r) => { + const t = r.measuredAt.getTime(); + if (t < windowStart || t > candidate.measuredAt.getTime()) return false; + const c = classifyBp(r.systolic, r.diastolic); + return c?.reason === candClass.reason; + }); + if (!confirmed) return null; + + return { + kind: "BLOOD_PRESSURE", + reason: candClass.reason, + // A symptom-coupled hypotensive breach escalates to severe-tier copy + // (shock signs → emergency); asymptomatic stays cautionary. + tier: + candClass.reason === "bp_hypotensive" && symptomCoupled + ? "severe" + : candClass.tier, + symptomCoupled, + value: candidate.systolic, + diastolic: candidate.diastolic, + }; +} + +/* ── glucose evaluation ───────────────────────────────────────────────── */ + +/** Classify a single glucose reading (mg/dL) against the absolute floors. */ +function classifyGlucose( + mgdl: number, +): { reason: SafetyFloorReason; tier: SafetyFloorTier } | null { + if (mgdl < GLUCOSE_HYPO_SEVERE) { + return { reason: "glucose_hypo_severe", tier: "severe" }; + } + if (mgdl < GLUCOSE_HYPO) { + return { reason: "glucose_hypo", tier: "caution" }; + } + if (mgdl >= GLUCOSE_HYPER) { + return { reason: "glucose_hyper", tier: "severe" }; + } + return null; +} + +/** + * Evaluate a fresh glucose reading against the absolute floors with the + * confirm gate. The confirm match is by FLOOR FAMILY, not exact reason: a + * follow-up reading that is hypo (< 70) confirms a candidate that is + * severe-hypo (< 54) and vice versa — both are "the low floor held on + * re-test". The same applies to the high floor. Hypoglycemia is acutely + * dangerous, so a severe-hypo candidate confirmed by ANY low re-test still + * escalates at the severe tier (the candidate's own tier wins). + */ +export function evaluateGlucose(input: { + candidate: GlucoseSample; + recent: GlucoseSample[]; + symptomCoupled: boolean; +}): SafetyFloorDecision | null { + const { candidate, recent, symptomCoupled } = input; + const candClass = classifyGlucose(candidate.mgdl); + if (!candClass) return null; + + const family = floorFamily(candClass.reason); + const windowStart = candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS; + const confirmed = recent.some((r) => { + const t = r.measuredAt.getTime(); + if (t < windowStart || t > candidate.measuredAt.getTime()) return false; + const c = classifyGlucose(r.mgdl); + return c != null && floorFamily(c.reason) === family; + }); + if (!confirmed) return null; + + return { + kind: "GLUCOSE", + reason: candClass.reason, + tier: candClass.tier, + symptomCoupled, + value: Math.round(candidate.mgdl), + diastolic: null, + }; +} + +/** Group a glucose reason into its directional floor family for confirm-matching. */ +function floorFamily(reason: SafetyFloorReason): "low" | "high" | "other" { + if (reason === "glucose_hypo" || reason === "glucose_hypo_severe") { + return "low"; + } + if (reason === "glucose_hyper") return "high"; + return "other"; +} diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 2a5654939..cd329e1ea 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -67,6 +67,20 @@ const disableCoachFlag = z "Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.", }); +// v1.18.6 — explicit diabetes opt-in. Shared by GET (current) and PATCH +// (request body + resolved response). `true` switches the glucose target +// resolver to the tighter ADA glycemic GOAL bands (fasting 80–130, +// postprandial < 180). Never inferred from a reading; asserts no diagnosis. +const diabetesFlag = z + .object({ + hasDiabetes: z.boolean(), + }) + .meta({ + id: "DiabetesFlag", + description: + "Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference only — never inferred from a value, never a diagnosis.", + }); + // v1.16.11 — per-user notification preferences. The PATCH request body // is the REAL runtime validator (`notificationPrefsSchema` — every // category and field optional, deep-merged server-side); the response @@ -415,6 +429,47 @@ export const profilePaths: NonNullable = { }, }, }, + "/api/auth/me/diabetes": { + get: { + tags: ["Auth"], + summary: "Read per-user diabetes opt-in flag", + description: + 'Returns the user\'s per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred from a reading; asserts no diagnosis.', + responses: { + "200": { + description: "Resolved flag.", + content: { + "application/json": { + schema: dataEnvelope(diabetesFlag, "GetDiabetesResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Toggle per-user diabetes opt-in flag", + description: + "Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from the body. Rate-limit 60/min per user.", + requestBody: { + required: true, + content: { "application/json": { schema: diabetesFlag } }, + }, + responses: { + "200": { + description: + "Resolved next-state echoed back for optimistic-update consumers.", + content: { + "application/json": { + schema: dataEnvelope(diabetesFlag, "PatchDiabetesResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/modules": { get: { tags: ["Auth"], diff --git a/src/lib/targets/__tests__/glucose-targets.test.ts b/src/lib/targets/__tests__/glucose-targets.test.ts new file mode 100644 index 000000000..1b2c10d1b --- /dev/null +++ b/src/lib/targets/__tests__/glucose-targets.test.ts @@ -0,0 +1,98 @@ +/** + * v1.18.6 — diabetes-aware glucose target resolver. + * + * Pins: flag OFF → general non-diabetic band; flag ON → tighter ADA goal band; + * a user override always wins over BOTH; the flag is consumed verbatim (never + * derived from a value). + */ +import { describe, expect, it } from "vitest"; +import { + resolveGlucoseTarget, + DIABETIC_GOAL_BANDS, +} from "../glucose-targets"; +import type { UserProfileForRange } from "@/lib/analytics/effective-range"; + +const profile: UserProfileForRange = { + heightCm: 178, + dateOfBirth: "1985-01-01", + gender: "MALE", +}; + +describe("resolveGlucoseTarget", () => { + it("uses the general non-diabetic band when the flag is OFF", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: false, + profile, + overrides: null, + }); + expect(r.source).toBe("default"); + expect(r.isOverride).toBe(false); + // General fasting normal band is 70–99 (non-diabetic). + expect(r.range?.greenMin).toBe(70); + expect(r.range?.greenMax).toBe(99); + }); + + it("uses the tighter ADA goal band when the flag is ON (fasting 80-130)", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: true, + profile, + overrides: null, + }); + expect(r.source).toBe("ADA goal (diabetes)"); + expect(r.range?.greenMin).toBe(DIABETIC_GOAL_BANDS.FASTING.min); + expect(r.range?.greenMax).toBe(DIABETIC_GOAL_BANDS.FASTING.max); + expect(r.range?.greenMin).toBe(80); + expect(r.range?.greenMax).toBe(130); + }); + + it("uses the ADA postprandial goal band (< 180) when the flag is ON", () => { + const r = resolveGlucoseTarget({ + context: "POSTPRANDIAL", + hasDiabetes: true, + profile, + overrides: null, + }); + expect(r.range?.greenMin).toBe(80); + expect(r.range?.greenMax).toBe(180); + }); + + it("lets a user override win over the diabetic goal band", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: true, + profile, + overrides: { BLOOD_GLUCOSE_FASTING: { min: 90, max: 120 } }, + }); + expect(r.source).toBe("custom"); + expect(r.isOverride).toBe(true); + expect(r.range?.greenMin).toBe(90); + expect(r.range?.greenMax).toBe(120); + }); + + it("lets a user override win over the general band (flag OFF)", () => { + const r = resolveGlucoseTarget({ + context: "FASTING", + hasDiabetes: false, + profile, + overrides: { BLOOD_GLUCOSE_FASTING: { min: 75, max: 105 } }, + }); + expect(r.source).toBe("custom"); + expect(r.range?.greenMin).toBe(75); + expect(r.range?.greenMax).toBe(105); + }); + + it("resolves the diabetic goal band identically for every context", () => { + for (const ctx of ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const) { + const r = resolveGlucoseTarget({ + context: ctx, + hasDiabetes: true, + profile, + overrides: null, + }); + expect(r.range?.greenMin).toBe(DIABETIC_GOAL_BANDS[ctx].min); + expect(r.range?.greenMax).toBe(DIABETIC_GOAL_BANDS[ctx].max); + } + }); +}); diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 7bae2df9d..1c2489df0 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -46,12 +46,10 @@ import type { MeasurementType, GlucoseContext, } from "@/generated/prisma/client"; -import { - getEffectiveRange, - type ThresholdOverridesJson, -} from "@/lib/analytics/effective-range"; +import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range"; import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; -import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveGlucoseUnit } from "@/lib/glucose"; +import { resolveGlucoseTarget } from "@/lib/targets/glucose-targets"; import { ensureUserMoodRollupsFresh, readMoodDayRollups, @@ -219,6 +217,9 @@ export async function buildTargetsResponse(user: AuthedUser) { dateOfBirth: true, gender: true, glucoseUnit: true, + // v1.18.6 — diabetes opt-in selects the tighter ADA glycemic goal + // band for glucose targets (resolved below via resolveGlucoseTarget). + hasDiabetes: true, thresholdsJson: true, // v1.11.5 — needed to collapse a dual-source sleep night to one // canonical source before reconstructing per-night asleep totals. @@ -1456,20 +1457,28 @@ export async function buildTargetsResponse(user: AuthedUser) { ) / 10 : null; - const metric = thresholdMetricForContext(ctx); - const eff = getEffectiveRange(metric, profileForRange, overrides); - const range = eff.range - ? { min: eff.range.greenMin, max: eff.range.greenMax } + // v1.18.6 — diabetes-aware target band. When the user has declared + // diabetes (and has NOT pinned a custom override) the tighter ADA glycemic + // GOAL band wins; otherwise the general non-diabetic band (or the override) + // is used exactly as before. The flag is never inferred from a value. + const resolved = resolveGlucoseTarget({ + context: ctx, + hasDiabetes: dbUser?.hasDiabetes ?? false, + profile: profileForRange, + overrides, + }); + const effRange = resolved.range; + const range = effRange + ? { min: effRange.greenMin, max: effRange.greenMax } : null; let classification: { category: string; color: string } | null = null; - if (range) { + if (range && effRange) { if (latest >= range.min && latest <= range.max) { classification = { category: "Optimal", color: "#50fa7b" }; } else if ( - eff.range && - latest >= eff.range.orangeMin && - latest <= eff.range.orangeMax + latest >= effRange.orangeMin && + latest <= effRange.orangeMax ) { classification = { category: "Elevated", color: "#f1fa8c" }; } else { @@ -1481,8 +1490,8 @@ export async function buildTargetsResponse(user: AuthedUser) { recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), makeRangeClassifier( range, - eff.range - ? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax } + effRange + ? { orangeMin: effRange.orangeMin, orangeMax: effRange.orangeMax } : undefined, ), ); @@ -1495,7 +1504,12 @@ export async function buildTargetsResponse(user: AuthedUser) { unit: glucoseUnit, range, classification, - source: eff.isOverride ? "Custom" : "ADA 2024 / DDG", + source: + resolved.source === "custom" + ? "Custom" + : resolved.source === "ADA goal (diabetes)" + ? "ADA goal (diabetes)" + : "ADA 2024 / DDG", ...consistency, }); } diff --git a/src/lib/targets/glucose-targets.ts b/src/lib/targets/glucose-targets.ts new file mode 100644 index 000000000..a52b6dfaa --- /dev/null +++ b/src/lib/targets/glucose-targets.ts @@ -0,0 +1,147 @@ +/** + * Diabetes-aware blood-glucose TARGET resolver (v1.18.6). + * + * One question: "what glucose band should THIS user be judged against on this + * context, right now?" The answer depends on a single explicit, user-declared + * preference — `User.hasDiabetes` — and NEVER on any reading. The flag is set + * through Settings (`PATCH /api/auth/me/diabetes`); it is never inferred from + * a value and never asserts a diagnosis. + * + * - Flag OFF (default): the general non-diabetic normal bands — the existing + * behaviour, resolved by the shared reference-range resolver + * (`getEffectiveRange`, which owns the band display) so a user-override + * still wins and nothing about the calm-state UI changes. + * - Flag ON: the tighter ADA glycemic GOAL bands for people living with + * diabetes — fasting / pre-prandial 80–130 mg/dL, peak post-prandial + * < 180 mg/dL (ADA Standards of Care §6, stable 2024–2026). A user + * override still wins (a clinician may pin an individualised goal), but + * absent an override the diabetic goal band — not the normal band — is the + * target. + * + * The diabetic goal targets are GOALS, not screening thresholds: a fasting 80 + * is "in goal" for someone managing diabetes, where the non-diabetic 70–99 + * normal band would call the same reading low-normal. This is exactly why we + * keep the two resolvers distinct rather than widening the general band. + * + * Pure given its inputs. The route resolves the flag + overrides and passes + * them in. mg/dL throughout (HealthLog canonical store unit); display-time + * conversion to mmol/L is the caller's job (`src/lib/glucose.ts`). + * + * Citation: American Diabetes Association, Standards of Care in Diabetes + * §6 "Glycemic Goals and Hypoglycemia", Diabetes Care 2024 47(Suppl. 1):S111. + * Pre-prandial plasma glucose goal 80–130 mg/dL; peak post-prandial < 180 + * mg/dL. https://diabetesjournals.org/care/article/47/Supplement_1/S111/153957 + */ + +import { + getEffectiveRange, + type ThresholdMetric, + type ThresholdOverridesJson, + type UserProfileForRange, +} from "@/lib/analytics/effective-range"; +import type { TrafficRange } from "@/lib/analytics/value-bands"; +import { thresholdMetricForContext } from "@/lib/glucose"; + +export type GlucoseContextKey = + | "FASTING" + | "POSTPRANDIAL" + | "RANDOM" + | "BEDTIME"; + +/** + * ADA glycemic GOAL bands for people living with diabetes (mg/dL). + * + * FASTING / pre-prandial: 80–130 (ADA pre-prandial plasma glucose goal). + * POSTPRANDIAL: floor 80 (avoid hypoglycemia), ceiling 180 (ADA peak + * post-prandial goal `< 180`; the inclusive 180 is the band edge). + * RANDOM: a random spot reading is not a defined ADA goal context; the + * post-prandial goal is the most clinically defensible comparator (a random + * reading is most often post-meal). Use the same 80–180 band. + * BEDTIME: ADA publishes no separate bedtime goal for adults; the ISPAD + * pediatric bedtime band (90–150) is the closest comparator and the slightly + * higher floor guards against nocturnal hypoglycemia. Kept identical to the + * non-diabetic bedtime band on purpose — the diabetic delta lives in the + * fasting/post-prandial goals, where ADA is explicit. + */ +const DIABETIC_GOAL_BANDS: Record< + GlucoseContextKey, + { min: number; max: number } +> = { + FASTING: { min: 80, max: 130 }, + POSTPRANDIAL: { min: 80, max: 180 }, + RANDOM: { min: 80, max: 180 }, + BEDTIME: { min: 90, max: 150 }, +}; + +/** Source label surfaced on the resolved range for the targets surface. */ +export type GlucoseTargetSource = "ADA goal (diabetes)" | "default" | "custom"; + +export interface ResolvedGlucoseTarget { + /** The green band the reading is judged against (mg/dL). */ + range: TrafficRange | null; + /** True when a user threshold override is in play (override always wins). */ + isOverride: boolean; + /** + * Which band family produced the range: the diabetic ADA goal, the general + * default, or a user custom override. Drives the targets-card source label. + */ + source: GlucoseTargetSource; +} + +/** + * Build the diabetic-goal `TrafficRange`. The orange wings mirror the general + * resolver's glucose convention (floor − 10 below, a modest ceiling above) so + * a reading just outside goal reads "elevated", not "high". + */ +function diabeticGoalRange(ctx: GlucoseContextKey): TrafficRange { + const band = DIABETIC_GOAL_BANDS[ctx]; + return { + greenMin: band.min, + greenMax: band.max, + orangeMin: band.min - 10, + // Above the goal ceiling stays "elevated" up to a modest margin before + // the red "high" band; matches the general resolver's wing width posture. + orangeMax: band.max + 30, + }; +} + +/** + * Resolve the glucose target band for one context. + * + * `hasDiabetes` is the user's explicit, declared preference. When `true` AND + * the user has NOT overridden the metric, the ADA diabetic goal band wins. + * Otherwise we defer entirely to the shared reference-range resolver so the + * general non-diabetic band (and any user override) behaves exactly as before. + * + * The flag is NEVER derived from a reading and asserts no diagnosis — it only + * selects the band the user is measured against. + */ +export function resolveGlucoseTarget(input: { + context: GlucoseContextKey; + hasDiabetes: boolean; + profile: UserProfileForRange; + overrides: ThresholdOverridesJson | null | undefined; +}): ResolvedGlucoseTarget { + const { context, hasDiabetes, profile, overrides } = input; + const metric = thresholdMetricForContext(context) as ThresholdMetric; + const eff = getEffectiveRange(metric, profile, overrides); + + // A user override always wins — a clinician-pinned individualised goal must + // not be silently replaced by the population ADA goal band. + if (eff.isOverride) { + return { range: eff.range, isOverride: true, source: "custom" }; + } + + if (hasDiabetes) { + return { + range: diabeticGoalRange(context), + isOverride: false, + source: "ADA goal (diabetes)", + }; + } + + return { range: eff.range, isOverride: false, source: "default" }; +} + +/** The ADA diabetic goal bands, exported for tests + documentation. */ +export { DIABETIC_GOAL_BANDS }; diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index a2b48acbf..1e39e7cd5 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -575,6 +575,12 @@ export const createMeasurementSchema = z // metadata) now persist the tag instead of seeing it disappear. // Accepts `null` so a client can explicitly clear the column. deviceType: z.string().min(1).max(32).nullable().optional(), + // v1.18.6 — optional "I feel unwell with this reading" flag. Transient + // (NOT persisted on the measurement row): it only lifts a confirmed + // safety-floor escalation from the asymptomatic "contact your doctor" + // copy to the symptom-coupled emergency copy. Defaults to false/absent — + // every existing client keeps the asymptomatic posture. Never a diagnosis. + symptomsPresent: z.boolean().optional(), }) .refine((data) => validateMeasurementRange(data.type, data.value) === null, { message: "Value out of plausible range",