diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a7fa4aa1c..13ad25428 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 732b16ed2..5b15961ad 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index dcb9a1849..e6752beca 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 57186f2e1..578367b41 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index bdb838530..625a8dd27 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index cbc9dd973..689c35b3d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 1b5a953fb..e15b6fe37 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/prisma/migrations/0225_v1276_mental_wellbeing_scores/migration.sql b/prisma/migrations/0225_v1276_mental_wellbeing_scores/migration.sql new file mode 100644 index 000000000..f93aed1fc --- /dev/null +++ b/prisma/migrations/0225_v1276_mental_wellbeing_scores/migration.sql @@ -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:%'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e2b483a3b..4c25019fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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:`) so the score rides -/// the existing chart / rollup infra — the trend, never the item content. +/// administration (linked by `externalId = assessment:`, `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") diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index aba10d668..ec64359db 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 315e547b0..457d82dd1 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -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 { // 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 { 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. diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index eb608b13d..d667a56f0 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -14,6 +14,12 @@ import type { MeasurementReminder } from "@/hooks/use-measurement-reminders"; * `` 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(" 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(); + 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 diff --git a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx index c27186808..2f8cb057f 100644 --- a/src/components/measurement-reminders/vorsorge-dashboard-card.tsx +++ b/src/components/measurement-reminders/vorsorge-dashboard-card.tsx @@ -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 ( - {t("measurementReminders.captureValue")} + {t( + isScreening + ? "measurementReminders.startCheckIn" + : "measurementReminders.captureValue", + )} ) : ( <> diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 461708c63..5c03acd8c 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -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({ - 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 }) => ( - {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")} {headerActions} diff --git a/src/components/mental-health/__tests__/check-in-nav.test.ts b/src/components/mental-health/__tests__/check-in-nav.test.ts index 9e0d4cdd1..784690c16 100644 --- a/src/components/mental-health/__tests__/check-in-nav.test.ts +++ b/src/components/mental-health/__tests__/check-in-nav.test.ts @@ -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); }); }); diff --git a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx index f983ed473..0c6868b49 100644 --- a/src/components/mental-health/__tests__/mental-wellbeing.test.tsx +++ b/src/components/mental-health/__tests__/mental-wellbeing.test.tsx @@ -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( { />, ); - 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(); - 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( - {}} - onOpenDetail={() => {}} - />, + {}} />, ); - // 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( - , + {}} />, ); - // 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( - , - ); - expect(html).toContain(mh.history.empty); - expect(html).not.toContain('data-slot="history-list"'); + expect(html).not.toContain(mh.noResultYet); }); }); diff --git a/src/components/mental-health/assessment-history-chart.tsx b/src/components/mental-health/assessment-history-chart.tsx deleted file mode 100644 index 7d8648c66..000000000 --- a/src/components/mental-health/assessment-history-chart.tsx +++ /dev/null @@ -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(() => { - 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 ( -

- {t("mentalHealth.history.empty")} -

