mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge feat/w-d-mental-wellbeing into release/v1.27.6
The Vorsorge card resolves both rules at once: the action button keeps its constant look in every state (v1.27.5), and a screening reminder's action reads 'start the check-in' instead of the numeric measure wording (this wave).
This commit is contained in:
commit
f1e5df52ab
28 files changed
+657
-886
No files matched your search
@@ -8131,8 +8131,9 @@ paths:
|
||||
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.
|
||||
description: Stores one administration (item answers encrypted) and writes the derived total as a server-owned
|
||||
PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the
|
||||
response carries the locale-aware crisis-resource set.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -11134,9 +11135,12 @@ components:
|
||||
- TOTAL_BODY_WATER
|
||||
- VISCERAL_FAT
|
||||
- BODY_MASS_INDEX
|
||||
description: Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS
|
||||
row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy
|
||||
or a matching lab result.
|
||||
- PHQ9_SCORE
|
||||
- GAD7_SCORE
|
||||
description: Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves
|
||||
on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the
|
||||
server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that
|
||||
resolves only on a manual satisfy or a matching lab result.
|
||||
MeasurementReminderUpdate:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+12
-24
@@ -6988,11 +6988,14 @@
|
||||
"BONE_MASS": "Knochenmasse",
|
||||
"TOTAL_BODY_WATER": "Körperwasser",
|
||||
"VISCERAL_FAT": "Viszeralfett",
|
||||
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)"
|
||||
"BODY_MASS_INDEX": "Body-Mass-Index (BMI)",
|
||||
"PHQ9_SCORE": "PHQ-9 · Stimmung",
|
||||
"GAD7_SCORE": "GAD-7 · Sorgen"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitalwerte",
|
||||
"bodyComposition": "Körperzusammensetzung"
|
||||
"bodyComposition": "Körperzusammensetzung",
|
||||
"mentalWellbeing": "Seelisches Wohlbefinden"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Noch keine Erinnerungen",
|
||||
@@ -7026,6 +7029,7 @@
|
||||
"selfPlanned": "Selbstgeplant",
|
||||
"captureValue": "Wert erfassen",
|
||||
"measureNow": "Jetzt messen",
|
||||
"startCheckIn": "Check-in starten",
|
||||
"nextDueLabel": "Nächste Fälligkeit:",
|
||||
"lastDoneLabel": "Zuletzt erledigt:",
|
||||
"customize": "Vorsorge anpassen",
|
||||
@@ -7627,6 +7631,7 @@
|
||||
"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.",
|
||||
"pageRhythmHint": "Beide Tests blicken auf die letzten zwei Wochen zurück — ein Check-in alle zwei bis vier Wochen ist deshalb ein guter Rhythmus.",
|
||||
"attributionLabel": "Quelle",
|
||||
"choosePrompt": "Check-in auswählen",
|
||||
"start": "Starten",
|
||||
@@ -7637,8 +7642,8 @@
|
||||
"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"
|
||||
"phq9": "PHQ-9 · Stimmung",
|
||||
"gad7": "GAD-7 · Sorgen"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Depressions-Screening, 9 Fragen",
|
||||
@@ -7650,13 +7655,6 @@
|
||||
"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",
|
||||
@@ -7681,14 +7679,11 @@
|
||||
"history": {
|
||||
"title": "Dein Verlauf",
|
||||
"empty": "Noch keine Check-ins. Dein Verlauf erscheint hier, sobald du einen abgeschlossen hast.",
|
||||
"chartTitle": "Punkteverlauf",
|
||||
"flaggedBadge": "Hilfe angezeigt",
|
||||
"totalLabel": "Punkte",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Einstufung"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Du musst das nicht allein durchstehen",
|
||||
@@ -7746,18 +7741,11 @@
|
||||
"gad7": "Angst-Screening mit 7 Fragen. Beziehe dich auf die letzten zwei Wochen."
|
||||
},
|
||||
"progress": "{current} / {total}",
|
||||
"questionAria": "Frage {current} von {total}",
|
||||
"next": "Weiter",
|
||||
"lastResult": "Letzter Test",
|
||||
"lastScore": "Letztes Ergebnis",
|
||||
"noResultYet": "Noch kein Check-in",
|
||||
"backToList": "Übersicht",
|
||||
"jumpFirst": "Zur ersten Frage springen",
|
||||
"jumpLast": "Zur letzten Frage springen",
|
||||
"review": {
|
||||
"title": "Überblick",
|
||||
"recap": "Du hast {answered} von {total} Fragen beantwortet."
|
||||
},
|
||||
"openDetail": "Verlauf anzeigen"
|
||||
"backToList": "Übersicht"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
|
||||
|
||||
+12
-24
@@ -6988,11 +6988,14 @@
|
||||
"BONE_MASS": "Bone mass",
|
||||
"TOTAL_BODY_WATER": "Body water",
|
||||
"VISCERAL_FAT": "Visceral fat",
|
||||
"BODY_MASS_INDEX": "Body mass index (BMI)"
|
||||
"BODY_MASS_INDEX": "Body mass index (BMI)",
|
||||
"PHQ9_SCORE": "PHQ-9 · mood",
|
||||
"GAD7_SCORE": "GAD-7 · worry"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Vitals",
|
||||
"bodyComposition": "Body composition"
|
||||
"bodyComposition": "Body composition",
|
||||
"mentalWellbeing": "Mental wellbeing"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No reminders yet",
|
||||
@@ -7026,6 +7029,7 @@
|
||||
"selfPlanned": "Self-planned",
|
||||
"captureValue": "Log value",
|
||||
"measureNow": "Measure now",
|
||||
"startCheckIn": "Start check-in",
|
||||
"nextDueLabel": "Next due:",
|
||||
"lastDoneLabel": "Last done:",
|
||||
"customize": "Customize checkups",
|
||||
@@ -7627,6 +7631,7 @@
|
||||
"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.",
|
||||
"pageRhythmHint": "Both tests look back over the last two weeks, so a check-in every two to four weeks is a comfortable rhythm.",
|
||||
"attributionLabel": "Attribution",
|
||||
"choosePrompt": "Choose a check-in",
|
||||
"start": "Start",
|
||||
@@ -7637,8 +7642,8 @@
|
||||
"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"
|
||||
"phq9": "PHQ-9 · mood",
|
||||
"gad7": "GAD-7 · worry"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Depression screen, 9 questions",
|
||||
@@ -7650,13 +7655,6 @@
|
||||
"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",
|
||||
@@ -7681,14 +7679,11 @@
|
||||
"history": {
|
||||
"title": "Your history",
|
||||
"empty": "No check-ins yet. Your trend will appear here once you complete one.",
|
||||
"chartTitle": "Score trend",
|
||||
"flaggedBadge": "Support shown",
|
||||
"totalLabel": "Score",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Category"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "You don't have to face this alone",
|
||||
@@ -7746,18 +7741,11 @@
|
||||
"gad7": "7-question anxiety screen. Answer for the last two weeks."
|
||||
},
|
||||
"progress": "{current} / {total}",
|
||||
"questionAria": "Question {current} of {total}",
|
||||
"next": "Next",
|
||||
"lastResult": "Last test",
|
||||
"lastScore": "Last result",
|
||||
"noResultYet": "No check-in yet",
|
||||
"backToList": "Overview",
|
||||
"jumpFirst": "Jump to first question",
|
||||
"jumpLast": "Jump to last question",
|
||||
"review": {
|
||||
"title": "Review",
|
||||
"recap": "You answered {answered} of {total} questions."
|
||||
},
|
||||
"openDetail": "Show trend"
|
||||
"backToList": "Overview"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
|
||||
|
||||
+12
-24
@@ -6988,11 +6988,14 @@
|
||||
"BONE_MASS": "Masa ósea",
|
||||
"TOTAL_BODY_WATER": "Agua corporal",
|
||||
"VISCERAL_FAT": "Grasa visceral",
|
||||
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)"
|
||||
"BODY_MASS_INDEX": "Índice de masa corporal (IMC)",
|
||||
"PHQ9_SCORE": "PHQ-9 · ánimo",
|
||||
"GAD7_SCORE": "GAD-7 · preocupación"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
"bodyComposition": "Composición corporal"
|
||||
"bodyComposition": "Composición corporal",
|
||||
"mentalWellbeing": "Bienestar mental"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aún no hay recordatorios",
|
||||
@@ -7026,6 +7029,7 @@
|
||||
"selfPlanned": "Autoplanificado",
|
||||
"captureValue": "Registrar valor",
|
||||
"measureNow": "Medir ahora",
|
||||
"startCheckIn": "Iniciar check-in",
|
||||
"nextDueLabel": "Próximo vencimiento:",
|
||||
"lastDoneLabel": "Última vez:",
|
||||
"customize": "Personalizar revisiones",
|
||||
@@ -7627,6 +7631,7 @@
|
||||
"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.",
|
||||
"pageRhythmHint": "Ambos tests se refieren a las últimas dos semanas, por lo que un check-in cada dos a cuatro semanas es un buen ritmo.",
|
||||
"attributionLabel": "Fuente",
|
||||
"choosePrompt": "Elige una revisión",
|
||||
"start": "Empezar",
|
||||
@@ -7637,8 +7642,8 @@
|
||||
"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"
|
||||
"phq9": "PHQ-9 · ánimo",
|
||||
"gad7": "GAD-7 · preocupación"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Cribado de depresión, 9 preguntas",
|
||||
@@ -7650,13 +7655,6 @@
|
||||
"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",
|
||||
@@ -7681,14 +7679,11 @@
|
||||
"history": {
|
||||
"title": "Tu historial",
|
||||
"empty": "Aún no hay revisiones. Tu tendencia aparecerá aquí cuando completes una.",
|
||||
"chartTitle": "Evolución de la puntuación",
|
||||
"flaggedBadge": "Apoyo mostrado",
|
||||
"totalLabel": "Puntuación",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Categoría"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "No tienes que afrontar esto a solas",
|
||||
@@ -7746,18 +7741,11 @@
|
||||
"gad7": "Cribado de ansiedad de 7 preguntas. Responde pensando en las últimas dos semanas."
|
||||
},
|
||||
"progress": "{current} / {total}",
|
||||
"questionAria": "Pregunta {current} de {total}",
|
||||
"next": "Siguiente",
|
||||
"lastResult": "Última prueba",
|
||||
"lastScore": "Último resultado",
|
||||
"noResultYet": "Aún sin check-in",
|
||||
"backToList": "Vista general",
|
||||
"jumpFirst": "Ir a la primera pregunta",
|
||||
"jumpLast": "Ir a la última pregunta",
|
||||
"review": {
|
||||
"title": "Resumen",
|
||||
"recap": "Has respondido {answered} de {total} preguntas."
|
||||
},
|
||||
"openDetail": "Ver evolución"
|
||||
"backToList": "Vista general"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
|
||||
|
||||
+12
-24
@@ -6988,11 +6988,14 @@
|
||||
"BONE_MASS": "Masse osseuse",
|
||||
"TOTAL_BODY_WATER": "Eau corporelle",
|
||||
"VISCERAL_FAT": "Graisse viscérale",
|
||||
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)"
|
||||
"BODY_MASS_INDEX": "Indice de masse corporelle (IMC)",
|
||||
"PHQ9_SCORE": "PHQ-9 · humeur",
|
||||
"GAD7_SCORE": "GAD-7 · inquiétude"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Constantes vitales",
|
||||
"bodyComposition": "Composition corporelle"
|
||||
"bodyComposition": "Composition corporelle",
|
||||
"mentalWellbeing": "Bien-être mental"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Aucun rappel pour l'instant",
|
||||
@@ -7026,6 +7029,7 @@
|
||||
"selfPlanned": "Auto-planifié",
|
||||
"captureValue": "Enregistrer la valeur",
|
||||
"measureNow": "Mesurer maintenant",
|
||||
"startCheckIn": "Commencer le check-in",
|
||||
"nextDueLabel": "Prochaine échéance :",
|
||||
"lastDoneLabel": "Dernière fois :",
|
||||
"customize": "Personnaliser les examens",
|
||||
@@ -7627,6 +7631,7 @@
|
||||
"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é.",
|
||||
"pageRhythmHint": "Les deux tests portent sur les deux dernières semaines — un check-in toutes les deux à quatre semaines est donc un bon rythme.",
|
||||
"attributionLabel": "Source",
|
||||
"choosePrompt": "Choisis un bilan",
|
||||
"start": "Commencer",
|
||||
@@ -7637,8 +7642,8 @@
|
||||
"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"
|
||||
"phq9": "PHQ-9 · humeur",
|
||||
"gad7": "GAD-7 · inquiétude"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Dépistage de la dépression, 9 questions",
|
||||
@@ -7650,13 +7655,6 @@
|
||||
"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",
|
||||
@@ -7681,14 +7679,11 @@
|
||||
"history": {
|
||||
"title": "Ton historique",
|
||||
"empty": "Aucun bilan pour le moment. Ta tendance apparaîtra ici dès que tu en auras terminé un.",
|
||||
"chartTitle": "Évolution du score",
|
||||
"flaggedBadge": "Aide affichée",
|
||||
"totalLabel": "Score",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Catégorie"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Tu n'as pas à affronter cela seul(e)",
|
||||
@@ -7746,18 +7741,11 @@
|
||||
"gad7": "Dépistage de l'anxiété en 7 questions. Répondez en pensant aux deux dernières semaines."
|
||||
},
|
||||
"progress": "{current} / {total}",
|
||||
"questionAria": "Question {current} sur {total}",
|
||||
"next": "Suivant",
|
||||
"lastResult": "Dernier test",
|
||||
"lastScore": "Dernier résultat",
|
||||
"noResultYet": "Pas encore de check-in",
|
||||
"backToList": "Vue d'ensemble",
|
||||
"jumpFirst": "Aller à la première question",
|
||||
"jumpLast": "Aller à la dernière question",
|
||||
"review": {
|
||||
"title": "Récapitulatif",
|
||||
"recap": "Vous avez répondu à {answered} questions sur {total}."
|
||||
},
|
||||
"openDetail": "Voir l’évolution"
|
||||
"backToList": "Vue d'ensemble"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
|
||||
|
||||
+12
-24
@@ -6988,11 +6988,14 @@
|
||||
"BONE_MASS": "Massa ossea",
|
||||
"TOTAL_BODY_WATER": "Acqua corporea",
|
||||
"VISCERAL_FAT": "Grasso viscerale",
|
||||
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)"
|
||||
"BODY_MASS_INDEX": "Indice di massa corporea (IMC)",
|
||||
"PHQ9_SCORE": "PHQ-9 · umore",
|
||||
"GAD7_SCORE": "GAD-7 · preoccupazione"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametri vitali",
|
||||
"bodyComposition": "Composizione corporea"
|
||||
"bodyComposition": "Composizione corporea",
|
||||
"mentalWellbeing": "Benessere mentale"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Ancora nessun promemoria",
|
||||
@@ -7026,6 +7029,7 @@
|
||||
"selfPlanned": "Autopianificato",
|
||||
"captureValue": "Registra valore",
|
||||
"measureNow": "Misura ora",
|
||||
"startCheckIn": "Avvia il check-in",
|
||||
"nextDueLabel": "Prossima scadenza:",
|
||||
"lastDoneLabel": "Ultima volta:",
|
||||
"customize": "Personalizza controlli",
|
||||
@@ -7627,6 +7631,7 @@
|
||||
"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.",
|
||||
"pageRhythmHint": "Entrambi i test riguardano le ultime due settimane, quindi un check-in ogni due-quattro settimane è un buon ritmo.",
|
||||
"attributionLabel": "Fonte",
|
||||
"choosePrompt": "Scegli un check-in",
|
||||
"start": "Inizia",
|
||||
@@ -7637,8 +7642,8 @@
|
||||
"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"
|
||||
"phq9": "PHQ-9 · umore",
|
||||
"gad7": "GAD-7 · preoccupazione"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Screening della depressione, 9 domande",
|
||||
@@ -7650,13 +7655,6 @@
|
||||
"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",
|
||||
@@ -7681,14 +7679,11 @@
|
||||
"history": {
|
||||
"title": "La tua cronologia",
|
||||
"empty": "Ancora nessun check-in. Il tuo andamento apparirà qui dopo che ne avrai completato uno.",
|
||||
"chartTitle": "Andamento del punteggio",
|
||||
"flaggedBadge": "Supporto mostrato",
|
||||
"totalLabel": "Punteggio",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Categoria"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Non devi affrontare tutto questo da solo/a",
|
||||
@@ -7746,18 +7741,11 @@
|
||||
"gad7": "Screening dell'ansia in 7 domande. Rispondi pensando alle ultime due settimane."
|
||||
},
|
||||
"progress": "{current} / {total}",
|
||||
"questionAria": "Domanda {current} di {total}",
|
||||
"next": "Avanti",
|
||||
"lastResult": "Ultimo test",
|
||||
"lastScore": "Ultimo risultato",
|
||||
"noResultYet": "Ancora nessun check-in",
|
||||
"backToList": "Panoramica",
|
||||
"jumpFirst": "Vai alla prima domanda",
|
||||
"jumpLast": "Vai all'ultima domanda",
|
||||
"review": {
|
||||
"title": "Riepilogo",
|
||||
"recap": "Hai risposto a {answered} domande su {total}."
|
||||
},
|
||||
"openDetail": "Mostra andamento"
|
||||
"backToList": "Panoramica"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
|
||||
|
||||
+12
-24
@@ -6988,11 +6988,14 @@
|
||||
"BONE_MASS": "Masa kostna",
|
||||
"TOTAL_BODY_WATER": "Woda w organizmie",
|
||||
"VISCERAL_FAT": "Tłuszcz trzewny",
|
||||
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)"
|
||||
"BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)",
|
||||
"PHQ9_SCORE": "PHQ-9 · nastrój",
|
||||
"GAD7_SCORE": "GAD-7 · lęk"
|
||||
},
|
||||
"typeGroups": {
|
||||
"vitals": "Parametry życiowe",
|
||||
"bodyComposition": "Skład ciała"
|
||||
"bodyComposition": "Skład ciała",
|
||||
"mentalWellbeing": "Dobrostan psychiczny"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Brak przypomnień",
|
||||
@@ -7026,6 +7029,7 @@
|
||||
"selfPlanned": "Zaplanowane samodzielnie",
|
||||
"captureValue": "Zapisz wartość",
|
||||
"measureNow": "Zmierz teraz",
|
||||
"startCheckIn": "Rozpocznij check-in",
|
||||
"nextDueLabel": "Następny termin:",
|
||||
"lastDoneLabel": "Ostatnio wykonano:",
|
||||
"customize": "Dostosuj badania",
|
||||
@@ -7627,6 +7631,7 @@
|
||||
"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.",
|
||||
"pageRhythmHint": "Oba testy dotyczą ostatnich dwóch tygodni, dlatego check-in co dwa–cztery tygodnie to dobry rytm.",
|
||||
"attributionLabel": "Źródło",
|
||||
"choosePrompt": "Wybierz sprawdzenie",
|
||||
"start": "Rozpocznij",
|
||||
@@ -7637,8 +7642,8 @@
|
||||
"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"
|
||||
"phq9": "PHQ-9 · nastrój",
|
||||
"gad7": "GAD-7 · lęk"
|
||||
},
|
||||
"instrumentSub": {
|
||||
"phq9": "Przesiew depresji, 9 pytań",
|
||||
@@ -7650,13 +7655,6 @@
|
||||
"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",
|
||||
@@ -7681,14 +7679,11 @@
|
||||
"history": {
|
||||
"title": "Twoja historia",
|
||||
"empty": "Brak sprawdzeń. Twój trend pojawi się tutaj po ukończeniu pierwszego.",
|
||||
"chartTitle": "Przebieg wyniku",
|
||||
"flaggedBadge": "Pokazano wsparcie",
|
||||
"totalLabel": "Wynik",
|
||||
"tab": {
|
||||
"phq9": "PHQ-9",
|
||||
"gad7": "GAD-7"
|
||||
},
|
||||
"bandLabel": "Kategoria"
|
||||
}
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Nie musisz mierzyć się z tym sam(a)",
|
||||
@@ -7746,18 +7741,11 @@
|
||||
"gad7": "Przesiew w kierunku lęku, 7 pytań. Odpowiadaj, myśląc o ostatnich dwóch tygodniach."
|
||||
},
|
||||
"progress": "{current} / {total}",
|
||||
"questionAria": "Pytanie {current} z {total}",
|
||||
"next": "Dalej",
|
||||
"lastResult": "Ostatni test",
|
||||
"lastScore": "Ostatni wynik",
|
||||
"noResultYet": "Brak check-inów",
|
||||
"backToList": "Przegląd",
|
||||
"jumpFirst": "Przejdź do pierwszego pytania",
|
||||
"jumpLast": "Przejdź do ostatniego pytania",
|
||||
"review": {
|
||||
"title": "Podsumowanie",
|
||||
"recap": "Odpowiedziano na {answered} z {total} pytań."
|
||||
},
|
||||
"openDetail": "Pokaż przebieg"
|
||||
"backToList": "Przegląd"
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- v1.27.6 — mental-wellbeing score rows become server-owned.
|
||||
--
|
||||
-- The `PHQ9_SCORE` / `GAD7_SCORE` enum values exist since 0213; the screener
|
||||
-- route has projected each completed administration onto a Measurement row
|
||||
-- from the start. Those rows were attributed `MANUAL`, which is a
|
||||
-- client-writable source — the projection is a server-derived value (the sum
|
||||
-- over the encrypted item answers), so it now writes `COMPUTED`, the same
|
||||
-- server-owned source RECOVERY_SCORE uses. Clients cannot attribute COMPUTED
|
||||
-- on any write surface, so a forged score trend point cannot enter through
|
||||
-- the measurement POST.
|
||||
--
|
||||
-- Re-attribute the existing projection rows so the trend is uniformly
|
||||
-- server-owned. Scoped to the `assessment:` externalId prefix the route
|
||||
-- stamps: a hand-entered score row a user may have logged deliberately
|
||||
-- through the generic measurement form keeps its MANUAL attribution.
|
||||
-- Idempotent, forward-only.
|
||||
UPDATE "measurements"
|
||||
SET "source" = 'COMPUTED'
|
||||
WHERE "type" IN ('PHQ9_SCORE', 'GAD7_SCORE')
|
||||
AND "source" = 'MANUAL'
|
||||
AND "external_id" LIKE 'assessment:%';
|
||||
@@ -1306,8 +1306,10 @@ enum AssessmentInstrument {
|
||||
/// 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.
|
||||
/// administration (linked by `externalId = assessment:<id>`, `source =
|
||||
/// COMPUTED` — server-owned like RECOVERY_SCORE, never client-attributable) 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")
|
||||
|
||||
@@ -37,6 +37,12 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn(() => Buffer.from("ciphertext")),
|
||||
}));
|
||||
|
||||
// v1.27.6 — a completed check-in kicks the eventful Vorsorge satisfy worker
|
||||
// so a plannable screening reminder resolves immediately.
|
||||
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
|
||||
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -55,6 +61,7 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -183,13 +190,27 @@ describe("POST /api/mental-health/assessments", () => {
|
||||
expect(createArg.data.item9Flagged).toBe(false);
|
||||
|
||||
// Derived projection: one PHQ9_SCORE/GAD7_SCORE Measurement per assessment.
|
||||
// Server-owned: the row carries the COMPUTED source (the RECOVERY_SCORE
|
||||
// precedent) — a client can never attribute COMPUTED on a write surface,
|
||||
// so the trend cannot be forged through the measurement POST.
|
||||
const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as {
|
||||
data: { type: string; value: number; unit: string; externalId: string };
|
||||
data: {
|
||||
type: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
source: string;
|
||||
externalId: string;
|
||||
};
|
||||
};
|
||||
expect(measArg.data.type).toBe("GAD7_SCORE");
|
||||
expect(measArg.data.value).toBe(6);
|
||||
expect(measArg.data.unit).toBe("score");
|
||||
expect(measArg.data.source).toBe("COMPUTED");
|
||||
expect(measArg.data.externalId).toBe("assessment:mha_2");
|
||||
|
||||
// The eventful Vorsorge satisfy worker is kicked so a plannable
|
||||
// screening reminder (PHQ9_SCORE / GAD7_SCORE) auto-resolves at once.
|
||||
expect(enqueueReminderSatisfy).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("dedups a repeat externalId: returns the existing administration, writes nothing new", async () => {
|
||||
@@ -229,6 +250,8 @@ describe("POST /api/mental-health/assessments", () => {
|
||||
);
|
||||
expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled();
|
||||
expect(prisma.measurement.create).not.toHaveBeenCalled();
|
||||
// No new trend point → nothing to satisfy either.
|
||||
expect(enqueueReminderSatisfy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists client provenance + externalId on a first write", async () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
@@ -308,9 +309,11 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
// chart/rollup infra reads the trend without ever touching item content. The
|
||||
// externalId anchors the trend point to this administration; duplicate trend
|
||||
// points are structurally impossible because a replayed externalId returns
|
||||
// the existing assessment above before reaching this create. `MeasurementSource`
|
||||
// carries no WEB/IOS member (a screener is questionnaire input, not a device
|
||||
// sample), so the row stays MANUAL — the WEB/IOS provenance + client
|
||||
// the existing assessment above before reaching this create. The row is
|
||||
// `COMPUTED` — a server-derived projection of the encrypted answers, exactly
|
||||
// like RECOVERY_SCORE: clients cannot attribute the COMPUTED source on any
|
||||
// write surface, so a forged PHQ9_SCORE / GAD7_SCORE trend point can never
|
||||
// enter through the measurement POST. The WEB/IOS provenance + client
|
||||
// externalId live on the assessment this row links to.
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
@@ -318,13 +321,19 @@ async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
type: INSTRUMENT_MEASUREMENT_TYPE[id] as MeasurementType,
|
||||
value: total,
|
||||
unit: "score",
|
||||
source: "MANUAL" as MeasurementSource,
|
||||
source: "COMPUTED" as MeasurementSource,
|
||||
measuredAt: when,
|
||||
notes: null,
|
||||
externalId: `assessment:${assessment.id}`,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.27.6 — a screening can be planned as a Vorsorge reminder keyed on
|
||||
// PHQ9_SCORE / GAD7_SCORE. Kick the eventful satisfy worker so completing
|
||||
// a check-in resolves the reminder immediately (the ingest-route
|
||||
// precedent); the 15-min cron stays the idempotent safety-net.
|
||||
await enqueueReminderSatisfy(user.id);
|
||||
|
||||
await auditLog("mental-health.create", {
|
||||
userId: user.id,
|
||||
// The score band is non-sensitive; item answers are NEVER logged.
|
||||
|
||||
@@ -14,6 +14,12 @@ import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
* `<EmptyState>` with an add action. These tests pin both.
|
||||
*/
|
||||
|
||||
// v1.27.6 — a screening reminder's primary action navigates to the
|
||||
// check-in page via the app router, which the SSR harness doesn't mount.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
const remindersMock = vi.fn();
|
||||
vi.mock("@/hooks/use-measurement-reminders", () => ({
|
||||
useMeasurementReminders: () => remindersMock(),
|
||||
@@ -136,6 +142,32 @@ describe("<VorsorgeSection> loading + empty", () => {
|
||||
expect(html).not.toContain("Log value");
|
||||
});
|
||||
|
||||
it("renders a 'Start check-in' action for a screening reminder (v1.27.6)", () => {
|
||||
// A PHQ-9 / GAD-7 reminder routes to the check-in page — the score is
|
||||
// never typed in, so the numeric "Measure now" wording would mislead.
|
||||
const reminder: MeasurementReminder = {
|
||||
id: "screening",
|
||||
label: "PHQ-9 check-in",
|
||||
measurementType: "PHQ9_SCORE",
|
||||
intervalDays: 28,
|
||||
rrule: null,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "VORSORGE",
|
||||
nextDueAt: null,
|
||||
notifyHour: 9,
|
||||
location: null,
|
||||
lastSatisfiedAt: null,
|
||||
enabled: true,
|
||||
createdAt: "2030-01-01T00:00:00.000Z",
|
||||
updatedAt: "2030-01-01T00:00:00.000Z",
|
||||
} as MeasurementReminder;
|
||||
remindersMock.mockReturnValue({ data: [reminder], isLoading: false });
|
||||
const html = render(<VorsorgeSection />);
|
||||
expect(html).toContain("Start check-in");
|
||||
expect(html).not.toContain("Measure now");
|
||||
});
|
||||
|
||||
it("reads a same-day due time as 'today' (calendar-day delta, not rolling 24h)", () => {
|
||||
// v1.18.9 recon — a reminder whose nextDue falls on the SAME local
|
||||
// calendar day must read "Due today" with the green status hue, even when
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -26,6 +27,7 @@ import { ListRow } from "@/components/ui/list-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
@@ -60,6 +62,7 @@ function resolveLabel(
|
||||
|
||||
export function VorsorgeDashboardCard() {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const { data: reminders, isLoading } = useMeasurementReminders();
|
||||
const { satisfy } = useMeasurementReminderMutations();
|
||||
const [now] = useState(() => Date.now());
|
||||
@@ -74,8 +77,12 @@ export function VorsorgeDashboardCard() {
|
||||
.filter((r) => r.enabled)
|
||||
.slice(0, SUMMARY_LIMIT);
|
||||
|
||||
// v1.27.6 — a screening reminder routes to the check-in page (a score is
|
||||
// never typed in); completing the test auto-satisfies the reminder.
|
||||
function onPrimaryAction(reminder: MeasurementReminder) {
|
||||
if (reminder.measurementType) {
|
||||
if (isScreeningReminderType(reminder.measurementType)) {
|
||||
router.push("/mental-wellbeing");
|
||||
} else if (reminder.measurementType) {
|
||||
setCapturing(reminder);
|
||||
} else {
|
||||
satisfy.mutate(reminder.id);
|
||||
@@ -119,6 +126,9 @@ export function VorsorgeDashboardCard() {
|
||||
{upcoming.map((reminder) => {
|
||||
const due = relativeDueKey(reminder.nextDueAt, now);
|
||||
const isLinked = reminder.measurementType != null;
|
||||
const isScreening = isScreeningReminderType(
|
||||
reminder.measurementType,
|
||||
);
|
||||
return (
|
||||
<ListRow
|
||||
key={reminder.id}
|
||||
@@ -146,7 +156,11 @@ export function VorsorgeDashboardCard() {
|
||||
{isLinked ? (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("measurementReminders.captureValue")}
|
||||
{t(
|
||||
isScreening
|
||||
? "measurementReminders.startCheckIn"
|
||||
: "measurementReminders.captureValue",
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Activity,
|
||||
CalendarClock,
|
||||
@@ -76,6 +77,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
|
||||
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
|
||||
import {
|
||||
useMeasurementReminders,
|
||||
useMeasurementReminderMutations,
|
||||
@@ -113,9 +115,23 @@ const TYPE_GROUPS = [
|
||||
"BODY_MASS_INDEX",
|
||||
],
|
||||
},
|
||||
// v1.27.6 — the mental-wellbeing screenings become plannable like any
|
||||
// other Vorsorge item. Auto-resolve rides the server-written PHQ9_SCORE /
|
||||
// GAD7_SCORE row a completed check-in produces.
|
||||
{
|
||||
group: "mentalWellbeing",
|
||||
types: ["PHQ9_SCORE", "GAD7_SCORE"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const INTERVAL_PRESETS = [7, 14, 30, 90, 180, 365] as const;
|
||||
// v1.27.6 — screening types get a dedicated primary action (the check-in
|
||||
// page, not the numeric MeasurementForm) and a 4-week default cadence
|
||||
// (both instruments cover the last two weeks).
|
||||
const MENTAL_SCREENING_DEFAULT_INTERVAL = "28";
|
||||
|
||||
// 28 joined in v1.27.6 as the screening default (every 4 weeks); 30 stays
|
||||
// so existing monthly reminders keep resolving onto a preset.
|
||||
const INTERVAL_PRESETS = [7, 14, 28, 30, 90, 180, 365] as const;
|
||||
// v1.18.2 — the sentinel the cadence picker writes when the user wants a
|
||||
// free interval or an RFC-5545 RRULE instead of a fixed preset.
|
||||
const CADENCE_CUSTOM = "custom";
|
||||
@@ -220,6 +236,7 @@ export function VorsorgeSection({
|
||||
variant?: "settings" | "page";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: reminders,
|
||||
isLoading,
|
||||
@@ -330,9 +347,14 @@ export function VorsorgeSection({
|
||||
|
||||
// The mark-done action. A free-text / self-planned exam satisfies
|
||||
// silently; a measurement-linked reminder opens the real value-entry
|
||||
// form so the user logs an actual reading.
|
||||
// form so the user logs an actual reading. A screening reminder
|
||||
// (v1.27.6) routes to the check-in page instead — the score is never
|
||||
// typed in, it falls out of a completed test, which then auto-satisfies
|
||||
// the reminder through the eventful worker.
|
||||
function onPrimaryAction(reminder: MeasurementReminder) {
|
||||
if (reminder.measurementType) {
|
||||
if (isScreeningReminderType(reminder.measurementType)) {
|
||||
router.push("/mental-wellbeing");
|
||||
} else if (reminder.measurementType) {
|
||||
setCapturing(reminder);
|
||||
} else {
|
||||
satisfy.mutate(reminder.id);
|
||||
@@ -495,9 +517,19 @@ export function VorsorgeSection({
|
||||
<NativeSelect
|
||||
id="vorsorge-type"
|
||||
value={form.measurementType}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, measurementType: e.target.value }))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
measurementType: nextType,
|
||||
// v1.27.6 — a screening covers the last two weeks, so
|
||||
// switching onto a screening type defaults the cadence
|
||||
// to every 4 weeks (still freely changeable).
|
||||
cadenceChoice: isScreeningReminderType(nextType)
|
||||
? MENTAL_SCREENING_DEFAULT_INTERVAL
|
||||
: f.cadenceChoice,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{TYPE_GROUPS.map(({ group, types }) => (
|
||||
<optgroup
|
||||
@@ -784,10 +816,13 @@ function VorsorgeCard({
|
||||
: "";
|
||||
const isCoach = reminder.origin === "COACH";
|
||||
const isLinked = reminder.measurementType != null;
|
||||
// v1.27.5 — due-ness never changes the ACTION BUTTON either: the button
|
||||
// keeps one constant look in every state (same rule as the medication
|
||||
// card's constant surface). Due / overdue reads only through the discreet
|
||||
// v1.27.5 — due-ness never changes the ACTION BUTTON: it keeps one
|
||||
// constant look in every state (same rule as the medication card's
|
||||
// constant surface). Due / overdue reads only through the discreet
|
||||
// coloured next-due text + the adherence meta line below.
|
||||
// v1.27.6 — a screening reminder's action is "start the check-in" (it
|
||||
// routes to the check-in page), never the numeric value-entry wording.
|
||||
const isScreening = isScreeningReminderType(reminder.measurementType);
|
||||
const isDue = due.key === "nextDue.today" || due.key === "overdueByDays";
|
||||
const progress = intervalProgress(reminder, now);
|
||||
|
||||
@@ -889,7 +924,11 @@ function VorsorgeCard({
|
||||
{isLinked ? (
|
||||
<>
|
||||
<Activity className="h-4 w-4" />
|
||||
{t("measurementReminders.measureNow")}
|
||||
{t(
|
||||
isScreening
|
||||
? "measurementReminders.startCheckIn"
|
||||
: "measurementReminders.measureNow",
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -952,7 +991,11 @@ function VorsorgeCard({
|
||||
disabled={busy}
|
||||
>
|
||||
{isLinked
|
||||
? t("measurementReminders.measureNow")
|
||||
? t(
|
||||
isScreening
|
||||
? "measurementReminders.startCheckIn"
|
||||
: "measurementReminders.measureNow",
|
||||
)
|
||||
: t("measurementReminders.markDone")}
|
||||
</Button>
|
||||
{headerActions}
|
||||
|
||||
@@ -2,23 +2,19 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
UNANSWERED,
|
||||
answeredCount,
|
||||
buildStepList,
|
||||
isComplete,
|
||||
isReviewStep,
|
||||
isLastStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
reachableUntilIndex,
|
||||
reviewStep,
|
||||
} from "../check-in-nav";
|
||||
|
||||
/**
|
||||
* The one-question-at-a-time check-in is LINEAR: the step list is
|
||||
* `[1..itemCount, review]`, backward navigation is always allowed, and the
|
||||
* forward ceiling never lets a user skip ahead onto an unanswered question.
|
||||
* These are the contracts the wizard's advance / back / reachability behaviour
|
||||
* rests on (the SSR-only test convention can't drive the clicks, so the pure
|
||||
* grammar is pinned here — the medication wizard's `wizard-payload` precedent).
|
||||
* The one-question-at-a-time check-in is LINEAR: the steps are exactly the
|
||||
* questions `1..itemCount`, backward navigation is always allowed, and the
|
||||
* last question arms the submit directly (v1.27.6 — no review/summary step).
|
||||
* These are the contracts the wizard's advance / back behaviour rests on
|
||||
* (the SSR-only test convention can't drive the clicks, so the pure grammar
|
||||
* is pinned here — the medication wizard's `wizard-payload` precedent).
|
||||
*/
|
||||
|
||||
const PHQ9 = 9;
|
||||
@@ -28,71 +24,35 @@ function blank(count: number): number[] {
|
||||
return Array(count).fill(UNANSWERED);
|
||||
}
|
||||
|
||||
describe("check-in step grammar", () => {
|
||||
it("builds questions + a single review dot (itemCount + 1)", () => {
|
||||
expect(buildStepList(PHQ9)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
expect(buildStepList(GAD7)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
expect(reviewStep(PHQ9)).toBe(10);
|
||||
expect(reviewStep(GAD7)).toBe(8);
|
||||
});
|
||||
|
||||
it("advances forward on a question and into review from the last question", () => {
|
||||
describe("check-in step grammar (v1.27.6 — questions only, no review step)", () => {
|
||||
it("advances forward and stays put on the last question (submit, not advance)", () => {
|
||||
expect(nextStep(1, PHQ9)).toBe(2);
|
||||
expect(nextStep(8, PHQ9)).toBe(9);
|
||||
// last question (9) → review (10)
|
||||
expect(nextStep(9, PHQ9)).toBe(10);
|
||||
// review stays put (submit, not advance)
|
||||
expect(nextStep(10, PHQ9)).toBe(10);
|
||||
// The last question is the end of the line — no review step behind it.
|
||||
expect(nextStep(9, PHQ9)).toBe(9);
|
||||
expect(nextStep(7, GAD7)).toBe(7);
|
||||
});
|
||||
|
||||
it("steps back, clamped at the first question", () => {
|
||||
expect(prevStep(10)).toBe(9);
|
||||
expect(prevStep(9)).toBe(8);
|
||||
expect(prevStep(2)).toBe(1);
|
||||
expect(prevStep(1)).toBe(1);
|
||||
});
|
||||
|
||||
it("identifies the review step", () => {
|
||||
expect(isReviewStep(9, PHQ9)).toBe(false);
|
||||
expect(isReviewStep(10, PHQ9)).toBe(true);
|
||||
expect(isReviewStep(7, GAD7)).toBe(false);
|
||||
expect(isReviewStep(8, GAD7)).toBe(true);
|
||||
it("identifies the last question per instrument", () => {
|
||||
expect(isLastStep(8, PHQ9)).toBe(false);
|
||||
expect(isLastStep(9, PHQ9)).toBe(true);
|
||||
expect(isLastStep(6, GAD7)).toBe(false);
|
||||
expect(isLastStep(7, GAD7)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reachability ceiling (never skip ahead to an unanswered question)", () => {
|
||||
it("with nothing answered, only the first question is reachable", () => {
|
||||
expect(reachableUntilIndex(blank(PHQ9))).toBe(0);
|
||||
});
|
||||
|
||||
it("the frontier follows the first unanswered item", () => {
|
||||
const items = blank(PHQ9);
|
||||
items[0] = 2; // Q1 answered → can reach Q2 (index 1)
|
||||
expect(reachableUntilIndex(items)).toBe(1);
|
||||
items[1] = 0;
|
||||
items[2] = 3; // Q3 answered too → frontier at the first gap (index 3)
|
||||
expect(reachableUntilIndex(items)).toBe(3);
|
||||
});
|
||||
|
||||
it("once every item is answered the review slot (last index) is reachable", () => {
|
||||
const items = blank(GAD7).map(() => 1);
|
||||
// step list length is itemCount + 1; review index == itemCount
|
||||
expect(reachableUntilIndex(items)).toBe(GAD7);
|
||||
expect(buildStepList(GAD7).length - 1).toBe(GAD7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeness + recap", () => {
|
||||
describe("completeness (gates the submit on the last question)", () => {
|
||||
it("isComplete requires every item answered", () => {
|
||||
expect(isComplete(blank(PHQ9))).toBe(false);
|
||||
const partial = blank(PHQ9).map((_, i) => (i < 5 ? 1 : UNANSWERED));
|
||||
expect(isComplete(partial)).toBe(false);
|
||||
expect(isComplete(blank(PHQ9).map(() => 0))).toBe(true);
|
||||
});
|
||||
|
||||
it("answeredCount counts only real (0–3) answers", () => {
|
||||
const items = blank(PHQ9);
|
||||
items[0] = 0;
|
||||
items[1] = 3;
|
||||
expect(answeredCount(items)).toBe(2);
|
||||
expect(isComplete(blank(GAD7).map(() => 3))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,9 @@ import en from "../../../../messages/en.json";
|
||||
* means clicks can't be driven here — the wizard's advance/back grammar is
|
||||
* pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable
|
||||
* contracts: the disclaimer renders ONLY on the landing (never while testing),
|
||||
* a positive item-9 surfaces the crisis card, and the history paints its chart
|
||||
* shell + dated list with severity + "support shown" badges.
|
||||
* a positive item-9 surfaces the crisis card, and the history paints its
|
||||
* dated list with severity + "support shown" badges — and NO trend chart
|
||||
* (v1.27.6: the score curve lives in Insights / Measurements, not here).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
@@ -74,7 +75,7 @@ describe("landing chrome (v1.25.5 — disclaimer + heading removed)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-in wizard scaffolding", () => {
|
||||
describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => {
|
||||
const html = withProviders(
|
||||
<CheckInWizard
|
||||
instrument="PHQ9"
|
||||
@@ -85,8 +86,13 @@ describe("check-in wizard scaffolding", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
it("reuses the medication wizard stepper", () => {
|
||||
expect(html).toContain('data-slot="wizard-stepper"');
|
||||
it("no longer renders the question-overview strip", () => {
|
||||
expect(html).not.toContain('data-slot="wizard-stepper"');
|
||||
});
|
||||
|
||||
it("no longer renders the review recap or the functional follow-up", () => {
|
||||
expect(html).not.toContain("You have answered");
|
||||
expect(html).not.toContain('data-slot="check-in-submit"');
|
||||
});
|
||||
|
||||
it("opens on the first question with the Back/Next footer", () => {
|
||||
@@ -174,9 +180,12 @@ describe("history rendering", () => {
|
||||
|
||||
const html = withProviders(<AssessmentHistory rows={rows} />);
|
||||
|
||||
it("paints the lazy chart shell + the dated list", () => {
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
it("paints the dated list WITHOUT a trend chart shell", () => {
|
||||
expect(html).toContain('data-slot="history-list"');
|
||||
// v1.27.6 — no chart on this surface: no lazy-chart skeleton, no
|
||||
// recharts container.
|
||||
expect(html).not.toContain('data-slot="skeleton"');
|
||||
expect(html).not.toContain("recharts");
|
||||
});
|
||||
|
||||
it("shows the PHQ-9 / GAD-7 toggle and a severity badge", () => {
|
||||
@@ -196,74 +205,42 @@ describe("history rendering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrument card → per-instrument trend (v1.25.12)", () => {
|
||||
it("exposes the card body as a detail target separate from Start", () => {
|
||||
describe("instrument card (v1.27.6 — med-/Vorsorge card anatomy)", () => {
|
||||
const lastRow: AssessmentRow = {
|
||||
id: "l1",
|
||||
instrument: "PHQ9",
|
||||
locale: "en",
|
||||
totalScore: 8,
|
||||
severityBand: "mild",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("renders the shared med-card header (title + category badge), no trend target", () => {
|
||||
const html = withProviders(
|
||||
<InstrumentCard
|
||||
instrument="PHQ9"
|
||||
last={undefined}
|
||||
onStart={() => {}}
|
||||
onOpenDetail={() => {}}
|
||||
/>,
|
||||
<InstrumentCard instrument="PHQ9" last={undefined} onStart={() => {}} />,
|
||||
);
|
||||
// The title + last-result block is a button that opens the Verlauf…
|
||||
expect(html).toContain('data-slot="instrument-card-open"');
|
||||
expect(html).toContain(mh.openDetail);
|
||||
// …and the Start action is still its own button.
|
||||
// The med-card header primitive paints the bold name + category badge.
|
||||
expect(html).toContain(mh.instrument.phq9);
|
||||
expect(html).toContain(mh.instrumentSub.phq9);
|
||||
// The former trend-detail button is gone…
|
||||
expect(html).not.toContain('data-slot="instrument-card-open"');
|
||||
// …and the Start action remains the single bottom-pinned action.
|
||||
expect(html).toContain(mh.start);
|
||||
// No history yet → the calm no-check-in line, no fabricated dashes.
|
||||
expect(html).toContain(mh.noResultYet);
|
||||
});
|
||||
|
||||
it("pins the history to one instrument: no toggle, bare (no titled card)", () => {
|
||||
const rows: AssessmentRow[] = [
|
||||
{
|
||||
id: "p1",
|
||||
instrument: "PHQ9",
|
||||
locale: "en",
|
||||
totalScore: 8,
|
||||
severityBand: "mild",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "g1",
|
||||
instrument: "GAD7",
|
||||
locale: "en",
|
||||
totalScore: 12,
|
||||
severityBand: "moderate",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
it("shows last test (relative) and last result (score + band word)", () => {
|
||||
const html = withProviders(
|
||||
<AssessmentHistory rows={rows} instrument="PHQ9" />,
|
||||
<InstrumentCard instrument="PHQ9" last={lastRow} onStart={() => {}} />,
|
||||
);
|
||||
// Pinned: the PHQ-9 / GAD-7 toggle + the titled landing card are gone…
|
||||
expect(html).toContain('data-pinned="PHQ9"');
|
||||
expect(html).not.toContain('role="tablist"');
|
||||
// …only the chosen instrument's row paints (its band), not the other's.
|
||||
expect(html).toContain(mh.lastResult);
|
||||
expect(html).toContain(mh.lastScore);
|
||||
// Score + band word ride one value slot ("8 · Mild").
|
||||
expect(html).toContain('data-slot="instrument-card-last-score"');
|
||||
expect(html).toContain(mh.band.PHQ9.mild);
|
||||
expect(html).not.toContain(mh.band.GAD7.moderate);
|
||||
});
|
||||
|
||||
it("shows the per-instrument empty state when that instrument has no rows", () => {
|
||||
const onlyGad: AssessmentRow[] = [
|
||||
{
|
||||
id: "g2",
|
||||
instrument: "GAD7",
|
||||
locale: "en",
|
||||
totalScore: 5,
|
||||
severityBand: "mild",
|
||||
item9Flagged: false,
|
||||
crisisShownAt: null,
|
||||
takenAt: "2026-06-20T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
const html = withProviders(
|
||||
<AssessmentHistory rows={onlyGad} instrument="PHQ9" />,
|
||||
);
|
||||
expect(html).toContain(mh.history.empty);
|
||||
expect(html).not.toContain('data-slot="history-list"');
|
||||
expect(html).not.toContain(mh.noResultYet);
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — per-instrument screener trend chart.
|
||||
*
|
||||
* v1.25.5 — aligned to the dashboard's chart language exactly: a single
|
||||
* `type="monotone"` line over the primary colour with a soft gradient area
|
||||
* fill beneath (same `fillOpacity` + margins the dashboard line charts use),
|
||||
* the shared grid / axis tokens, and `RichChartTooltip`. The earlier
|
||||
* severity-band `ReferenceArea`s — unique to this surface — are gone so it
|
||||
* reads like every other trend; the band still surfaces in the tooltip.
|
||||
*
|
||||
* One instrument at a time: PHQ-9 (0–27) and GAD-7 (0–21) have different ranges
|
||||
* and bands, so the parent toggles which series this paints.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
|
||||
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
import type { AssessmentRow, InstrumentId } from "./types";
|
||||
|
||||
interface ChartPoint {
|
||||
timestamp: number;
|
||||
value: number;
|
||||
band: string;
|
||||
}
|
||||
|
||||
export function AssessmentHistoryChart({
|
||||
instrument,
|
||||
rows,
|
||||
}: {
|
||||
instrument: InstrumentId;
|
||||
/** Rows for THIS instrument, any order (sorted internally, ascending time). */
|
||||
rows: AssessmentRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const def = INSTRUMENTS[instrument];
|
||||
|
||||
const points = useMemo<ChartPoint[]>(() => {
|
||||
return [...rows]
|
||||
.sort(
|
||||
(a, b) => new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
|
||||
)
|
||||
.map((r) => ({
|
||||
timestamp: new Date(r.takenAt).getTime(),
|
||||
value: r.totalScore,
|
||||
band: r.severityBand,
|
||||
}));
|
||||
}, [rows]);
|
||||
|
||||
const animate = !prefersReducedMotion();
|
||||
const primary = "var(--primary)";
|
||||
const gradientId = `mh-trend-${instrument}`;
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<p className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={CHART_HEIGHT_PX}>
|
||||
<ComposedChart
|
||||
data={points}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primary} stopOpacity={0.18} />
|
||||
<stop offset="95%" stopColor={primary} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={(ts: number) => formatDateShort(new Date(ts))}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
minTickGap={32}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, def.maxScore]}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
stroke="var(--border)"
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
content={(props) => {
|
||||
const active = props.active ?? false;
|
||||
const payload = props.payload as
|
||||
ReadonlyArray<{ payload?: ChartPoint }> | undefined;
|
||||
const point = payload?.[0]?.payload;
|
||||
if (!active || !point) {
|
||||
return <RichChartTooltip active={false} rows={[]} />;
|
||||
}
|
||||
const rowsOut: RichTooltipRow[] = [
|
||||
{
|
||||
name: t("mentalHealth.history.totalLabel"),
|
||||
value: String(point.value),
|
||||
color: primary,
|
||||
},
|
||||
{
|
||||
name: t("mentalHealth.history.bandLabel"),
|
||||
value: t(`mentalHealth.band.${instrument}.${point.band}`),
|
||||
color: "var(--muted-foreground)",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={formatDateShort(new Date(point.timestamp))}
|
||||
rows={rowsOut}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="none"
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={primary}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: primary }}
|
||||
activeDot={{ r: 5 }}
|
||||
isAnimationActive={animate}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — the history card: a per-instrument trend chart over a band-shaded
|
||||
* plot, then the dated list with severity badges. Mirrors the labs detail spine
|
||||
* (chart → list) and lazy-loads Recharts through `next/dynamic` so it stays off
|
||||
* the surface's first-load JS, with a `ChartSkeleton` shell.
|
||||
* v1.25.3 — the history card: a dated list with severity badges.
|
||||
*
|
||||
* PHQ-9 / GAD-7 toggle: the two instruments have different score ranges + bands,
|
||||
* so the chart paints one at a time.
|
||||
* v1.27.6 — the trend CHART is gone from this surface. A rising score curve
|
||||
* on the check-in page itself reads as a verdict at the exact moment someone
|
||||
* is taking stock of how they feel; the page stays a calm list of past
|
||||
* check-ins. The score trend still exists — the totals ride PHQ9_SCORE /
|
||||
* GAD7_SCORE measurement rows, so Insights / Measurements carry the curve
|
||||
* for whoever seeks it out.
|
||||
*
|
||||
* PHQ-9 / GAD-7 toggle: the two instruments have different score ranges +
|
||||
* bands, so the list shows one at a time.
|
||||
*
|
||||
* CRISIS-SAFETY: a flagged PHQ-9 row carries a discreet "support shown" marker;
|
||||
* tapping it re-surfaces the STATIC crisis-resource card derived from the row's
|
||||
@@ -16,16 +20,11 @@
|
||||
* leaves the safety boundary.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { crisisResourcesForLocale } from "@/lib/mental-health/crisis-resources";
|
||||
import { Activity } from "lucide-react";
|
||||
@@ -33,25 +32,6 @@ import { Activity } from "lucide-react";
|
||||
import { CrisisCard } from "./crisis-card";
|
||||
import type { AssessmentRow, CrisisSet, InstrumentId } from "./types";
|
||||
|
||||
// Defer the recharts trend chart so recharts stays off the surface's first-load
|
||||
// JS (the labs detail precedent). The `<ChartSkeleton>` shell holds the layout.
|
||||
const AssessmentHistoryChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({
|
||||
default: mod.AssessmentHistoryChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function AssessmentHistoryChart(
|
||||
props: ComponentProps<typeof AssessmentHistoryChartLazy>,
|
||||
) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<AssessmentHistoryChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function lower(id: InstrumentId): "phq9" | "gad7" {
|
||||
return id === "PHQ9" ? "phq9" : "gad7";
|
||||
}
|
||||
@@ -68,73 +48,57 @@ function crisisFromRow(row: AssessmentRow): CrisisSet {
|
||||
};
|
||||
}
|
||||
|
||||
export function AssessmentHistory({
|
||||
rows,
|
||||
instrument,
|
||||
}: {
|
||||
rows: AssessmentRow[];
|
||||
/**
|
||||
* Pin the history to a single instrument. When set, the PHQ-9 / GAD-7
|
||||
* toggle + the outer Card chrome are dropped — the surface is a per-
|
||||
* instrument detail (opened from an instrument card; the chrome + title
|
||||
* come from the host sheet). Unset, it renders the combined landing card
|
||||
* with the toggle, exactly as before.
|
||||
*/
|
||||
instrument?: InstrumentId;
|
||||
}) {
|
||||
export function AssessmentHistory({ rows }: { rows: AssessmentRow[] }) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
const [tab, setTab] = useState<InstrumentId>(instrument ?? "PHQ9");
|
||||
const [tab, setTab] = useState<InstrumentId>("PHQ9");
|
||||
// Which flagged row's crisis card is currently expanded (re-surfaced).
|
||||
const [openCrisisRowId, setOpenCrisisRowId] = useState<string | null>(null);
|
||||
|
||||
const pinned = instrument != null;
|
||||
// When pinned, the tab is forced to the host instrument so re-opening the
|
||||
// detail for the other instrument never paints a stale series.
|
||||
const activeTab = pinned ? instrument : tab;
|
||||
|
||||
const forTab = useMemo(
|
||||
() =>
|
||||
rows
|
||||
.filter((r) => r.instrument === activeTab)
|
||||
.filter((r) => r.instrument === tab)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(),
|
||||
),
|
||||
[rows, activeTab],
|
||||
[rows, tab],
|
||||
);
|
||||
|
||||
// Pinned: emptiness is per-instrument (the host opened THIS instrument's
|
||||
// detail). Combined: emptiness spans both instruments, as before.
|
||||
const isEmpty = pinned ? forTab.length === 0 : rows.length === 0;
|
||||
const isEmpty = rows.length === 0;
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("mentalHealth.history.title")}
|
||||
description={t("mentalHealth.history.empty")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* PHQ-9 / GAD-7 toggle — the two ranges + band sets can't share an
|
||||
axis, so the chart + list paint one instrument at a time. Hidden
|
||||
when pinned: the host already chose the instrument. */}
|
||||
{!pinned && (
|
||||
return (
|
||||
<Card data-slot="mental-health-history">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.history.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
title={t("mentalHealth.history.title")}
|
||||
description={t("mentalHealth.history.empty")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* PHQ-9 / GAD-7 toggle — the two ranges + band sets differ, so
|
||||
the list shows one instrument at a time. */}
|
||||
<div
|
||||
className="flex gap-1"
|
||||
role="tablist"
|
||||
aria-label={t("mentalHealth.history.chartTitle")}
|
||||
aria-label={t("mentalHealth.history.title")}
|
||||
>
|
||||
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => (
|
||||
<Button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === id}
|
||||
aria-selected={tab === id}
|
||||
size="sm"
|
||||
variant={activeTab === id ? "secondary" : "ghost"}
|
||||
variant={tab === id ? "secondary" : "ghost"}
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => {
|
||||
setTab(id);
|
||||
@@ -145,88 +109,61 @@ export function AssessmentHistory({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssessmentHistoryChart instrument={activeTab} rows={forTab} />
|
||||
|
||||
{forTab.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" data-slot="history-list">
|
||||
{forTab.map((row) => {
|
||||
const flagged = row.item9Flagged;
|
||||
const open = openCrisisRowId === row.id;
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(row.takenAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{flagged && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpenCrisisRowId(open ? null : row.id)
|
||||
}
|
||||
data-slot="history-flagged-marker"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{t("mentalHealth.history.flaggedBadge")}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{t(
|
||||
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
{forTab.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("mentalHealth.history.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5" data-slot="history-list">
|
||||
{forTab.map((row) => {
|
||||
const flagged = row.item9Flagged;
|
||||
const open = openCrisisRowId === row.id;
|
||||
return (
|
||||
<li key={row.id} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(row.takenAt)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{flagged && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
aria-expanded={open}
|
||||
onClick={() =>
|
||||
setOpenCrisisRowId(open ? null : row.id)
|
||||
}
|
||||
data-slot="history-flagged-marker"
|
||||
>
|
||||
<Badge variant="outline">
|
||||
{t("mentalHealth.history.flaggedBadge")}
|
||||
</Badge>
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary">
|
||||
{t(
|
||||
`mentalHealth.band.${row.instrument}.${row.severityBand}`,
|
||||
)}
|
||||
</Badge>
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.totalScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{flagged && open && (
|
||||
<CrisisCard crisis={crisisFromRow(row)} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Pinned: render bare (the host sheet owns the chrome + title). Combined:
|
||||
// the landing's titled history card, unchanged.
|
||||
if (pinned) {
|
||||
return (
|
||||
<div
|
||||
data-slot="mental-health-history"
|
||||
data-pinned={instrument}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-slot="mental-health-history">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("mentalHealth.history.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">{body}</CardContent>
|
||||
{flagged && open && (
|
||||
<CrisisCard crisis={crisisFromRow(row)} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,61 +2,32 @@
|
||||
* v1.25.3 — pure step grammar for the one-question-at-a-time check-in.
|
||||
*
|
||||
* The PHQ-9 / GAD-7 screeners are LINEAR (no mode/cadence branching like the
|
||||
* medication wizard), so the step list is a fixed `[1..itemCount, review]` and
|
||||
* the reachability ceiling is just "the question you are currently working on"
|
||||
* — you can always step backward to change an answer, never skip ahead onto an
|
||||
* unanswered question. Keeping the navigation here (mirroring the medication
|
||||
* wizard's `wizard-payload.ts`) makes the advance/back/reachability contract
|
||||
* medication wizard), so the steps are exactly the questions `1..itemCount`:
|
||||
* you can always step backward to change an answer, and never skip ahead past
|
||||
* an unanswered question. Keeping the navigation here (mirroring the
|
||||
* medication wizard's `wizard-payload.ts`) makes the advance/back contract
|
||||
* unit-testable without driving a DOM.
|
||||
*
|
||||
* Step numbers are 1-based and run `1..itemCount` for the questions; the review
|
||||
* step is `itemCount + 1`. The optional functional-difficulty control folds
|
||||
* into the review step (it is unscored), so the dot count stays at
|
||||
* `itemCount + 1` rather than gaining its own dot.
|
||||
* v1.27.6 — the review/summary step is gone: answering the last question
|
||||
* arms the submit directly (no "9 of 9 answered" interstitial demanding one
|
||||
* more decision). Step numbers are 1-based and run `1..itemCount`.
|
||||
*
|
||||
* An unanswered item carries the sentinel `-1`.
|
||||
*/
|
||||
|
||||
export const UNANSWERED = -1;
|
||||
|
||||
/** The 1-based review step number for an instrument of `itemCount` items. */
|
||||
export function reviewStep(itemCount: number): number {
|
||||
return itemCount + 1;
|
||||
}
|
||||
|
||||
/** Ordered raw step numbers fed straight to `WizardStepper`: questions + review. */
|
||||
export function buildStepList(itemCount: number): number[] {
|
||||
return Array.from({ length: itemCount + 1 }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
/** Index (into the step list) of the highest forward-reachable slot. */
|
||||
export function reachableUntilIndex(items: readonly number[]): number {
|
||||
const firstUnanswered = items.findIndex((v) => v < 0);
|
||||
// All answered → the review slot (last index) is reachable.
|
||||
if (firstUnanswered === -1) return items.length;
|
||||
// Otherwise the frontier is the question currently being answered; its index
|
||||
// in the step list equals its 0-based item index.
|
||||
return firstUnanswered;
|
||||
}
|
||||
|
||||
/** Every item answered (0–3) — gates the review step + submit. */
|
||||
/** Every item answered (0–3) — gates the submit on the last question. */
|
||||
export function isComplete(items: readonly number[]): boolean {
|
||||
return items.length > 0 && items.every((v) => v >= 0);
|
||||
}
|
||||
|
||||
/** How many items carry a real answer (for the review recap). */
|
||||
export function answeredCount(items: readonly number[]): number {
|
||||
return items.filter((v) => v >= 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next step after `step`. A question advances to the next question, the
|
||||
* last question advances to the review step, and the review step stays put
|
||||
* (submit, not advance).
|
||||
* The next step after `step`: a question advances to the next question; the
|
||||
* last question stays put (submit, not advance).
|
||||
*/
|
||||
export function nextStep(step: number, itemCount: number): number {
|
||||
const review = reviewStep(itemCount);
|
||||
if (step >= review) return review;
|
||||
if (step >= itemCount) return itemCount;
|
||||
return step + 1;
|
||||
}
|
||||
|
||||
@@ -65,7 +36,7 @@ export function prevStep(step: number): number {
|
||||
return step > 1 ? step - 1 : 1;
|
||||
}
|
||||
|
||||
/** Whether `step` is the review step for this instrument. */
|
||||
export function isReviewStep(step: number, itemCount: number): boolean {
|
||||
return step >= reviewStep(itemCount);
|
||||
/** Whether `step` is the last question for this instrument. */
|
||||
export function isLastStep(step: number, itemCount: number): boolean {
|
||||
return step >= itemCount;
|
||||
}
|
||||
@@ -1,44 +1,39 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.25.3 — one-question-at-a-time check-in.
|
||||
* v1.25.3 — one-question-at-a-time check-in, rendered INLINE on the page
|
||||
* (not a modal), because here the page *is* the surface.
|
||||
*
|
||||
* Replaces the all-questions-at-once `<ol>` wall with a linear stepper that
|
||||
* reuses the medication wizard's presentational `WizardStepper` (the dot row +
|
||||
* "Question 3 of 9" caption) and its Back/Next footer grammar — rendered INLINE
|
||||
* on the page (not a modal), because here the page *is* the surface. The step
|
||||
* navigation lives in `check-in-nav.ts` (pure, linear: backward always allowed,
|
||||
* never skip ahead to an unanswered question).
|
||||
* v1.27.6 — slimmed to questions only. The question-overview strip (the
|
||||
* dot/jump row borrowed from the medication wizard) and the "N of N
|
||||
* answered" review interstitial are gone: a screening is a quiet, linear
|
||||
* moment, not a form to audit. The Back button stays; a small "3 / 9"
|
||||
* caption keeps orientation without turning progress into a scoreboard.
|
||||
* Answering the last question arms the submit directly — the next thing
|
||||
* the user sees is the dignified result view.
|
||||
*
|
||||
* Header: instrument title + ONE standardized explanation line, NO disclaimer
|
||||
* (§2 — the "voluntary self-test, not a diagnosis" disclaimer renders only on
|
||||
* the landing + behind the InfoHint, never while taking the test). The optional
|
||||
* functional-difficulty control folds into the review step.
|
||||
* Header: instrument title + ONE standardized explanation line, NO
|
||||
* disclaimer (§2 — the "voluntary self-test, not a diagnosis" disclaimer
|
||||
* renders only on the landing, never while taking the test).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { WizardStepper } from "@/components/medications/wizard/wizard-stepper";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { INSTRUMENTS } from "@/lib/mental-health/instruments";
|
||||
|
||||
import {
|
||||
UNANSWERED,
|
||||
answeredCount,
|
||||
buildStepList,
|
||||
isComplete,
|
||||
isReviewStep,
|
||||
isLastStep,
|
||||
nextStep,
|
||||
prevStep,
|
||||
reachableUntilIndex,
|
||||
UNANSWERED,
|
||||
} from "./check-in-nav";
|
||||
import type { InstrumentId } from "./types";
|
||||
|
||||
const SCALE = [0, 1, 2, 3] as const;
|
||||
const FUNCTIONAL = [0, 1, 2, 3] as const;
|
||||
|
||||
function lower(id: InstrumentId): "phq9" | "gad7" {
|
||||
return id === "PHQ9" ? "phq9" : "gad7";
|
||||
@@ -52,8 +47,8 @@ export function CheckInWizard({
|
||||
isError,
|
||||
}: {
|
||||
instrument: InstrumentId;
|
||||
/** Submit the completed check-in. `functional` is null when unanswered. */
|
||||
onSubmit: (items: number[], functional: number | null) => void;
|
||||
/** Submit the completed check-in. */
|
||||
onSubmit: (items: number[]) => void;
|
||||
/** Return to the landing (abandons the in-progress check-in). */
|
||||
onBack: () => void;
|
||||
isPending: boolean;
|
||||
@@ -67,17 +62,14 @@ export function CheckInWizard({
|
||||
const [items, setItems] = useState<number[]>(() =>
|
||||
Array(itemCount).fill(UNANSWERED),
|
||||
);
|
||||
const [functional, setFunctional] = useState<number | null>(null);
|
||||
|
||||
const stepList = buildStepList(itemCount);
|
||||
const onReview = isReviewStep(step, itemCount);
|
||||
const questionIndex = step - 1; // 0-based item index for a question step
|
||||
const onLast = isLastStep(step, itemCount);
|
||||
const questionIndex = step - 1; // 0-based item index for the current question
|
||||
const complete = isComplete(items);
|
||||
const reachableUntil = reachableUntilIndex(items);
|
||||
|
||||
// a11y: announce each step by moving focus to its heading (the question text
|
||||
// / the review title). Mirrors the medication dialog's per-step title focus
|
||||
// so a keyboard / screen-reader user hears the new question after an
|
||||
// a11y: announce each step by moving focus to its heading (the question
|
||||
// text). Mirrors the medication dialog's per-step title focus so a
|
||||
// keyboard / screen-reader user hears the new question after an
|
||||
// auto-advance, not silence.
|
||||
const headingRef = useRef<HTMLHeadingElement | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -90,15 +82,12 @@ export function CheckInWizard({
|
||||
next[questionIndex] = value;
|
||||
return next;
|
||||
});
|
||||
// Forward-on-select: advance to the next question, or onto the review step
|
||||
// from the last question. The footer Next is the redundant keyboard path.
|
||||
setStep((s) => nextStep(s, itemCount));
|
||||
// Forward-on-select: advance to the next question. On the last question
|
||||
// the selection stays visible and the footer submit arms — submitting is
|
||||
// a deliberate second tap, never a side effect of answering.
|
||||
if (!onLast) setStep((s) => nextStep(s, itemCount));
|
||||
}
|
||||
|
||||
const srLabel = onReview
|
||||
? t("mentalHealth.review.title")
|
||||
: t("mentalHealth.questionAria", { current: step, total: itemCount });
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-5"
|
||||
@@ -118,7 +107,7 @@ export function CheckInWizard({
|
||||
</Button>
|
||||
|
||||
{/* In-test header — instrument title + ONE standardized explanation line.
|
||||
No disclaimer here (§2); the (i) tooltip on the landing carries it. */}
|
||||
No disclaimer here (§2); the landing carries it. */}
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t(`mentalHealth.instrument.${key}`)}
|
||||
@@ -129,114 +118,48 @@ export function CheckInWizard({
|
||||
</header>
|
||||
|
||||
<Card className="gap-0 p-0">
|
||||
<div className="border-border/70 space-y-1.5 border-b p-4 sm:p-6">
|
||||
<WizardStepper
|
||||
steps={stepList}
|
||||
current={step}
|
||||
reachableUntil={reachableUntil}
|
||||
labels={Object.fromEntries([
|
||||
...Array.from({ length: itemCount }, (_, i) => [
|
||||
i + 1,
|
||||
String(i + 1),
|
||||
]),
|
||||
[itemCount + 1, t("mentalHealth.review.title")],
|
||||
])}
|
||||
onJump={(target) => setStep(target)}
|
||||
onFirst={() => setStep(1)}
|
||||
onLast={() => setStep(itemCount + 1)}
|
||||
firstEnabled={step > 1}
|
||||
lastEnabled={complete && !onReview}
|
||||
firstLabel={t("mentalHealth.jumpFirst")}
|
||||
lastLabel={t("mentalHealth.jumpLast")}
|
||||
srLabel={srLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardContent className="flex flex-col gap-5 p-4 sm:p-6">
|
||||
{!onReview ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mentalHealth.progress", {
|
||||
current: step,
|
||||
total: itemCount,
|
||||
})}
|
||||
</p>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 text-base font-medium outline-none"
|
||||
>
|
||||
{t(`mentalHealth.items.${key}.${step}`)}
|
||||
</h2>
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
role="group"
|
||||
aria-label={t(`mentalHealth.items.${key}.${step}`)}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
// 44px touch-target floor (WCAG 2.5.5).
|
||||
className="min-h-11 justify-start"
|
||||
variant={items[questionIndex] === v ? "default" : "outline"}
|
||||
aria-pressed={items[questionIndex] === v}
|
||||
onClick={() => selectAnswer(v)}
|
||||
>
|
||||
{t(`mentalHealth.options.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5">
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 text-base font-medium outline-none"
|
||||
>
|
||||
{t("mentalHealth.review.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("mentalHealth.review.recap", {
|
||||
answered: answeredCount(items),
|
||||
total: itemCount,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* The optional, unscored functional-difficulty follow-up folds
|
||||
into the review step rather than its own dot (§1). */}
|
||||
<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-col gap-2"
|
||||
role="group"
|
||||
aria-label={t("mentalHealth.functionalTitle")}
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("mentalHealth.progress", {
|
||||
current: step,
|
||||
total: itemCount,
|
||||
})}
|
||||
</p>
|
||||
<h2
|
||||
ref={headingRef}
|
||||
tabIndex={-1}
|
||||
className="scroll-mt-4 text-base font-medium outline-none"
|
||||
>
|
||||
{t(`mentalHealth.items.${key}.${step}`)}
|
||||
</h2>
|
||||
<div
|
||||
className="flex flex-col gap-2"
|
||||
role="group"
|
||||
aria-label={t(`mentalHealth.items.${key}.${step}`)}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
// 44px touch-target floor (WCAG 2.5.5).
|
||||
className="min-h-11 justify-start"
|
||||
variant={items[questionIndex] === v ? "default" : "outline"}
|
||||
aria-pressed={items[questionIndex] === v}
|
||||
onClick={() => selectAnswer(v)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{FUNCTIONAL.map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
className="min-h-11 justify-start"
|
||||
variant={functional === v ? "default" : "outline"}
|
||||
aria-pressed={functional === v}
|
||||
onClick={() => setFunctional(v)}
|
||||
>
|
||||
{t(`mentalHealth.functional.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
{t("mentalHealth.error")}
|
||||
</p>
|
||||
)}
|
||||
{t(`mentalHealth.options.${v}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("mentalHealth.error")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="border-border/70 flex items-center justify-between gap-2 border-t p-4 sm:p-6">
|
||||
@@ -250,7 +173,7 @@ export function CheckInWizard({
|
||||
>
|
||||
{t("mentalHealth.back")}
|
||||
</Button>
|
||||
{!onReview ? (
|
||||
{!onLast ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11"
|
||||
@@ -263,8 +186,8 @@ export function CheckInWizard({
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className={cn("h-11")}
|
||||
onClick={() => onSubmit(items, functional)}
|
||||
className="h-11"
|
||||
onClick={() => onSubmit(items)}
|
||||
disabled={!complete || isPending}
|
||||
aria-busy={isPending || undefined}
|
||||
data-slot="check-in-submit"
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
/**
|
||||
* v1.25.3 — one neutral card per instrument on the landing.
|
||||
*
|
||||
* Mirrors the Vorsorge / medication card grammar: a header (title + sub),
|
||||
* a discreet "last result" line when history exists (band badge + relative
|
||||
* date), and a single bottom-pinned Start action. The surface stays NEUTRAL
|
||||
* v1.27.6 — rebuilt onto the shared med-/Vorsorge card anatomy so the two
|
||||
* test cards read exactly like their peers: `MedicationCardHeader` (bold
|
||||
* name + outline category badge), a label/value block in the next-last
|
||||
* grammar ("last test" → relative day, "last result" → score + band word),
|
||||
* and a single bottom-pinned Start action. The surface stays NEUTRAL
|
||||
* regardless of the last band — no severity tint (house rule: status reads
|
||||
* through a discreet badge only).
|
||||
* through discreet text only, never a card wash).
|
||||
*/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { relativeCalendarDate } from "@/lib/i18n/relative-time";
|
||||
|
||||
@@ -24,18 +27,11 @@ export function InstrumentCard({
|
||||
instrument,
|
||||
last,
|
||||
onStart,
|
||||
onOpenDetail,
|
||||
}: {
|
||||
instrument: InstrumentId;
|
||||
/** Most-recent assessment for this instrument, or undefined when none yet. */
|
||||
last: AssessmentRow | undefined;
|
||||
onStart: () => void;
|
||||
/**
|
||||
* Open this instrument's trend detail. The card BODY (title + last-result
|
||||
* line) is the navigation target — mirroring how a medication card opens its
|
||||
* detail — while the bottom-pinned Start button stays a separate action.
|
||||
*/
|
||||
onOpenDetail: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { date: formatDate } = useFormatters();
|
||||
@@ -43,44 +39,48 @@ export function InstrumentCard({
|
||||
const title = t(`mentalHealth.instrument.${key}`);
|
||||
|
||||
return (
|
||||
<Card className="h-full gap-3" data-slot="instrument-card">
|
||||
<CardContent className="flex h-full flex-col space-y-3.5 p-4">
|
||||
{/* Body is the detail target: clicking the title / last-result block
|
||||
opens this instrument's Verlauf, like a medication card. The Start
|
||||
button below is a separate, deliberately non-overlapping action. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenDetail}
|
||||
data-slot="instrument-card-open"
|
||||
aria-label={`${title} — ${t("mentalHealth.openDetail")}`}
|
||||
className="focus-visible:ring-ring/60 hover:bg-accent/40 -m-1 flex flex-1 cursor-pointer flex-col gap-3.5 rounded-md p-1 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-lg leading-none font-semibold">{title}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t(`mentalHealth.instrumentSub.${key}`)}
|
||||
</span>
|
||||
<Card className="h-full gap-3 md:gap-3" data-slot="instrument-card">
|
||||
<MedicationCardHeader
|
||||
name={title}
|
||||
dose=""
|
||||
categoryLabel={t(`mentalHealth.instrumentSub.${key}`)}
|
||||
/>
|
||||
<CardContent className="flex h-full flex-col space-y-3.5">
|
||||
{/* Label/value block in the Vorsorge card's next-last grammar. With
|
||||
no history yet, one calm line replaces the pair — no dashes
|
||||
pretending a value exists. */}
|
||||
{last ? (
|
||||
<div className="min-h-[2.75rem] space-y-1.5 text-sm">
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("mentalHealth.lastResult")}
|
||||
</span>
|
||||
<span className="text-foreground text-right">
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-baseline justify-between gap-3">
|
||||
<span className="min-w-0 flex-shrink truncate font-medium">
|
||||
{t("mentalHealth.lastScore")}
|
||||
</span>
|
||||
<span
|
||||
className="text-foreground text-right"
|
||||
data-slot="instrument-card-last-score"
|
||||
>
|
||||
{last.totalScore}
|
||||
{" · "}
|
||||
{t(`mentalHealth.band.${last.instrument}.${last.severityBand}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last-test line in the medication-card grammar: label left, the
|
||||
relative day (today / yesterday / date) right-aligned. The severity
|
||||
band is intentionally NOT shown here — the trend behind the card
|
||||
carries it. */}
|
||||
<div className="text-muted-foreground flex min-h-5 items-center justify-between gap-2 text-xs">
|
||||
{last ? (
|
||||
<>
|
||||
<span>{t("mentalHealth.lastResult")}</span>
|
||||
<span className="shrink-0">
|
||||
{relativeCalendarDate(last.takenAt, t, formatDate)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("mentalHealth.noResultYet")}</span>
|
||||
)}
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-[2.75rem] items-center text-sm">
|
||||
{t("mentalHealth.noResultYet")}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="pt-0">
|
||||
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot. */}
|
||||
<div className="mt-auto pt-0">
|
||||
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
|
||||
{t("mentalHealth.start")}
|
||||
</Button>
|
||||
|
||||
@@ -15,10 +15,14 @@
|
||||
*
|
||||
* v1.25.3 — this is now the ORCHESTRATOR + landing only; the 387-line monolith
|
||||
* split into focused components (`instrument-card`, `check-in-wizard`,
|
||||
* `assessment-result`, `assessment-history` + its lazy chart, `crisis-card`).
|
||||
* `assessment-result`, `assessment-history`, `crisis-card`).
|
||||
* The landing follows the card grammar of Vorsorge / Medications: instrument
|
||||
* cards (last-result line + Start) + a history card. The disclaimer renders
|
||||
* ONLY here (a muted caption) and behind the InfoHint — never while testing.
|
||||
*
|
||||
* v1.27.6 — the trend chart (and the per-card trend sheet) left this surface;
|
||||
* the score curve lives in Insights / Measurements via the PHQ9_SCORE /
|
||||
* GAD7_SCORE measurement rows. The landing keeps the calm dated list only.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -27,7 +31,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
|
||||
import { AssessmentHistory } from "./assessment-history";
|
||||
import { AssessmentResult } from "./assessment-result";
|
||||
@@ -47,11 +50,6 @@ export function MentalWellbeing() {
|
||||
const [phase, setPhase] = useState<Phase>("choose");
|
||||
const [instrument, setInstrument] = useState<InstrumentId>("PHQ9");
|
||||
const [result, setResult] = useState<CreateResponse | null>(null);
|
||||
// Which instrument's trend detail is open (null = closed). Clicking a card
|
||||
// body surfaces THAT instrument's Verlauf in a sheet; Start stays separate.
|
||||
const [detailInstrument, setDetailInstrument] = useState<InstrumentId | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
@@ -95,11 +93,14 @@ export function MentalWellbeing() {
|
||||
setPhase("choose");
|
||||
}
|
||||
|
||||
function submit(items: number[], functional: number | null) {
|
||||
// v1.27.6 — the review step (and with it the optional functional-difficulty
|
||||
// follow-up) left the web wizard; the check-in submits the scored items
|
||||
// directly after the last question. The API keeps accepting
|
||||
// `functionalDifficulty` for clients that still capture it.
|
||||
function submit(items: number[]) {
|
||||
mutation.mutate({
|
||||
instrument,
|
||||
items,
|
||||
...(functional !== null ? { functionalDifficulty: functional } : {}),
|
||||
locale,
|
||||
});
|
||||
}
|
||||
@@ -115,6 +116,13 @@ export function MentalWellbeing() {
|
||||
description={t("mentalHealth.pageDescription")}
|
||||
/>
|
||||
|
||||
{/* v1.27.6 — one gentle cadence hint: both instruments ask about
|
||||
the last two weeks, so every 2–4 weeks is a sensible rhythm.
|
||||
Body prose (foreground), not another muted description line. */}
|
||||
<p className="max-w-prose text-sm">
|
||||
{t("mentalHealth.pageRhythmHint")}
|
||||
</p>
|
||||
|
||||
<section aria-label={t("mentalHealth.choosePrompt")}>
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
@@ -124,7 +132,6 @@ export function MentalWellbeing() {
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
onOpenDetail={() => setDetailInstrument(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -132,33 +139,6 @@ export function MentalWellbeing() {
|
||||
</section>
|
||||
|
||||
<AssessmentHistory rows={history?.assessments ?? []} />
|
||||
|
||||
{/* Per-instrument trend detail, opened from a card body. Reuses the
|
||||
history's chart + dated list, pinned to the chosen instrument. */}
|
||||
<ResponsiveSheet
|
||||
open={detailInstrument !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailInstrument(null);
|
||||
}}
|
||||
contentWidth="lg"
|
||||
title={
|
||||
detailInstrument
|
||||
? t(
|
||||
`mentalHealth.instrument.${
|
||||
detailInstrument === "PHQ9" ? "phq9" : "gad7"
|
||||
}`,
|
||||
)
|
||||
: t("mentalHealth.history.title")
|
||||
}
|
||||
description={t("mentalHealth.history.chartTitle")}
|
||||
>
|
||||
{detailInstrument && (
|
||||
<AssessmentHistory
|
||||
rows={history?.assessments ?? []}
|
||||
instrument={detailInstrument}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -160,6 +160,52 @@ describe("runReminderSatisfyForUser", () => {
|
||||
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("satisfies a screening reminder from the server-written score row (v1.27.6)", async () => {
|
||||
// A completed PHQ-9 check-in writes a COMPUTED PHQ9_SCORE measurement;
|
||||
// the screening reminder resolves from that row like any typed reminder.
|
||||
const measuredAt = new Date("2026-07-01T10:00:00Z");
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
reminderRow({ measurementType: "PHQ9_SCORE", intervalDays: 28 }),
|
||||
],
|
||||
measurement: { measuredAt },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => true);
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date("2026-07-01T12:00:00Z"),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.satisfied).toBe(1);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
|
||||
expect(updates[0].data.lastSatisfiedAt).toEqual(measuredAt);
|
||||
});
|
||||
|
||||
it("produces no engine activity for a screening reminder when the mental-health module is off", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
reminderRow({ measurementType: "GAD7_SCORE", intervalDays: 28 }),
|
||||
],
|
||||
measurement: { measuredAt: new Date("2026-07-01T10:00:00Z") },
|
||||
});
|
||||
const isModuleEnabled = vi.fn(async () => false);
|
||||
|
||||
const summary = await runReminderSatisfyForUser(
|
||||
prisma as never,
|
||||
"u1",
|
||||
new Date(),
|
||||
{ isModuleEnabled },
|
||||
);
|
||||
|
||||
expect(summary.skippedModuleDisabled).toBe(1);
|
||||
expect(summary.satisfied).toBe(0);
|
||||
expect(isModuleEnabled).toHaveBeenCalledWith("u1", "mentalHealth");
|
||||
expect(updates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is forward-only: a stale event is a no-op (cron + hook converge)", async () => {
|
||||
const { prisma, updates } = makePrisma({
|
||||
reminders: [
|
||||
|
||||
@@ -51,9 +51,11 @@ import { satisfyReminder } from "@/lib/measurement-reminders/satisfy";
|
||||
* pressure, pulse, body composition) or has no module of its own. A
|
||||
* `null` reminder type (free-text Vorsorge entry) also yields `null`.
|
||||
*
|
||||
* Only the two secondary-domain types carry a gate: glucose and sleep.
|
||||
* Everything else is core and dispatches regardless — disabling a module
|
||||
* must never silence a core-vital reminder.
|
||||
* Only the secondary-domain types carry a gate: glucose, sleep, and the
|
||||
* mental-wellbeing screenings (v1.27.6 — the PHQ-9 / GAD-7 module is
|
||||
* opt-in, so a screening reminder must fall silent the moment the module
|
||||
* is off). Everything else is core and dispatches regardless — disabling
|
||||
* a module must never silence a core-vital reminder.
|
||||
*/
|
||||
function moduleForMeasurementType(
|
||||
type: MeasurementType | null,
|
||||
@@ -63,6 +65,9 @@ function moduleForMeasurementType(
|
||||
return "glucose";
|
||||
case "SLEEP_DURATION":
|
||||
return "sleep";
|
||||
case "PHQ9_SCORE":
|
||||
case "GAD7_SCORE":
|
||||
return "mentalHealth";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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.",
|
||||
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE measurement (source COMPUTED — never client-writable). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: createAssessmentSchema },
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
describe("measurementReminderTypeEnum (V3 — completeness)", () => {
|
||||
const allowed = measurementReminderTypeEnum.options;
|
||||
|
||||
it("exposes the full active set: vitals + body composition", () => {
|
||||
it("exposes the full active set: vitals + body composition + screenings", () => {
|
||||
expect(new Set(allowed)).toEqual(
|
||||
new Set([
|
||||
// Vitals
|
||||
@@ -34,10 +34,22 @@ describe("measurementReminderTypeEnum (V3 — completeness)", () => {
|
||||
"TOTAL_BODY_WATER",
|
||||
"VISCERAL_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
// v1.27.6 — plannable mental-wellbeing screenings
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a plannable screening reminder with the 4-week default cadence", () => {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "PHQ-9 Check-in",
|
||||
measurementType: "PHQ9_SCORE",
|
||||
intervalDays: 28,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a newly-added body-composition type on create", () => {
|
||||
const parsed = createMeasurementReminderSchema.safeParse({
|
||||
label: "Körperzusammensetzung messen",
|
||||
|
||||
@@ -470,6 +470,24 @@ describe("measurement validation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a COMPUTED-attributed screener score row on a client write", () => {
|
||||
// v1.27.6 — the mental-wellbeing screener projects each completed
|
||||
// administration onto a COMPUTED-source PHQ9_SCORE / GAD7_SCORE row
|
||||
// (the RECOVERY_SCORE precedent). COMPUTED is not client-attributable,
|
||||
// so a client can never mint a server-owned score trend point.
|
||||
for (const type of ["PHQ9_SCORE", "GAD7_SCORE"]) {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
type,
|
||||
value: 5,
|
||||
measuredAt: "2026-06-02T12:00:00Z",
|
||||
source: "COMPUTED",
|
||||
});
|
||||
expect(parsed.success, `expected COMPUTED ${type} to be rejected`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => {
|
||||
for (const source of ["MANUAL", "APPLE_HEALTH"]) {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
|
||||
@@ -56,17 +56,44 @@ export const measurementReminderTypeEnum = z
|
||||
"TOTAL_BODY_WATER",
|
||||
"VISCERAL_FAT",
|
||||
"BODY_MASS_INDEX",
|
||||
// v1.27.6 — the mental-wellbeing screeners become plannable Vorsorge
|
||||
// items. Not a passive wearable score: a screening is an active "go and
|
||||
// do it" action exactly like stepping on the scale. Auto-resolves from
|
||||
// the server-owned COMPUTED PHQ9_SCORE / GAD7_SCORE row the assessment
|
||||
// route writes on completion. Gated on the opt-in mentalHealth module in
|
||||
// the dispatcher (see `moduleForMeasurementType`).
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
])
|
||||
.meta({
|
||||
id: "MeasurementReminderType",
|
||||
description:
|
||||
"Auto-resolve target metric (vitals + body-composition family). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel). Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
"Auto-resolve target metric (vitals + body-composition family + the PHQ-9 / GAD-7 screenings). BP resolves on BLOOD_PRESSURE_SYS (the SYS row is the 'a BP was measured' sentinel); a screening reminder resolves on the server-written PHQ9_SCORE / GAD7_SCORE row when a check-in completes. Omit for a free-text Vorsorge that resolves only on a manual satisfy or a matching lab result.",
|
||||
});
|
||||
|
||||
export type MeasurementReminderType = z.infer<
|
||||
typeof measurementReminderTypeEnum
|
||||
>;
|
||||
|
||||
/**
|
||||
* v1.27.6 — the reminder types that are SCREENINGS, not numeric readings.
|
||||
* Every surface with a per-reminder primary action branches on this: a
|
||||
* screening routes to the check-in page (`/mental-wellbeing`) instead of
|
||||
* opening the numeric MeasurementForm — a score is never typed in, it
|
||||
* falls out of a completed test (which then auto-satisfies the reminder).
|
||||
*/
|
||||
export const SCREENING_REMINDER_TYPES: ReadonlySet<string> = new Set([
|
||||
"PHQ9_SCORE",
|
||||
"GAD7_SCORE",
|
||||
]);
|
||||
|
||||
/** Whether a reminder's `measurementType` targets a screening check-in. */
|
||||
export function isScreeningReminderType(
|
||||
type: string | null | undefined,
|
||||
): boolean {
|
||||
return type != null && SCREENING_REMINDER_TYPES.has(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC-5545 RRULE guard. The recurrence engine parses it; here we only
|
||||
* bound the length and require the `FREQ=` token so a typo doesn't reach
|
||||
|
||||
Reference in new issue
Block a user