- ); - } - - return ( - - - - - - - - - - formatDateShort(new Date(ts))} - tick={{ fontSize: 11, fill: "var(--muted-foreground)" }} - stroke="var(--border)" - minTickGap={32} - /> - - { - const active = props.active ?? false; - const payload = props.payload as - ReadonlyArray<{ payload?: ChartPoint }> | undefined; - const point = payload?.[0]?.payload; - if (!active || !point) { - return ; - } - 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 ( - - ); - }} - /> - - - - - ); -} diff --git a/src/components/mental-health/assessment-history.tsx b/src/components/mental-health/assessment-history.tsx index d570fe7e9..1bda8bfe8 100644 --- a/src/components/mental-health/assessment-history.tsx +++ b/src/components/mental-health/assessment-history.tsx @@ -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 `` shell holds the layout. -const AssessmentHistoryChartLazy = dynamic( - () => - importWithRetry(() => import("./assessment-history-chart")).then((mod) => ({ - default: mod.AssessmentHistoryChart, - })), - { ssr: false, loading: () => }, -); -function AssessmentHistoryChart( - props: ComponentProps, -) { - return ( - - - - ); -} - 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(instrument ?? "PHQ9"); + const [tab, setTab] = useState("PHQ9"); // Which flagged row's crisis card is currently expanded (re-surfaced). const [openCrisisRowId, setOpenCrisisRowId] = useState(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 ? ( - } - 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 ( + + + + {t("mentalHealth.history.title")} + + + + {isEmpty ? ( + } + 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. */}
{(["PHQ9", "GAD7"] as InstrumentId[]).map((id) => ( ))}
- )} - - - {forTab.length === 0 ? ( -

- {t("mentalHealth.history.empty")} -

- ) : ( -
    - {forTab.map((row) => { - const flagged = row.item9Flagged; - const open = openCrisisRowId === row.id; - return ( -
  • -
    - - {formatDate(row.takenAt)} - -
    - {flagged && ( - - )} - - {t( - `mentalHealth.band.${row.instrument}.${row.severityBand}`, - )} - - - {row.totalScore} + {forTab.length === 0 ? ( +

    + {t("mentalHealth.history.empty")} +

    + ) : ( +
      + {forTab.map((row) => { + const flagged = row.item9Flagged; + const open = openCrisisRowId === row.id; + return ( +
    • +
      + + {formatDate(row.takenAt)} +
      + {flagged && ( + + )} + + {t( + `mentalHealth.band.${row.instrument}.${row.severityBand}`, + )} + + + {row.totalScore} + +
      -
    - {flagged && open && ( - - )} -
  • - ); - })} -
- )} - - )} - - ); - - // Pinned: render bare (the host sheet owns the chrome + title). Combined: - // the landing's titled history card, unchanged. - if (pinned) { - return ( -
- {body} -
- ); - } - - return ( - - - - {t("mentalHealth.history.title")} - - - {body} + {flagged && open && ( + + )} + + ); + })} + + )} + + )} +
); } diff --git a/src/components/mental-health/check-in-nav.ts b/src/components/mental-health/check-in-nav.ts index 125d04a6b..dbe4fe983 100644 --- a/src/components/mental-health/check-in-nav.ts +++ b/src/components/mental-health/check-in-nav.ts @@ -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; } diff --git a/src/components/mental-health/check-in-wizard.tsx b/src/components/mental-health/check-in-wizard.tsx index 8d337b859..d7b178857 100644 --- a/src/components/mental-health/check-in-wizard.tsx +++ b/src/components/mental-health/check-in-wizard.tsx @@ -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 `
    ` 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(() => Array(itemCount).fill(UNANSWERED), ); - const [functional, setFunctional] = useState(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(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 (
    {/* 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. */}

    {t(`mentalHealth.instrument.${key}`)} @@ -129,114 +118,48 @@ export function CheckInWizard({

    -
    - [ - 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} - /> -
    - - {!onReview ? ( -
    -

    - {t("mentalHealth.progress", { - current: step, - total: itemCount, - })} -

    -

    - {t(`mentalHealth.items.${key}.${step}`)} -

    -
    - {SCALE.map((v) => ( - - ))} -
    -
    - ) : ( -
    -

    - {t("mentalHealth.review.title")} -

    -

    - {t("mentalHealth.review.recap", { - answered: answeredCount(items), - total: itemCount, - })} -

    - - {/* The optional, unscored functional-difficulty follow-up folds - into the review step rather than its own dot (§1). */} -
    - - {t("mentalHealth.functionalTitle")} - -
    +

    + {t("mentalHealth.progress", { + current: step, + total: itemCount, + })} +

    +

    + {t(`mentalHealth.items.${key}.${step}`)} +

    +
    + {SCALE.map((v) => ( + - ))} -
    -
    - - {isError && ( -

    - {t("mentalHealth.error")} -

    - )} + {t(`mentalHealth.options.${v}`)} + + ))}
    - )} + + {isError && ( +

    + {t("mentalHealth.error")} +

    + )} +
    @@ -250,7 +173,7 @@ export function CheckInWizard({ > {t("mentalHealth.back")} - {!onReview ? ( + {!onLast ? ( + )} -
    + {/* Bottom-pinned single primary action — the med-/Vorsorge card slot. */} +
    diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index 8f40e14cf..daa62b61d 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -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("choose"); const [instrument, setInstrument] = useState("PHQ9"); const [result, setResult] = useState(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( - 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. */} +

    + {t("mentalHealth.pageRhythmHint")} +

    +

    {t("mentalHealth.choosePrompt")}

      @@ -124,7 +132,6 @@ export function MentalWellbeing() { instrument={id} last={lastByInstrument.get(id)} onStart={() => begin(id)} - onOpenDetail={() => setDetailInstrument(id)} /> ))} @@ -132,33 +139,6 @@ export function MentalWellbeing() {
    - - {/* Per-instrument trend detail, opened from a card body. Reuses the - history's chart + dated list, pinned to the chosen instrument. */} - { - 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 && ( - - )} - )} diff --git a/src/lib/jobs/__tests__/reminder-satisfy.test.ts b/src/lib/jobs/__tests__/reminder-satisfy.test.ts index 7995ce86c..add9c1298 100644 --- a/src/lib/jobs/__tests__/reminder-satisfy.test.ts +++ b/src/lib/jobs/__tests__/reminder-satisfy.test.ts @@ -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: [ diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 186ad7cdf..1138cf54b 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -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; } diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 61d19e9ef..21f66a1fd 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -108,7 +108,7 @@ export const mentalHealthPaths: NonNullable = { 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 }, diff --git a/src/lib/validations/__tests__/measurement-reminders.test.ts b/src/lib/validations/__tests__/measurement-reminders.test.ts index 219e395ee..9ac7458cc 100644 --- a/src/lib/validations/__tests__/measurement-reminders.test.ts +++ b/src/lib/validations/__tests__/measurement-reminders.test.ts @@ -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", diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index eefaa73d8..b110d1e9f 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -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({ diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index 3c17f2ea4..39794123a 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -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 = 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