mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #274 from MBombeck/release/v1.14.0
release/v1.14.0 — animated health scores, score explanations, mood-factor correlations, log past doses
This commit is contained in:
49 files changed
+4515
-775
No files matched your search
@@ -2,6 +2,20 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.14.0] — 2026-06-06 — animated health scores, score explanations, mood-factor correlations, log past doses
|
||||
|
||||
### Added
|
||||
|
||||
- **Each health score now explains itself.** The Readiness, Sleep, Recovery, Strain and Stress score sheets carry a short, plain-language read of why the score landed where it did, drawn from the inputs that moved it. It fills even without an AI provider configured, so the explanation is always there.
|
||||
- **See how your rated factors track your body.** When you rate factors like work, sleep quality or stress on a mood entry, HealthLog now relates those ratings to your vitals — for example how your sleep or resting heart rate differs on the days you rate work low versus high — as a new card that the assistant can also cite. Associations only: each is shown with a confidence level, computed over a stable window, and never presented as cause.
|
||||
- **Log a dose you took earlier.** The medications "Add" button now offers a choice — create a new medication, or log an intake — and the intake path accepts a backdated date and time against an existing medication.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The health scores got a signature redesign.** The score strip reveals with a calm, staggered animation — each ring springs to its value while the number counts up — with a distinct colour per score (readiness green, recovery teal, sleep blue, stress amber, strain violet), a soft glow, and a faint "vs your normal" reference arc on the scores that carry a history. The motion plays once per visit and fully respects reduced-motion; the colours hold their contrast in both light and dark themes.
|
||||
- **Sparse medications show where you stand at a glance.** A weekly injection or other "every N days" medication now shows its open-cycle status on the card — "next dose in N days", "due today", "overdue", or "no cycles yet" — instead of leaning on a percentage that says little between doses.
|
||||
- **Smaller refinements across Insights.** The three trend cards (mood, weight, blood pressure) are now exactly the same height; the daily-briefing heading sits above its card; and the assistant tone and length controls sit side by side to cut scrolling.
|
||||
|
||||
## [1.13.3] — 2026-06-05 — health score scores blood pressure again, steps in Insights
|
||||
|
||||
### Fixed
|
||||
|
||||
+29
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.13.3
|
||||
version: 1.14.0
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -9306,6 +9306,14 @@ components:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Why the value could not be produced; null when status is 'ok'.
|
||||
assessment:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/DerivedAssessment"
|
||||
- type: "null"
|
||||
description: "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the
|
||||
per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric
|
||||
and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less
|
||||
accounts + the demo always get one) and warmer AI prose overrides it once cached."
|
||||
required:
|
||||
- metric
|
||||
- status
|
||||
@@ -9314,6 +9322,7 @@ components:
|
||||
- confidence
|
||||
- provenance
|
||||
- reason
|
||||
- assessment
|
||||
additionalProperties: false
|
||||
description: Flat `Derived<T>` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no
|
||||
narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let
|
||||
@@ -9400,6 +9409,25 @@ components:
|
||||
- windowDays
|
||||
- computedAt
|
||||
additionalProperties: false
|
||||
DerivedAssessment:
|
||||
type: object
|
||||
properties:
|
||||
text:
|
||||
type: string
|
||||
description: Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.
|
||||
source:
|
||||
type: string
|
||||
description: "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached."
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: When the text was produced / last warmed.
|
||||
required:
|
||||
- text
|
||||
- source
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
DerivedBatchResponseEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+45
-1
@@ -590,6 +590,24 @@
|
||||
"editMedication": "Medikament bearbeiten",
|
||||
"newMedication": "Neues Medikament",
|
||||
"firstMedication": "Erstes Medikament anlegen",
|
||||
"addChoice": {
|
||||
"logIntake": "Einnahme erfassen",
|
||||
"newMedication": "Neues Medikament"
|
||||
},
|
||||
"logIntake": {
|
||||
"dialogTitle": "Einnahme erfassen",
|
||||
"dialogDescription": "Erfasse eine Dosis für ein vorhandenes Medikament. Du kannst sie auf ein vergangenes Datum und eine vergangene Uhrzeit zurückdatieren.",
|
||||
"medicationLabel": "Medikament",
|
||||
"slotLabel": "Einnahmeslot",
|
||||
"slotNone": "Kein bestimmter Slot",
|
||||
"takenAtLabel": "Datum und Uhrzeit",
|
||||
"takenAtHint": "Standard ist jetzt; wähle ein früheres Datum und eine frühere Uhrzeit, um eine vergangene Dosis zu erfassen.",
|
||||
"skippedLabel": "Als ausgelassen erfassen",
|
||||
"noMedications": "Lege zuerst ein Medikament an, dann kannst du Einnahmen dafür erfassen.",
|
||||
"cancel": "Abbrechen",
|
||||
"submit": "Einnahme erfassen",
|
||||
"slotError": "Der gewählte Slot konnte keiner Uhrzeit zugeordnet werden. Bitte wähle einen anderen Slot oder „Kein bestimmter Slot“."
|
||||
},
|
||||
"emptyTitle": "Noch keine Medikamente",
|
||||
"emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um Compliance zu erfassen.",
|
||||
"loadFailed": "Medikamente konnten nicht geladen werden",
|
||||
@@ -667,6 +685,12 @@
|
||||
"lastIntake": "Letzte Einnahme:",
|
||||
"nextIntake": "Nächste Einnahme:",
|
||||
"dayStreak": "Tage Serie",
|
||||
"cycleNextDoseToday": "Nächste Dosis heute",
|
||||
"cycleNextDoseTomorrow": "Nächste Dosis morgen",
|
||||
"cycleNextDoseInDays": "Nächste Dosis in {days} Tagen",
|
||||
"cycleDueToday": "Heute fällig",
|
||||
"cycleOverdue": "Überfällig",
|
||||
"cycleNoClosedCycles": "Noch keine abgeschlossenen Dosiszyklen",
|
||||
"taken": "Genommen",
|
||||
"skipped": "Übersprungen",
|
||||
"intakeToastTaken": "{name} als genommen erfasst",
|
||||
@@ -2622,6 +2646,26 @@
|
||||
"detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.",
|
||||
"detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst."
|
||||
},
|
||||
"factorCrosstab": {
|
||||
"title": "Deine bewerteten Faktoren und deine Vitalwerte",
|
||||
"description": "Wie sich ein Vitalwert an den Tagen unterscheidet, an denen du einen Faktor niedrig bewertet hast, verglichen mit den Tagen mit hoher Bewertung.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"metricSleepDuration": "Schlaf",
|
||||
"metricSteps": "Schritte",
|
||||
"metricRestingHeartRate": "Ruhepuls",
|
||||
"metricHrv": "Herzfrequenzvariabilität",
|
||||
"metricWeight": "Gewicht",
|
||||
"metricBloodPressureSys": "Systolisch",
|
||||
"unitHours": "Std.",
|
||||
"unitScore": "Pkt.",
|
||||
"unitSteps": "Schritte",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitMmHg": "mmHg",
|
||||
"detail": "Durchschnittlich {lowAvg} {unit} an den {lowDays} Tagen mit niedriger {factor}-Bewertung gegenüber {highAvg} {unit} an den Tagen mit hoher Bewertung.",
|
||||
"detailInverse": "Durchschnittlich {lowAvg} {unit} an deinen {lowDays} schlechteren {factor}-Tagen gegenüber {highAvg} {unit} an deinen besseren."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Was auffällt",
|
||||
"subheading": "Entdeckte Zusammenhänge",
|
||||
@@ -4824,4 +4868,4 @@
|
||||
},
|
||||
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker."
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -590,6 +590,24 @@
|
||||
"editMedication": "Edit medication",
|
||||
"newMedication": "New medication",
|
||||
"firstMedication": "Add first medication",
|
||||
"addChoice": {
|
||||
"logIntake": "Log an intake",
|
||||
"newMedication": "New medication"
|
||||
},
|
||||
"logIntake": {
|
||||
"dialogTitle": "Log an intake",
|
||||
"dialogDescription": "Record a dose against an existing medication. You can backdate it to a past date and time.",
|
||||
"medicationLabel": "Medication",
|
||||
"slotLabel": "Schedule slot",
|
||||
"slotNone": "No specific slot",
|
||||
"takenAtLabel": "Date and time",
|
||||
"takenAtHint": "Defaults to now; pick an earlier date and time to log a past dose.",
|
||||
"skippedLabel": "Logged as skipped",
|
||||
"noMedications": "Add a medication first, then you can log intakes against it.",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Log intake",
|
||||
"slotError": "The selected slot couldn’t be matched to a time. Pick another slot or “No specific slot”."
|
||||
},
|
||||
"emptyTitle": "No medications yet",
|
||||
"emptyDescription": "Add medications, dosages, and reminder windows to track compliance.",
|
||||
"loadFailed": "Could not load medications",
|
||||
@@ -667,6 +685,12 @@
|
||||
"lastIntake": "Last intake:",
|
||||
"nextIntake": "Next intake:",
|
||||
"dayStreak": "day streak",
|
||||
"cycleNextDoseToday": "Next dose today",
|
||||
"cycleNextDoseTomorrow": "Next dose tomorrow",
|
||||
"cycleNextDoseInDays": "Next dose in {days} days",
|
||||
"cycleDueToday": "Due today",
|
||||
"cycleOverdue": "Overdue",
|
||||
"cycleNoClosedCycles": "No closed dose cycles yet",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"intakeToastTaken": "{name} recorded as taken",
|
||||
@@ -2622,6 +2646,26 @@
|
||||
"detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.",
|
||||
"detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise."
|
||||
},
|
||||
"factorCrosstab": {
|
||||
"title": "Your rated factors and your vitals",
|
||||
"description": "How a vital differs on the days you rated each factor low compared with the days you rated it high.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"metricSleepDuration": "Sleep",
|
||||
"metricSteps": "Steps",
|
||||
"metricRestingHeartRate": "Resting heart rate",
|
||||
"metricHrv": "Heart-rate variability",
|
||||
"metricWeight": "Weight",
|
||||
"metricBloodPressureSys": "Systolic",
|
||||
"unitHours": "h",
|
||||
"unitScore": "pts",
|
||||
"unitSteps": "steps",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitMmHg": "mmHg",
|
||||
"detail": "Averages {lowAvg} {unit} on the {lowDays} days you rated {factor} low vs {highAvg} {unit} on the days you rated it high.",
|
||||
"detailInverse": "Averages {lowAvg} {unit} on your {lowDays} worse {factor} days vs {highAvg} {unit} on your better ones."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "What stands out",
|
||||
"subheading": "Discovered relationships",
|
||||
@@ -4824,4 +4868,4 @@
|
||||
},
|
||||
"footer": "Shared from HealthLog — a self-hosted personal health tracker."
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -590,6 +590,24 @@
|
||||
"editMedication": "Editar medicamento",
|
||||
"newMedication": "Nuevo medicamento",
|
||||
"firstMedication": "Añadir el primer medicamento",
|
||||
"addChoice": {
|
||||
"logIntake": "Registrar una toma",
|
||||
"newMedication": "Nuevo medicamento"
|
||||
},
|
||||
"logIntake": {
|
||||
"dialogTitle": "Registrar una toma",
|
||||
"dialogDescription": "Registra una dosis de un medicamento existente. Puedes posfecharla a una fecha y hora pasadas.",
|
||||
"medicationLabel": "Medicamento",
|
||||
"slotLabel": "Franja del horario",
|
||||
"slotNone": "Sin franja concreta",
|
||||
"takenAtLabel": "Fecha y hora",
|
||||
"takenAtHint": "Por defecto es ahora; elige una fecha y hora anteriores para registrar una dosis pasada.",
|
||||
"skippedLabel": "Registrar como omitida",
|
||||
"noMedications": "Añade primero un medicamento y luego podrás registrar tomas para él.",
|
||||
"cancel": "Cancelar",
|
||||
"submit": "Registrar toma",
|
||||
"slotError": "No se pudo asignar una hora al intervalo seleccionado. Elige otro intervalo o «Sin intervalo específico»."
|
||||
},
|
||||
"emptyTitle": "Aún no hay medicamentos",
|
||||
"emptyDescription": "Crea medicamentos, dosis y franjas horarias para registrar el cumplimiento.",
|
||||
"loadFailed": "No se han podido cargar los medicamentos",
|
||||
@@ -667,6 +685,12 @@
|
||||
"lastIntake": "Última toma:",
|
||||
"nextIntake": "Próxima toma:",
|
||||
"dayStreak": "días de racha",
|
||||
"cycleNextDoseToday": "Próxima dosis hoy",
|
||||
"cycleNextDoseTomorrow": "Próxima dosis mañana",
|
||||
"cycleNextDoseInDays": "Próxima dosis en {days} días",
|
||||
"cycleDueToday": "Pendiente hoy",
|
||||
"cycleOverdue": "Atrasada",
|
||||
"cycleNoClosedCycles": "Aún no hay ciclos de dosis completados",
|
||||
"taken": "Tomada",
|
||||
"skipped": "Omitida",
|
||||
"intakeToastTaken": "{name} registrada como tomada",
|
||||
@@ -2622,6 +2646,26 @@
|
||||
"detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.",
|
||||
"detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto."
|
||||
},
|
||||
"factorCrosstab": {
|
||||
"title": "Tus factores valorados y tus constantes",
|
||||
"description": "Cómo difiere una constante en los días en que valoraste bajo cada factor frente a los días en que lo valoraste alto.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"metricSleepDuration": "Sueño",
|
||||
"metricSteps": "Pasos",
|
||||
"metricRestingHeartRate": "Frecuencia cardíaca en reposo",
|
||||
"metricHrv": "Variabilidad de la frecuencia cardíaca",
|
||||
"metricWeight": "Peso",
|
||||
"metricBloodPressureSys": "Sistólica",
|
||||
"unitHours": "h",
|
||||
"unitScore": "pts",
|
||||
"unitSteps": "pasos",
|
||||
"unitBpm": "ppm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitMmHg": "mmHg",
|
||||
"detail": "Promedia {lowAvg} {unit} en los {lowDays} días que valoraste {factor} bajo frente a {highAvg} {unit} en los días que lo valoraste alto.",
|
||||
"detailInverse": "Promedia {lowAvg} {unit} en tus {lowDays} peores días de {factor} frente a {highAvg} {unit} en los mejores."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Lo que destaca",
|
||||
"subheading": "Relaciones descubiertas",
|
||||
@@ -4824,4 +4868,4 @@
|
||||
},
|
||||
"footer": "Compartido desde HealthLog — un registro de salud autoalojado."
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -590,6 +590,24 @@
|
||||
"editMedication": "Modifier le médicament",
|
||||
"newMedication": "Nouveau médicament",
|
||||
"firstMedication": "Ajouter le premier médicament",
|
||||
"addChoice": {
|
||||
"logIntake": "Enregistrer une prise",
|
||||
"newMedication": "Nouveau médicament"
|
||||
},
|
||||
"logIntake": {
|
||||
"dialogTitle": "Enregistrer une prise",
|
||||
"dialogDescription": "Enregistrez une dose pour un médicament existant. Vous pouvez l’antidater à une date et une heure passées.",
|
||||
"medicationLabel": "Médicament",
|
||||
"slotLabel": "Créneau du planning",
|
||||
"slotNone": "Aucun créneau précis",
|
||||
"takenAtLabel": "Date et heure",
|
||||
"takenAtHint": "Par défaut maintenant ; choisissez une date et une heure antérieures pour enregistrer une dose passée.",
|
||||
"skippedLabel": "Enregistrer comme ignorée",
|
||||
"noMedications": "Ajoutez d’abord un médicament, puis vous pourrez enregistrer des prises pour celui-ci.",
|
||||
"cancel": "Annuler",
|
||||
"submit": "Enregistrer la prise",
|
||||
"slotError": "Le créneau sélectionné n’a pas pu être associé à une heure. Choisissez un autre créneau ou « Aucun créneau précis »."
|
||||
},
|
||||
"emptyTitle": "Aucun médicament pour l’instant",
|
||||
"emptyDescription": "Crée des médicaments, des doses et des fenêtres horaires pour suivre l’observance.",
|
||||
"loadFailed": "Impossible de charger les médicaments",
|
||||
@@ -667,6 +685,12 @@
|
||||
"lastIntake": "Dernière prise :",
|
||||
"nextIntake": "Prochaine prise :",
|
||||
"dayStreak": "jours de série",
|
||||
"cycleNextDoseToday": "Prochaine dose aujourd'hui",
|
||||
"cycleNextDoseTomorrow": "Prochaine dose demain",
|
||||
"cycleNextDoseInDays": "Prochaine dose dans {days} jours",
|
||||
"cycleDueToday": "À prendre aujourd'hui",
|
||||
"cycleOverdue": "En retard",
|
||||
"cycleNoClosedCycles": "Aucun cycle de dose terminé pour l'instant",
|
||||
"taken": "Pris",
|
||||
"skipped": "Sauté",
|
||||
"intakeToastTaken": "{name} enregistré comme pris",
|
||||
@@ -2622,6 +2646,26 @@
|
||||
"detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.",
|
||||
"detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon."
|
||||
},
|
||||
"factorCrosstab": {
|
||||
"title": "Vos facteurs notés et vos constantes",
|
||||
"description": "Comment une constante diffère les jours où vous avez noté un facteur bas par rapport aux jours où vous l'avez noté haut.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"metricSleepDuration": "Sommeil",
|
||||
"metricSteps": "Pas",
|
||||
"metricRestingHeartRate": "Fréquence cardiaque au repos",
|
||||
"metricHrv": "Variabilité de la fréquence cardiaque",
|
||||
"metricWeight": "Poids",
|
||||
"metricBloodPressureSys": "Systolique",
|
||||
"unitHours": "h",
|
||||
"unitScore": "pts",
|
||||
"unitSteps": "pas",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitMmHg": "mmHg",
|
||||
"detail": "En moyenne {lowAvg} {unit} les {lowDays} jours où vous avez noté {factor} bas contre {highAvg} {unit} les jours où vous l'avez noté haut.",
|
||||
"detailInverse": "En moyenne {lowAvg} {unit} sur vos {lowDays} pires jours de {factor} contre {highAvg} {unit} sur les meilleurs."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Ce qui ressort",
|
||||
"subheading": "Relations découvertes",
|
||||
@@ -4824,4 +4868,4 @@
|
||||
},
|
||||
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé."
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -590,6 +590,24 @@
|
||||
"editMedication": "Modifica farmaco",
|
||||
"newMedication": "Nuovo farmaco",
|
||||
"firstMedication": "Aggiungi il primo farmaco",
|
||||
"addChoice": {
|
||||
"logIntake": "Registra un'assunzione",
|
||||
"newMedication": "Nuovo farmaco"
|
||||
},
|
||||
"logIntake": {
|
||||
"dialogTitle": "Registra un'assunzione",
|
||||
"dialogDescription": "Registra una dose per un farmaco esistente. Puoi retrodatarla a una data e un'ora passate.",
|
||||
"medicationLabel": "Farmaco",
|
||||
"slotLabel": "Fascia della pianificazione",
|
||||
"slotNone": "Nessuna fascia specifica",
|
||||
"takenAtLabel": "Data e ora",
|
||||
"takenAtHint": "Per impostazione predefinita è adesso; scegli una data e un'ora precedenti per registrare una dose passata.",
|
||||
"skippedLabel": "Registra come saltata",
|
||||
"noMedications": "Aggiungi prima un farmaco, poi potrai registrarne le assunzioni.",
|
||||
"cancel": "Annulla",
|
||||
"submit": "Registra assunzione",
|
||||
"slotError": "Non è stato possibile associare un orario alla fascia selezionata. Scegli un’altra fascia o «Nessuna fascia specifica»."
|
||||
},
|
||||
"emptyTitle": "Ancora nessun farmaco",
|
||||
"emptyDescription": "Crea farmaci, dosi e fasce orarie per registrare l’aderenza.",
|
||||
"loadFailed": "Impossibile caricare i farmaci",
|
||||
@@ -667,6 +685,12 @@
|
||||
"lastIntake": "Ultima assunzione:",
|
||||
"nextIntake": "Prossima assunzione:",
|
||||
"dayStreak": "giorni di serie",
|
||||
"cycleNextDoseToday": "Prossima dose oggi",
|
||||
"cycleNextDoseTomorrow": "Prossima dose domani",
|
||||
"cycleNextDoseInDays": "Prossima dose tra {days} giorni",
|
||||
"cycleDueToday": "In scadenza oggi",
|
||||
"cycleOverdue": "In ritardo",
|
||||
"cycleNoClosedCycles": "Ancora nessun ciclo di dose completato",
|
||||
"taken": "Assunta",
|
||||
"skipped": "Saltata",
|
||||
"intakeToastTaken": "{name} registrata come assunta",
|
||||
@@ -2622,6 +2646,26 @@
|
||||
"detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.",
|
||||
"detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti."
|
||||
},
|
||||
"factorCrosstab": {
|
||||
"title": "I tuoi fattori valutati e i tuoi parametri vitali",
|
||||
"description": "Come cambia un parametro vitale nei giorni in cui hai valutato basso un fattore rispetto ai giorni in cui lo hai valutato alto.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"metricSleepDuration": "Sonno",
|
||||
"metricSteps": "Passi",
|
||||
"metricRestingHeartRate": "Frequenza cardiaca a riposo",
|
||||
"metricHrv": "Variabilità della frequenza cardiaca",
|
||||
"metricWeight": "Peso",
|
||||
"metricBloodPressureSys": "Sistolica",
|
||||
"unitHours": "h",
|
||||
"unitScore": "pti",
|
||||
"unitSteps": "passi",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitMmHg": "mmHg",
|
||||
"detail": "In media {lowAvg} {unit} nei {lowDays} giorni in cui hai valutato basso {factor} contro {highAvg} {unit} nei giorni in cui lo hai valutato alto.",
|
||||
"detailInverse": "In media {lowAvg} {unit} nei tuoi {lowDays} giorni peggiori di {factor} contro {highAvg} {unit} in quelli migliori."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Cosa risalta",
|
||||
"subheading": "Relazioni scoperte",
|
||||
@@ -4824,4 +4868,4 @@
|
||||
},
|
||||
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted."
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-1
@@ -590,6 +590,24 @@
|
||||
"editMedication": "Edytuj lek",
|
||||
"newMedication": "Nowy lek",
|
||||
"firstMedication": "Dodaj pierwszy lek",
|
||||
"addChoice": {
|
||||
"logIntake": "Zarejestruj przyjęcie",
|
||||
"newMedication": "Nowy lek"
|
||||
},
|
||||
"logIntake": {
|
||||
"dialogTitle": "Zarejestruj przyjęcie",
|
||||
"dialogDescription": "Zarejestruj dawkę istniejącego leku. Możesz wstawić ją wstecznie na przeszłą datę i godzinę.",
|
||||
"medicationLabel": "Lek",
|
||||
"slotLabel": "Przedział harmonogramu",
|
||||
"slotNone": "Bez konkretnego przedziału",
|
||||
"takenAtLabel": "Data i godzina",
|
||||
"takenAtHint": "Domyślnie teraz; wybierz wcześniejszą datę i godzinę, aby zarejestrować przeszłą dawkę.",
|
||||
"skippedLabel": "Zarejestruj jako pominięte",
|
||||
"noMedications": "Najpierw dodaj lek, a potem będziesz mógł rejestrować jego przyjęcia.",
|
||||
"cancel": "Anuluj",
|
||||
"submit": "Zarejestruj przyjęcie",
|
||||
"slotError": "Nie udało się przypisać godziny do wybranego slotu. Wybierz inny slot lub „Brak konkretnego slotu”."
|
||||
},
|
||||
"emptyTitle": "Brak leków",
|
||||
"emptyDescription": "Utwórz leki, dawki i okna czasowe, aby śledzić przestrzeganie.",
|
||||
"loadFailed": "Nie udało się załadować leków",
|
||||
@@ -667,6 +685,12 @@
|
||||
"lastIntake": "Ostatnie przyjęcie:",
|
||||
"nextIntake": "Następne przyjęcie:",
|
||||
"dayStreak": "dni serii",
|
||||
"cycleNextDoseToday": "Następna dawka dzisiaj",
|
||||
"cycleNextDoseTomorrow": "Następna dawka jutro",
|
||||
"cycleNextDoseInDays": "Następna dawka za {days} dni",
|
||||
"cycleDueToday": "Termin dzisiaj",
|
||||
"cycleOverdue": "Zaległa",
|
||||
"cycleNoClosedCycles": "Brak zakończonych cykli dawkowania",
|
||||
"taken": "Przyjęte",
|
||||
"skipped": "Pominięte",
|
||||
"intakeToastTaken": "{name} oznaczone jako przyjęte",
|
||||
@@ -2622,6 +2646,26 @@
|
||||
"detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.",
|
||||
"detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni."
|
||||
},
|
||||
"factorCrosstab": {
|
||||
"title": "Twoje ocenione czynniki i parametry życiowe",
|
||||
"description": "Jak parametr życiowy różni się w dniach, w których oceniłeś dany czynnik nisko, w porównaniu z dniami, w których oceniłeś go wysoko.",
|
||||
"pairLabel": "{factor} · {metric}",
|
||||
"metricSleepDuration": "Sen",
|
||||
"metricSteps": "Kroki",
|
||||
"metricRestingHeartRate": "Tętno spoczynkowe",
|
||||
"metricHrv": "Zmienność rytmu serca",
|
||||
"metricWeight": "Waga",
|
||||
"metricBloodPressureSys": "Skurczowe",
|
||||
"unitHours": "godz.",
|
||||
"unitScore": "pkt",
|
||||
"unitSteps": "kroki",
|
||||
"unitBpm": "bpm",
|
||||
"unitMs": "ms",
|
||||
"unitKg": "kg",
|
||||
"unitMmHg": "mmHg",
|
||||
"detail": "Średnio {lowAvg} {unit} w {lowDays} dniach z niską oceną {factor} wobec {highAvg} {unit} w dniach z wysoką oceną.",
|
||||
"detailInverse": "Średnio {lowAvg} {unit} w Twoich {lowDays} gorszych dniach {factor} wobec {highAvg} {unit} w lepszych."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Co się wyróżnia",
|
||||
"subheading": "Wykryte relacje",
|
||||
@@ -4824,4 +4868,4 @@
|
||||
},
|
||||
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia."
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.13.3",
|
||||
"version": "1.14.0",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -167,6 +167,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
provenance: derived.provenance,
|
||||
reason:
|
||||
derived.status === "insufficient" ? derived.reason : null,
|
||||
// The batch backs the dashboard GRID; per-score assessment text is
|
||||
// a detail-sheet concern served by the single-metric route. Always
|
||||
// null here so the wire shape matches `DerivedMetricResponse`
|
||||
// without paying the assessment cost on a 17-item grid read.
|
||||
assessment: null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
DERIVED_METRIC_IDS,
|
||||
type DerivedMetricId,
|
||||
} from "@/lib/insights/derived";
|
||||
import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -80,6 +82,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { metric, status: derived.status },
|
||||
});
|
||||
|
||||
// v1.13.2 — additive per-score assessment: a short "why is this score what
|
||||
// it is" explanation, keyed to the SAME id the caller passed (READINESS,
|
||||
// SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any
|
||||
// other id, and null when status !== "ok". Always a non-empty deterministic
|
||||
// text when present (the demo + provider-less accounts fill); warmer AI
|
||||
// prose overrides it once cached. The warm enqueue is fire-and-forget — this
|
||||
// never blocks on an LLM round-trip.
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolvedLocale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const assessment = await resolveDerivedAssessment({
|
||||
metric,
|
||||
userId: user.id,
|
||||
derived,
|
||||
locale: resolvedLocale,
|
||||
});
|
||||
|
||||
// Flatten the discriminated union for the wire: `metric` tags it,
|
||||
// `value`/`reason` are nullable so iOS decodes one stable shape.
|
||||
return apiSuccess({
|
||||
@@ -90,5 +112,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
confidence: derived.status === "ok" ? derived.confidence : null,
|
||||
provenance: derived.provenance,
|
||||
reason: derived.status === "insufficient" ? derived.reason : null,
|
||||
assessment,
|
||||
});
|
||||
});
|
||||
+244
-2
@@ -208,6 +208,36 @@
|
||||
*/
|
||||
--dose-accent: oklch(0.714 0.203 305.504);
|
||||
|
||||
/*
|
||||
* v1.14.0 — wellness-ring per-metric gradients (Dracula dark).
|
||||
* Five DISTINCT single-hue two-stop arcs (from = darker, to = lighter):
|
||||
* readiness green · recovery teal · sleep indigo-blue · stress amber ·
|
||||
* strain violet. Vivid on the dark `--card`. Each `to` doubles as the
|
||||
* ring's bloom colour. The Alucard light set lives on `:root.light`.
|
||||
*/
|
||||
--ring-readiness-from: #1f9d57;
|
||||
--ring-readiness-to: #5cf08f;
|
||||
--tile-readiness: #50fa7b;
|
||||
--ring-recovery-from: #0e9e94;
|
||||
--ring-recovery-to: #4ff0d2;
|
||||
--tile-recovery: #8be9fd;
|
||||
--ring-sleep-from: #4f6ef0;
|
||||
--ring-sleep-to: #8fb4ff;
|
||||
--tile-sleep: #6f8bff;
|
||||
--ring-stress-from: #e0922f;
|
||||
--ring-stress-to: #ffce6b;
|
||||
--tile-stress: #ffb86c;
|
||||
--ring-strain-from: #8f6bf0;
|
||||
--ring-strain-to: #c4a8ff;
|
||||
--tile-strain: #bd93f9;
|
||||
/* Band fallback (anatomy detail view, no per-metric hue). */
|
||||
--ring-green-from: #1f9d57;
|
||||
--ring-green-to: #5cf08f;
|
||||
--ring-yellow-from: #e0922f;
|
||||
--ring-yellow-to: #ffce6b;
|
||||
--ring-red-from: #c93a2a;
|
||||
--ring-red-to: #ff7a6b;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -253,6 +283,35 @@
|
||||
--sidebar-border: #cfcfde;
|
||||
--sidebar-ring: #644ac9;
|
||||
|
||||
/*
|
||||
* v1.14.0 — wellness-ring per-metric gradients (Alucard light). DEEPER +
|
||||
* more saturated than the dark set so each hue holds AA-grade presence on
|
||||
* the white `--card` (raw Dracula cyan/green wash out on white). Same five
|
||||
* distinct hues: readiness green · recovery teal · sleep royal-blue ·
|
||||
* stress amber · strain violet.
|
||||
*/
|
||||
--ring-readiness-from: #0f7a3a;
|
||||
--ring-readiness-to: #18a34e;
|
||||
--tile-readiness: #14720a;
|
||||
--ring-recovery-from: #07857c;
|
||||
--ring-recovery-to: #0e9c8f;
|
||||
--tile-recovery: #0a8f86;
|
||||
--ring-sleep-from: #2d5fc4;
|
||||
--ring-sleep-to: #4f7ae0;
|
||||
--tile-sleep: #2d5fc4;
|
||||
--ring-stress-from: #b06a16;
|
||||
--ring-stress-to: #c47a1e;
|
||||
--tile-stress: #a34d14;
|
||||
--ring-strain-from: #6b4fd0;
|
||||
--ring-strain-to: #8a6ff0;
|
||||
--tile-strain: #644ac9;
|
||||
--ring-green-from: #0f7a3a;
|
||||
--ring-green-to: #18a34e;
|
||||
--ring-yellow-from: #b06a16;
|
||||
--ring-yellow-to: #d68a2a;
|
||||
--ring-red-from: #a3271a;
|
||||
--ring-red-to: #cb3a2a;
|
||||
|
||||
/*
|
||||
* Status-pill text in light mode (Alucard). The bright Dracula
|
||||
* green / orange / red read 1.2–2.7:1 on a near-white tint; these
|
||||
@@ -399,6 +458,8 @@
|
||||
*/
|
||||
.wellness-tile {
|
||||
--tile-hue: var(--dracula-purple);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
140deg,
|
||||
color-mix(in srgb, var(--tile-hue) 14%, var(--card)) 0%,
|
||||
@@ -406,10 +467,191 @@
|
||||
);
|
||||
border: 1px solid color-mix(in srgb, var(--tile-hue) 22%, transparent);
|
||||
box-shadow: 0 10px 30px -26px color-mix(in srgb, var(--tile-hue) 40%, transparent);
|
||||
transition:
|
||||
transform 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
box-shadow 200ms cubic-bezier(0.22, 0.61, 0.36, 1),
|
||||
border-color 200ms cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.wellness-tile:hover {
|
||||
border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent);
|
||||
/*
|
||||
* Faint static film-grain over the tile — lifts the flat CSS gradient to a
|
||||
* "material" surface (the Oura touch). Inline SVG fractalNoise data-URI, zero
|
||||
* network cost, one paint, no animation (reduced-motion-safe by construction).
|
||||
* Opacity kept <=3% so it never reads as dirty on the white Alucard card.
|
||||
*/
|
||||
.wellness-tile::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
opacity: 0.025;
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.wellness-tile:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--tile-hue) 38%, transparent);
|
||||
box-shadow: 0 16px 40px -24px color-mix(in srgb, var(--tile-hue) 52%, transparent);
|
||||
}
|
||||
.wellness-tile:hover [data-slot="score-ring"] {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
}
|
||||
.wellness-tile:active {
|
||||
transform: translateY(0) scale(0.985);
|
||||
}
|
||||
/* So the ring scales smoothly with the tile lift instead of snapping. */
|
||||
.wellness-tile [data-slot="score-ring"] {
|
||||
transition: transform 200ms var(--ring-ease);
|
||||
}
|
||||
|
||||
/*
|
||||
* v1.14.0 — the wellness-strip "signature reveal" motion system.
|
||||
*
|
||||
* The whole moment plays ONCE per session (the strip toggles
|
||||
* `data-revealed="true"` once, gated on a sessionStorage flag) so a
|
||||
* background Apple-Health-sync refetch never re-triggers it. Three layers:
|
||||
* 1. tile rise+fade (staggered left-to-right by an inline animation-delay),
|
||||
* 2. the ring's spring-fill arc sweep (in score-ring.tsx, --ring-spring),
|
||||
* 3. a conic SHEEN that sweeps the arc once (the "expensive" specular touch).
|
||||
* A single prefers-reduced-motion block at the bottom zeroes all of it.
|
||||
*/
|
||||
@property --shimmer-angle {
|
||||
syntax: "<angle>";
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Sampled spring (stiffness ~120, damping ~14): overshoots ~6% then settles
|
||||
— the Oura/WHOOP fill feel. Falls back to the browser default on very old
|
||||
engines that ignore linear() (instant fill, not broken). */
|
||||
--ring-spring: linear(
|
||||
0, 0.06, 0.23, 0.46, 0.7, 0.89, 1.02, 1.06, 1.03, 1, 0.99, 1
|
||||
);
|
||||
--ring-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.wellness-tile-rise {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
[data-revealed="true"] .wellness-tile-rise {
|
||||
animation: wellnessTileRise 440ms var(--ring-ease) forwards;
|
||||
animation-delay: var(--reveal-delay, 0ms);
|
||||
}
|
||||
@keyframes wellnessTileRise {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* The conic sheen overlay sits over the ring; opacity animates from 0 so a
|
||||
browser without @property support (Safari <16.4 / FF <128) shows nothing
|
||||
rather than a frozen highlight band. */
|
||||
.wellness-ring-sheen {
|
||||
position: absolute;
|
||||
inset: 6%;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
/* A specular highlight must always LIGHTEN. Under mix-blend overlay a
|
||||
near-black light-mode `--card-foreground` would darken the tile into a
|
||||
grey band, so the sheen rides a fixed white in both themes. */
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(255, 255, 255, 0.72) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
-webkit-mask: conic-gradient(
|
||||
from var(--shimmer-angle),
|
||||
transparent 0deg,
|
||||
transparent 38deg,
|
||||
#000 58deg,
|
||||
transparent 80deg,
|
||||
transparent 360deg
|
||||
);
|
||||
mask: conic-gradient(
|
||||
from var(--shimmer-angle),
|
||||
transparent 0deg,
|
||||
transparent 38deg,
|
||||
#000 58deg,
|
||||
transparent 80deg,
|
||||
transparent 360deg
|
||||
);
|
||||
}
|
||||
[data-revealed="true"] .wellness-ring-sheen {
|
||||
animation: ringSheen 1500ms var(--ring-ease) forwards;
|
||||
animation-delay: calc(var(--reveal-delay, 0ms) + 550ms);
|
||||
}
|
||||
@keyframes ringSheen {
|
||||
0% {
|
||||
--shimmer-angle: 0deg;
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
85% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
--shimmer-angle: 360deg;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Token-tinted bloom behind the progress arc. The breathing pulse runs only
|
||||
on the strongest (green-band) ring so the strip celebrates a good score
|
||||
rather than jittering all five. `--ring-glow` is set inline per ring. */
|
||||
.wellness-ring-arc {
|
||||
filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ring-glow) 45%, transparent));
|
||||
}
|
||||
.wellness-ring-arc--pulse {
|
||||
/* Breathe a few times to celebrate a strong score, then settle — an
|
||||
infinite loop would repaint the (non-compositable) drop-shadow filter
|
||||
forever. Motion happens, then the surface goes calm. */
|
||||
animation: ringGlow 3.4s ease-in-out 2s 3;
|
||||
}
|
||||
@keyframes ringGlow {
|
||||
0%,
|
||||
100% {
|
||||
filter: drop-shadow(0 0 2px color-mix(in srgb, var(--ring-glow) 40%, transparent));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 8px color-mix(in srgb, var(--ring-glow) 65%, transparent));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wellness-tile,
|
||||
.wellness-tile:hover,
|
||||
.wellness-tile:active,
|
||||
.wellness-tile [data-slot="score-ring"],
|
||||
.wellness-tile:hover [data-slot="score-ring"] {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
.wellness-tile-rise,
|
||||
[data-revealed="true"] .wellness-tile-rise {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
[data-revealed="true"] .wellness-ring-sheen {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
.wellness-ring-arc,
|
||||
.wellness-ring-arc--pulse {
|
||||
filter: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -12,11 +12,18 @@ import { MedicationWizardDialog } from "@/components/medications/wizard/Medicati
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { LogIntakeDialog } from "@/components/medications/log-intake-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Loader2, Pill, Plus } from "lucide-react";
|
||||
import { CheckCircle2, Loader2, Pill, Plus } from "lucide-react";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -129,6 +136,10 @@ export default function MedicationsPage() {
|
||||
// `<AdvancedSettingsSheet>` mounted below, keyed to the selected
|
||||
// medication (reused, not duplicated per card).
|
||||
const [advancedMed, setAdvancedMed] = useState<Medication | null>(null);
|
||||
// v1.14.0 — the medications-page "Add" choice. The top button now offers
|
||||
// two paths: log an intake (incl. a backdated one) against an existing
|
||||
// medication, or create a new medication (the existing wizard).
|
||||
const [logIntakeOpen, setLogIntakeOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenFromUrl) {
|
||||
@@ -256,13 +267,33 @@ export default function MedicationsPage() {
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* v1.12.2 — match the dashboard Add button's responsive tap-target
|
||||
floor (`min-h-11 sm:min-h-9`) so both primary "add" entry points
|
||||
clear the WCAG 2.5.5 44px mobile minimum identically. */}
|
||||
<Button className="min-h-11 sm:min-h-9" onClick={openCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -393,6 +424,29 @@ export default function MedicationsPage() {
|
||||
onSuccess={closeDialog}
|
||||
/>
|
||||
|
||||
{/* v1.14.0 — log-intake path of the "Add" choice. Lists the active
|
||||
medications, lets the user pin a schedule slot, and picks a
|
||||
date+time that supports backdating. Submits to the existing
|
||||
per-medication intake route (same slot upsert as a live "Taken"
|
||||
tap) so the one-row-per-dose-slot invariant holds. */}
|
||||
{logIntakeOpen && (
|
||||
<LogIntakeDialog
|
||||
open={logIntakeOpen}
|
||||
onOpenChange={setLogIntakeOpen}
|
||||
medications={activeMeds.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
schedules: m.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
label: s.label,
|
||||
dose: s.dose,
|
||||
timesOfDay: s.timesOfDay,
|
||||
})),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* v1.7.1 — one shared advanced-settings sheet for every card.
|
||||
Reuses the exact component the detail page mounts (Data /
|
||||
Reminders / Lifecycle / Danger zone); the card's sliders button
|
||||
|
||||
@@ -358,7 +358,8 @@ export function MoodChart({
|
||||
const rawCount = useMemo<number>(() => {
|
||||
if (!data?.entries?.length) return 0;
|
||||
return data.entries.reduce(
|
||||
(acc, entry) => acc + (Number.isFinite(entry.samples) ? entry.samples : 0),
|
||||
(acc, entry) =>
|
||||
acc + (Number.isFinite(entry.samples) ? entry.samples : 0),
|
||||
0,
|
||||
);
|
||||
}, [data]);
|
||||
@@ -566,58 +567,353 @@ export function MoodChart({
|
||||
|
||||
if (!isLoading && !data?.entries?.length) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot={mini ? "chart-mini" : undefined}
|
||||
// v1.4.28 R3c-Insights — collapse the Card envelope in mini
|
||||
// mode (FB-K1). The default `<Card>` paints `py-6 gap-6` —
|
||||
// ~48 px of vertical chrome that pulled the mood mini chart
|
||||
// band ~52 px lower than the `<HealthChart mini>` siblings
|
||||
// in the trends row. The mini override gives mood the same
|
||||
// `~p-2` shell HealthChart uses so the chart series anchor
|
||||
// at the same top edge across BP / weight / mood tiles.
|
||||
//
|
||||
// D-H5 follow-up — the Card primitive carries `rounded-xl
|
||||
// border bg-card shadow-sm` by default. HealthChart mini
|
||||
// paints `rounded-md border bg-card` (no shadow). Without an
|
||||
// explicit `rounded-md` override the mood tile painted a
|
||||
// visibly heavier corner radius than BP / weight in the same
|
||||
// row. Align to the `rounded-md` shell so the three trend
|
||||
// tiles share one corner radius.
|
||||
//
|
||||
// v1.4.37 W4a item 5 — collapse the Card's inter-row gap to
|
||||
// 0 so the only space between the title and the chart strip
|
||||
// is the CardHeader's own `pb-1` (4 px), matching the
|
||||
// `mb-1` HealthChart paints below its plain `<div>` title.
|
||||
// Pre-fix Mood's `gap-1` (4 px) on top of the header `pb-1`
|
||||
// stacked an extra 4 px below the title, which read as a
|
||||
// ~12-16 px taller card than the BP/weight siblings inside
|
||||
// the trends row.
|
||||
className={
|
||||
mini ? "gap-0 rounded-md py-2 shadow-none" : undefined
|
||||
}
|
||||
// v1.13.1 — TRENDS-ROW MOOD CARD HEIGHT. In mini mode the title +
|
||||
// chart body render inside the exact same flat shell `<HealthChart
|
||||
// mini>` uses (`bg-card border-border rounded-md border p-2` with a
|
||||
// plain `<div>` title), instead of the `<Card>/<CardHeader>/
|
||||
// <CardTitle>` chrome that stacked extra vertical space above and
|
||||
// below the "Stimmung" heading. Aligning the STRUCTURE — not just
|
||||
// the padding values — makes the three trend cards share one title
|
||||
// baseline and one height. Full (non-mini) mode keeps the Card path.
|
||||
const chartBody = isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !chartData?.length ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
</div>
|
||||
) : chartData.length < 3 ? (
|
||||
// v1.4.16 B1a — sparse-data placeholder consistent with the
|
||||
// BP/weight/pulse charts. Height tracks the chart strip's
|
||||
// shared CHART_HEIGHT_PX so the empty state preserves the
|
||||
// trend-row rhythm (v1.4.27 — was 280, now 240 to match every
|
||||
// other dashboard chart card).
|
||||
//
|
||||
// v1.4.43 W2-CHART-GATE — split the copy on the raw mood-entry
|
||||
// count. A user with 50 mood entries across 2 calendar days
|
||||
// sees `chartData.length = 2` (daily-aggregated) but the raw
|
||||
// count (sum of `samples`) is 50. Surface "need more days"
|
||||
// in that scenario so the call-to-action matches reality.
|
||||
rawCount >= 3 ? (
|
||||
<ChartEmptyState
|
||||
title={t("charts.needMoreDistinctDaysTitle")}
|
||||
description={t("charts.needMoreDistinctDaysDescription")}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
) : (
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
// v1.12.7 — the mood line plots a 1–5 score band; the former
|
||||
// 240/280 px full-mode height left a tall dead strip below the
|
||||
// line on the mood subpage. Tighten the default to 200/220 px so
|
||||
// the chart fits its content without the empty gap. A per-mount
|
||||
// `--chart-height` override (trends-row mini, etc.) still wins, so
|
||||
// only the unoverridden full-mode mount shrinks.
|
||||
className={`${
|
||||
mini
|
||||
? "h-[var(--chart-height,140px)]"
|
||||
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
|
||||
} touch-pan-y`}
|
||||
>
|
||||
<CardHeader
|
||||
className={
|
||||
mini ? "px-2 pb-1 [&]:gap-0" : "pb-2"
|
||||
}
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
|
||||
five mood-score ticks ([1,2,3,4,5]), which Recharts
|
||||
syncs with CartesianGrid by default. That left the
|
||||
mini variant painting five horizontal lines while
|
||||
the BP / Weight / Pulse minis painted six (their
|
||||
auto-generated YAxis ticks land on six bands in
|
||||
typical health ranges). The explicit coordinates
|
||||
generator below produces six evenly-spaced lines
|
||||
so the trends row reads as a single rhythm. */}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
horizontalCoordinatesGenerator={({ offset }) => {
|
||||
// `offset` carries the chart's plot-area metrics.
|
||||
// Six evenly-spaced y-coordinates inside the plot
|
||||
// area (top + 4 intermediate + bottom) match the
|
||||
// density of the BP/Weight/Pulse minis.
|
||||
const top = offset.top ?? 0;
|
||||
const height = offset.height ?? 0;
|
||||
if (height <= 0) return [];
|
||||
const lines = 6;
|
||||
return Array.from({ length: lines }, (_, i) =>
|
||||
Math.round(top + (height * i) / (lines - 1)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{showBands &&
|
||||
VALUE_BANDS.map((band) => (
|
||||
<ReferenceArea
|
||||
key={`band-${band.min}-${band.max}`}
|
||||
y1={band.min}
|
||||
y2={band.max}
|
||||
fill={band.color}
|
||||
fillOpacity={band.opacity}
|
||||
strokeOpacity={0}
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
))}
|
||||
{/* v1.4.18 — personal-baseline gated behind the
|
||||
Trend toggle (maintainer: "only when a trend is being
|
||||
displayed"). Default OFF. */}
|
||||
{showTrend && personalBaseline != null && (
|
||||
<ReferenceLine
|
||||
y={personalBaseline}
|
||||
stroke={COLOR_MAIN}
|
||||
strokeDasharray="2 4"
|
||||
strokeOpacity={0.4}
|
||||
strokeWidth={1}
|
||||
ifOverflow="discard"
|
||||
label={{
|
||||
value: t("charts.personalBaseline"),
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="pointIndex"
|
||||
domain={[0, maxPointIndex]}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) =>
|
||||
tzFmt.date(
|
||||
new Date(chartData[Math.round(value)]?.timestamp ?? Date.now()),
|
||||
)
|
||||
}
|
||||
// v1.4.29 — Recharts ignores `interval` on numeric
|
||||
// axes; pass explicit tick positions instead.
|
||||
ticks={computeTickPositions(chartData ?? [], viewportWidth)}
|
||||
padding={{ left: 10, right: 10 }}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[1, 5]}
|
||||
ticks={[1, 2, 3, 4, 5]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={65}
|
||||
tickMargin={10}
|
||||
tickFormatter={formatMoodTick}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={(props) => {
|
||||
const {
|
||||
active,
|
||||
payload,
|
||||
label: rechartsLabel,
|
||||
} = props as unknown as {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name?: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
payload?: ChartDataPoint;
|
||||
}>;
|
||||
label?: number;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
const ts =
|
||||
payload[0]?.payload?.timestamp ??
|
||||
(typeof rechartsLabel === "number"
|
||||
? chartData?.[Math.round(rechartsLabel)]?.timestamp
|
||||
: undefined);
|
||||
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
|
||||
const rows: RichTooltipRow[] = [];
|
||||
const hoverPoint = payload[0]?.payload as
|
||||
| ChartDataPoint
|
||||
| undefined;
|
||||
for (const item of payload) {
|
||||
if (typeof item.value !== "number") continue;
|
||||
const dataKey = String(item.dataKey ?? "");
|
||||
// Skip auxiliary lines (`ma`, `trend`,
|
||||
// `scoreCompare`). Comparison value rendered as
|
||||
// delta on the primary row + own row below.
|
||||
if (
|
||||
dataKey === "ma" ||
|
||||
dataKey === "trend" ||
|
||||
dataKey === "scoreCompare"
|
||||
)
|
||||
continue;
|
||||
let delta: string | undefined;
|
||||
const compareValue =
|
||||
effectiveCompareBaseline !== "none" && hoverPoint
|
||||
? hoverPoint.scoreCompare
|
||||
: undefined;
|
||||
if (
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
const diff = item.value - compareValue;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
|
||||
delta = t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.deltaVs.lastMonth"
|
||||
: "comparison.deltaVs.lastYear",
|
||||
).replace("{delta}", formatted);
|
||||
}
|
||||
} else if (personalBaseline != null) {
|
||||
const diff = item.value - personalBaseline;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${Math.abs(diff).toFixed(1)}`;
|
||||
delta = t("charts.deltaVsBaseline").replace(
|
||||
"{delta}",
|
||||
formatted,
|
||||
);
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
name: t("charts.moodScore"),
|
||||
value: formatTooltipValue(item.value),
|
||||
color: item.color ?? COLOR_MAIN,
|
||||
delta,
|
||||
});
|
||||
if (
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
rows.push({
|
||||
name: `${t("charts.moodScore")} · ${t(
|
||||
"comparison.tooltipPrior",
|
||||
)}`,
|
||||
value: formatTooltipValue(compareValue),
|
||||
color: item.color ?? COLOR_MAIN,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return <RichChartTooltip active label={dateLabel} rows={rows} />;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
name="score"
|
||||
stroke={COLOR_MAIN}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: COLOR_MAIN }}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
{showMA && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ma"
|
||||
name="ma"
|
||||
stroke={COLOR_MA}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{showTrend && (
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="trend"
|
||||
name="trend"
|
||||
stroke={COLOR_TREND}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="8 4"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.16 B8 — comparison overlay (mood). Same
|
||||
dimmed dashed treatment the BP/weight/pulse chart
|
||||
uses; mood is a single metric so we only render
|
||||
one overlay line. Suppressed when there's no prior
|
||||
data via hasComparisonData. */}
|
||||
{effectiveCompareBaseline !== "none" && hasComparisonData && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="scoreCompare"
|
||||
name="scoreCompare"
|
||||
stroke={COLOR_MAIN}
|
||||
strokeWidth={1.25}
|
||||
strokeDasharray="4 3"
|
||||
strokeOpacity={0.45}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
legendType="none"
|
||||
data-slot="chart-compare-line-mood"
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
// v1.13.1 — mini mode: flat shell that exactly mirrors `<HealthChart
|
||||
// mini>` (bg-card border rounded-md p-2 + plain `<div>` title), so the
|
||||
// three trend cards share one title baseline and one height.
|
||||
if (mini) {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-mini"
|
||||
className="bg-card border-border rounded-md border p-2"
|
||||
>
|
||||
<div className="text-muted-foreground mb-1 text-[10px] font-medium tracking-wider uppercase">
|
||||
{displayTitle}
|
||||
</div>
|
||||
{chartBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
{/* v1.4.19 A2 — mobile-first header: stack title row above
|
||||
controls row on small viewports so the bucket / comparison
|
||||
chips never push the range tabs into a 2nd line. ≥sm goes
|
||||
back to side-by-side. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle
|
||||
className={
|
||||
mini
|
||||
? "text-muted-foreground text-[10px] font-medium tracking-wider uppercase"
|
||||
: "text-base font-medium"
|
||||
}
|
||||
>
|
||||
<CardTitle className="text-base font-medium">
|
||||
{displayTitle}
|
||||
</CardTitle>
|
||||
{activeBucket !== "day" && !mini && (
|
||||
{activeBucket !== "day" && (
|
||||
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
|
||||
{t(
|
||||
activeBucket === "week"
|
||||
@@ -628,389 +924,63 @@ export function MoodChart({
|
||||
)}
|
||||
{/* v1.4.16 B8 — comparison caption (mood).
|
||||
v1.4.19 A2 — hidden on mobile to free up the title row. */}
|
||||
{!mini &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
hasComparisonData && (
|
||||
<span
|
||||
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
|
||||
data-slot="chart-compare-caption"
|
||||
>
|
||||
{t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!mini &&
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
!hasComparisonData && (
|
||||
<span
|
||||
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
|
||||
data-slot="chart-compare-unavailable"
|
||||
>
|
||||
{t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.unavailable.lastMonth"
|
||||
: "comparison.unavailable.lastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{effectiveCompareBaseline !== "none" && hasComparisonData && (
|
||||
<span
|
||||
className="text-dose-accent bg-dose-accent/10 hidden rounded-md border border-current/30 px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex"
|
||||
data-slot="chart-compare-caption"
|
||||
>
|
||||
{t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.captionLastMonth"
|
||||
: "comparison.captionLastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{effectiveCompareBaseline !== "none" && !hasComparisonData && (
|
||||
<span
|
||||
className="text-muted-foreground bg-muted/40 hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide sm:inline-flex"
|
||||
data-slot="chart-compare-unavailable"
|
||||
>
|
||||
{t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.unavailable.lastMonth"
|
||||
: "comparison.unavailable.lastYear",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
|
||||
data-slot="chart-header-controls"
|
||||
>
|
||||
{TIME_RANGES_KEYS.map((r) => (
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={rangePoints === r.points ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => setRangePoints(r.points)}
|
||||
title={t(r.titleKey)}
|
||||
data-slot="chart-range-tab"
|
||||
>
|
||||
{t(r.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
{/* v1.4.18 — overlay-controls dropdown next to the
|
||||
range tabs. Only painted when the chart is bound to
|
||||
a persistent chartKey; ad-hoc usages (/insights mood
|
||||
preview, recommendation card) stay clean. */}
|
||||
{chartKey ? (
|
||||
<ChartOverlayControls
|
||||
prefs={overlayPrefs.prefs}
|
||||
onChange={overlayPrefs.setPrefs}
|
||||
hasComparisonData={hasComparisonData}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!mini && (
|
||||
<div
|
||||
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
|
||||
data-slot="chart-header-controls"
|
||||
>
|
||||
{TIME_RANGES_KEYS.map((r) => (
|
||||
<Button
|
||||
key={r.labelKey}
|
||||
variant={rangePoints === r.points ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="min-h-11 px-2 text-xs sm:px-3"
|
||||
onClick={() => setRangePoints(r.points)}
|
||||
title={t(r.titleKey)}
|
||||
data-slot="chart-range-tab"
|
||||
>
|
||||
{t(r.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
{/* v1.4.18 — overlay-controls dropdown next to the
|
||||
range tabs. Only painted when the chart is bound to
|
||||
a persistent chartKey; ad-hoc usages (/insights mood
|
||||
preview, recommendation card) stay clean. */}
|
||||
{chartKey ? (
|
||||
<ChartOverlayControls
|
||||
prefs={overlayPrefs.prefs}
|
||||
onChange={overlayPrefs.setPrefs}
|
||||
hasComparisonData={hasComparisonData}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={mini ? "px-2" : undefined}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !chartData?.length ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
</div>
|
||||
) : chartData.length < 3 ? (
|
||||
// v1.4.16 B1a — sparse-data placeholder consistent with the
|
||||
// BP/weight/pulse charts. Height tracks the chart strip's
|
||||
// shared CHART_HEIGHT_PX so the empty state preserves the
|
||||
// trend-row rhythm (v1.4.27 — was 280, now 240 to match every
|
||||
// other dashboard chart card).
|
||||
//
|
||||
// v1.4.43 W2-CHART-GATE — split the copy on the raw mood-entry
|
||||
// count. A user with 50 mood entries across 2 calendar days
|
||||
// sees `chartData.length = 2` (daily-aggregated) but the raw
|
||||
// count (sum of `samples`) is 50. Surface "need more days"
|
||||
// in that scenario so the call-to-action matches reality.
|
||||
rawCount >= 3 ? (
|
||||
<ChartEmptyState
|
||||
title={t("charts.needMoreDistinctDaysTitle")}
|
||||
description={t("charts.needMoreDistinctDaysDescription")}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
) : (
|
||||
<ChartEmptyState
|
||||
title={t("charts.emptyStateTitle")}
|
||||
description={t("charts.emptyStateDescription")}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
// v1.12.7 — the mood line plots a 1–5 score band; the former
|
||||
// 240/280 px full-mode height left a tall dead strip below the
|
||||
// line on the mood subpage. Tighten the default to 200/220 px so
|
||||
// the chart fits its content without the empty gap. A per-mount
|
||||
// `--chart-height` override (trends-row mini, etc.) still wins, so
|
||||
// only the unoverridden full-mode mount shrinks.
|
||||
className={`${
|
||||
mini
|
||||
? "h-[var(--chart-height,140px)]"
|
||||
: "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]"
|
||||
} touch-pan-y`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartDataWithCompare ?? chartData}
|
||||
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
|
||||
five mood-score ticks ([1,2,3,4,5]), which Recharts
|
||||
syncs with CartesianGrid by default. That left the
|
||||
mini variant painting five horizontal lines while
|
||||
the BP / Weight / Pulse minis painted six (their
|
||||
auto-generated YAxis ticks land on six bands in
|
||||
typical health ranges). The explicit coordinates
|
||||
generator below produces six evenly-spaced lines
|
||||
so the trends row reads as a single rhythm. */}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
horizontalCoordinatesGenerator={({ offset }) => {
|
||||
// `offset` carries the chart's plot-area metrics.
|
||||
// Six evenly-spaced y-coordinates inside the plot
|
||||
// area (top + 4 intermediate + bottom) match the
|
||||
// density of the BP/Weight/Pulse minis.
|
||||
const top = offset.top ?? 0;
|
||||
const height = offset.height ?? 0;
|
||||
if (height <= 0) return [];
|
||||
const lines = 6;
|
||||
return Array.from({ length: lines }, (_, i) =>
|
||||
Math.round(top + (height * i) / (lines - 1)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{showBands &&
|
||||
VALUE_BANDS.map((band) => (
|
||||
<ReferenceArea
|
||||
key={`band-${band.min}-${band.max}`}
|
||||
y1={band.min}
|
||||
y2={band.max}
|
||||
fill={band.color}
|
||||
fillOpacity={band.opacity}
|
||||
strokeOpacity={0}
|
||||
ifOverflow="discard"
|
||||
/>
|
||||
))}
|
||||
{/* v1.4.18 — personal-baseline gated behind the
|
||||
Trend toggle (maintainer: "only when a trend is being
|
||||
displayed"). Default OFF. */}
|
||||
{showTrend && personalBaseline != null && (
|
||||
<ReferenceLine
|
||||
y={personalBaseline}
|
||||
stroke={COLOR_MAIN}
|
||||
strokeDasharray="2 4"
|
||||
strokeOpacity={0.4}
|
||||
strokeWidth={1}
|
||||
ifOverflow="discard"
|
||||
label={{
|
||||
value: t("charts.personalBaseline"),
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 10,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="pointIndex"
|
||||
domain={[0, maxPointIndex]}
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) =>
|
||||
tzFmt.date(
|
||||
new Date(
|
||||
chartData[Math.round(value)]?.timestamp ?? Date.now(),
|
||||
),
|
||||
)
|
||||
}
|
||||
// v1.4.29 — Recharts ignores `interval` on numeric
|
||||
// axes; pass explicit tick positions instead.
|
||||
ticks={computeTickPositions(
|
||||
chartData ?? [],
|
||||
viewportWidth,
|
||||
)}
|
||||
padding={{ left: 10, right: 10 }}
|
||||
tickMargin={10}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[1, 5]}
|
||||
ticks={[1, 2, 3, 4, 5]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={65}
|
||||
tickMargin={10}
|
||||
tickFormatter={formatMoodTick}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{
|
||||
stroke: "var(--muted-foreground)",
|
||||
strokeOpacity: 0.3,
|
||||
strokeDasharray: "3 3",
|
||||
}}
|
||||
content={(props) => {
|
||||
const {
|
||||
active,
|
||||
payload,
|
||||
label: rechartsLabel,
|
||||
} = props as unknown as {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
name?: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
dataKey?: string;
|
||||
payload?: ChartDataPoint;
|
||||
}>;
|
||||
label?: number;
|
||||
};
|
||||
if (!active || !payload?.length) return null;
|
||||
const ts =
|
||||
payload[0]?.payload?.timestamp ??
|
||||
(typeof rechartsLabel === "number"
|
||||
? chartData?.[Math.round(rechartsLabel)]?.timestamp
|
||||
: undefined);
|
||||
const dateLabel = ts ? tzFmt.date(new Date(ts)) : "";
|
||||
const rows: RichTooltipRow[] = [];
|
||||
const hoverPoint = payload[0]?.payload as
|
||||
| ChartDataPoint
|
||||
| undefined;
|
||||
for (const item of payload) {
|
||||
if (typeof item.value !== "number") continue;
|
||||
const dataKey = String(item.dataKey ?? "");
|
||||
// Skip auxiliary lines (`ma`, `trend`,
|
||||
// `scoreCompare`). Comparison value rendered as
|
||||
// delta on the primary row + own row below.
|
||||
if (
|
||||
dataKey === "ma" ||
|
||||
dataKey === "trend" ||
|
||||
dataKey === "scoreCompare"
|
||||
)
|
||||
continue;
|
||||
let delta: string | undefined;
|
||||
const compareValue =
|
||||
effectiveCompareBaseline !== "none" && hoverPoint
|
||||
? hoverPoint.scoreCompare
|
||||
: undefined;
|
||||
if (
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
const diff = item.value - compareValue;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${Math.abs(diff).toFixed(
|
||||
1,
|
||||
)}`;
|
||||
delta = t(
|
||||
effectiveCompareBaseline === "lastMonth"
|
||||
? "comparison.deltaVs.lastMonth"
|
||||
: "comparison.deltaVs.lastYear",
|
||||
).replace("{delta}", formatted);
|
||||
}
|
||||
} else if (personalBaseline != null) {
|
||||
const diff = item.value - personalBaseline;
|
||||
if (Math.abs(diff) < 0.05) {
|
||||
delta = t("charts.deltaUnchanged");
|
||||
} else {
|
||||
const sign = diff > 0 ? "+" : "−";
|
||||
const formatted = `${sign}${Math.abs(diff).toFixed(
|
||||
1,
|
||||
)}`;
|
||||
delta = t("charts.deltaVsBaseline").replace(
|
||||
"{delta}",
|
||||
formatted,
|
||||
);
|
||||
}
|
||||
}
|
||||
rows.push({
|
||||
name: t("charts.moodScore"),
|
||||
value: formatTooltipValue(item.value),
|
||||
color: item.color ?? COLOR_MAIN,
|
||||
delta,
|
||||
});
|
||||
if (
|
||||
effectiveCompareBaseline !== "none" &&
|
||||
typeof compareValue === "number" &&
|
||||
Number.isFinite(compareValue)
|
||||
) {
|
||||
rows.push({
|
||||
name: `${t("charts.moodScore")} · ${t(
|
||||
"comparison.tooltipPrior",
|
||||
)}`,
|
||||
value: formatTooltipValue(compareValue),
|
||||
color: item.color ?? COLOR_MAIN,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<RichChartTooltip active label={dateLabel} rows={rows} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="score"
|
||||
name="score"
|
||||
stroke={COLOR_MAIN}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: COLOR_MAIN }}
|
||||
activeDot={{ r: 5 }}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
{showMA && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ma"
|
||||
name="ma"
|
||||
stroke={COLOR_MA}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{showTrend && (
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="trend"
|
||||
name="trend"
|
||||
stroke={COLOR_TREND}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="8 4"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{/* v1.4.16 B8 — comparison overlay (mood). Same
|
||||
dimmed dashed treatment the BP/weight/pulse chart
|
||||
uses; mood is a single metric so we only render
|
||||
one overlay line. Suppressed when there's no prior
|
||||
data via hasComparisonData. */}
|
||||
{effectiveCompareBaseline !== "none" && hasComparisonData && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="scoreCompare"
|
||||
name="scoreCompare"
|
||||
stroke={COLOR_MAIN}
|
||||
strokeWidth={1.25}
|
||||
strokeDasharray="4 3"
|
||||
strokeOpacity={0.45}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={animationsEnabled}
|
||||
animationDuration={animationsEnabled ? 600 : 0}
|
||||
animationEasing="ease-out"
|
||||
legendType="none"
|
||||
data-slot="chart-compare-line-mood"
|
||||
/>
|
||||
)}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardContent>{chartBody}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -240,72 +240,80 @@ export function CoachSettingsSheet({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4">
|
||||
{/* Tone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="coach-prefs-tone" className="text-xs font-medium">
|
||||
{t("insights.coach.settingsToneLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.tone}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-tone"
|
||||
data-slot="coach-prefs-tone"
|
||||
{/* v1.13.1 — Tone + Verbosity side by side (2-col on ≥sm,
|
||||
stacked on mobile) so the two short selects don't add a
|
||||
full extra scroll length to the sheet. */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{/* Tone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-tone"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="warm">
|
||||
{t("insights.coach.settingsToneWarm")}
|
||||
</SelectItem>
|
||||
<SelectItem value="neutral">
|
||||
{t("insights.coach.settingsToneNeutral")}
|
||||
</SelectItem>
|
||||
<SelectItem value="concise">
|
||||
{t("insights.coach.settingsToneConcise")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{t("insights.coach.settingsToneLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.tone}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-tone"
|
||||
data-slot="coach-prefs-tone"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="warm">
|
||||
{t("insights.coach.settingsToneWarm")}
|
||||
</SelectItem>
|
||||
<SelectItem value="neutral">
|
||||
{t("insights.coach.settingsToneNeutral")}
|
||||
</SelectItem>
|
||||
<SelectItem value="concise">
|
||||
{t("insights.coach.settingsToneConcise")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Verbosity */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-verbosity"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
{t("insights.coach.settingsVerbosityLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.verbosity}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
verbosity: value as CoachVerbosity,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-verbosity"
|
||||
data-slot="coach-prefs-verbosity"
|
||||
{/* Verbosity */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
htmlFor="coach-prefs-verbosity"
|
||||
className="text-xs font-medium"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="brief">
|
||||
{t("insights.coach.settingsVerbosityBrief")}
|
||||
</SelectItem>
|
||||
<SelectItem value="default">
|
||||
{t("insights.coach.settingsVerbosityDefault")}
|
||||
</SelectItem>
|
||||
<SelectItem value="detailed">
|
||||
{t("insights.coach.settingsVerbosityDetailed")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{t("insights.coach.settingsVerbosityLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.verbosity}
|
||||
onValueChange={(value) =>
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
verbosity: value as CoachVerbosity,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="coach-prefs-verbosity"
|
||||
data-slot="coach-prefs-verbosity"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="brief">
|
||||
{t("insights.coach.settingsVerbosityBrief")}
|
||||
</SelectItem>
|
||||
<SelectItem value="default">
|
||||
{t("insights.coach.settingsVerbosityDefault")}
|
||||
</SelectItem>
|
||||
<SelectItem value="detailed">
|
||||
{t("insights.coach.settingsVerbosityDetailed")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
|
||||
@@ -496,7 +504,10 @@ export function CoachSettingsSheet({
|
||||
data-slot="coach-prefs-save"
|
||||
>
|
||||
{save.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{t("insights.coach.settingsSave")}
|
||||
</Button>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
@@ -38,7 +38,10 @@ import type {
|
||||
* dedicated routed sub-page yet (hrv, resting_hr, steps, …) fall
|
||||
* through to `null` and render as a plain row (no link wrap).
|
||||
*/
|
||||
const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null> = {
|
||||
const METRIC_HREF: Record<
|
||||
DailyBriefingKeyFinding["sourceMetric"],
|
||||
string | null
|
||||
> = {
|
||||
bp: "/insights/blood-pressure",
|
||||
weight: "/insights/weight",
|
||||
pulse: "/insights/pulse",
|
||||
@@ -240,10 +243,10 @@ function BriefingSkeleton() {
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/60 h-3 w-11/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-10/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-9/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-8/12 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-3 w-10/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-3 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-3 w-8/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
@@ -251,7 +254,7 @@ function BriefingSkeleton() {
|
||||
key={i}
|
||||
className="border-border/40 bg-card/30 flex h-16 items-center rounded-md border p-3"
|
||||
>
|
||||
<div className="bg-muted/60 h-3 w-1/3 animate-pulse motion-reduce:animate-none rounded" />
|
||||
<div className="bg-muted/60 h-3 w-1/3 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -270,109 +273,121 @@ export function DailyBriefing({
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<Card data-slot="daily-briefing" className="overflow-hidden">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="text-dose-accent h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.dailyBriefing.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
{metaSlot && (
|
||||
<div
|
||||
data-slot="daily-briefing-meta-slot"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{metaSlot}
|
||||
</div>
|
||||
)}
|
||||
// v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
|
||||
// moves OUT of the card into an `<h2>` above it (mirroring the
|
||||
// TrendsRow / wellness-strip section headers) so the card opens
|
||||
// straight on its content and the surface reclaims the vertical
|
||||
// space the in-card header used to occupy.
|
||||
<section
|
||||
data-slot="daily-briefing-section"
|
||||
aria-label={t("insights.dailyBriefing.title")}
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="text-dose-accent h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.dailyBriefing.title")}
|
||||
</h2>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{t("insights.dailyBriefing.loadingLabel")}
|
||||
</span>
|
||||
<BriefingSkeleton />
|
||||
</>
|
||||
) : briefing ? (
|
||||
<div className="space-y-4">
|
||||
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
|
||||
{metaSlot && (
|
||||
<div
|
||||
data-slot="daily-briefing-meta-slot"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{metaSlot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Card data-slot="daily-briefing" className="overflow-hidden">
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{t("insights.dailyBriefing.loadingLabel")}
|
||||
</span>
|
||||
<BriefingSkeleton />
|
||||
</>
|
||||
) : briefing ? (
|
||||
<div className="space-y-4">
|
||||
{/* v1.4.27 B1 — the leading narrative paragraph dropped.
|
||||
The hero strip subtitle on `/insights` already renders
|
||||
the same `briefing.paragraph` text directly above this
|
||||
card, so the user used to read the same string twice
|
||||
within 200 px. The card now opens straight on the
|
||||
structured key-findings list, which is the part the
|
||||
hero subtitle cannot surface. */}
|
||||
{briefing.keyFindings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-findings-title"
|
||||
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.keyFindingsTitle")}
|
||||
</p>
|
||||
<div data-slot="daily-briefing-findings" className="space-y-2">
|
||||
{briefing.keyFindings.map((finding, index) => (
|
||||
<KeyFindingRow
|
||||
key={`${finding.sourceMetric}-${index}`}
|
||||
finding={finding}
|
||||
/>
|
||||
))}
|
||||
{briefing.keyFindings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
data-slot="daily-briefing-findings-title"
|
||||
className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{t("insights.dailyBriefing.keyFindingsTitle")}
|
||||
</p>
|
||||
<div
|
||||
data-slot="daily-briefing-findings"
|
||||
className="space-y-2"
|
||||
>
|
||||
{briefing.keyFindings.map((finding, index) => (
|
||||
<KeyFindingRow
|
||||
key={`${finding.sourceMetric}-${index}`}
|
||||
finding={finding}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updatedAt && (
|
||||
<p
|
||||
data-slot="daily-briefing-updated"
|
||||
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
|
||||
>
|
||||
{t("insights.heroGenerated", {
|
||||
time: formatRelativeTime(updatedAt, t),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-empty"
|
||||
variant="plain"
|
||||
icon={<FileText className="size-5" />}
|
||||
title={t("insights.dailyBriefing.emptyTitle")}
|
||||
description={t("insights.dailyBriefing.emptyDescription")}
|
||||
action={
|
||||
onRegenerate ? (
|
||||
// v1.4.28 BK-M2 — switch the empty-state CTA from
|
||||
// `variant="outline"` to the default (filled) variant
|
||||
// so the briefing card matches the dashboard
|
||||
// empty-state CTA shape. Empty-state actions on a card
|
||||
// surface read as the single primary affordance and
|
||||
// earn the filled chip across surfaces in v1.4.28.
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-empty-cta"
|
||||
className="gap-1.5"
|
||||
)}
|
||||
{updatedAt && (
|
||||
<p
|
||||
data-slot="daily-briefing-updated"
|
||||
className="text-muted-foreground border-border/60 border-t pt-3 text-right text-xs"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>
|
||||
{regenerating
|
||||
? t("insights.heroRegenerating")
|
||||
: t("insights.dailyBriefing.emptyAction")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{t("insights.heroGenerated", {
|
||||
time: formatRelativeTime(updatedAt, t),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-empty"
|
||||
variant="plain"
|
||||
icon={<FileText className="size-5" />}
|
||||
title={t("insights.dailyBriefing.emptyTitle")}
|
||||
description={t("insights.dailyBriefing.emptyDescription")}
|
||||
action={
|
||||
onRegenerate ? (
|
||||
// v1.4.28 BK-M2 — switch the empty-state CTA from
|
||||
// `variant="outline"` to the default (filled) variant
|
||||
// so the briefing card matches the dashboard
|
||||
// empty-state CTA shape. Empty-state actions on a card
|
||||
// surface read as the single primary affordance and
|
||||
// earn the filled chip across surfaces in v1.4.28.
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="daily-briefing-empty-cta"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>
|
||||
{regenerating
|
||||
? t("insights.heroRegenerating")
|
||||
: t("insights.dailyBriefing.emptyAction")}
|
||||
</span>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
/**
|
||||
* v1.13.x — per-metric ring hues for the wellness-score strip.
|
||||
* v1.14.0 — per-metric ring hues for the wellness-score strip.
|
||||
*
|
||||
* The premium-redesign signature is Apple's: a SINGLE-hue, TWO-stop
|
||||
* gradient per ring (dark→light tones of the *same* colour), never a
|
||||
* multi-hue rainbow. Each metric leans toward an intentional hue (Oura's
|
||||
* per-metric move): readiness greener, sleep bluer/indigo, recovery +
|
||||
* strain turquoise, stress amber.
|
||||
* The premium signature is a SINGLE-hue, TWO-stop gradient per ring
|
||||
* (dark → light tones of the *same* colour), never a multi-hue rainbow.
|
||||
* Each metric leans toward one intentional, distinct hue (Oura's per-metric
|
||||
* move): readiness green, recovery teal/turquoise, sleep indigo-blue, stress
|
||||
* amber, strain violet — five clearly different hues so the strip never reads
|
||||
* as "a wash of green/cyan".
|
||||
*
|
||||
* Every stop rides the SEMANTIC / `--dracula-*` tokens (not raw hex), so
|
||||
* the Alucard light-mode overrides (`--success #14720a`, `--info #036a96`,
|
||||
* `--warning #a34d14`, `--destructive #cb3a2a`) apply automatically and the
|
||||
* arc holds contrast in both themes. The band semantic is never carried by
|
||||
* the hue: it still rides `data-band` + the band word + the aria-label, so
|
||||
* a red-band readiness still reads "low" in copy while its ring leans green.
|
||||
* The actual colours live as CSS custom properties in `globals.css`
|
||||
* (`--ring-<key>-from` / `--ring-<key>-to` / `--tile-<key>`) with a Dracula
|
||||
* dark set on `:root` and an Alucard light set on `:root.light`, so each hue
|
||||
* is HAND-TUNED per theme — deep + saturated enough to hold contrast on the
|
||||
* white Alucard card, vivid on the dark Dracula card. (The v1.13.x version
|
||||
* mixed through ambiguous semantic tokens — `--info` == `--dracula-cyan` —
|
||||
* which collapsed three of the five rings to cyan in the dark theme and
|
||||
* washed recovery/strain out on white. Dedicated per-theme vars fix that.)
|
||||
*
|
||||
* The band semantic is never carried by the hue: it still rides `data-band`
|
||||
* + the band word + the aria-label, so a red-band readiness still reads "low"
|
||||
* in copy while its ring leans green.
|
||||
*/
|
||||
|
||||
import type { ScoreBand } from "./band-tokens";
|
||||
@@ -21,32 +28,37 @@ import type { ScoreBand } from "./band-tokens";
|
||||
export type RingHue = "readiness" | "sleep" | "recovery" | "stress" | "strain";
|
||||
|
||||
/**
|
||||
* Single-hue two-stop gradient `[from, to]` (dark → light) per metric, plus
|
||||
* a band fallback for the anatomy detail view (which passes no `hue`, so the
|
||||
* ring keeps its green/yellow/red band semantics there). Values are CSS
|
||||
* colour strings consumed straight by the SVG `<linearGradient>` stops.
|
||||
* Single-hue two-stop gradient `[from, to]` (dark → light) per metric, plus a
|
||||
* band fallback for the anatomy detail view (which passes no `hue`, so the
|
||||
* ring keeps its green/yellow/red band semantics there). Values are CSS var
|
||||
* references resolved per theme — consumed straight by the SVG
|
||||
* `<linearGradient>` stops.
|
||||
*/
|
||||
export const RING_GRADIENT: Record<RingHue | ScoreBand, [string, string]> = {
|
||||
// Readiness / Tagesform — a touch greener.
|
||||
readiness: ["color-mix(in srgb, var(--success) 88%, #000)", "var(--success)"],
|
||||
// Sleep score — bluer, leaning indigo via `--primary`.
|
||||
sleep: ["color-mix(in srgb, var(--info) 65%, var(--primary))", "var(--info)"],
|
||||
// Recovery — turquoise (cyan↔green).
|
||||
recovery: [
|
||||
"color-mix(in srgb, var(--dracula-cyan) 78%, var(--dracula-green))",
|
||||
"var(--dracula-cyan)",
|
||||
],
|
||||
// Stress — amber (the "tension" metric; keeps the green/amber/red vocab honest).
|
||||
stress: ["color-mix(in srgb, var(--warning) 88%, #000)", "var(--warning)"],
|
||||
// Strain — turquoise leaning slightly violet to distinguish from Recovery.
|
||||
strain: [
|
||||
"color-mix(in srgb, var(--dracula-cyan) 80%, var(--dracula-purple))",
|
||||
"var(--dracula-cyan)",
|
||||
],
|
||||
readiness: ["var(--ring-readiness-from)", "var(--ring-readiness-to)"],
|
||||
recovery: ["var(--ring-recovery-from)", "var(--ring-recovery-to)"],
|
||||
sleep: ["var(--ring-sleep-from)", "var(--ring-sleep-to)"],
|
||||
stress: ["var(--ring-stress-from)", "var(--ring-stress-to)"],
|
||||
strain: ["var(--ring-strain-from)", "var(--ring-strain-to)"],
|
||||
// Band fallback for the anatomy detail view (no per-metric hue passed):
|
||||
green: ["color-mix(in srgb, var(--success) 88%, #000)", "var(--success)"],
|
||||
yellow: ["color-mix(in srgb, var(--warning) 88%, #000)", "var(--warning)"],
|
||||
red: ["color-mix(in srgb, var(--destructive) 88%, #000)", "var(--destructive)"],
|
||||
green: ["var(--ring-green-from)", "var(--ring-green-to)"],
|
||||
yellow: ["var(--ring-yellow-from)", "var(--ring-yellow-to)"],
|
||||
red: ["var(--ring-red-from)", "var(--ring-red-to)"],
|
||||
};
|
||||
|
||||
/**
|
||||
* The `to` (light) stop per key — used as the ring's glow colour
|
||||
* (`--ring-glow`) so the bloom matches the brighter end of the arc.
|
||||
*/
|
||||
export const RING_GLOW: Record<RingHue | ScoreBand, string> = {
|
||||
readiness: "var(--ring-readiness-to)",
|
||||
recovery: "var(--ring-recovery-to)",
|
||||
sleep: "var(--ring-sleep-to)",
|
||||
stress: "var(--ring-stress-to)",
|
||||
strain: "var(--ring-strain-to)",
|
||||
green: "var(--ring-green-to)",
|
||||
yellow: "var(--ring-yellow-to)",
|
||||
red: "var(--ring-red-to)",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -54,9 +66,9 @@ export const RING_GRADIENT: Record<RingHue | ScoreBand, [string, string]> = {
|
||||
* mixes (gently) over the theme `--card`. Set inline by each `RingTile`.
|
||||
*/
|
||||
export const TILE_HUE: Record<RingHue, string> = {
|
||||
readiness: "var(--success)",
|
||||
sleep: "var(--info)",
|
||||
recovery: "var(--dracula-cyan)",
|
||||
stress: "var(--warning)",
|
||||
strain: "var(--dracula-cyan)",
|
||||
readiness: "var(--tile-readiness)",
|
||||
sleep: "var(--tile-sleep)",
|
||||
recovery: "var(--tile-recovery)",
|
||||
stress: "var(--tile-stress)",
|
||||
strain: "var(--tile-strain)",
|
||||
};
|
||||
@@ -1,50 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCountUp } from "@/hooks/use-count-up";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { bandForScore, clampScore, type ScoreBand } from "./band-tokens";
|
||||
import { RING_GRADIENT, type RingHue } from "./ring-hues";
|
||||
import { RING_GRADIENT, RING_GLOW, type RingHue } from "./ring-hues";
|
||||
|
||||
/**
|
||||
* v1.13.x — the composite-score dial, now a hand-rolled SVG arc gauge
|
||||
* (Recharts retired FROM THIS COMPONENT ONLY; it stays the default for real
|
||||
* data charts elsewhere). A score dial is a fixed-geometry gauge, not a data
|
||||
* chart, so a ~40-LOC SVG gives exact control over a thin round-cap arc, a
|
||||
* faint same-hue track, and a single-hue two-stop gradient — the premium
|
||||
* Apple/Oura signature — at 0 KB runtime and with no per-tile resize
|
||||
* observer.
|
||||
* v1.14.0 — the composite-score dial: a hand-rolled SVG arc gauge with a
|
||||
* premium "signature reveal" (Oura/WHOOP-tier), 0 KB runtime, no animation
|
||||
* library. A score dial is a fixed-geometry gauge, not a data chart, so SVG
|
||||
* gives exact control over a thin round-cap arc, a segmented same-hue track,
|
||||
* a single-hue two-stop gradient, a faint "vs your baseline" ghost arc, and a
|
||||
* token-tinted bloom — all CSS-driven.
|
||||
*
|
||||
* The arc is a single `<circle stroke-dasharray>` swept by `stroke-dashoffset`,
|
||||
* filled with a JSX `<linearGradient>` (NOT a `dangerouslySetInnerHTML` —
|
||||
* `<defs>`/`<linearGradient>` are real SVG JSX). The `<svg>` is rotated -90°
|
||||
* so the arc starts at 12 o'clock and sweeps clockwise without per-point trig.
|
||||
* The centred number is real DOM text (not SVG `<text>`), so a long label can
|
||||
* never clip under the SVG viewbox and the number uses the design tokens.
|
||||
* Motion (all CSS-first, all gated on `prefers-reduced-motion`):
|
||||
* • the arc sweeps from empty on mount via a `linear()` SPRING that
|
||||
* overshoots ~6% then settles (`--ring-spring`); the spring is swapped for
|
||||
* a plain ease-out above score 94 so a high arc never wraps past full;
|
||||
* • `delayMs` staggers the sweep + the count-up so the strip reveals
|
||||
* left-to-right and each number trails its arc;
|
||||
* • a conic SHEEN + the tile rise + glow live in `globals.css`, gated on the
|
||||
* strip's `data-revealed` flag (set once per session) so a background
|
||||
* refetch never re-triggers the moment.
|
||||
*
|
||||
* `score === null` renders the provisional/empty state: an unfilled ring with
|
||||
* an em-dash and a localised "not enough data yet" caption.
|
||||
* The arc/track/ghost are real SVG JSX (`<linearGradient>` is NOT
|
||||
* `dangerouslySetInnerHTML`). The `<svg>` is rotated -90° so the arc starts at
|
||||
* 12 o'clock and sweeps clockwise without per-point trig. The centred number
|
||||
* is real DOM text so a long label never clips under the viewBox.
|
||||
*
|
||||
* a11y: `role="img"` + an aria-label restating the number + band, so the ring
|
||||
* is never colour-only. `prefers-reduced-motion` paints the final arc offset
|
||||
* with no transition and disables the count-up.
|
||||
*
|
||||
* `hue` selects a per-metric single-hue gradient (readiness greener, sleep
|
||||
* bluer, recovery/strain turquoise, stress amber). When omitted — the anatomy
|
||||
* detail view — the gradient falls back to the score's band token
|
||||
* (green/yellow/red), preserving the band semantics there. The band is always
|
||||
* carried by `data-band` + the band word the tile renders + the aria-label,
|
||||
* regardless of the ring hue.
|
||||
* `score === null` renders the provisional/empty state. a11y: `role="img"` +
|
||||
* an aria-label restating number + band, so the ring is never colour-only;
|
||||
* the band always rides `data-band` regardless of arc hue.
|
||||
*/
|
||||
|
||||
// The SVG is a fixed 0 0 100 100 viewBox; CSS scales it to `dims.px`.
|
||||
const VIEW = 100;
|
||||
const STROKE = 9; // ~9% of size → Apple-class thinness (was ~22%).
|
||||
const STROKE = 9; // ~9% of size → Apple-class thinness.
|
||||
const R = (VIEW - STROKE) / 2; // radius so the stroke sits inside the box.
|
||||
const C = 2 * Math.PI * R; // circumference for the dash math.
|
||||
const CX = VIEW / 2;
|
||||
// Above this score the spring overshoot (~6% of the swept distance) would push
|
||||
// the arc past a full circle and wrap — use a non-overshoot ease-out instead.
|
||||
const SPRING_SAFE_MAX = 94;
|
||||
|
||||
const SIZE: Record<
|
||||
NonNullable<ScoreRingProps["size"]>,
|
||||
@@ -64,7 +64,7 @@ export interface ScoreRingProps {
|
||||
label?: string;
|
||||
/** Render size. `sm` in a grid tile, `md`/`lg` on the anatomy view. */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Disable the sweep + count-up (e.g. when already animated by a parent). */
|
||||
/** Disable the sweep + count-up (e.g. when already revealed this session). */
|
||||
animate?: boolean;
|
||||
/**
|
||||
* Per-metric hue for the single-hue two-stop arc gradient. When omitted the
|
||||
@@ -72,11 +72,22 @@ export interface ScoreRingProps {
|
||||
* anatomy detail view keeps band semantics that way.
|
||||
*/
|
||||
hue?: RingHue;
|
||||
/**
|
||||
* Stagger offset (ms): delays the arc sweep + the count-up start so a row of
|
||||
* rings reveals left-to-right and each number trails its own arc.
|
||||
*/
|
||||
delayMs?: number;
|
||||
/**
|
||||
* Optional "your normal" reference (0..100). When set + finite, a faint
|
||||
* thinner ghost arc renders under the live arc so the user sees today vs
|
||||
* their baseline at a glance. Omit (null) when no trailing series exists.
|
||||
*/
|
||||
baseline?: number | null;
|
||||
/**
|
||||
* Retained for API compatibility. The premium redesign drops the old
|
||||
* white-arc-on-dark-slab treatment in favour of the per-metric `hue`
|
||||
* gradient on the gentle `.wellness-tile`, so this no longer changes the
|
||||
* paint; it is kept so existing call sites + tests don't break.
|
||||
* gradient, so this no longer changes the paint; kept so existing call sites
|
||||
* + tests don't break.
|
||||
*/
|
||||
variant?: "band" | "onGradient";
|
||||
className?: string;
|
||||
@@ -89,6 +100,8 @@ export function ScoreRing({
|
||||
size = "md",
|
||||
animate = true,
|
||||
hue,
|
||||
delayMs = 0,
|
||||
baseline,
|
||||
className,
|
||||
}: ScoreRingProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -98,19 +111,39 @@ export function ScoreRing({
|
||||
const hasScore = score != null && Number.isFinite(score);
|
||||
const clamped = hasScore ? clampScore(score) : 0;
|
||||
const resolvedBand: ScoreBand = band ?? bandForScore(clamped);
|
||||
const hueKey = hue ?? resolvedBand;
|
||||
|
||||
// Single-hue two-stop gradient. The per-metric `hue` leans the colour
|
||||
// (Oura's move); with no `hue` the anatomy view falls back to the band
|
||||
// token so its green/yellow/red semantics survive.
|
||||
const [from, to] = RING_GRADIENT[hue ?? resolvedBand];
|
||||
// (Oura's move); with no `hue` the anatomy view falls back to the band token.
|
||||
const [from, to] = RING_GRADIENT[hueKey];
|
||||
const glow = RING_GLOW[hueKey];
|
||||
|
||||
const reduced = prefersReducedMotion();
|
||||
const shouldAnimate = animate && hasScore && !reduced;
|
||||
const dash = (clamped / 100) * C;
|
||||
|
||||
// Count-up only feeds the centred number; the arc sweeps via the CSS
|
||||
// stroke-dashoffset transition (or paints final under reduced motion).
|
||||
const hasBaseline =
|
||||
baseline != null && Number.isFinite(baseline) && hasScore;
|
||||
const baselineDash = hasBaseline ? (clampScore(baseline) / 100) * C : 0;
|
||||
|
||||
// Arc starts empty (offset C) and sweeps to its fill on the next frame after
|
||||
// mount, so the spring transition actually plays on first paint (transitions
|
||||
// don't run on the initial commit). With motion off, it paints final at once.
|
||||
const [armed, setArmed] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate) return;
|
||||
const raf = requestAnimationFrame(() => setArmed(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [shouldAnimate]);
|
||||
const filled = !shouldAnimate || armed;
|
||||
|
||||
const easing =
|
||||
clamped > SPRING_SAFE_MAX ? "var(--ring-ease)" : "var(--ring-spring)";
|
||||
|
||||
const displayed = useCountUp(clamped, {
|
||||
enabled: animate && hasScore && !reduced,
|
||||
enabled: shouldAnimate,
|
||||
durationMs: 1000,
|
||||
startDelayMs: delayMs + 180,
|
||||
});
|
||||
const displayedRounded = Math.round(displayed);
|
||||
|
||||
@@ -129,7 +162,13 @@ export function ScoreRing({
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("relative shrink-0", className)}
|
||||
style={{ width: dims.px, height: dims.px }}
|
||||
style={
|
||||
{
|
||||
width: dims.px,
|
||||
height: dims.px,
|
||||
"--ring-glow": glow,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW} ${VIEW}`}
|
||||
@@ -143,19 +182,45 @@ export function ScoreRing({
|
||||
<stop offset="100%" stopColor={to} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Faint same-hue track — 12% of the ring colour, never a bright
|
||||
competing ring. */}
|
||||
{/* Segmented same-hue tick track — reads as an instrument, not a bar. */}
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CX}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke={`url(#${gid})`}
|
||||
strokeOpacity={0.12}
|
||||
strokeOpacity={0.16}
|
||||
strokeWidth={STROKE}
|
||||
strokeDasharray="0.5 4.2"
|
||||
/>
|
||||
{/* The progress arc — single-hue gradient, round cap. */}
|
||||
{/* "vs your baseline" ghost arc — thinner, faint, under the live arc. */}
|
||||
{hasBaseline ? (
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CX}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke={`url(#${gid})`}
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={STROKE * 0.42}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={C}
|
||||
style={{
|
||||
strokeDashoffset: filled ? C - baselineDash : C,
|
||||
transition: shouldAnimate
|
||||
? `stroke-dashoffset 420ms var(--ring-ease) ${delayMs}ms`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* The progress arc — single-hue gradient, round cap, spring sweep, bloom. */}
|
||||
<circle
|
||||
className={cn(
|
||||
"wellness-ring-arc",
|
||||
shouldAnimate &&
|
||||
resolvedBand === "green" &&
|
||||
"wellness-ring-arc--pulse",
|
||||
)}
|
||||
cx={CX}
|
||||
cy={CX}
|
||||
r={R}
|
||||
@@ -164,19 +229,17 @@ export function ScoreRing({
|
||||
strokeWidth={STROKE}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={C}
|
||||
style={
|
||||
reduced
|
||||
? { strokeDashoffset: C - dash }
|
||||
: {
|
||||
strokeDashoffset: C - dash,
|
||||
transition:
|
||||
"stroke-dashoffset 700ms cubic-bezier(.22,.61,.36,1)",
|
||||
}
|
||||
}
|
||||
style={{
|
||||
strokeDashoffset: filled ? C - dash : C,
|
||||
transition: shouldAnimate
|
||||
? `stroke-dashoffset 1150ms ${easing} ${delayMs}ms`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
{/* Centred number as real DOM text — easier to style with the design
|
||||
tokens + tabular-nums, and no SVG-text clipping on a long label. */}
|
||||
{/* Conic specular sheen — sweeps the arc once on the strip reveal. */}
|
||||
<div className="wellness-ring-sheen" aria-hidden="true" />
|
||||
{/* Centred number as real DOM text — design tokens + tabular-nums. */}
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { useState, type ComponentType } from "react";
|
||||
import Link from "next/link";
|
||||
import { Activity, Flame, Gauge, HeartPulse, Moon, RefreshCw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -50,6 +50,15 @@ interface ScoreStripProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Mean of the trailing series excluding the newest point = "your normal". */
|
||||
function baselineOf(series: number[] | undefined): number | null {
|
||||
if (!series || series.length < 3) return null;
|
||||
const past = series.slice(0, -1); // exclude today (newest)
|
||||
if (past.length === 0) return null;
|
||||
const mean = past.reduce((sum, v) => sum + v, 0) / past.length;
|
||||
return Number.isFinite(mean) ? mean : null;
|
||||
}
|
||||
|
||||
/** A score ring wrapped in a link to its anatomy detail page. */
|
||||
function RingTile({
|
||||
score,
|
||||
@@ -60,6 +69,9 @@ function RingTile({
|
||||
metricSlot,
|
||||
hue,
|
||||
icon,
|
||||
index,
|
||||
animate,
|
||||
baseline,
|
||||
}: {
|
||||
score: number;
|
||||
band: ScoreBand;
|
||||
@@ -69,22 +81,37 @@ function RingTile({
|
||||
metricSlot: string;
|
||||
hue: RingHue;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
/** Position in the rendered strip — drives the left-to-right stagger. */
|
||||
index: number;
|
||||
/** Whether the once-per-session reveal plays (false = paint final). */
|
||||
animate: boolean;
|
||||
/** "Your normal" reference for the ghost arc, or null when no series. */
|
||||
baseline: number | null;
|
||||
}) {
|
||||
const Icon = icon;
|
||||
const delayMs = index * 75;
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
data-slot="wellness-score-tile"
|
||||
data-metric={metricSlot}
|
||||
// v1.13.x — the wellness tile drops the saturated `.score-tile-gradient`
|
||||
// dark slab for the gentle, hero-family `.wellness-tile`: a low-opacity
|
||||
// mix of the metric's `--tile-hue` over the theme `--card`. The surface
|
||||
// is luminous in both themes, so the header/number ride `--foreground`
|
||||
// (no pinned white) and the per-metric `hue` arc is the only saturated
|
||||
// thing on the tile — calm, like the greeting tile. The band stays
|
||||
// conveyed by the word label below + the ring's `data-band` + aria-label.
|
||||
style={{ "--tile-hue": TILE_HUE[hue] } as React.CSSProperties}
|
||||
className="wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
// v1.14.0 — gentle, hero-family `.wellness-tile`: a low-opacity mix of
|
||||
// the metric's `--tile-hue` over the theme `--card`, with a faint film
|
||||
// grain (::after) for material depth. The per-metric `hue` arc is the
|
||||
// only saturated thing on the tile. On a fresh session the tile rises +
|
||||
// fades in staggered (`wellness-tile-rise` + `--reveal-delay`), gated by
|
||||
// the strip's `data-revealed`; the band stays conveyed by the word below
|
||||
// + the ring's `data-band` + aria-label.
|
||||
style={
|
||||
{
|
||||
"--tile-hue": TILE_HUE[hue],
|
||||
"--reveal-delay": `${delayMs}ms`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
|
||||
animate && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
<div className="text-foreground flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
@@ -97,7 +124,15 @@ function RingTile({
|
||||
the header above. The per-metric `hue` gradient leans the arc
|
||||
colour; the band stays conveyed by the word label below + the
|
||||
ring's `data-band` + aria-label. */}
|
||||
<ScoreRing score={score} band={band} size="sm" hue={hue} />
|
||||
<ScoreRing
|
||||
score={score}
|
||||
band={band}
|
||||
size="sm"
|
||||
hue={hue}
|
||||
animate={animate}
|
||||
delayMs={delayMs}
|
||||
baseline={baseline}
|
||||
/>
|
||||
<span
|
||||
data-slot="wellness-score-band-word"
|
||||
className="text-muted-foreground text-center text-xs"
|
||||
@@ -118,6 +153,25 @@ export function WellnessScores({
|
||||
}: ScoreStripProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// The signature reveal plays ONCE per browser session. A background
|
||||
// Apple-Health-sync refetch re-renders this strip repeatedly (the v1.8.7
|
||||
// lesson — sync evicted the derived cache all day); replaying the cascade on
|
||||
// every refetch would read as cheap + janky. The sessionStorage flag is set
|
||||
// on the first Insights visit; subsequent mounts paint the final state with
|
||||
// no stagger/sweep/sheen. Lazily computed once on the client (the strip only
|
||||
// renders after the client-side batch resolves, so there is no SSR tile to
|
||||
// mismatch).
|
||||
const [play] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
if (sessionStorage.getItem("wellness-strip-revealed")) return false;
|
||||
sessionStorage.setItem("wellness-strip-revealed", "1");
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// A failed shared batch must read as an error, not as "no scores" — the
|
||||
// strip is top-of-page, so the only Retry lives here (mirrors the Vitals
|
||||
// error card styling; one Retry recovers both strips).
|
||||
@@ -177,6 +231,9 @@ export function WellnessScores({
|
||||
metricSlot="READINESS"
|
||||
hue="readiness"
|
||||
icon={Gauge}
|
||||
index={tiles.length}
|
||||
animate={play}
|
||||
baseline={null}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -192,6 +249,9 @@ export function WellnessScores({
|
||||
metricSlot="RECOVERY_SCORE"
|
||||
hue="recovery"
|
||||
icon={HeartPulse}
|
||||
index={tiles.length}
|
||||
animate={play}
|
||||
baseline={baselineOf(recovery.value.series)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -207,6 +267,9 @@ export function WellnessScores({
|
||||
metricSlot="SLEEP_SCORE"
|
||||
hue="sleep"
|
||||
icon={Moon}
|
||||
index={tiles.length}
|
||||
animate={play}
|
||||
baseline={null}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -222,6 +285,9 @@ export function WellnessScores({
|
||||
metricSlot="STRESS_SCORE"
|
||||
hue="stress"
|
||||
icon={Activity}
|
||||
index={tiles.length}
|
||||
animate={play}
|
||||
baseline={baselineOf(stress.value.series)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -237,6 +303,9 @@ export function WellnessScores({
|
||||
metricSlot="STRAIN_SCORE"
|
||||
hue="strain"
|
||||
icon={Flame}
|
||||
index={tiles.length}
|
||||
animate={play}
|
||||
baseline={baselineOf(strain.value.series)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -257,6 +326,15 @@ export function WellnessScores({
|
||||
5: "lg:grid-cols-5",
|
||||
};
|
||||
const lgCols = LG_COLS[Math.min(tiles.length, 5)] ?? "lg:grid-cols-5";
|
||||
// The sm/md row caps at 3 columns; track the actual tile count there too so
|
||||
// two or four active scores don't leave an empty cell that reads as a
|
||||
// missing tile (the same fill rule the lg row already follows).
|
||||
const SM_COLS: Record<number, string> = {
|
||||
1: "sm:grid-cols-1",
|
||||
2: "sm:grid-cols-2",
|
||||
3: "sm:grid-cols-3",
|
||||
};
|
||||
const smCols = SM_COLS[Math.min(tiles.length, 3)] ?? "sm:grid-cols-3";
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -270,7 +348,10 @@ export function WellnessScores({
|
||||
/>
|
||||
<div
|
||||
data-slot="wellness-scores-grid"
|
||||
className={cn("grid grid-cols-2 gap-3 sm:grid-cols-3", lgCols)}
|
||||
// `data-revealed` triggers the CSS tile-rise + sheen keyframes on the
|
||||
// fresh-session reveal; absent on subsequent mounts (paint final).
|
||||
data-revealed={play ? "true" : undefined}
|
||||
className={cn("grid grid-cols-2 gap-3", smCols, lgCols)}
|
||||
>
|
||||
{tiles}
|
||||
</div>
|
||||
|
||||
@@ -118,9 +118,13 @@ export function InsightStatusCard({
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
data-testid="insight-status-card-loading"
|
||||
className="gap-2 py-4 md:gap-3 md:py-5"
|
||||
// v1.13.1 — match the canonical `gap-1.5` + `pb-1` heading-to-body
|
||||
// rhythm the populated / preparing / empty states use, so the
|
||||
// heading and the first skeleton line sit on the same tight
|
||||
// baseline across every assessment-card state.
|
||||
className="gap-1.5 py-4 md:py-5"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { moodTagIcon } from "@/components/mood/mood-tag-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MoodInfluenceConfidence } from "./mood-tag-influence";
|
||||
|
||||
/**
|
||||
* v1.14.0 — RATED-factor × vital crosstab card.
|
||||
*
|
||||
* The cross-domain bridge: for each factor the user scores per entry (work /
|
||||
* sleep-quality / stress …), a vital's mean on the days the factor was rated
|
||||
* LOW (below its median) vs HIGH, the delta, and a confidence chip. The math
|
||||
* is pre-computed in `mood-aggregates.ts` (`computeFactorMetricCrosstab`) —
|
||||
* the same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the
|
||||
* relations surface uses; only FDR-surviving rows reach here. An inverse
|
||||
* factor (stress / conflict) already runs its split on the flipped series, so
|
||||
* "low" always means a worse day; the phrasing reads "your worse <factor>
|
||||
* days". Observational only: the page-level "associations, not causes" footer
|
||||
* carries the caveat once.
|
||||
*/
|
||||
|
||||
export type MoodFactorCrosstabDisplay =
|
||||
| "hours"
|
||||
| "score"
|
||||
| "steps"
|
||||
| "bpm"
|
||||
| "ms"
|
||||
| "kg"
|
||||
| "mmHg";
|
||||
export type MoodFactorCrosstabMode = "sameDay" | "nextDay";
|
||||
|
||||
export interface MoodFactorMetricCrosstabRow {
|
||||
factor: string;
|
||||
labelKey: string;
|
||||
categoryKey: string;
|
||||
icon: string | null;
|
||||
inverse: boolean;
|
||||
metricKey: string;
|
||||
display: MoodFactorCrosstabDisplay;
|
||||
mode: MoodFactorCrosstabMode;
|
||||
lowDays: number;
|
||||
highDays: number;
|
||||
lowAvg: number;
|
||||
highAvg: number;
|
||||
delta: number;
|
||||
pValue: number;
|
||||
qValue: number;
|
||||
confidence: MoodInfluenceConfidence;
|
||||
}
|
||||
|
||||
const METRIC_LABEL_KEY: Record<string, string> = {
|
||||
sleepDuration: "insights.mood.factorCrosstab.metricSleepDuration",
|
||||
steps: "insights.mood.factorCrosstab.metricSteps",
|
||||
restingHeartRate: "insights.mood.factorCrosstab.metricRestingHeartRate",
|
||||
heartRateVariability: "insights.mood.factorCrosstab.metricHrv",
|
||||
weight: "insights.mood.factorCrosstab.metricWeight",
|
||||
bloodPressureSystolic: "insights.mood.factorCrosstab.metricBloodPressureSys",
|
||||
};
|
||||
|
||||
const UNIT_KEY: Record<MoodFactorCrosstabDisplay, string> = {
|
||||
hours: "insights.mood.factorCrosstab.unitHours",
|
||||
score: "insights.mood.factorCrosstab.unitScore",
|
||||
steps: "insights.mood.factorCrosstab.unitSteps",
|
||||
bpm: "insights.mood.factorCrosstab.unitBpm",
|
||||
ms: "insights.mood.factorCrosstab.unitMs",
|
||||
kg: "insights.mood.factorCrosstab.unitKg",
|
||||
mmHg: "insights.mood.factorCrosstab.unitMmHg",
|
||||
};
|
||||
|
||||
const CONFIDENCE_KEY: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "insights.mood.influence.confidenceLow",
|
||||
medium: "insights.mood.influence.confidenceMedium",
|
||||
high: "insights.mood.influence.confidenceHigh",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASS: Record<MoodInfluenceConfidence, string> = {
|
||||
low: "bg-secondary text-muted-foreground",
|
||||
medium: "bg-info/15 text-info",
|
||||
high: "bg-success/15 text-success",
|
||||
};
|
||||
|
||||
/** Whole numbers for steps; one decimal otherwise. */
|
||||
function fmt(value: number, display: MoodFactorCrosstabDisplay): string {
|
||||
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
|
||||
}
|
||||
|
||||
export function MoodFactorMetricCrosstab({
|
||||
rows,
|
||||
}: {
|
||||
rows: MoodFactorMetricCrosstabRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="mood-factor-metric-crosstab">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("insights.mood.factorCrosstab.description")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{rows.map((row) => {
|
||||
const Icon = moodTagIcon(row.icon);
|
||||
const factorLabel = t(row.labelKey);
|
||||
const metricLabel = t(
|
||||
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
|
||||
);
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
// `delta` = lowAvg − highAvg: positive = the vital runs higher on the
|
||||
// low-factor (worse) days. The number stays NEUTRAL — a higher value
|
||||
// is good for steps but bad for resting HR, and this board is
|
||||
// observational ("associations, not causes"), so colouring it
|
||||
// green/red would imply a health verdict the data doesn't support.
|
||||
// The sign prefix + `data-direction` carry the direction.
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
return (
|
||||
<li
|
||||
key={`${row.metricKey}:${row.factor}`}
|
||||
className="flex flex-col gap-1.5 py-2"
|
||||
data-slot="mood-factor-crosstab-row"
|
||||
data-metric={row.metricKey}
|
||||
data-direction={up ? "up" : "down"}
|
||||
data-confidence={row.confidence}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{Icon && (
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={`${factorLabel} · ${metricLabel}`}
|
||||
>
|
||||
{t("insights.mood.factorCrosstab.pairLabel", {
|
||||
factor: factorLabel,
|
||||
metric: metricLabel,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
CONFIDENCE_CLASS[row.confidence],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_KEY[row.confidence])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
row.inverse
|
||||
? "insights.mood.factorCrosstab.detailInverse"
|
||||
: "insights.mood.factorCrosstab.detail",
|
||||
{
|
||||
factor: factorLabel,
|
||||
metric: metricLabel,
|
||||
lowAvg: fmt(row.lowAvg, row.display),
|
||||
highAvg: fmt(row.highAvg, row.display),
|
||||
unit,
|
||||
lowDays: row.lowDays,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
Clock,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Link2,
|
||||
Sparkles,
|
||||
@@ -69,10 +70,7 @@ const MoodTimeOfDayChart = dynamic(
|
||||
loading: () => <Skeleton className="h-[220px] w-full rounded-md" />,
|
||||
},
|
||||
);
|
||||
import {
|
||||
MoodTagBreakdown,
|
||||
type MoodTagRow,
|
||||
} from "./mood-tag-breakdown";
|
||||
import { MoodTagBreakdown, type MoodTagRow } from "./mood-tag-breakdown";
|
||||
import {
|
||||
MoodCorrelationCards,
|
||||
type MoodMetricCorrelationData,
|
||||
@@ -92,14 +90,15 @@ import {
|
||||
MoodTagInfluence,
|
||||
type MoodTagInfluenceRow,
|
||||
} from "./mood-tag-influence";
|
||||
import {
|
||||
MoodBetterDays,
|
||||
type MoodBetterDayFactor,
|
||||
} from "./mood-better-days";
|
||||
import { MoodBetterDays, type MoodBetterDayFactor } from "./mood-better-days";
|
||||
import {
|
||||
MoodTagMetricCrosstab,
|
||||
type MoodTagMetricCrosstabRow,
|
||||
} from "./mood-tag-metric-crosstab";
|
||||
import {
|
||||
MoodFactorMetricCrosstab,
|
||||
type MoodFactorMetricCrosstabRow,
|
||||
} from "./mood-factor-metric-crosstab";
|
||||
|
||||
/**
|
||||
* v1.8.5 — additional Mood Insights sections.
|
||||
@@ -136,6 +135,9 @@ interface MoodInsightsResponse {
|
||||
// Optional only to tolerate a stale pre-v1.12.0 cached payload during a
|
||||
// rollout; the live endpoint always populates it.
|
||||
tagMetricCrosstab?: MoodTagMetricCrosstabRow[];
|
||||
// Optional only to tolerate a stale pre-v1.14.0 cached payload during a
|
||||
// rollout; the live endpoint always populates it.
|
||||
factorCrosstab?: MoodFactorMetricCrosstabRow[];
|
||||
narratives: MoodNarrativeItem[];
|
||||
correlations: {
|
||||
sleep: MoodMetricCorrelationData;
|
||||
@@ -250,13 +252,19 @@ export function MoodInsightsSections({
|
||||
const hasBetterDays = betterDays.length > 0;
|
||||
const crosstabRows = data.tagMetricCrosstab ?? [];
|
||||
const hasCrosstab = crosstabRows.length > 0;
|
||||
const factorCrosstabRows = data.factorCrosstab ?? [];
|
||||
const hasFactorCrosstab = factorCrosstabRows.length > 0;
|
||||
|
||||
// v1.12.7 — the Stimmungskalender is lifted above the line chart on the page,
|
||||
// so it renders as its own region here.
|
||||
if (region === "heatmap") {
|
||||
return (
|
||||
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
|
||||
<MoodHeatmap cells={heatmapCells} days={data.heatmap.windowDays} stretch />
|
||||
<MoodHeatmap
|
||||
cells={heatmapCells}
|
||||
days={data.heatmap.windowDays}
|
||||
stretch
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -273,9 +281,12 @@ export function MoodInsightsSections({
|
||||
<Card
|
||||
aria-live="polite"
|
||||
data-slot="insight-assessment"
|
||||
className="animate-insight-in gap-2 py-4 md:gap-3 md:py-5"
|
||||
// v1.13.1 — match the canonical `gap-1.5` + `pb-1` heading-to-body
|
||||
// rhythm the other assessment cards use, so the heading sits tight
|
||||
// above its body instead of floating ~16-24 px above it.
|
||||
className="animate-insight-in gap-1.5 py-4 md:py-5"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardHeader className="pb-1">
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.mood.betterDays.title")}
|
||||
@@ -309,9 +320,7 @@ export function MoodInsightsSections({
|
||||
{hasInTargetTile && (
|
||||
<MoodInTargetTile pct={data.summary.inTargetPct} />
|
||||
)}
|
||||
{hasStabilityTile && (
|
||||
<MoodStabilityTile stability={data.stability} />
|
||||
)}
|
||||
{hasStabilityTile && <MoodStabilityTile stability={data.stability} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -343,10 +352,7 @@ export function MoodInsightsSections({
|
||||
)}
|
||||
|
||||
{hasStructuredTags && (
|
||||
<SectionCard
|
||||
title={t("insights.mood.structuredTagsTitle")}
|
||||
icon={Tags}
|
||||
>
|
||||
<SectionCard title={t("insights.mood.structuredTagsTitle")} icon={Tags}>
|
||||
<MoodStructuredTagBreakdown tags={data.structuredTags} />
|
||||
</SectionCard>
|
||||
)}
|
||||
@@ -358,7 +364,10 @@ export function MoodInsightsSections({
|
||||
)}
|
||||
|
||||
{hasInfluence && (
|
||||
<SectionCard title={t("insights.mood.influence.title")} icon={TrendingUp}>
|
||||
<SectionCard
|
||||
title={t("insights.mood.influence.title")}
|
||||
icon={TrendingUp}
|
||||
>
|
||||
<MoodTagInfluence rows={influenceRows} />
|
||||
</SectionCard>
|
||||
)}
|
||||
@@ -369,10 +378,17 @@ export function MoodInsightsSections({
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard
|
||||
title={t("insights.mood.correlationsTitle")}
|
||||
icon={Link2}
|
||||
>
|
||||
{hasFactorCrosstab && (
|
||||
<SectionCard
|
||||
title={t("insights.mood.factorCrosstab.title")}
|
||||
icon={Gauge}
|
||||
>
|
||||
<MoodFactorMetricCrosstab rows={factorCrosstabRows} />
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title={t("insights.mood.correlationsTitle")} icon={Link2}>
|
||||
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("insights.mood.correlationsDescription")}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MedicationComplianceBars } from "@/components/medications/card-parts/me
|
||||
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
|
||||
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
|
||||
import type { CurrentCycle } from "@/lib/analytics/compliance";
|
||||
|
||||
/**
|
||||
* v1.7.2 — the medication-card status pill, compliance bars, intake-action
|
||||
@@ -146,6 +148,143 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.14.0 — the open-cycle status line. A calm, rate-decoupled status driven
|
||||
* by `currentCycle.state` so a sparse weekly / rolling med between doses
|
||||
* surfaces "next dose in N days" / "due today" / "overdue" rather than leaning
|
||||
* on a percentage that misreads an on-schedule med as a scary 0%.
|
||||
*/
|
||||
describe("medication cycle status — open-cycle line", () => {
|
||||
const baseCycle: CurrentCycle = {
|
||||
state: "on_track",
|
||||
nextDueAt: null,
|
||||
graceUntil: null,
|
||||
hasClosedCycles: true,
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Pin the clock to a stable Berlin-noon instant (12:00 CEST) so the
|
||||
// component's Berlin day-bucketing is deterministic regardless of the CI
|
||||
// runner's timezone — a UTC runner near midnight would otherwise read
|
||||
// "now" as the next Berlin day and drift every count by one.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-15T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// A future instant exactly N×24 h from the pinned noon, so every offset lands
|
||||
// mid-Berlin-day and the whole-day delta is exactly N in any runtime tz.
|
||||
function inDays(n: number): Date {
|
||||
return new Date(Date.now() + n * DAY_MS);
|
||||
}
|
||||
|
||||
it("renders nothing when state is none (PRN / paused / ended)", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus cycle={{ ...baseCycle, state: "none" }} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the neutral no-closed-cycles state with the dashed glyph", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{
|
||||
...baseCycle,
|
||||
state: "on_track",
|
||||
nextDueAt: inDays(3),
|
||||
hasClosedCycles: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("No closed dose cycles yet");
|
||||
expect(html).toContain("lucide-circle-dashed");
|
||||
expect(html).toContain("text-muted-foreground");
|
||||
// never leaks a relative count when there's no closed cycle to anchor.
|
||||
expect(html).not.toContain("Next dose in");
|
||||
});
|
||||
|
||||
it("on_track several days out reads the relative-day phrasing in success tone", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(4) }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose in 4 days");
|
||||
expect(html).toContain("text-success");
|
||||
expect(html).toContain("lucide-circle-check");
|
||||
});
|
||||
|
||||
it("on_track reads the same day count when nextDueAt arrives as an ISO string", () => {
|
||||
// Over the wire `nextDueAt` is JSON, so it reaches the card as a string
|
||||
// even though the type says `Date`. The component must re-hydrate it for
|
||||
// the Berlin day-bucketing — a raw string would silently drift the count.
|
||||
const iso = inDays(4).toISOString() as unknown as Date;
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: iso }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose in 4 days");
|
||||
});
|
||||
|
||||
it("on_track one day out reads tomorrow", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: inDays(1) }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose tomorrow");
|
||||
expect(html).not.toContain("in 1 days");
|
||||
});
|
||||
|
||||
it("on_track later today reads today", () => {
|
||||
// Six hours past the pinned noon — still the same Berlin calendar day.
|
||||
const later = new Date(Date.now() + 6 * 60 * 60 * 1000);
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{ ...baseCycle, state: "on_track", nextDueAt: later }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Next dose today");
|
||||
});
|
||||
|
||||
it("due reads the amber due-today line", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{
|
||||
...baseCycle,
|
||||
state: "due",
|
||||
nextDueAt: new Date(),
|
||||
graceUntil: inDays(0),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Due today");
|
||||
expect(html).toContain("text-warning");
|
||||
expect(html).toContain("lucide-calendar-clock");
|
||||
});
|
||||
|
||||
it("missed reads the destructive overdue line", () => {
|
||||
const html = render(
|
||||
<MedicationCycleStatus
|
||||
cycle={{
|
||||
...baseCycle,
|
||||
state: "missed",
|
||||
nextDueAt: inDays(-3),
|
||||
graceUntil: inDays(-3),
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Overdue");
|
||||
expect(html).toContain("text-destructive");
|
||||
expect(html).toContain("lucide-triangle-alert");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Cross-variant streak-token parity: with a positive streak seeded, the
|
||||
* flame on BOTH the generic and the GLP-1 card resolves to the semantic
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
runLogIntake,
|
||||
runRecordIntake,
|
||||
runUndoIntake,
|
||||
} from "@/components/medications/use-medication-intake";
|
||||
@@ -255,6 +256,99 @@ describe("runRecordIntake — shared C1 failure toast + C2 Undo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runLogIntake — manual backdated intake from the Add choice", () => {
|
||||
it("posts a backdated takenAt against the medication's intake route and returns true", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn() });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const queryClient = fakeQueryClient();
|
||||
|
||||
const ok = await runLogIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
takenAt: "2026-06-01T08:30:00.000Z",
|
||||
t,
|
||||
queryClient,
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/medications/med-1/intake");
|
||||
const body = JSON.parse((init as RequestInit).body as string);
|
||||
// Backdated instant is carried verbatim; no slot → no scheduledFor.
|
||||
expect(body).toEqual({ skipped: false, takenAt: "2026-06-01T08:30:00.000Z" });
|
||||
expect(body).not.toHaveProperty("scheduledFor");
|
||||
expect(queryClient.invalidateQueries).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"medications.intakeToastTaken:Mounjaro",
|
||||
);
|
||||
});
|
||||
|
||||
it("threads scheduledFor (the chosen slot) so the write routes through the canonical slot upsert", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn() });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await runLogIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
takenAt: "2026-06-01T05:10:00.000Z",
|
||||
scheduledFor: "2026-06-01T05:00:00.000Z",
|
||||
t,
|
||||
queryClient: fakeQueryClient(),
|
||||
});
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
);
|
||||
expect(body).toEqual({
|
||||
skipped: false,
|
||||
takenAt: "2026-06-01T05:10:00.000Z",
|
||||
scheduledFor: "2026-06-01T05:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits takenAt on a skipped log and shows the skipped toast", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn() });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await runLogIntake({
|
||||
medication,
|
||||
skipped: true,
|
||||
takenAt: "2026-06-01T08:30:00.000Z",
|
||||
t,
|
||||
queryClient: fakeQueryClient(),
|
||||
});
|
||||
|
||||
const body = JSON.parse(
|
||||
(fetchMock.mock.calls[0][1] as RequestInit).body as string,
|
||||
);
|
||||
expect(body).toEqual({ skipped: true });
|
||||
expect(body).not.toHaveProperty("takenAt");
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
"medications.intakeToastSkipped:Mounjaro",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces the failure toast and returns false on a non-ok POST", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, json: vi.fn() }));
|
||||
const queryClient = fakeQueryClient();
|
||||
|
||||
const ok = await runLogIntake({
|
||||
medication,
|
||||
skipped: false,
|
||||
takenAt: "2026-06-01T08:30:00.000Z",
|
||||
t,
|
||||
queryClient,
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"medications.intakeToastFailed:Mounjaro",
|
||||
);
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
expect(queryClient.invalidateQueries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runUndoIntake — shared soft-delete", () => {
|
||||
it("reverts via DELETE and confirms on success", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { AlertTriangle, CalendarClock, CircleCheck, CircleDashed } from "lucide-react";
|
||||
|
||||
import type { CurrentCycle } from "@/lib/analytics/compliance";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toBerlinDate } from "@/lib/medications/window-status";
|
||||
|
||||
interface MedicationCycleStatusProps {
|
||||
/** The open-cycle descriptor from `complianceDisplay.currentCycle`. */
|
||||
cycle: CurrentCycle;
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whole-day delta between two instants, measured on the user's calendar
|
||||
* (Europe/Berlin) so "in 2 days" lines up with the day a sparse dose lands on
|
||||
* rather than a raw 48-hour count. Positive = future, 0 = today, negative =
|
||||
* past. Mirrors the day-bucketing the card already does for its next/last slot.
|
||||
*/
|
||||
function calendarDayDelta(target: Date, now: Date): number {
|
||||
const a = toBerlinDate(target);
|
||||
const b = toBerlinDate(now);
|
||||
const targetMidnight = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||||
const nowMidnight = new Date(b.getFullYear(), b.getMonth(), b.getDate());
|
||||
return Math.round((targetMidnight.getTime() - nowMidnight.getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.14.0 — the open-cycle status line for the medication cards.
|
||||
*
|
||||
* The compliance percentage rows score CLOSED cycles. A sparse weekly /
|
||||
* rolling med (a GLP-1 injection) that is simply between doses has no scored
|
||||
* cycle in flight, so the percentage alone reads as a scary 0% / 100% that
|
||||
* says nothing about whether the user is actually on schedule. This line is
|
||||
* decoupled from the rate: it reports the open cycle's calm status — when the
|
||||
* next dose lands, whether it's due or overdue, or that there's not enough
|
||||
* history to score yet.
|
||||
*
|
||||
* Driven by `currentCycle.state` (`on_track` / `due` / `missed` / `none`) plus
|
||||
* `hasClosedCycles`. Colour follows the same semantic ramp the status pill
|
||||
* uses (success / warning / destructive) and pairs every tier with a Lucide
|
||||
* glyph so colour-blind users can disambiguate — WCAG 1.4.1 (Use of Color).
|
||||
* It complements the compliance number; it does not replace it.
|
||||
*/
|
||||
export function MedicationCycleStatus({ cycle }: MedicationCycleStatusProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// No projected next dose (PRN, paused, ended) → render nothing; the card
|
||||
// keeps its existing inventory.
|
||||
if (cycle.state === "none") return null;
|
||||
|
||||
// Neutral "not enough history yet" wins over the relative phrasing: a
|
||||
// brand-new sparse med has a next dose but no closed cycle to anchor the
|
||||
// percentage rows, so we say so plainly rather than implying a score.
|
||||
if (!cycle.hasClosedCycles) {
|
||||
return (
|
||||
<p className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<CircleDashed className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("medications.cycleNoClosedCycles")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
let tone: string;
|
||||
let icon: React.ReactNode;
|
||||
let label: string;
|
||||
|
||||
if (cycle.state === "missed") {
|
||||
tone = "text-destructive";
|
||||
icon = <AlertTriangle className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
label = t("medications.cycleOverdue");
|
||||
} else if (cycle.state === "due") {
|
||||
tone = "text-warning";
|
||||
icon = <CalendarClock className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
label = t("medications.cycleDueToday");
|
||||
} else {
|
||||
// on_track — the next dose simply hasn't come round yet.
|
||||
tone = "text-success";
|
||||
icon = <CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />;
|
||||
const now = new Date();
|
||||
// `nextDueAt` is typed `Date` but arrives as an ISO string over the wire;
|
||||
// re-hydrate it so the Berlin day-bucketing (toLocaleString with a
|
||||
// timeZone) actually applies — a raw string would silently skip it and
|
||||
// drift the count by a day near midnight / off Berlin.
|
||||
const days = cycle.nextDueAt
|
||||
? calendarDayDelta(new Date(cycle.nextDueAt), now)
|
||||
: null;
|
||||
if (days === null || days <= 0) {
|
||||
// on_track with the slot today (now < dueAt but same calendar day).
|
||||
label = t("medications.cycleNextDoseToday");
|
||||
} else if (days === 1) {
|
||||
label = t("medications.cycleNextDoseTomorrow");
|
||||
} else {
|
||||
label = t("medications.cycleNextDoseInDays", { days });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<p className={`flex items-center gap-1.5 text-xs font-medium ${tone}`}>
|
||||
{icon}
|
||||
{label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MedicationComplianceBars,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
|
||||
import {
|
||||
@@ -491,6 +492,16 @@ export function Glp1MedicationCard({
|
||||
<MedicationComplianceSkeleton />
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — the real win for a weekly GLP-1: between
|
||||
injections the percentage rows are vacuous, so this calm,
|
||||
rate-decoupled line reports "next dose in N days" / "due today" /
|
||||
"overdue" (or a neutral "no closed cycles yet") instead of a 0%
|
||||
that misreads a perfectly on-schedule med. `state: "none"`
|
||||
collapses to nothing inside the part. */}
|
||||
{medication.active && display?.currentCycle && (
|
||||
<MedicationCycleStatus cycle={display.currentCycle} />
|
||||
)}
|
||||
|
||||
{/* Primary actions row — shared with the generic medication card.
|
||||
The GLP-1-specific side-effect quick-log lives in the
|
||||
header-actions overflow (kebab), not this row, so Mounjaro and
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.14.0 — manual intake logger reachable from the medications-page
|
||||
* "Add" choice. The top-of-page Add button now offers two paths: create a
|
||||
* new medication (the existing wizard) or LOG AN INTAKE against an existing
|
||||
* medication — including a backdated one.
|
||||
*
|
||||
* The form picks a medication, optionally pins a schedule slot, and a
|
||||
* date+time that defaults to "now" but accepts any past instant. It submits
|
||||
* to the existing `POST /api/medications/{id}/intake` route: `takenAt`
|
||||
* carries the (possibly backdated) instant, and a chosen slot supplies
|
||||
* `scheduledFor` so the write routes through the server's canonical slot
|
||||
* upsert — the same snap/upsert path a normal "Taken" tap uses — keeping
|
||||
* the one-row-per-dose-slot invariant intact. No new endpoint is minted.
|
||||
*
|
||||
* The submit orchestration lives in the pure, injection-tested
|
||||
* `runLogIntake` (see `use-medication-intake.ts`); this component owns only
|
||||
* the form state + the medication/slot pickers.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { runLogIntake } from "@/components/medications/use-medication-intake";
|
||||
|
||||
interface LogIntakeSchedule {
|
||||
windowStart: string;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
timesOfDay?: string[];
|
||||
}
|
||||
|
||||
export interface LogIntakeMedication {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
schedules: LogIntakeSchedule[];
|
||||
}
|
||||
|
||||
export interface LogIntakeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Active medications the user can log against. */
|
||||
medications: LogIntakeMedication[];
|
||||
}
|
||||
|
||||
/** Sentinel option value meaning "no specific schedule slot" (PRN). */
|
||||
const NO_SLOT = "__none__";
|
||||
|
||||
/**
|
||||
* `<input type="datetime-local">` shape `YYYY-MM-DDTHH:mm` for the given
|
||||
* Date rendered in the user's local time. Mirrors the intake-edit dialog so
|
||||
* both date pickers read the same way.
|
||||
*/
|
||||
function toDateTimeLocal(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct HH:mm slot times for a medication, derived from each schedule's
|
||||
* first-class `timesOfDay` (falling back to `windowStart`). Sorted so the
|
||||
* morning slot leads. Empty for a medication with no time-anchored slots
|
||||
* (the PRN-only case), which simply hides the slot picker.
|
||||
*/
|
||||
function slotTimesFor(med: LogIntakeMedication | undefined): string[] {
|
||||
if (!med) return [];
|
||||
const times = new Set<string>();
|
||||
for (const s of med.schedules) {
|
||||
const fromTimes = s.timesOfDay && s.timesOfDay.length > 0;
|
||||
if (fromTimes) {
|
||||
for (const tod of s.timesOfDay!) times.add(tod);
|
||||
} else if (s.windowStart) {
|
||||
times.add(s.windowStart);
|
||||
}
|
||||
}
|
||||
return Array.from(times).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine a `YYYY-MM-DD` date part (read off the picked `takenAt`) with an
|
||||
* `HH:mm` slot time into an ISO instant in the user's local timezone. This
|
||||
* is the slot instant on the chosen day; the server snaps it to the
|
||||
* canonical slot via the same upsert a live "Taken" tap uses.
|
||||
*/
|
||||
function slotInstantIso(takenAtLocal: string, slotHm: string): string | null {
|
||||
const datePart = takenAtLocal.slice(0, 10);
|
||||
if (datePart.length !== 10) return null;
|
||||
const d = new Date(`${datePart}T${slotHm}`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function LogIntakeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
medications,
|
||||
}: LogIntakeDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [medicationId, setMedicationId] = useState<string>(
|
||||
() => medications[0]?.id ?? "",
|
||||
);
|
||||
const [slot, setSlot] = useState<string>(NO_SLOT);
|
||||
const [takenAt, setTakenAt] = useState<string>(() =>
|
||||
toDateTimeLocal(new Date()),
|
||||
);
|
||||
const [skipped, setSkipped] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const selected = useMemo(
|
||||
() => medications.find((m) => m.id === medicationId),
|
||||
[medications, medicationId],
|
||||
);
|
||||
const slotTimes = useMemo(() => slotTimesFor(selected), [selected]);
|
||||
|
||||
function handleMedicationChange(id: string) {
|
||||
setMedicationId(id);
|
||||
// Reset the slot when switching medications — the prior slot may not
|
||||
// exist on the new schedule.
|
||||
setSlot(NO_SLOT);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!medicationId || busy) return;
|
||||
const med = medications.find((m) => m.id === medicationId);
|
||||
if (!med) return;
|
||||
// A pinned slot must resolve to a real instant; if it can't, surface an
|
||||
// error rather than silently logging the dose through the unscheduled path
|
||||
// (which would miss the canonical slot upsert and could duplicate the row).
|
||||
let scheduledFor: string | undefined;
|
||||
if (slot !== NO_SLOT) {
|
||||
const iso = slotInstantIso(takenAt, slot);
|
||||
if (!iso) {
|
||||
toast.error(t("medications.logIntake.slotError"));
|
||||
return;
|
||||
}
|
||||
scheduledFor = iso;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const ok = await runLogIntake({
|
||||
medication: { id: med.id, name: med.name },
|
||||
skipped,
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
scheduledFor,
|
||||
t,
|
||||
queryClient,
|
||||
});
|
||||
if (ok) onOpenChange(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !busy && onOpenChange(o)}>
|
||||
<DialogContent data-slot="log-intake-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.logIntake.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.logIntake.dialogDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{medications.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-sm">
|
||||
{t("medications.logIntake.noMedications")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="log-intake-medication">
|
||||
{t("medications.logIntake.medicationLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-medication"
|
||||
value={medicationId}
|
||||
onChange={(e) => handleMedicationChange(e.target.value)}
|
||||
>
|
||||
{medications.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.dose ? `${m.name} — ${m.dose}` : m.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{slotTimes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="log-intake-slot">
|
||||
{t("medications.logIntake.slotLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="log-intake-slot"
|
||||
value={slot}
|
||||
onChange={(e) => setSlot(e.target.value)}
|
||||
>
|
||||
<option value={NO_SLOT}>
|
||||
{t("medications.logIntake.slotNone")}
|
||||
</option>
|
||||
{slotTimes.map((time) => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="log-intake-taken-at">
|
||||
{t("medications.logIntake.takenAtLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="log-intake-taken-at"
|
||||
type="datetime-local"
|
||||
value={takenAt}
|
||||
max={toDateTimeLocal(new Date())}
|
||||
onChange={(e) => setTakenAt(e.target.value)}
|
||||
disabled={skipped}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.logIntake.takenAtHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
htmlFor="log-intake-skipped"
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.logIntake.skippedLabel")}
|
||||
</span>
|
||||
<Switch
|
||||
id="log-intake-skipped"
|
||||
checked={skipped}
|
||||
onCheckedChange={setSkipped}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("medications.logIntake.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={busy || medications.length === 0 || !medicationId}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.logIntake.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MedicationComplianceBars,
|
||||
MedicationComplianceSkeleton,
|
||||
} from "@/components/medications/card-parts/medication-compliance-bars";
|
||||
import { MedicationCycleStatus } from "@/components/medications/card-parts/medication-cycle-status";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import {
|
||||
MedicationNextLastSlot,
|
||||
@@ -402,6 +403,16 @@ export function MedicationCard({
|
||||
<MedicationComplianceSkeleton />
|
||||
))}
|
||||
|
||||
{/* Open-cycle status line — calm, rate-decoupled. A sparse weekly /
|
||||
rolling med between doses reports "next dose in N days" / "due
|
||||
today" / "overdue" (or a neutral "no closed cycles yet") instead of
|
||||
leaning on a percentage that reads as a scary 0% for a med that is
|
||||
actually on schedule. Rendered only when the server supplies the
|
||||
descriptor; `state: "none"` collapses to nothing inside the part. */}
|
||||
{medication.active && display?.currentCycle && (
|
||||
<MedicationCycleStatus cycle={display.currentCycle} />
|
||||
)}
|
||||
|
||||
{/* Quick actions — primary buttons of the medication card. The
|
||||
content above reserves constant-height slots, so the card bodies
|
||||
in a grid row are equal height and `mt-auto` pins the action row
|
||||
|
||||
@@ -155,6 +155,65 @@ export async function runRecordIntake(deps: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.14.0 — pure orchestration for a manual, possibly BACKDATED intake
|
||||
* logged against an existing medication from the medications-page "Add"
|
||||
* choice (the "log an intake" branch). Mirrors {@link runRecordIntake}'s
|
||||
* dependency-injected shape so it is unit-testable without a React render.
|
||||
*
|
||||
* Backdating: the dialog carries a user-picked `takenAt` (local time
|
||||
* converted to ISO) which the per-medication intake route accepts with no
|
||||
* future/past restriction. When the user also names a schedule slot the
|
||||
* dialog supplies `scheduledFor` (the slot instant on the chosen day), so
|
||||
* the write routes through the server's canonical slot upsert — the same
|
||||
* snap/upsert path a normal "Taken" tap uses — preserving the
|
||||
* one-row-per-dose-slot medical invariant. With no slot the route follows
|
||||
* its unscheduled/PRN insert path.
|
||||
*/
|
||||
export async function runLogIntake(deps: {
|
||||
medication: MedicationIntakeIdentity;
|
||||
/** True logs a skipped slot; false records a taken dose. */
|
||||
skipped: boolean;
|
||||
/** ISO instant the dose was actually taken; ignored when skipped. */
|
||||
takenAt: string;
|
||||
/**
|
||||
* ISO slot instant on the chosen day when the user pinned a schedule
|
||||
* slot. Omit/undefined to route through the unscheduled/PRN path.
|
||||
*/
|
||||
scheduledFor?: string;
|
||||
t: Translator;
|
||||
queryClient: QueryClient;
|
||||
}): Promise<boolean> {
|
||||
const { medication, skipped, takenAt, scheduledFor, t, queryClient } = deps;
|
||||
try {
|
||||
const body: Record<string, unknown> = { skipped };
|
||||
if (!skipped) body.takenAt = takenAt;
|
||||
if (scheduledFor) body.scheduledFor = scheduledFor;
|
||||
const res = await fetch(`/api/medications/${medication.id}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
|
||||
return false;
|
||||
}
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(
|
||||
t(
|
||||
skipped
|
||||
? "medications.intakeToastSkipped"
|
||||
: "medications.intakeToastTaken",
|
||||
{ name: medication.name },
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure soft-delete undo for a just-recorded intake, dependencies injected
|
||||
* for the same testability reason as {@link runRecordIntake}.
|
||||
|
||||
@@ -17,9 +17,9 @@ import { useEffect, useRef, useState } from "react";
|
||||
*/
|
||||
export function useCountUp(
|
||||
target: number,
|
||||
options: { durationMs?: number; enabled?: boolean } = {},
|
||||
options: { durationMs?: number; enabled?: boolean; startDelayMs?: number } = {},
|
||||
): number {
|
||||
const { durationMs = 600, enabled = true } = options;
|
||||
const { durationMs = 600, enabled = true, startDelayMs = 0 } = options;
|
||||
// Seed at the final value so the reduced-motion / SSR path is already
|
||||
// correct without any setState; only the animated path drives frames,
|
||||
// and those setState calls all happen inside the rAF callback (async).
|
||||
@@ -60,7 +60,7 @@ export function useCountUp(
|
||||
return;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let start = 0;
|
||||
|
||||
const tick = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
@@ -76,20 +76,36 @@ export function useCountUp(
|
||||
}
|
||||
};
|
||||
|
||||
// First frame (and every subsequent one) runs inside the rAF callback,
|
||||
// so the initial 0-state set is asynchronous — no cascading render.
|
||||
frameRef.current = window.requestAnimationFrame((now) => {
|
||||
// Hold the number at 0 during the stagger delay so it matches the empty
|
||||
// ring, then start the rAF loop. Every setState runs inside a rAF/timeout
|
||||
// callback (async) — never synchronously in the effect body — so there is
|
||||
// no cascading render. `startDelayMs` lets the strip stagger each tile's
|
||||
// count-up so the number trails its arc, left-to-right.
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const begin = () => {
|
||||
frameRef.current = window.requestAnimationFrame((now) => {
|
||||
start = now;
|
||||
tick(now);
|
||||
});
|
||||
};
|
||||
const arm = window.requestAnimationFrame(() => {
|
||||
setValue(0);
|
||||
tick(now);
|
||||
if (startDelayMs > 0) {
|
||||
timeoutId = setTimeout(begin, startDelayMs);
|
||||
} else {
|
||||
begin();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(arm);
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
if (frameRef.current != null) {
|
||||
window.cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [target, durationMs, enabled]);
|
||||
}, [target, durationMs, enabled, startDelayMs]);
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -21,10 +21,14 @@ describe("base-system assessment prompt — grounding floor preserved", () => {
|
||||
if (locale === "en") {
|
||||
expect(p).toMatch(/own baseline/i);
|
||||
expect(p).toMatch(/never against a population norm/i);
|
||||
expect(p).toMatch(/a value inside the user's normal swing is NOT a finding/i);
|
||||
// v1.13.x — the normal-swing verdict is now a pre-computed boolean the
|
||||
// prompt leads from; the grounding floor is that an in-swing value is
|
||||
// explicitly NOT a finding (phrased against `outsideNormalSwing`).
|
||||
expect(p).toMatch(/outsideNormalSwing: false.*not a finding/i);
|
||||
} else {
|
||||
expect(p).toMatch(/EIGENE Baseline/);
|
||||
expect(p).toMatch(/nicht gegen Bevölkerungsnormen/i);
|
||||
expect(p).toMatch(/outsideNormalSwing: false.*kein Befund/i);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* Version of the per-metric assessment base prompt + its signal contract.
|
||||
*
|
||||
* Bump this whenever the base-system sections, the signal-block instruction,
|
||||
* or the shared snapshot shape change, so the cross-feature quality
|
||||
* attribution (and the per-score assessment provenance) can slice prose
|
||||
* before/after a prompt edit. The other AI surfaces already carry their own
|
||||
* (`insight-generator.ts` `PROMPT_VERSION`, `period-narrative`
|
||||
* `NARRATIVE_PROMPT_VERSION`); the base system had none — this is it.
|
||||
*
|
||||
* 5.0.0 — the SIGNAL block lands: the snapshot now carries a pre-computed
|
||||
* `current · baseline · signed delta · spread · outsideNormalSwing`
|
||||
* descriptor and the prompt leads from it instead of asking the model to
|
||||
* derive the comparison from raw buckets.
|
||||
*/
|
||||
export const PROMPT_VERSION = "5.0.0" as const;
|
||||
|
||||
/**
|
||||
* Base system prompt for the per-metric Insights *assessment* cards.
|
||||
*
|
||||
@@ -67,33 +84,33 @@ const ASSESSMENT_SECTIONS: readonly AssessmentSection[] = [
|
||||
{
|
||||
id: "data",
|
||||
en: `YOUR DATA (graded snapshot):
|
||||
- signal: the finished comparison — { metric, unit, current, currentWindowDays, baseline, baselineLabel, delta, deltaPct, spread, outsideNormalSwing, direction, normalRange?, placement?, n, newestDaysAgo, contributors? }. \`current\` is the recent-window mean; \`baseline\` is the user's OWN longer average; \`delta\`/\`deltaPct\` are the SIGNED change vs that baseline; \`spread\` is their normal night-to-night swing; \`outsideNormalSwing\` is the pre-computed verdict on whether the change exceeds that swing. These are already computed — state them, do NOT recompute them. \`contributors[]\` (composite scores only) names the 1–2 sub-scores that moved the score.
|
||||
- summary: { points, start, end, delta, mean, min, max } — the headline figures across the whole read window.
|
||||
- series.recent[]: the last ~21 days, one row per day ({ date, min, max, mean, n }).
|
||||
- series.weekly[]: the ~10 weeks before that, one ISO-week aggregate each.
|
||||
- series.monthly[]: the ~12 months before that, one calendar-month aggregate each.
|
||||
- series.yearly[]: everything older, one row per year ({ year, mean, min, max, n, slope }).
|
||||
- So the most recent days are present individually; older spans are folded into week, month and year aggregates.
|
||||
- Read the personal baseline from the weekly/monthly/yearly means; the short window is the recent days.
|
||||
- A slice can be empty when the history is short — do not pretend it exists.`,
|
||||
- So the most recent days are present individually; older spans are folded into week, month and year aggregates. Consult the series only for a specific day; lead from the signal block.
|
||||
- A slice can be empty when the history is short — do not pretend it exists. When \`signal.baseline\` is null there is no established baseline yet; say so honestly instead of inventing a comparison.`,
|
||||
de: `DEINE DATENGRUNDLAGE (graded snapshot):
|
||||
- signal: der fertige Vergleich — { metric, unit, current, currentWindowDays, baseline, baselineLabel, delta, deltaPct, spread, outsideNormalSwing, direction, normalRange?, placement?, n, newestDaysAgo, contributors? }. \`current\` ist das recent-Mittel; \`baseline\` ist der EIGENE längere Durchschnitt; \`delta\`/\`deltaPct\` sind die VORZEICHENBEHAFTETE Änderung gegenüber dieser Baseline; \`spread\` ist die normale Schwankung; \`outsideNormalSwing\` ist das vorab berechnete Urteil, ob die Änderung diese Schwankung übersteigt. Diese Werte sind bereits berechnet — nenne sie, rechne sie NICHT neu. \`contributors[]\` (nur Score-Komposite) benennt die 1–2 Teil-Scores, die den Score bewegt haben.
|
||||
- summary: { points, start, end, delta, mean, min, max } — die Eckwerte des gesamten Lesefensters.
|
||||
- series.recent[]: die jüngsten ~21 Tage, je ein Eintrag pro Tag ({ date, min, max, mean, n }).
|
||||
- series.weekly[]: die ~10 Wochen davor, je ein ISO-Wochen-Aggregat.
|
||||
- series.monthly[]: die ~12 Monate davor, je ein Monatsaggregat.
|
||||
- series.yearly[]: alles ältere, je ein Jahr ({ year, mean, min, max, n, slope }).
|
||||
- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate.
|
||||
- Lies die persönliche Baseline aus den weekly/monthly/yearly-Mitteln; das kurze Fenster sind die recent-Tage.
|
||||
- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn.`,
|
||||
- Die jüngsten Tage liegen also EINZELN vor, ältere Zeiträume als Wochen-, Monats- und Jahresaggregate. Ziehe die series nur für einen konkreten Tag heran; führe sonst mit dem signal-Block.
|
||||
- Ein Slice kann leer sein, wenn die Historie kurz ist. Dann nicht so tun, als gäbe es ihn. Ist \`signal.baseline\` null, gibt es noch keine etablierte Baseline; sage das ehrlich, statt einen Vergleich zu erfinden.`,
|
||||
},
|
||||
{
|
||||
id: "build",
|
||||
en: `HOW TO BUILD THE ASSESSMENT (as flowing prose):
|
||||
1. NAME the current finding with a concrete number from the snapshot (e.g. the recent mean or the latest daily value).
|
||||
2. PLACE it against the user's OWN baseline: compare the short window (recent, ~week) to the long baseline (monthly/yearly mean). Report only SIGNIFICANT deviations — a value inside the user's normal swing is NOT a finding.
|
||||
1. NAME the current finding with a concrete number — the signal block's \`current\` (or, for a day-level question, a value from series.recent).
|
||||
2. PLACE it against the user's OWN baseline using the signal block's pre-computed \`delta\` vs \`baselineLabel\`: state the signed change, not a re-derived one. Treat \`outsideNormalSwing: false\` as "inside your usual range — not a finding" and do NOT manufacture a trend; treat \`outsideNormalSwing: true\` as the real, reportable change. When a \`normalRange\`/\`placement\` is present, use it only as a coarse secondary anchor — the personal delta leads.
|
||||
3. ONE step — ONLY IF IT IS REAL: close with EXACTLY ONE concrete, doable suggestion WHEN the finding genuinely implies an action. One message = one behaviour, no list. If the value is steady and in a good place and there is nothing useful to do, do NOT manufacture a step — affirm briefly and name one thing worth keeping an eye on instead. A fabricated step is exactly the platitude we ban.`,
|
||||
de: `SO BAUST DU DIE EINSCHÄTZUNG (als fließender Text):
|
||||
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl aus dem Snapshot (z.B. das recent-Mittel oder den letzten Tageswert).
|
||||
2. EINORDNEN gegen die EIGENE Baseline: Vergleiche das kurze Fenster (recent, ~Woche) mit der langen Baseline (monthly/yearly-Mittel). Melde nur SIGNIFIKANTE Abweichungen — eine Zahl, die innerhalb der normalen Schwankung des Nutzers liegt, ist KEIN Befund.
|
||||
1. BENENNEN: Nenne den aktuellen Befund mit einer konkreten Zahl — dem \`current\` aus dem signal-Block (oder, für eine Tagesfrage, einem Wert aus series.recent).
|
||||
2. EINORDNEN gegen die EIGENE Baseline mit dem vorab berechneten \`delta\` gegenüber \`baselineLabel\`: nenne die vorzeichenbehaftete Änderung, leite sie nicht neu her. Behandle \`outsideNormalSwing: false\` als "innerhalb der üblichen Schwankung — kein Befund" und ERFINDE KEINEN Trend; behandle \`outsideNormalSwing: true\` als die echte, meldenswerte Änderung. Liegt ein \`normalRange\`/\`placement\` vor, nutze es nur als groben sekundären Anker — das persönliche delta führt.
|
||||
3. EIN SCHRITT — NUR WENN ER ECHT IST: Schließe mit GENAU EINER konkreten, machbaren Empfehlung, WENN aus dem Befund wirklich etwas Umsetzbares folgt. Eine Botschaft = ein Verhalten, keine Liste. Ist der Wert stabil und im grünen Bereich und gibt es nichts sinnvoll zu tun, dann ERZWINGE KEINEN Schritt — bestätige kurz und nenne stattdessen einen Punkt, den man im Auge behalten kann. Ein erfundener Schritt ist genau die Floskel, die wir vermeiden.`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -94,6 +94,28 @@ describe("discoverCorrelations", () => {
|
||||
expect(result.pairsTested).toBe(1);
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("surfaces a RATED-factor channel and humanises its namespace prefix", () => {
|
||||
// A `FACTOR:work` behaviour whose next-day sleep tracks it linearly.
|
||||
const n = 30;
|
||||
const factorVals = Array.from({ length: n }, (_, i) => (i % 5) + 1);
|
||||
const sleepVals = [400, ...factorVals.slice(0, n - 1).map((v) => v * 30 + 300)];
|
||||
const result = discoverCorrelations(
|
||||
[
|
||||
{ key: "FACTOR:work", role: "behaviour", points: series(factorVals) },
|
||||
{ key: "SLEEP_DURATION", role: "outcome", points: series(sleepVals) },
|
||||
],
|
||||
{ fdrQ: 0.1 },
|
||||
);
|
||||
expect(result.discovered).toHaveLength(1);
|
||||
const pair = result.discovered[0];
|
||||
expect(pair.behaviour).toBe("FACTOR:work");
|
||||
// The interpretation strips the namespace and reads "rated work", never
|
||||
// the raw key, and stays causation-banned.
|
||||
expect(pair.interpretation).toContain("rated work");
|
||||
expect(pair.interpretation).not.toContain("FACTOR:");
|
||||
expect(pair.interpretation).toMatch(/not a cause/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── F3 — MOOD as a discoverable OUTCOME channel ─────────────────────
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildMetricSignal,
|
||||
placeInBand,
|
||||
NORMAL_SWING_K,
|
||||
} from "@/lib/insights/metric-signal";
|
||||
import type { GradedSeries } from "@/lib/insights/graded-series";
|
||||
|
||||
function graded(partial: Partial<GradedSeries>): GradedSeries {
|
||||
return {
|
||||
recent: partial.recent ?? [],
|
||||
weekly: partial.weekly ?? [],
|
||||
monthly: partial.monthly ?? [],
|
||||
yearly: partial.yearly ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildMetricSignal", () => {
|
||||
it("returns null when the recent window is empty (no current value)", () => {
|
||||
const signal = buildMetricSignal({
|
||||
metric: "resting heart rate",
|
||||
direction: "lower-better",
|
||||
graded: graded({ monthly: [{ month: "2026-05", min: 60, max: 70, mean: 66, n: 30 }] }),
|
||||
});
|
||||
expect(signal).toBeNull();
|
||||
});
|
||||
|
||||
it("computes current, baseline, signed delta and deltaPct from the series", () => {
|
||||
const signal = buildMetricSignal({
|
||||
metric: "resting heart rate",
|
||||
unit: "bpm",
|
||||
direction: "lower-better",
|
||||
graded: graded({
|
||||
recent: [
|
||||
{ date: "2026-06-01", min: 60, max: 62, mean: 61, n: 1 },
|
||||
{ date: "2026-06-02", min: 60, max: 62, mean: 61, n: 1 },
|
||||
],
|
||||
monthly: [
|
||||
{ month: "2026-04", min: 64, max: 68, mean: 66, n: 30 },
|
||||
{ month: "2026-05", min: 64, max: 68, mean: 66, n: 30 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(signal).not.toBeNull();
|
||||
expect(signal!.current).toBe(61);
|
||||
expect(signal!.baseline).toBe(66);
|
||||
expect(signal!.delta).toBe(-5);
|
||||
expect(signal!.deltaPct).toBeCloseTo(-7.6, 1);
|
||||
expect(signal!.n).toBe(2);
|
||||
expect(signal!.unit).toBe("bpm");
|
||||
expect(signal!.baselineLabel).toBe("your monthly average");
|
||||
});
|
||||
|
||||
it("falls back monthly→weekly→yearly for the baseline", () => {
|
||||
const weekly = buildMetricSignal({
|
||||
metric: "x",
|
||||
direction: "higher-better",
|
||||
graded: graded({
|
||||
recent: [{ date: "d", min: 10, max: 10, mean: 10, n: 1 }],
|
||||
weekly: [{ weekISO: "2026-W20", min: 8, max: 8, mean: 8, n: 7 }],
|
||||
}),
|
||||
});
|
||||
expect(weekly!.baseline).toBe(8);
|
||||
expect(weekly!.baselineLabel).toBe("your recent-weeks average");
|
||||
|
||||
const yearly = buildMetricSignal({
|
||||
metric: "x",
|
||||
direction: "higher-better",
|
||||
graded: graded({
|
||||
recent: [{ date: "d", min: 10, max: 10, mean: 10, n: 1 }],
|
||||
yearly: [{ year: "2025", min: 6, max: 6, mean: 6, n: 100, slope: null }],
|
||||
}),
|
||||
});
|
||||
expect(yearly!.baseline).toBe(6);
|
||||
expect(yearly!.baselineLabel).toBe("your long-term average");
|
||||
});
|
||||
|
||||
it("nulls baseline/delta when there is no longer-window history", () => {
|
||||
const signal = buildMetricSignal({
|
||||
metric: "x",
|
||||
direction: "higher-better",
|
||||
graded: graded({ recent: [{ date: "d", min: 10, max: 10, mean: 10, n: 1 }] }),
|
||||
});
|
||||
expect(signal!.baseline).toBeNull();
|
||||
expect(signal!.delta).toBeNull();
|
||||
expect(signal!.deltaPct).toBeNull();
|
||||
expect(signal!.outsideNormalSwing).toBeNull();
|
||||
expect(signal!.baselineLabel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags outsideNormalSwing via |delta| > k·spread", () => {
|
||||
// baseline means 60/60/60/60 → spread 0; a non-zero delta is outside.
|
||||
const flat = buildMetricSignal({
|
||||
metric: "x",
|
||||
direction: "lower-better",
|
||||
graded: graded({
|
||||
recent: [{ date: "d", min: 70, max: 70, mean: 70, n: 1 }],
|
||||
monthly: [
|
||||
{ month: "a", min: 60, max: 60, mean: 60, n: 1 },
|
||||
{ month: "b", min: 60, max: 60, mean: 60, n: 1 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(flat!.spread).toBe(0);
|
||||
expect(flat!.outsideNormalSwing).toBe(true);
|
||||
|
||||
// wide spread swallows a small delta as inside the normal swing.
|
||||
const noisy = buildMetricSignal({
|
||||
metric: "x",
|
||||
direction: "lower-better",
|
||||
graded: graded({
|
||||
recent: [{ date: "d", min: 62, max: 62, mean: 62, n: 1 }],
|
||||
monthly: [
|
||||
{ month: "a", min: 50, max: 50, mean: 50, n: 1 },
|
||||
{ month: "b", min: 70, max: 70, mean: 70, n: 1 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
// baseline 60, spread ~14.1, delta 2 → 2 <= 1*14.1 → inside.
|
||||
expect(noisy!.outsideNormalSwing).toBe(false);
|
||||
expect(NORMAL_SWING_K).toBe(1);
|
||||
});
|
||||
|
||||
it("places the current value against a normal range", () => {
|
||||
const signal = buildMetricSignal({
|
||||
metric: "x",
|
||||
direction: "lower-better",
|
||||
graded: graded({
|
||||
recent: [{ date: "d", min: 110, max: 110, mean: 110, n: 1 }],
|
||||
monthly: [{ month: "a", min: 100, max: 100, mean: 100, n: 1 }],
|
||||
}),
|
||||
normalRange: { low: 60, high: 100 },
|
||||
normalRangeSource: "age-sex-adjusted",
|
||||
});
|
||||
expect(signal!.normalRange).toEqual({ low: 60, high: 100, source: "age-sex-adjusted" });
|
||||
expect(signal!.placement).toBe("above band");
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeInBand", () => {
|
||||
it("classifies below / in / above", () => {
|
||||
expect(placeInBand(50, { low: 60, high: 100 })).toBe("below band");
|
||||
expect(placeInBand(80, { low: 60, high: 100 })).toBe("in band");
|
||||
expect(placeInBand(120, { low: 60, high: 100 })).toBe("above band");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
computeTagInfluence,
|
||||
computeBetterDays,
|
||||
computeTagMetricCrosstab,
|
||||
computeFactorMetricCrosstab,
|
||||
buildFactorDailySeries,
|
||||
CROSSTAB_MIN_PRESENT_DAYS,
|
||||
CROSSTAB_MIN_ABSENT_DAYS,
|
||||
CROSSTAB_MAX_ROWS,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
type CrossMetricMeasurement,
|
||||
type MoodAggregateEntry,
|
||||
type MoodMetricCorrelation,
|
||||
type RatedFactorScore,
|
||||
type StructuredTagRef,
|
||||
type TagInfluence,
|
||||
} from "../mood-aggregates";
|
||||
@@ -1094,3 +1097,158 @@ describe("computeTagMetricCrosstab", () => {
|
||||
expect(row!.withoutAvg).toBeCloseTo(6, 1); // 3 × 120 = 360 min = 6 h
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFactorDailySeries", () => {
|
||||
function factorEntry(
|
||||
offset: number,
|
||||
score: number,
|
||||
factors: RatedFactorScore[],
|
||||
): MoodAggregateEntry {
|
||||
return { ...entry(offset, score, null), ratedFactors: factors };
|
||||
}
|
||||
|
||||
const WORK = (rating: number, inverse = false): RatedFactorScore => ({
|
||||
key: "work",
|
||||
categoryKey: "life",
|
||||
labelKey: "mood.tag.work",
|
||||
icon: "Briefcase",
|
||||
rating,
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse,
|
||||
});
|
||||
|
||||
it("means a factor's ratings per day", () => {
|
||||
const entries = [
|
||||
// two entries same day → mean of (4, 2) = 3.
|
||||
{ ...factorEntry(0, 4, [WORK(4)]) },
|
||||
{ ...factorEntry(0, 2, [WORK(2)]) },
|
||||
factorEntry(1, 3, [WORK(5)]),
|
||||
];
|
||||
const series = buildFactorDailySeries(entries, NOW, 365);
|
||||
const work = series.get("work");
|
||||
expect(work).toBeDefined();
|
||||
expect(work!.byDay.get(dayKey(0))).toBeCloseTo(3, 5);
|
||||
expect(work!.byDay.get(dayKey(1))).toBeCloseTo(5, 5);
|
||||
expect(work!.ref.inverse).toBe(false);
|
||||
});
|
||||
|
||||
it("flips an inverse factor across its scale midpoint so up = better", () => {
|
||||
// inverse stress rated 5 (worst) maps to (1+5)-5 = 1 (worst on the
|
||||
// flipped axis); rated 1 (best) maps to 5.
|
||||
const entries = [
|
||||
factorEntry(0, 2, [WORK(5, true)]),
|
||||
factorEntry(1, 4, [WORK(1, true)]),
|
||||
];
|
||||
const series = buildFactorDailySeries(entries, NOW, 365);
|
||||
const work = series.get("work")!;
|
||||
expect(work.byDay.get(dayKey(0))).toBeCloseTo(1, 5);
|
||||
expect(work.byDay.get(dayKey(1))).toBeCloseTo(5, 5);
|
||||
expect(work.ref.inverse).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeFactorMetricCrosstab", () => {
|
||||
function factorEntry(
|
||||
offset: number,
|
||||
score: number,
|
||||
factors: RatedFactorScore[],
|
||||
): MoodAggregateEntry {
|
||||
return { ...entry(offset, score, null), ratedFactors: factors };
|
||||
}
|
||||
|
||||
function meas(offset: number, type: string, value: number): CrossMetricMeasurement {
|
||||
return { type, value, measuredAt: new Date(NOW.getTime() - offset * dayMs) };
|
||||
}
|
||||
|
||||
const work = (rating: number, inverse = false): RatedFactorScore => ({
|
||||
key: "work",
|
||||
categoryKey: "life",
|
||||
labelKey: "mood.tag.work",
|
||||
icon: "Briefcase",
|
||||
rating,
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse,
|
||||
});
|
||||
|
||||
it("surfaces a low-factor-day vital deviation via the median split", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
// 12 LOW-work days (rating 2, below the median of 3.5) with short sleep
|
||||
// (~6 h = 360 min); 12 HIGH-work days (rating 5) with long sleep (~8 h).
|
||||
for (let i = 0; i < 12; i++) {
|
||||
entries.push(factorEntry(i, 3, [work(2)]));
|
||||
measurements.push(meas(i, "SLEEP_DURATION", 360 + (i % 2 === 0 ? 6 : -6)));
|
||||
}
|
||||
for (let i = 12; i < 24; i++) {
|
||||
entries.push(factorEntry(i, 4, [work(5)]));
|
||||
measurements.push(meas(i, "SLEEP_DURATION", 480 + (i % 2 === 0 ? 6 : -6)));
|
||||
}
|
||||
const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const row = rows.find((r) => r.factor === "work" && r.metricKey === "sleepDuration");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.display).toBe("hours");
|
||||
expect(row!.lowDays).toBe(12);
|
||||
expect(row!.highDays).toBe(12);
|
||||
expect(row!.lowAvg).toBeCloseTo(6, 1);
|
||||
expect(row!.highAvg).toBeCloseTo(8, 1);
|
||||
// delta = lowAvg − highAvg → negative: sleep runs lower on low-work days.
|
||||
expect(row!.delta).toBeLessThan(0);
|
||||
expect(row!.delta).toBeCloseTo(-2, 1);
|
||||
expect(row!.confidence).toBe("high");
|
||||
expect(row!.qValue).toBeLessThanOrEqual(0.1);
|
||||
expect(row!.inverse).toBe(false);
|
||||
});
|
||||
|
||||
it("carries the inverse flag through and still splits worse-vs-better on the flipped axis", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
// stress is inverse: rating 5 = worst day. On the flipped axis it becomes
|
||||
// 1 (low/worse). Worse-stress days carry higher next-day RHR (D → D+1).
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
entries.push(factorEntry(i, 3, [work(5, true)])); // worse → flipped low
|
||||
measurements.push(meas(i - 1, "RESTING_HEART_RATE", 64 + (i % 2 === 0 ? 1 : -1)));
|
||||
}
|
||||
for (let i = 13; i <= 24; i++) {
|
||||
entries.push(factorEntry(i, 4, [work(1, true)])); // better → flipped high
|
||||
measurements.push(meas(i - 1, "RESTING_HEART_RATE", 56 + (i % 2 === 0 ? 1 : -1)));
|
||||
}
|
||||
const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW });
|
||||
const row = rows.find((r) => r.metricKey === "restingHeartRate");
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.mode).toBe("nextDay");
|
||||
expect(row!.inverse).toBe(true);
|
||||
// worse-stress (low flipped) days → higher RHR; delta = low − high > 0.
|
||||
expect(row!.lowAvg).toBeCloseTo(64, 0);
|
||||
expect(row!.highAvg).toBeCloseTo(56, 0);
|
||||
expect(row!.delta).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("drops a factor with too few rated days to split defensibly", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
// only 6 rated days < 5 + 5 floor for a clean median split.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
entries.push(factorEntry(i, 3, [work(i < 3 ? 2 : 5)]));
|
||||
measurements.push(meas(i, "SLEEP_DURATION", i < 3 ? 360 : 480));
|
||||
}
|
||||
expect(CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS).toBeGreaterThan(6);
|
||||
const rows = computeFactorMetricCrosstab({ entries, measurements, now: NOW });
|
||||
expect(rows.find((r) => r.factor === "work")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns nothing when there are no rated factors at all", () => {
|
||||
const entries: MoodAggregateEntry[] = [];
|
||||
const measurements: CrossMetricMeasurement[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
entries.push(entry(i, 3, null));
|
||||
measurements.push(meas(i, "SLEEP_DURATION", 420));
|
||||
}
|
||||
expect(computeFactorMetricCrosstab({ entries, measurements, now: NOW })).toEqual([]);
|
||||
});
|
||||
|
||||
it("caps the surface at CROSSTAB_MAX_ROWS", () => {
|
||||
expect(CROSSTAB_MAX_ROWS).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
auditLog: { findFirst: vi.fn(), create: vi.fn() },
|
||||
moodEntry: { findMany: vi.fn() },
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -43,6 +44,11 @@ function stubCompletion(
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// The snapshot threads the user's source priority into the factor crosstab;
|
||||
// default to "no custom priority" so every test resolves it without stubbing.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("generateMoodStatusForUser — graded payload", () => {
|
||||
|
||||
@@ -161,11 +161,24 @@ function interpret(behaviour: string, outcome: string, r: number): string {
|
||||
return `${b} and next-day ${o} move together only weakly in this window.`;
|
||||
}
|
||||
|
||||
/** Lower-case, space-separated label from a channel key. */
|
||||
/**
|
||||
* Lower-case, space-separated label from a channel key.
|
||||
*
|
||||
* v1.14.0 — a `FACTOR:<key>` channel (a RATED mood factor folded into the
|
||||
* matrix) strips its namespace prefix so the phrasing reads "rated <factor>"
|
||||
* rather than leaking the raw key. The prefix exists only to keep a factor
|
||||
* key from colliding with a `MeasurementType` in the channel set.
|
||||
*/
|
||||
function humanise(key: string): string {
|
||||
if (key.startsWith("FACTOR:")) {
|
||||
return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`;
|
||||
}
|
||||
return key.replace(/_/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */
|
||||
export const FACTOR_CHANNEL_PREFIX = "FACTOR:";
|
||||
|
||||
/**
|
||||
* Run the FDR-controlled discovery over the behaviour × outcome matrix.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveDeterministicAssessment,
|
||||
buildScoreSignal,
|
||||
isAssessableDerivedScore,
|
||||
ASSESSABLE_DERIVED_SCORES,
|
||||
} from "@/lib/insights/derived/derived-assessment";
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
import type { ReadinessValue } from "@/lib/insights/derived/readiness";
|
||||
import type { SleepScoreValue } from "@/lib/insights/derived/sleep-score";
|
||||
import type { WellnessScoreValue } from "@/lib/insights/derived/wellness-scores";
|
||||
|
||||
const NOW = new Date("2026-06-05T12:00:00Z");
|
||||
|
||||
function okDerived<T>(value: T): Derived<T> {
|
||||
return {
|
||||
status: "ok",
|
||||
value,
|
||||
coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] },
|
||||
confidence: { score: 90, band: "high" },
|
||||
provenance: { inputs: ["X"], source: "DAY", windowDays: 30, computedAt: NOW.toISOString() },
|
||||
};
|
||||
}
|
||||
|
||||
const insufficient: Derived<unknown> = {
|
||||
status: "insufficient",
|
||||
coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: ["X"] },
|
||||
provenance: { inputs: ["X"], source: "none", windowDays: 0, computedAt: NOW.toISOString() },
|
||||
reason: "no_score_in_window",
|
||||
};
|
||||
|
||||
const readiness: ReadinessValue = {
|
||||
score: 64,
|
||||
band: "yellow",
|
||||
components: [
|
||||
{ key: "rhr", value: 40, weight: 0.3 },
|
||||
{ key: "hrv", value: 80, weight: 0.3 },
|
||||
{ key: "sleep", value: 70, weight: 0.25 },
|
||||
{ key: "respiratory", value: null, weight: 0 },
|
||||
{ key: "mood", value: 60, weight: 0.15 },
|
||||
],
|
||||
};
|
||||
|
||||
const sleepScore: SleepScoreValue = {
|
||||
score: 82,
|
||||
band: "green",
|
||||
night: "2026-06-04",
|
||||
asleepMinutes: 440,
|
||||
inBedMinutes: 470,
|
||||
subScores: [
|
||||
{ key: "sufficiency", value: 95, weight: 0.3 },
|
||||
{ key: "efficiency", value: 90, weight: 0.25 },
|
||||
{ key: "consistency", value: 70, weight: 0.2 },
|
||||
{ key: "timing", value: 80, weight: 0.1 },
|
||||
{ key: "composition", value: 88, weight: 0.15 },
|
||||
],
|
||||
windowNights: 20,
|
||||
};
|
||||
|
||||
const recovery: WellnessScoreValue = {
|
||||
score: 55,
|
||||
band: "yellow",
|
||||
trendDelta: -8,
|
||||
daysInWindow: 12,
|
||||
asOf: NOW.toISOString(),
|
||||
series: [60, 58, 55],
|
||||
};
|
||||
|
||||
describe("assessable derived scores", () => {
|
||||
it("is exactly the five iOS ids", () => {
|
||||
expect([...ASSESSABLE_DERIVED_SCORES].sort()).toEqual([
|
||||
"READINESS",
|
||||
"RECOVERY_SCORE",
|
||||
"SLEEP_SCORE",
|
||||
"STRAIN_SCORE",
|
||||
"STRESS_SCORE",
|
||||
]);
|
||||
expect(isAssessableDerivedScore("READINESS")).toBe(true);
|
||||
expect(isAssessableDerivedScore("BMI")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDeterministicAssessment", () => {
|
||||
it("returns null for a non-assessable metric", () => {
|
||||
expect(
|
||||
resolveDeterministicAssessment("BMI", okDerived({ score: 50, band: "green" }), "en", NOW),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when status !== ok (the locked contract)", () => {
|
||||
expect(
|
||||
resolveDeterministicAssessment("READINESS", insufficient, "en", NOW),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("always fills a non-empty deterministic text for an ok score", () => {
|
||||
const a = resolveDeterministicAssessment("READINESS", okDerived(readiness), "en", NOW);
|
||||
expect(a).not.toBeNull();
|
||||
expect(a!.source).toBe("deterministic");
|
||||
expect(a!.text.length).toBeGreaterThan(0);
|
||||
expect(a!.updatedAt).toBe(NOW.toISOString());
|
||||
});
|
||||
|
||||
it("names the score and the weakest contributors for a yellow readiness (en)", () => {
|
||||
const a = resolveDeterministicAssessment("READINESS", okDerived(readiness), "en", NOW);
|
||||
expect(a!.text).toContain("64 out of 100");
|
||||
// rhr (40) is the lowest present contributor → mentioned.
|
||||
expect(a!.text.toLowerCase()).toContain("resting heart rate");
|
||||
// never references the null (respiratory) contributor.
|
||||
expect(a!.text.toLowerCase()).not.toContain("respiratory");
|
||||
});
|
||||
|
||||
it("affirms the strongest contributor for a green sleep score (de)", () => {
|
||||
const a = resolveDeterministicAssessment("SLEEP_SCORE", okDerived(sleepScore), "de", NOW);
|
||||
expect(a!.text).toContain("82 von 100");
|
||||
expect(a!.text).toContain("im guten Bereich");
|
||||
// sufficiency (95) is the strongest → affirmed for a green band.
|
||||
expect(a!.text).toContain("Schlafmenge");
|
||||
});
|
||||
|
||||
it("uses the trend for a contributor-less recovery score (en)", () => {
|
||||
const a = resolveDeterministicAssessment("RECOVERY_SCORE", okDerived(recovery), "en", NOW);
|
||||
expect(a!.text).toContain("55 out of 100");
|
||||
// trendDelta -8 → "8 points lower".
|
||||
expect(a!.text).toContain("−8 points lower");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScoreSignal", () => {
|
||||
it("carries contributors for READINESS", () => {
|
||||
const signal = buildScoreSignal("READINESS", readiness, "en");
|
||||
expect(signal!.current).toBe(64);
|
||||
expect(signal!.contributors).toHaveLength(5);
|
||||
expect(signal!.metric).toBe("your readiness");
|
||||
});
|
||||
|
||||
it("derives a signed delta from trendDelta for RECOVERY", () => {
|
||||
const signal = buildScoreSignal("RECOVERY_SCORE", recovery, "en");
|
||||
expect(signal!.current).toBe(55);
|
||||
expect(signal!.baseline).toBe(63); // 55 - (-8)
|
||||
expect(signal!.delta).toBe(-8);
|
||||
expect(signal!.direction).toBe("higher-better");
|
||||
});
|
||||
|
||||
it("frames stress/strain as lower-better", () => {
|
||||
const strain = buildScoreSignal("STRAIN_SCORE", recovery, "en");
|
||||
expect(strain!.direction).toBe("lower-better");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* v1.13.2 — AI-warm layer for the per-derived-SCORE assessment.
|
||||
*
|
||||
* The deterministic text (in `derived-assessment.ts`) always fills the field
|
||||
* synchronously, so a provider-less account and the demo are never empty.
|
||||
* THIS module adds the warmer AI prose on top, reusing the exact
|
||||
* stale-while-revalidate plumbing the per-metric status cards use:
|
||||
*
|
||||
* - `resolveDerivedAssessment` (the route entry) reads a fresh cached AI
|
||||
* assessment for today; on a hit it serves it (source = provider type).
|
||||
* On a miss it serves the deterministic text and, when a provider is
|
||||
* usable, fire-and-forget enqueues a warm generation out of band — the
|
||||
* GET never blocks on an LLM round-trip.
|
||||
* - `generateDerivedScoreAssessment` (the worker entry) builds the
|
||||
* signal-led prompt, runs the user's provider chain through
|
||||
* `runStatusCompletion`, and persists the text under the same
|
||||
* `auditLog` cache row the status cards use.
|
||||
*
|
||||
* The cache action is `insights.derived-score:<ID>-status.<locale>` so the
|
||||
* read, the worker, and the enqueue helper all speak one vocabulary.
|
||||
*
|
||||
* Server-only — reads the DB + the provider chain.
|
||||
*/
|
||||
import {
|
||||
getBaseSystemPrompt,
|
||||
PROMPT_VERSION,
|
||||
} from "@/lib/ai/prompts/base-system";
|
||||
import {
|
||||
normalizeLocale,
|
||||
normalizeSummaryText,
|
||||
parseSummaryFromContent,
|
||||
persistStatusInsight,
|
||||
type SupportedLocale,
|
||||
} from "@/lib/insights/status-shared";
|
||||
import { runStatusCompletion } from "@/lib/insights/status-provider";
|
||||
import {
|
||||
readFreshStatusText,
|
||||
resolveReadOnlyStatusMiss,
|
||||
statusCacheAction,
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
buildScoreSignal,
|
||||
isAssessableDerivedScore,
|
||||
resolveDeterministicAssessment,
|
||||
type DerivedAssessment,
|
||||
} from "./derived-assessment";
|
||||
import type { Derived } from "./types";
|
||||
import type { DerivedMetricId } from "./registry";
|
||||
import type { MetricSignal } from "@/lib/insights/metric-signal";
|
||||
|
||||
/** The `metric` scope id this generator warms — `derived-score:<ID>`. */
|
||||
export function derivedScoreScope(
|
||||
metric: DerivedMetricId,
|
||||
): `derived-score:${DerivedMetricId}` {
|
||||
return `derived-score:${metric}`;
|
||||
}
|
||||
|
||||
/** Read a `derived-score:<ID>` scope back to its `DerivedMetricId`. */
|
||||
export function parseDerivedScoreScope(scope: string): DerivedMetricId | null {
|
||||
if (!scope.startsWith("derived-score:")) return null;
|
||||
return scope.slice("derived-score:".length) as DerivedMetricId;
|
||||
}
|
||||
|
||||
// ── prompt ────────────────────────────────────────────────────────────────
|
||||
|
||||
function scoreSystemPrompt(locale: SupportedLocale): string {
|
||||
const base = getBaseSystemPrompt(locale);
|
||||
const section =
|
||||
locale === "en"
|
||||
? `ARCHETYPE — COMPOSITE WELLNESS SCORE:
|
||||
- This is a 0–100 composite score, not a raw measurement. Explain WHY it sits where it does by naming the 1–2 contributors in \`signal.contributors[]\` that moved it most — a lower contributor value drags the score down. Each contributor is itself 0–100.
|
||||
- Lead with the score and its standing, then attribute it to the contributor(s). Do not invent a contributor that is not listed. When there are no contributors, use the trend (signal.delta) instead.
|
||||
- This is a daily wellness proxy, not a clinical or training-recovery verdict. Stay descriptive, never diagnostic.`
|
||||
: `ARCHETYP — ZUSAMMENGESETZTER WELLNESS-SCORE:
|
||||
- Das ist ein 0–100-Kompositscore, keine Rohmessung. Erkläre, WARUM er dort liegt, indem du die 1–2 Beiträge aus \`signal.contributors[]\` nennst, die ihn am stärksten bewegt haben — ein niedriger Beitragswert zieht den Score nach unten. Jeder Beitrag ist selbst 0–100.
|
||||
- Führe mit dem Score und seiner Einordnung, dann ordne ihn den Beiträgen zu. Erfinde keinen Beitrag, der nicht gelistet ist. Gibt es keine Beiträge, nutze stattdessen den Trend (signal.delta).
|
||||
- Das ist ein täglicher Wellness-Indikator, kein klinisches Urteil und keine Trainings-Recovery-Bewertung. Bleibe beschreibend, nie diagnostisch.`;
|
||||
return `${base}\n\n${section}`;
|
||||
}
|
||||
|
||||
function scoreUserPrompt(
|
||||
signal: MetricSignal,
|
||||
band: string,
|
||||
todayKey: string,
|
||||
locale: SupportedLocale,
|
||||
): string {
|
||||
const snapshot = JSON.stringify(
|
||||
{ promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Write one short assessment of why ${signal.metric} is the score it is today: name the score and its standing, then attribute it to the 1–2 contributors that moved it most. Keep it to 2–3 sentences, descriptive only.
|
||||
|
||||
${snapshot}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Schreibe eine kurze Einschätzung, warum ${signal.metric} heute diesen Score hat: nenne den Score und seine Einordnung und ordne ihn dann den 1–2 stärksten Beiträgen zu. Halte dich an 2–3 Sätze, rein beschreibend.
|
||||
|
||||
${snapshot}`;
|
||||
}
|
||||
|
||||
// ── read (route entry) ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the per-score assessment for the route. Always returns the
|
||||
* deterministic text (never empty for an ok score); serves a fresh cached AI
|
||||
* assessment instead when one exists for today, and warms one out of band on
|
||||
* a miss. Returns null when the metric is not assessable or status !== "ok".
|
||||
*/
|
||||
export async function resolveDerivedAssessment(args: {
|
||||
metric: DerivedMetricId;
|
||||
userId: string;
|
||||
derived: Derived<unknown>;
|
||||
locale: string | null | undefined;
|
||||
now?: Date;
|
||||
}): Promise<DerivedAssessment | null> {
|
||||
const now = args.now ?? new Date();
|
||||
const locale = normalizeLocale(args.locale);
|
||||
|
||||
const deterministic = resolveDeterministicAssessment(
|
||||
args.metric,
|
||||
args.derived,
|
||||
locale,
|
||||
now,
|
||||
);
|
||||
// Not assessable, or status !== ok → no field (the locked contract).
|
||||
if (!deterministic) return null;
|
||||
|
||||
const scope = derivedScoreScope(args.metric);
|
||||
const cacheAction = statusCacheAction(scope, locale);
|
||||
const todayKey = toBerlinDayKey(now);
|
||||
|
||||
// Fresh AI text for today → serve it (warmer prose overrides the template).
|
||||
const fresh = await readFreshStatusText({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
force: false,
|
||||
});
|
||||
if (fresh) {
|
||||
return { text: fresh.text, source: "ai", updatedAt: fresh.updatedAt };
|
||||
}
|
||||
|
||||
// No fresh AI text. Warm one out of band (best-effort, never blocks): the
|
||||
// read-only resolver enqueues a generation when a provider is usable and
|
||||
// de-dupes per (user, scope, locale). Serve the deterministic text now; the
|
||||
// next read upgrades to the warmed prose.
|
||||
await resolveReadOnlyStatusMiss({
|
||||
userId: args.userId,
|
||||
metric: scope,
|
||||
locale,
|
||||
});
|
||||
|
||||
return deterministic;
|
||||
}
|
||||
|
||||
// ── generate (worker entry) ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Worker entry: build the signal-led prompt for one derived score, run the
|
||||
* provider chain, and persist the AI assessment under the shared status-cache
|
||||
* row. Recomputes the derived value via the passed `derived` so the worker
|
||||
* runs the same numbers the route did. A no-provider / timeout / error
|
||||
* outcome simply writes nothing — the route keeps serving the deterministic
|
||||
* text, so the field is never empty.
|
||||
*/
|
||||
export async function generateDerivedScoreAssessment(args: {
|
||||
metric: DerivedMetricId;
|
||||
userId: string;
|
||||
derived: Derived<unknown>;
|
||||
locale: string | null | undefined;
|
||||
now?: Date;
|
||||
}): Promise<void> {
|
||||
const now = args.now ?? new Date();
|
||||
const locale = normalizeLocale(args.locale);
|
||||
if (!isAssessableDerivedScore(args.metric)) return;
|
||||
if (args.derived.status !== "ok") return;
|
||||
|
||||
const signal = buildScoreSignal(args.metric, args.derived.value, locale);
|
||||
if (!signal) return;
|
||||
const band =
|
||||
(args.derived.value as { band?: string }).band ?? "yellow";
|
||||
|
||||
const scope = derivedScoreScope(args.metric);
|
||||
const cacheAction = statusCacheAction(scope, locale);
|
||||
const todayKey = toBerlinDayKey(now);
|
||||
|
||||
const outcome = await runStatusCompletion({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
consentSurface: "insights",
|
||||
systemPrompt: scoreSystemPrompt(locale),
|
||||
userPrompt: scoreUserPrompt(signal, band, todayKey, locale),
|
||||
temperature: 0.45,
|
||||
maxTokens: 600,
|
||||
});
|
||||
|
||||
if (outcome.kind !== "ok") {
|
||||
annotate({
|
||||
action: { name: "insights.derived-assessment.skip" },
|
||||
meta: { metric: args.metric, reason: outcome.kind },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const text = normalizeSummaryText(parseSummaryFromContent(outcome.content));
|
||||
if (!text) return;
|
||||
|
||||
await persistStatusInsight({
|
||||
userId: args.userId,
|
||||
cacheAction,
|
||||
todayKey,
|
||||
locale,
|
||||
text,
|
||||
providerType: outcome.providerType,
|
||||
model: outcome.model,
|
||||
tokensUsed: outcome.tokensUsed,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "insights.derived-assessment.warmed" },
|
||||
meta: { metric: args.metric, provider: outcome.providerType },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* v1.13.2 — per-derived-SCORE assessment text ("why is this score what it is").
|
||||
*
|
||||
* iOS asks for a short "Einschätzung" on each derived-score detail sheet
|
||||
* (READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE)
|
||||
* explaining WHY the score sits where it does, referencing the score's
|
||||
* own contributors. The contract (LOCKED with iOS) is an additive,
|
||||
* non-breaking field on `GET /api/insights/derived`:
|
||||
*
|
||||
* assessment: { text, source, updatedAt } | null
|
||||
*
|
||||
* null whenever the metric `status !== "ok"`; otherwise always a non-empty
|
||||
* `text` (so a provider-less account and the demo always get one). The
|
||||
* shape follows the v1.12.12 deterministic-narrative model: a deterministic
|
||||
* fallback composed from the SAME structured signal the model would read —
|
||||
* never empty — PLUS warmer AI prose when a provider is configured + warm.
|
||||
*
|
||||
* `source` is `"deterministic"` for the template text, or the provider type
|
||||
* (`"openai"`, `"anthropic"`, …) when a cached AI assessment is served.
|
||||
*
|
||||
* Pure prose lives here; the AI-warm read + enqueue is a thin layer over the
|
||||
* existing status cache (the same `auditLog` row pattern the per-metric cards
|
||||
* use), keyed `insights.derived-score:<ID>-status.<locale>`. de/en only — the
|
||||
* narratives, like the period narrative, are bilingual by design.
|
||||
*/
|
||||
import { buildMetricSignal, type MetricSignal } from "@/lib/insights/metric-signal";
|
||||
import type { GradedSeries } from "@/lib/insights/graded-series";
|
||||
import type { ReadinessValue, ReadinessComponentKey } from "./readiness";
|
||||
import type { SleepScoreValue, SleepSubScoreKey } from "./sleep-score";
|
||||
import type { WellnessScoreValue } from "./wellness-scores";
|
||||
import type { Derived } from "./types";
|
||||
import type { DerivedMetricId } from "./registry";
|
||||
|
||||
type Locale = "de" | "en";
|
||||
|
||||
/** The additive per-score assessment field on the derived response. */
|
||||
export interface DerivedAssessment {
|
||||
/** Non-empty short explanation of why the score is what it is. */
|
||||
text: string;
|
||||
/** "deterministic" for the template, or the provider type for AI prose. */
|
||||
source: string;
|
||||
/** ISO timestamp the text was produced / last warmed. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived-score ids that carry a per-score assessment. These are the
|
||||
* five iOS requests; the assessment is keyed to the SAME id the caller
|
||||
* passed (so READINESS → assessment on READINESS, etc.). Any other derived
|
||||
* id returns no assessment (the field stays null / absent).
|
||||
*/
|
||||
export const ASSESSABLE_DERIVED_SCORES: ReadonlySet<DerivedMetricId> = new Set([
|
||||
"READINESS",
|
||||
"SLEEP_SCORE",
|
||||
"RECOVERY_SCORE",
|
||||
"STRAIN_SCORE",
|
||||
"STRESS_SCORE",
|
||||
]);
|
||||
|
||||
/** `true` when the id is one iOS requests a per-score assessment for. */
|
||||
export function isAssessableDerivedScore(metric: DerivedMetricId): boolean {
|
||||
return ASSESSABLE_DERIVED_SCORES.has(metric);
|
||||
}
|
||||
|
||||
// ── bilingual labels ────────────────────────────────────────────────────
|
||||
|
||||
/** Display label for each assessable score. */
|
||||
const SCORE_LABELS: Record<string, { de: string; en: string }> = {
|
||||
READINESS: { de: "deine Tagesform", en: "your readiness" },
|
||||
SLEEP_SCORE: { de: "dein Schlafscore", en: "your sleep score" },
|
||||
RECOVERY_SCORE: { de: "deine Erholung", en: "your recovery" },
|
||||
STRAIN_SCORE: { de: "deine Belastung", en: "your strain" },
|
||||
STRESS_SCORE: { de: "dein Stress", en: "your stress" },
|
||||
};
|
||||
|
||||
/** Readiness contributor labels (its `components[].key`). */
|
||||
const READINESS_COMPONENT_LABELS: Record<
|
||||
ReadinessComponentKey,
|
||||
{ de: string; en: string }
|
||||
> = {
|
||||
rhr: { de: "dein Ruhepuls", en: "your resting heart rate" },
|
||||
hrv: { de: "deine Herzfrequenzvariabilität", en: "your heart-rate variability" },
|
||||
sleep: { de: "dein Schlaf", en: "your sleep" },
|
||||
respiratory: { de: "deine Atemfrequenz", en: "your respiratory rate" },
|
||||
mood: { de: "deine Stimmung", en: "your mood" },
|
||||
};
|
||||
|
||||
/** Sleep sub-score labels (its `subScores[].key`). */
|
||||
const SLEEP_SUBSCORE_LABELS: Record<
|
||||
SleepSubScoreKey,
|
||||
{ de: string; en: string }
|
||||
> = {
|
||||
sufficiency: { de: "die Schlafmenge", en: "sleep duration" },
|
||||
efficiency: { de: "die Schlafeffizienz", en: "sleep efficiency" },
|
||||
consistency: { de: "die Regelmäßigkeit", en: "sleep consistency" },
|
||||
timing: { de: "der Schlafrhythmus", en: "sleep timing" },
|
||||
composition: { de: "die Schlafphasen", en: "sleep composition" },
|
||||
};
|
||||
|
||||
function scoreLabel(metric: string, locale: Locale): string {
|
||||
return SCORE_LABELS[metric]?.[locale] ?? (locale === "de" ? "dein Wert" : "your score");
|
||||
}
|
||||
|
||||
/** Band → bilingual standing phrase. */
|
||||
function bandPhrase(
|
||||
band: "green" | "yellow" | "red",
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "de") {
|
||||
return band === "green"
|
||||
? "im guten Bereich"
|
||||
: band === "yellow"
|
||||
? "im mittleren Bereich"
|
||||
: "im niedrigen Bereich";
|
||||
}
|
||||
return band === "green"
|
||||
? "in a good place"
|
||||
: band === "yellow"
|
||||
? "in the middle"
|
||||
: "on the low side";
|
||||
}
|
||||
|
||||
// ── signal builders per score ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a one-day graded series from a single score value so a derived
|
||||
* score (which carries no raw bucket series) can flow through the SAME
|
||||
* `buildMetricSignal` contract every other surface uses. The recent slice
|
||||
* holds today's score; an optional trend baseline (score − trendDelta)
|
||||
* becomes a one-point monthly slice so the signal carries a signed delta.
|
||||
*/
|
||||
function singlePointGraded(
|
||||
score: number,
|
||||
baseline: number | null,
|
||||
): GradedSeries {
|
||||
return {
|
||||
recent: [{ date: "today", min: score, max: score, mean: score, n: 1 }],
|
||||
weekly: [],
|
||||
monthly:
|
||||
baseline !== null
|
||||
? [{ month: "baseline", min: baseline, max: baseline, mean: baseline, n: 1 }]
|
||||
: [],
|
||||
yearly: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a derived OK value for one assessable score to its `MetricSignal`. */
|
||||
export function buildScoreSignal(
|
||||
metric: DerivedMetricId,
|
||||
value: unknown,
|
||||
locale: Locale,
|
||||
): MetricSignal | null {
|
||||
const label = scoreLabel(metric, locale);
|
||||
if (metric === "READINESS") {
|
||||
const v = value as ReadinessValue;
|
||||
const signal = buildMetricSignal({
|
||||
metric: label,
|
||||
direction: "higher-better",
|
||||
graded: singlePointGraded(v.score, null),
|
||||
});
|
||||
if (!signal) return null;
|
||||
signal.contributors = v.components.map((c) => ({
|
||||
key: c.key,
|
||||
value: c.value,
|
||||
weight: c.weight,
|
||||
}));
|
||||
return signal;
|
||||
}
|
||||
if (metric === "SLEEP_SCORE") {
|
||||
const v = value as SleepScoreValue;
|
||||
const signal = buildMetricSignal({
|
||||
metric: label,
|
||||
direction: "higher-better",
|
||||
graded: singlePointGraded(v.score, null),
|
||||
});
|
||||
if (!signal) return null;
|
||||
signal.contributors = v.subScores.map((s) => ({
|
||||
key: s.key,
|
||||
value: s.value,
|
||||
weight: s.weight,
|
||||
}));
|
||||
return signal;
|
||||
}
|
||||
// RECOVERY / STRESS / STRAIN — a persisted composite with a trend delta but
|
||||
// no component breakdown. The trend baseline (score − trendDelta) gives a
|
||||
// signed delta; direction follows the metric (recovery higher-better,
|
||||
// stress/strain lower-better).
|
||||
const v = value as WellnessScoreValue;
|
||||
const baseline =
|
||||
v.trendDelta !== null ? v.score - v.trendDelta : null;
|
||||
const direction = metric === "RECOVERY_SCORE" ? "higher-better" : "lower-better";
|
||||
return buildMetricSignal({
|
||||
metric: label,
|
||||
direction,
|
||||
graded: singlePointGraded(v.score, baseline),
|
||||
});
|
||||
}
|
||||
|
||||
// ── deterministic prose ────────────────────────────────────────────────────
|
||||
|
||||
/** Format a signed integer with the typographic minus glyph. */
|
||||
function fmtSignedInt(n: number): string {
|
||||
const sign = n > 0 ? "+" : n < 0 ? "−" : "±";
|
||||
return `${sign}${Math.abs(Math.round(n))}`;
|
||||
}
|
||||
|
||||
/** Rank present contributors by how far they sit from a full 100 (impact). */
|
||||
function rankContributors(
|
||||
signal: MetricSignal,
|
||||
): { key: string; value: number }[] {
|
||||
return (signal.contributors ?? [])
|
||||
.filter(
|
||||
(c): c is { key: string; value: number; weight: number } =>
|
||||
c.value !== null,
|
||||
)
|
||||
.map((c) => ({ key: c.key, value: c.value, weight: c.weight }))
|
||||
.sort((a, b) => a.value - b.value);
|
||||
}
|
||||
|
||||
function contributorLabel(
|
||||
metric: DerivedMetricId,
|
||||
key: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (metric === "READINESS") {
|
||||
return (
|
||||
READINESS_COMPONENT_LABELS[key as ReadinessComponentKey]?.[locale] ?? key
|
||||
);
|
||||
}
|
||||
if (metric === "SLEEP_SCORE") {
|
||||
return SLEEP_SUBSCORE_LABELS[key as SleepSubScoreKey]?.[locale] ?? key;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the deterministic per-score assessment from the score's signal +
|
||||
* its contributors. Always returns a non-empty, factual, non-causal text.
|
||||
* Leads with the score + standing, then names the 1–2 lowest contributors
|
||||
* (the ones holding the score back) or the trend for the contributor-less
|
||||
* scores.
|
||||
*/
|
||||
export function buildDeterministicScoreAssessment(
|
||||
metric: DerivedMetricId,
|
||||
signal: MetricSignal,
|
||||
band: "green" | "yellow" | "red",
|
||||
locale: Locale,
|
||||
): string {
|
||||
const label = scoreLabel(metric, locale);
|
||||
const score = Math.round(signal.current);
|
||||
const standing = bandPhrase(band, locale);
|
||||
|
||||
const sentences: string[] = [];
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? `${capitalise(label)} liegt heute bei ${score} von 100 — ${standing}.`
|
||||
: `${capitalise(label)} is ${score} out of 100 today — ${standing}.`,
|
||||
);
|
||||
|
||||
const ranked = rankContributors(signal);
|
||||
if (ranked.length > 0) {
|
||||
// Name the 1–2 weakest contributors (driving the score down) when the
|
||||
// score is not maxed; affirm the strongest when it is high.
|
||||
if (band === "green") {
|
||||
const top = ranked[ranked.length - 1];
|
||||
const topLabel = contributorLabel(metric, top.key, locale);
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? `Getragen vor allem von ${topLabel}.`
|
||||
: `Carried mostly by ${topLabel}.`,
|
||||
);
|
||||
} else {
|
||||
const weak = ranked.slice(0, 2).map((c) =>
|
||||
contributorLabel(metric, c.key, locale),
|
||||
);
|
||||
const joined =
|
||||
locale === "de"
|
||||
? joinList(weak, "und")
|
||||
: joinList(weak, "and");
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? `Am stärksten gedämpft durch ${joined}.`
|
||||
: `Held back most by ${joined}.`,
|
||||
);
|
||||
}
|
||||
} else if (signal.delta !== null && signal.delta !== 0) {
|
||||
// No contributor breakdown (recovery/stress/strain): use the trend.
|
||||
const dir =
|
||||
signal.delta > 0
|
||||
? locale === "de"
|
||||
? "höher"
|
||||
: "higher"
|
||||
: locale === "de"
|
||||
? "niedriger"
|
||||
: "lower";
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? `Das sind ${fmtSignedInt(signal.delta)} Punkte ${dir} als zuletzt.`
|
||||
: `That is ${fmtSignedInt(signal.delta)} points ${dir} than your recent average.`,
|
||||
);
|
||||
} else {
|
||||
sentences.push(
|
||||
locale === "de"
|
||||
? "Das entspricht deinem üblichen Niveau."
|
||||
: "That is in line with your usual level.",
|
||||
);
|
||||
}
|
||||
|
||||
return sentences.join(" ");
|
||||
}
|
||||
|
||||
function capitalise(s: string): string {
|
||||
return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function joinList(items: string[], conjunction: string): string {
|
||||
if (items.length === 0) return "";
|
||||
if (items.length === 1) return items[0];
|
||||
return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the assessment field for a derived metric result. Returns null
|
||||
* when the metric is not assessable or its status is not `ok` (the locked
|
||||
* contract: assessment is null unless status === "ok"). Otherwise always a
|
||||
* non-empty deterministic text; a warmer AI text overrides it when one has
|
||||
* been cached (see `derived-assessment-ai.ts`).
|
||||
*/
|
||||
export function resolveDeterministicAssessment(
|
||||
metric: DerivedMetricId,
|
||||
derived: Derived<unknown>,
|
||||
locale: Locale,
|
||||
now: Date,
|
||||
): DerivedAssessment | null {
|
||||
if (!isAssessableDerivedScore(metric)) return null;
|
||||
if (derived.status !== "ok") return null;
|
||||
|
||||
const signal = buildScoreSignal(metric, derived.value, locale);
|
||||
if (!signal) return null;
|
||||
|
||||
const band = readBand(derived.value);
|
||||
const text = buildDeterministicScoreAssessment(metric, signal, band, locale);
|
||||
return {
|
||||
text,
|
||||
source: "deterministic",
|
||||
updatedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Read the band off any assessable score value (all carry `band`). */
|
||||
function readBand(value: unknown): "green" | "yellow" | "red" {
|
||||
const b = (value as { band?: unknown }).band;
|
||||
return b === "green" || b === "yellow" || b === "red" ? b : "yellow";
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* v1.13.x — the shared metric SIGNAL block.
|
||||
*
|
||||
* Every per-metric AI surface today sends the model raw graded buckets and
|
||||
* an instruction to "read the personal baseline from the means and compare
|
||||
* the two". That comparison is arithmetic an LLM does unreliably, and the
|
||||
* significance verdict ("is this inside my normal swing?") has no number to
|
||||
* anchor to — which is exactly where generic prose leaks in. The fix
|
||||
* (research-ai-quality.md §4) is a single token-lean, signal-RICH descriptor
|
||||
* that hands the model the comparison already finished:
|
||||
*
|
||||
* metric · current · own baseline · signed delta (+%) · spread /
|
||||
* normal-swing verdict · coarse normal-range placement · n / recency ·
|
||||
* (for a composite score) the 1–2 biggest-moving contributors.
|
||||
*
|
||||
* This is "surface C" of the audit — the generic per-metric assessment base —
|
||||
* built first because it is also the per-score-assessment path.
|
||||
*
|
||||
* Pure + side-effect-free: it folds data the snapshot builders already hold
|
||||
* (the graded series, `summarizeSeries`, the registry norms, the profile).
|
||||
* No DB read, no provider call. The deterministic per-score assessment writer
|
||||
* and the generic metric-status snapshot both build their signal from here, so
|
||||
* one builder is the single source of the comparison every surface used to
|
||||
* re-derive by hand.
|
||||
*/
|
||||
import type { GradedSeries } from "@/lib/insights/graded-series";
|
||||
import type { NormRange } from "@/lib/insights/derived/norms";
|
||||
|
||||
/** Favourable-direction framing — mirrors the metric-status registry. */
|
||||
export type MetricDirection =
|
||||
| "higher-better"
|
||||
| "lower-better"
|
||||
| "target-band";
|
||||
|
||||
/** One contributor to a composite score (the Oura move for per-score cards). */
|
||||
export interface MetricSignalContributor {
|
||||
/** Stable component key (e.g. "rhr", "sufficiency"). */
|
||||
key: string;
|
||||
/** 0..100 component score, or null when the input was missing. */
|
||||
value: number | null;
|
||||
/** Effective weight after null-redistribution, 0..1. */
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The token-lean signal descriptor for one metric. Every numeric field is
|
||||
* pre-computed so the model states it rather than re-deriving it. Optional
|
||||
* fields are omitted (not nulled) when they do not apply, keeping the JSON
|
||||
* the prompt carries small.
|
||||
*/
|
||||
export interface MetricSignal {
|
||||
/** Natural-language label (no enum leak into prose). */
|
||||
metric: string;
|
||||
/** Unit string, when the metric has one. */
|
||||
unit?: string;
|
||||
/** Recent-window mean — the headline number (NOT a start/end value). */
|
||||
current: number;
|
||||
/** Days the recent window spans. */
|
||||
currentWindowDays: number;
|
||||
/** The user's OWN longer baseline (monthly mean, fallback weekly→yearly). */
|
||||
baseline: number | null;
|
||||
/** What the baseline IS, in words ("your 90-day average"). */
|
||||
baselineLabel?: string;
|
||||
/** Signed `current − baseline`, pre-computed. */
|
||||
delta: number | null;
|
||||
/** Signed percent change vs baseline, pre-computed. */
|
||||
deltaPct: number | null;
|
||||
/** Spread (SD) of the baseline window — the "normal swing". */
|
||||
spread: number | null;
|
||||
/** `|delta| > k·spread` — the significance verdict as a boolean, not a guess. */
|
||||
outsideNormalSwing: boolean | null;
|
||||
/** Favourable-direction framing. */
|
||||
direction: MetricDirection;
|
||||
/** Coarse population anchor (age/sex when sharpened), optional. */
|
||||
normalRange?: { low: number; high: number; source?: string };
|
||||
/** Pre-computed placement vs `normalRange`. */
|
||||
placement?: "below band" | "in band" | "above band";
|
||||
/** Sample count backing the recent window. */
|
||||
n: number;
|
||||
/** Recency of the newest reading, in days (null when unknown). */
|
||||
newestDaysAgo: number | null;
|
||||
/** For composite/derived SCORES only — the 1–2 biggest-moving contributors. */
|
||||
contributors?: MetricSignalContributor[];
|
||||
}
|
||||
|
||||
/** `k` in `|delta| > k·spread`. One spread of deviation is the finding floor. */
|
||||
export const NORMAL_SWING_K = 1;
|
||||
|
||||
function round(value: number, digits = 1): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/** Mean of a numeric array, or null when empty. */
|
||||
function meanOf(values: number[]): number | null {
|
||||
if (values.length === 0) return null;
|
||||
let sum = 0;
|
||||
for (const v of values) sum += v;
|
||||
return sum / values.length;
|
||||
}
|
||||
|
||||
/** Sample standard deviation, or null with fewer than two points. */
|
||||
function sampleSd(values: number[]): number | null {
|
||||
if (values.length < 2) return null;
|
||||
const m = meanOf(values);
|
||||
if (m === null) return null;
|
||||
let acc = 0;
|
||||
for (const v of values) acc += (v - m) ** 2;
|
||||
return Math.sqrt(acc / (values.length - 1));
|
||||
}
|
||||
|
||||
/** Placement of a value against a coarse normal range. */
|
||||
export function placeInBand(
|
||||
value: number,
|
||||
range: { low: number; high: number },
|
||||
): "below band" | "in band" | "above band" {
|
||||
if (value < range.low) return "below band";
|
||||
if (value > range.high) return "above band";
|
||||
return "in band";
|
||||
}
|
||||
|
||||
export interface BuildMetricSignalArgs {
|
||||
/** Natural-language metric label. */
|
||||
metric: string;
|
||||
unit?: string;
|
||||
direction: MetricDirection;
|
||||
/** The graded snapshot the surface already built. */
|
||||
graded: GradedSeries;
|
||||
/** Coarse normal range (sharpened by age/sex upstream), optional. */
|
||||
normalRange?: NormRange | null;
|
||||
/** Where the normal range came from, for the `source` annotation. */
|
||||
normalRangeSource?: string;
|
||||
/** Newest-reading recency, in days, when known. */
|
||||
newestDaysAgo?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the §4.1 signal block from a graded series.
|
||||
*
|
||||
* - `current` = mean of the recent daily means (the short window).
|
||||
* - `baseline` = mean of the monthly means, falling back to weekly then
|
||||
* yearly when the longer slices are empty (short history). When NO longer
|
||||
* slice exists the baseline is null and the deltas/verdict are null too —
|
||||
* an honest "no baseline yet" rather than a fabricated comparison.
|
||||
* - `spread` = SD of the baseline window's means (the normal swing).
|
||||
* - `outsideNormalSwing` = `|delta| > k·spread`.
|
||||
*
|
||||
* Returns null only when the recent window itself is empty (no current value
|
||||
* to anchor on) — the caller then skips the signal block entirely.
|
||||
*/
|
||||
export function buildMetricSignal(
|
||||
args: BuildMetricSignalArgs,
|
||||
): MetricSignal | null {
|
||||
const { graded } = args;
|
||||
const recentMeans = graded.recent.map((b) => b.mean);
|
||||
const current = meanOf(recentMeans);
|
||||
if (current === null) return null;
|
||||
|
||||
const recentN = graded.recent.reduce((s, b) => s + b.n, 0);
|
||||
|
||||
// The longer baseline: monthly means first (the audit's canonical own
|
||||
// baseline), then weekly, then yearly — whichever the history supports.
|
||||
let baselineMeans: number[] = graded.monthly.map((b) => b.mean);
|
||||
let baselineLabel = "your monthly average";
|
||||
if (baselineMeans.length === 0) {
|
||||
baselineMeans = graded.weekly.map((b) => b.mean);
|
||||
baselineLabel = "your recent-weeks average";
|
||||
}
|
||||
if (baselineMeans.length === 0) {
|
||||
baselineMeans = graded.yearly.map((b) => b.mean);
|
||||
baselineLabel = "your long-term average";
|
||||
}
|
||||
|
||||
const baseline = meanOf(baselineMeans);
|
||||
const spread = sampleSd(baselineMeans);
|
||||
|
||||
let delta: number | null = null;
|
||||
let deltaPct: number | null = null;
|
||||
let outsideNormalSwing: boolean | null = null;
|
||||
if (baseline !== null) {
|
||||
delta = round(current - baseline, 2);
|
||||
deltaPct = baseline !== 0 ? round(((current - baseline) / baseline) * 100, 1) : null;
|
||||
if (spread !== null && spread > 0) {
|
||||
outsideNormalSwing = Math.abs(current - baseline) > NORMAL_SWING_K * spread;
|
||||
} else {
|
||||
// No usable spread (a flat or single-point baseline): a non-zero delta
|
||||
// is the only signal we can honestly give — treat any real change as
|
||||
// outside the (zero) swing, an exact match as inside it.
|
||||
outsideNormalSwing = delta !== 0;
|
||||
}
|
||||
}
|
||||
|
||||
const signal: MetricSignal = {
|
||||
metric: args.metric,
|
||||
...(args.unit ? { unit: args.unit } : {}),
|
||||
current: round(current, 2),
|
||||
currentWindowDays: graded.recent.length,
|
||||
baseline: baseline !== null ? round(baseline, 2) : null,
|
||||
...(baseline !== null ? { baselineLabel } : {}),
|
||||
delta,
|
||||
deltaPct,
|
||||
spread: spread !== null ? round(spread, 2) : null,
|
||||
outsideNormalSwing,
|
||||
direction: args.direction,
|
||||
n: recentN,
|
||||
newestDaysAgo: args.newestDaysAgo ?? null,
|
||||
};
|
||||
|
||||
if (args.normalRange) {
|
||||
signal.normalRange = {
|
||||
low: args.normalRange.low,
|
||||
high: args.normalRange.high,
|
||||
...(args.normalRangeSource ? { source: args.normalRangeSource } : {}),
|
||||
};
|
||||
signal.placement = placeInBand(round(current, 2), {
|
||||
low: args.normalRange.low,
|
||||
high: args.normalRange.high,
|
||||
});
|
||||
}
|
||||
|
||||
return signal;
|
||||
}
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlation-context";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { lookupNormalRange } from "@/lib/insights/derived/norms";
|
||||
import { buildMetricSignal } from "@/lib/insights/metric-signal";
|
||||
import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system";
|
||||
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
@@ -320,8 +322,24 @@ export async function generateMetricStatus(args: {
|
||||
const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex);
|
||||
const normalRange = sharpenedRange ?? meta.normalRange;
|
||||
|
||||
// v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the
|
||||
// normal-swing verdict, computed server-side so the model states it rather
|
||||
// than re-deriving it from the raw buckets (research-ai-quality.md §4/§5C).
|
||||
// Built from the same graded series the snapshot already carries; null only
|
||||
// when the recent window is empty, in which case it is omitted.
|
||||
const signal = buildMetricSignal({
|
||||
metric: meta.displayName,
|
||||
unit: meta.unit,
|
||||
direction: meta.direction,
|
||||
graded,
|
||||
normalRange: normalRange ?? null,
|
||||
normalRangeSource: sharpenedRange ? "age-sex-adjusted" : undefined,
|
||||
newestDaysAgo,
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
promptVersion: PROMPT_VERSION,
|
||||
generatedForDay: todayKey,
|
||||
focus: meta.id,
|
||||
metric: {
|
||||
@@ -345,6 +363,7 @@ export async function generateMetricStatus(args: {
|
||||
newestMeasurementDaysAgo: newestDaysAgo,
|
||||
},
|
||||
[meta.id]: {
|
||||
...(signal ? { signal } : {}),
|
||||
summary,
|
||||
series: graded,
|
||||
latestDayFocus: latest
|
||||
|
||||
@@ -109,6 +109,44 @@ export interface MoodAggregateEntry {
|
||||
* aggregate tests (flat tags only) keep their narrow fixtures.
|
||||
*/
|
||||
structuredTags?: StructuredTagRef[];
|
||||
/**
|
||||
* v1.14.0 — per-entry RATED factor scores (work / sleep-quality /
|
||||
* stress …) from the same `mood_entry_tag_links` join, but carrying the
|
||||
* numeric `rating` instead of a present/absent flag. Optional so legacy
|
||||
* fixtures keep their narrow shape. A RATED factor is a *continuous*
|
||||
* daily signal, fed into the factor crosstab (low-vs-high-day vital
|
||||
* deviation) and the discovery matrix — distinct from BINARY structured
|
||||
* tags, which carry no `rating`.
|
||||
*/
|
||||
ratedFactors?: RatedFactorScore[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A RATED mood factor's score on a single entry, resolved from the
|
||||
* catalog. Carries the scale + `inverse` flag so the analytics layer can
|
||||
* apply the documented sign-flip ("higher rating = worse day" → flip so
|
||||
* "up" always reads as better) once, at the series boundary.
|
||||
*/
|
||||
export interface RatedFactorScore {
|
||||
/** Stable factor key (e.g. `work`). */
|
||||
key: string;
|
||||
/** Parent category key. */
|
||||
categoryKey: string;
|
||||
/** i18n message key for the factor label. */
|
||||
labelKey: string;
|
||||
/** Lucide icon name, or null. */
|
||||
icon: string | null;
|
||||
/** The per-entry rating, bounded to `scaleMin..scaleMax` at ingest. */
|
||||
rating: number;
|
||||
/** Inclusive scale bounds (default 1..5). */
|
||||
scaleMin: number;
|
||||
scaleMax: number;
|
||||
/**
|
||||
* `true` when a higher rating means a WORSE day (stress / conflict).
|
||||
* The series builder flips an inverse factor's rating to
|
||||
* `(scaleMin + scaleMax) - rating` so "up" always reads as better.
|
||||
*/
|
||||
inverse: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1043,6 +1081,313 @@ export function computeTagMetricCrosstab(args: {
|
||||
.slice(0, CROSSTAB_MAX_ROWS);
|
||||
}
|
||||
|
||||
// --- RATED factor × health-metric crosstab (low- vs high-factor days) ---
|
||||
|
||||
/**
|
||||
* v1.14.0 — the flagship cross-domain insight: "on days you rated <factor>
|
||||
* low, your <vital> ran X below baseline".
|
||||
*
|
||||
* A RATED mood factor (work / sleep-quality / stress …) is a CONTINUOUS
|
||||
* per-day score, unlike the BINARY structured tags `computeTagMetricCrosstab`
|
||||
* handles. To reuse the EXACT same Welch + FDR engine, this thresholds the
|
||||
* factor's daily mean into a binary membership — a day is "low" when its
|
||||
* factor mean sits BELOW the factor's own median over the window, "high" at
|
||||
* or above. The median split is robust + self-calibrating (always two
|
||||
* non-empty groups when the factor has spread), and invents no fixed cutoff.
|
||||
*
|
||||
* `inverse` is applied at the SERIES boundary (the factor's per-day mean is
|
||||
* flipped to `(scaleMin + scaleMax) - raw` for an inverse factor like
|
||||
* stress), so the median split — and therefore the "low day" label — always
|
||||
* means "a worse day for this factor", honestly, without per-call casing.
|
||||
*
|
||||
* Same honesty discipline as the tag crosstab: both sides need
|
||||
* `CROSSTAB_MIN_PRESENT_DAYS` / `CROSSTAB_MIN_ABSENT_DAYS` paired metric
|
||||
* days, the family is BH-FDR corrected as one (across every factor × metric
|
||||
* × direction pair), survivors clear p < 0.05 AND q ≤ `CROSSTAB_FDR_Q`, and
|
||||
* the surface is capped + ranked. Observational only — the card renders the
|
||||
* standing "association, not cause" caption.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The metric channels the factor crosstab pairs each RATED factor against.
|
||||
* Broader than the tag crosstab (which is activity-focused) because the
|
||||
* value of a RATED factor is its bridge from a SUBJECTIVE score to an
|
||||
* OBJECTIVE vital. `sameDay` for same-night/same-day metrics (sleep,
|
||||
* steps); `nextDay` (D → D+1) for overnight-recovery metrics (RHR / HRV),
|
||||
* matching the plausible "today's factor → tomorrow's body" direction.
|
||||
*/
|
||||
export const FACTOR_CROSSTAB_METRICS: Record<
|
||||
string,
|
||||
{
|
||||
type: MeasurementType;
|
||||
mode: CrosstabMode;
|
||||
display: "hours" | "score" | "steps" | "bpm" | "ms" | "kg" | "mmHg";
|
||||
}
|
||||
> = {
|
||||
sleepDuration: { type: "SLEEP_DURATION", mode: "sameDay", display: "hours" },
|
||||
steps: { type: "ACTIVITY_STEPS", mode: "sameDay", display: "steps" },
|
||||
restingHeartRate: {
|
||||
type: "RESTING_HEART_RATE",
|
||||
mode: "nextDay",
|
||||
display: "bpm",
|
||||
},
|
||||
heartRateVariability: {
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
mode: "nextDay",
|
||||
display: "ms",
|
||||
},
|
||||
weight: { type: "WEIGHT", mode: "sameDay", display: "kg" },
|
||||
bloodPressureSystolic: {
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
mode: "sameDay",
|
||||
display: "mmHg",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type FactorCrosstabMetricKey = keyof typeof FACTOR_CROSSTAB_METRICS;
|
||||
|
||||
/** Distinct measurement types the factor crosstab reads — single-sourced. */
|
||||
export const FACTOR_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from(
|
||||
new Set(Object.values(FACTOR_CROSSTAB_METRICS).map((m) => m.type)),
|
||||
);
|
||||
|
||||
export interface FactorMetricCrosstabRow {
|
||||
/** Stable RATED-factor key. */
|
||||
factor: string;
|
||||
/** i18n label key for the factor. */
|
||||
labelKey: string;
|
||||
/** Parent category key, for grouping/icon. */
|
||||
categoryKey: string;
|
||||
/** Lucide icon name, or null. */
|
||||
icon: string | null;
|
||||
/**
|
||||
* `true` when the factor is inverse-scaled (stress / conflict). The
|
||||
* UI flips the phrasing — "your worse <factor> days" — but the split
|
||||
* itself already runs on the flipped series, so a "low" row always
|
||||
* means a worse day regardless.
|
||||
*/
|
||||
inverse: boolean;
|
||||
/** Which metric channel this row compares against. */
|
||||
metricKey: FactorCrosstabMetricKey;
|
||||
/** Display unit hint for the client formatter. */
|
||||
display: FactorMetricDisplay;
|
||||
/** Pairing mode used (echoed so the UI can caption "next-day"). */
|
||||
mode: CrosstabMode;
|
||||
/** Days the factor was rated LOW (below its median) with a paired metric. */
|
||||
lowDays: number;
|
||||
/** Days the factor was rated HIGH (at/above its median) with a paired metric. */
|
||||
highDays: number;
|
||||
/** Mean metric on low-factor days (display unit). */
|
||||
lowAvg: number;
|
||||
/** Mean metric on high-factor days (display unit). */
|
||||
highAvg: number;
|
||||
/** lowAvg − highAvg (display unit). Negative = vital runs lower on low days. */
|
||||
delta: number;
|
||||
/** Welch two-sided p-value for the difference of means. */
|
||||
pValue: number;
|
||||
/** Benjamini-Hochberg adjusted q-value across the tested family. */
|
||||
qValue: number;
|
||||
/** Discrete confidence band (p + min per-group day count). */
|
||||
confidence: InfluenceConfidence;
|
||||
}
|
||||
|
||||
type FactorMetricDisplay = (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]["display"];
|
||||
|
||||
/** Convert a raw metric value to its factor-crosstab display unit. */
|
||||
function toFactorDisplayUnit(value: number, display: FactorMetricDisplay): number {
|
||||
return display === "hours" ? value / 60 : value;
|
||||
}
|
||||
|
||||
/** Median of a numeric array (sorted-copy, mean-of-two for even length). */
|
||||
function median(values: number[]): number {
|
||||
if (values.length === 0) return Number.NaN;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
}
|
||||
|
||||
/** Reference data for one RATED factor's daily series + its meta. */
|
||||
interface FactorDailySeries {
|
||||
ref: Pick<RatedFactorScore, "key" | "labelKey" | "categoryKey" | "icon" | "inverse">;
|
||||
/** Day key → factor's daily-mean score, inverse-flipped if needed. */
|
||||
byDay: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a per-factor daily-mean series, applying the `inverse` sign-flip
|
||||
* once. For each tz-anchored day, the mean of the factor's ratings across
|
||||
* the day's entries; an inverse factor's rating `r` maps to
|
||||
* `(scaleMin + scaleMax) - r` BEFORE averaging so "up" always reads as a
|
||||
* better day. Pure. Exported for the discovery-channel path (period
|
||||
* narrative) so the two consumers can never diverge on the flip.
|
||||
*/
|
||||
export function buildFactorDailySeries(
|
||||
entries: MoodAggregateEntry[],
|
||||
now: Date,
|
||||
windowDays: number,
|
||||
): Map<string, FactorDailySeries> {
|
||||
const cutoff = now.getTime() - windowDays * MS_PER_DAY;
|
||||
const acc = new Map<
|
||||
string,
|
||||
{
|
||||
ref: FactorDailySeries["ref"];
|
||||
byDay: Map<string, { sum: number; count: number }>;
|
||||
}
|
||||
>();
|
||||
for (const entry of entries) {
|
||||
if (entry.moodLoggedAt.getTime() < cutoff) continue;
|
||||
if (!entry.ratedFactors) continue;
|
||||
for (const f of entry.ratedFactors) {
|
||||
if (!Number.isFinite(f.rating)) continue;
|
||||
// The documented sign-flip: an inverse factor's rating is mirrored
|
||||
// across its scale midpoint so a higher series value always means a
|
||||
// better day. Done here, once, at the series boundary.
|
||||
const value = f.inverse
|
||||
? f.scaleMin + f.scaleMax - f.rating
|
||||
: f.rating;
|
||||
const slot =
|
||||
acc.get(f.key) ??
|
||||
{
|
||||
ref: {
|
||||
key: f.key,
|
||||
labelKey: f.labelKey,
|
||||
categoryKey: f.categoryKey,
|
||||
icon: f.icon,
|
||||
inverse: f.inverse,
|
||||
},
|
||||
byDay: new Map<string, { sum: number; count: number }>(),
|
||||
};
|
||||
const cur = slot.byDay.get(entry.date) ?? { sum: 0, count: 0 };
|
||||
cur.sum += value;
|
||||
cur.count += 1;
|
||||
slot.byDay.set(entry.date, cur);
|
||||
acc.set(f.key, slot);
|
||||
}
|
||||
}
|
||||
const out = new Map<string, FactorDailySeries>();
|
||||
for (const [key, slot] of acc) {
|
||||
const byDay = new Map<string, number>();
|
||||
for (const [day, agg] of slot.byDay) byDay.set(day, agg.sum / agg.count);
|
||||
out.set(key, { ref: slot.ref, byDay });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface FactorCrosstabCandidate {
|
||||
row: Omit<FactorMetricCrosstabRow, "qValue">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the RATED-factor × metric crosstab. Pure over already-fetched
|
||||
* rows. Mirrors `computeTagMetricCrosstab` but splits each factor's
|
||||
* continuous daily score into low/high by its own median, then runs the
|
||||
* same Welch + FDR engine.
|
||||
*/
|
||||
export function computeFactorMetricCrosstab(args: {
|
||||
entries: MoodAggregateEntry[];
|
||||
measurements: CrossMetricMeasurement[];
|
||||
now: Date;
|
||||
windowDays?: number;
|
||||
userPriorityJson?: unknown;
|
||||
}): FactorMetricCrosstabRow[] {
|
||||
const { entries, measurements, now } = args;
|
||||
const windowDays = args.windowDays ?? 365;
|
||||
const userPriorityJson = args.userPriorityJson ?? null;
|
||||
|
||||
const factorSeries = buildFactorDailySeries(entries, now, windowDays);
|
||||
if (factorSeries.size === 0) return [];
|
||||
|
||||
const candidates: FactorCrosstabCandidate[] = [];
|
||||
|
||||
for (const [metricKey, cfg] of Object.entries(FACTOR_CROSSTAB_METRICS) as Array<
|
||||
[FactorCrosstabMetricKey, (typeof FACTOR_CROSSTAB_METRICS)[FactorCrosstabMetricKey]]
|
||||
>) {
|
||||
const metricByDay = metricDayMap(measurements, cfg.type, userPriorityJson);
|
||||
if (metricByDay.size === 0) continue;
|
||||
|
||||
for (const [factorKey, series] of factorSeries) {
|
||||
// The median split is over the factor's own rated days only, so a
|
||||
// sparse factor never borrows another's threshold.
|
||||
const ratedDays = [...series.byDay.values()];
|
||||
if (ratedDays.length < CROSSTAB_MIN_PRESENT_DAYS + CROSSTAB_MIN_ABSENT_DAYS) {
|
||||
continue;
|
||||
}
|
||||
const split = median(ratedDays);
|
||||
|
||||
const lowVals: number[] = [];
|
||||
const highVals: number[] = [];
|
||||
for (const [dayKey, score] of series.byDay) {
|
||||
const metricDayKey =
|
||||
cfg.mode === "nextDay" ? shiftDayKey(dayKey, 1) : dayKey;
|
||||
const metricValue = metricByDay.get(metricDayKey);
|
||||
if (metricValue == null || !Number.isFinite(metricValue)) continue;
|
||||
const display = toFactorDisplayUnit(metricValue, cfg.display);
|
||||
// Below the median is a "low" (worse, after the inverse flip) day;
|
||||
// at or above is "high". A perfectly bimodal factor with all days
|
||||
// exactly on the median falls entirely into "high" and fails the
|
||||
// low-side floor below — honest: no contrast, no row.
|
||||
if (score < split) lowVals.push(display);
|
||||
else highVals.push(display);
|
||||
}
|
||||
|
||||
if (
|
||||
lowVals.length < CROSSTAB_MIN_PRESENT_DAYS ||
|
||||
highVals.length < CROSSTAB_MIN_ABSENT_DAYS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const welch = welchTTest(lowVals, highVals);
|
||||
const lowAvg = lowVals.reduce((s, v) => s + v, 0) / lowVals.length;
|
||||
const highAvg = highVals.reduce((s, v) => s + v, 0) / highVals.length;
|
||||
const delta = lowAvg - highAvg;
|
||||
if (delta === 0) continue;
|
||||
|
||||
const pValue = welch.status === "ok" ? welch.pValue : 1;
|
||||
const minGroupDays = Math.min(lowVals.length, highVals.length);
|
||||
|
||||
candidates.push({
|
||||
row: {
|
||||
factor: factorKey,
|
||||
labelKey: series.ref.labelKey,
|
||||
categoryKey: series.ref.categoryKey,
|
||||
icon: series.ref.icon,
|
||||
inverse: series.ref.inverse,
|
||||
metricKey,
|
||||
display: cfg.display,
|
||||
mode: cfg.mode,
|
||||
lowDays: lowVals.length,
|
||||
highDays: highVals.length,
|
||||
lowAvg: round(lowAvg, 2),
|
||||
highAvg: round(highAvg, 2),
|
||||
delta: round(delta, 2),
|
||||
pValue,
|
||||
confidence: influenceConfidence(pValue, minGroupDays),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) return [];
|
||||
|
||||
// One BH family across every factor × metric pair tested — the same
|
||||
// step-up the tag crosstab + discovery engine run.
|
||||
const qValues = fdrAdjust(candidates.map((c) => c.row.pValue));
|
||||
|
||||
return candidates
|
||||
.map((c, i) => ({ ...c.row, qValue: Math.round(qValues[i] * 1000) / 1000 }))
|
||||
.filter((row) => row.pValue < 0.05 && row.qValue <= CROSSTAB_FDR_Q)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.qValue - b.qValue ||
|
||||
Math.abs(b.delta) - Math.abs(a.delta) ||
|
||||
a.factor.localeCompare(b.factor),
|
||||
)
|
||||
.slice(0, CROSSTAB_MAX_ROWS);
|
||||
}
|
||||
|
||||
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
|
||||
|
||||
/**
|
||||
@@ -1486,6 +1831,15 @@ export interface MoodAggregates {
|
||||
* renders the standing "association, not cause" caption.
|
||||
*/
|
||||
tagMetricCrosstab: TagMetricCrosstabRow[];
|
||||
/**
|
||||
* v1.14.0 — per-RATED-factor × health-metric crosstab: for each factor
|
||||
* the user scores per entry (work / sleep-quality / stress …), a vital's
|
||||
* mean on the days the factor was rated LOW vs HIGH (median split, same-day
|
||||
* or D→D+1), Welch-tested + FDR-corrected. The cross-domain bridge from a
|
||||
* subjective score to an objective vital. Observational; the card renders
|
||||
* the standing "association, not cause" caption.
|
||||
*/
|
||||
factorCrosstab: FactorMetricCrosstabRow[];
|
||||
/**
|
||||
* v1.8.6 — ranked, threshold-gated narrative takeaways. The "read
|
||||
* this first" layer above the charts; the same array feeds the LLM
|
||||
@@ -1572,6 +1926,12 @@ export function computeMoodAggregates(args: {
|
||||
now,
|
||||
userPriorityJson,
|
||||
});
|
||||
const factorCrosstab = computeFactorMetricCrosstab({
|
||||
entries,
|
||||
measurements,
|
||||
now,
|
||||
userPriorityJson,
|
||||
});
|
||||
|
||||
// Distinct day keys the user logged on — drives the streak takeaway.
|
||||
const loggedDayKeys = Array.from(new Set(entries.map((entry) => entry.date)));
|
||||
@@ -1603,6 +1963,7 @@ export function computeMoodAggregates(args: {
|
||||
tagInfluence,
|
||||
betterDays,
|
||||
tagMetricCrosstab,
|
||||
factorCrosstab,
|
||||
narratives: computeMoodNarratives({
|
||||
daily: moodDaily,
|
||||
weekday,
|
||||
@@ -1646,13 +2007,20 @@ export async function fetchMoodAggregates(
|
||||
tz: true,
|
||||
// v1.8.5 — pull the structured-tag links + their catalog rows so
|
||||
// the breakdown can fold structured tags next to the flat ones.
|
||||
// v1.14.0 — also pull the per-link `rating` + the factor's
|
||||
// kind/scale/inverse so RATED factors feed the factor crosstab.
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: {
|
||||
select: {
|
||||
key: true,
|
||||
labelKey: true,
|
||||
icon: true,
|
||||
kind: true,
|
||||
scaleMin: true,
|
||||
scaleMax: true,
|
||||
inverse: true,
|
||||
category: { select: { key: true } },
|
||||
},
|
||||
},
|
||||
@@ -1667,12 +2035,31 @@ export async function fetchMoodAggregates(
|
||||
tags: parseTags(row.tags),
|
||||
moodLoggedAt: row.moodLoggedAt,
|
||||
tz: row.tz,
|
||||
structuredTags: row.tagLinks.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
labelKey: link.moodTag.labelKey,
|
||||
icon: link.moodTag.icon,
|
||||
})),
|
||||
// BINARY links carry the structured-tag breakdown; RATED links
|
||||
// (a non-null `rating`) carry the factor score. One join, two axes.
|
||||
structuredTags: row.tagLinks
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
labelKey: link.moodTag.labelKey,
|
||||
icon: link.moodTag.icon,
|
||||
})),
|
||||
ratedFactors: row.tagLinks
|
||||
.filter(
|
||||
(link): link is typeof link & { rating: number } =>
|
||||
link.moodTag.kind === "RATED" && link.rating != null,
|
||||
)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
labelKey: link.moodTag.labelKey,
|
||||
icon: link.moodTag.icon,
|
||||
rating: link.rating,
|
||||
scaleMin: link.moodTag.scaleMin,
|
||||
scaleMax: link.moodTag.scaleMax,
|
||||
inverse: link.moodTag.inverse,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -1691,6 +2078,10 @@ export async function fetchMoodAggregates(
|
||||
new Set<MeasurementType>([
|
||||
...(Object.values(CORRELATION_METRICS) as MeasurementType[]),
|
||||
...CROSSTAB_METRIC_TYPES,
|
||||
// v1.14.0 — union the factor-crosstab vital channels (RHR /
|
||||
// HRV / steps / sleep / weight / BP-sys) so the factor board
|
||||
// has its measurements without a second query.
|
||||
...FACTOR_CROSSTAB_METRIC_TYPES,
|
||||
]),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
MOOD_GREEN_MIN,
|
||||
MOOD_ORANGE_MAX,
|
||||
MOOD_ORANGE_MIN,
|
||||
type FactorMetricCrosstabRow,
|
||||
computeBetterDays,
|
||||
computeFactorMetricCrosstab,
|
||||
computeInTargetPct,
|
||||
computeMoodMetricCorrelation,
|
||||
computeMoodStability,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
} from "@/lib/insights/status-cache";
|
||||
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { toBerlinDayKey } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
@@ -64,6 +67,21 @@ function stripRankingOnly(row: TagInfluenceRow): Omit<TagInfluenceRow, "pooledSd
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a factor-crosstab row to the load-bearing stats for the prompt — drop
|
||||
* the UI-only `icon`. The factor key + metric + low/high averages + delta + n
|
||||
* + confidence + inverse flag are what the model reasons over; the Lucide icon
|
||||
* is chrome and has no place in the prompt payload (shown == sent for the
|
||||
* statistics, not the glyph).
|
||||
*/
|
||||
function stripFactorCrosstab(
|
||||
row: FactorMetricCrosstabRow,
|
||||
): Omit<FactorMetricCrosstabRow, "icon"> {
|
||||
const { icon: _icon, ...rest } = row;
|
||||
void _icon;
|
||||
return rest;
|
||||
}
|
||||
|
||||
export async function generateMoodStatusForUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
@@ -151,13 +169,20 @@ export async function generateMoodStatusForUser(
|
||||
// v1.8.6 — pull the structured-tag links so the snapshot feeds
|
||||
// `computeMoodNarratives` the same structured-tag pool the visible
|
||||
// mood page does (shown == sent for the tag→mood takeaways).
|
||||
// v1.14.0 — also pull the per-link `rating` + factor kind/scale/inverse
|
||||
// so RATED factors feed the factor crosstab in the snapshot.
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: {
|
||||
select: {
|
||||
key: true,
|
||||
labelKey: true,
|
||||
icon: true,
|
||||
kind: true,
|
||||
scaleMin: true,
|
||||
scaleMax: true,
|
||||
inverse: true,
|
||||
category: { select: { key: true } },
|
||||
},
|
||||
},
|
||||
@@ -172,12 +197,29 @@ export async function generateMoodStatusForUser(
|
||||
tags: row.tags,
|
||||
moodLoggedAt: row.moodLoggedAt,
|
||||
tz: row.tz,
|
||||
structuredTags: (row.tagLinks ?? []).map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
labelKey: link.moodTag.labelKey,
|
||||
icon: link.moodTag.icon,
|
||||
})),
|
||||
structuredTags: (row.tagLinks ?? [])
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
labelKey: link.moodTag.labelKey,
|
||||
icon: link.moodTag.icon,
|
||||
})),
|
||||
ratedFactors: (row.tagLinks ?? [])
|
||||
.filter(
|
||||
(link): link is typeof link & { rating: number } =>
|
||||
link.moodTag.kind === "RATED" && link.rating != null,
|
||||
)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
categoryKey: link.moodTag.category.key,
|
||||
labelKey: link.moodTag.labelKey,
|
||||
icon: link.moodTag.icon,
|
||||
rating: link.rating,
|
||||
scaleMin: link.moodTag.scaleMin,
|
||||
scaleMax: link.moodTag.scaleMax,
|
||||
inverse: link.moodTag.inverse,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -225,17 +267,54 @@ export async function generateMoodStatusForUser(
|
||||
// 1095 = 365 d × 3 channels so power users do not pull unbounded
|
||||
// rows. Order desc + reverse so the downstream filters see oldest-
|
||||
// first as before.
|
||||
// v1.14.0 — union the factor-crosstab vital channels (RHR / HRV / steps /
|
||||
// sleep, on top of weight / BP-sys) so the factor board the snapshot carries
|
||||
// has its measurements; cap lifted to 365 d × 8 channels. `source` /
|
||||
// `deviceType` ride along so the crosstab's per-day canonical-source pick
|
||||
// de-dups a cumulative metric reported by two sources on the same day.
|
||||
// The crosstab + correlations only pair against the ~365-day window, so
|
||||
// bound the read to that window: it lets Postgres use the (userId,
|
||||
// measuredAt) index, caps memory to rows that can actually pair, and stops a
|
||||
// data-rich Apple-Health account from spending the 50k cap on a recency-
|
||||
// biased all-recent slice. `deletedAt: null` keeps soft-deleted rows (common
|
||||
// on HRV/RHR/steps re-sync) out of the FDR-gated factor deltas the model
|
||||
// cites as fact — matching the visible `/api/mood/insights` board exactly.
|
||||
const crossMetricWindowStart = new Date(
|
||||
now.getTime() - 365 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const CROSS_METRIC_ROW_CAP = 50_000;
|
||||
const measurements = await prisma.measurement
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] },
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: crossMetricWindowStart },
|
||||
type: {
|
||||
in: [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"PULSE",
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"ACTIVITY_STEPS",
|
||||
"SLEEP_DURATION",
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1095,
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
take: CROSS_METRIC_ROW_CAP,
|
||||
select: {
|
||||
type: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
},
|
||||
})
|
||||
.then((rows) => rows.reverse());
|
||||
if (measurements.length >= CROSS_METRIC_ROW_CAP) {
|
||||
annotate({ meta: { cross_metric_truncated: true } });
|
||||
}
|
||||
|
||||
const weightSeries = applyPayloadBudget(
|
||||
measurements
|
||||
@@ -312,6 +391,22 @@ export async function generateMoodStatusForUser(
|
||||
),
|
||||
});
|
||||
|
||||
// v1.14.0 — RATED-factor × vital crosstab: "on days you rated <factor> low,
|
||||
// your <vital> ran X below baseline". Computed from the same entries +
|
||||
// measurements the snapshot already holds so the model's prose matches the
|
||||
// visible factor board (shown == sent). FDR-controlled + min-N-gated inside
|
||||
// `computeFactorMetricCrosstab` — never fabricated on thin data.
|
||||
// Thread the user's real source priority so the snapshot's per-day canonical
|
||||
// pick matches the visible factor board (a user who prefers WHOOP over Apple
|
||||
// for sleep collapses a SUM channel to the same source on screen + in prose).
|
||||
const userPriorityJson = await loadUserSourcePriority(userId);
|
||||
const factorCrosstab = computeFactorMetricCrosstab({
|
||||
entries,
|
||||
measurements,
|
||||
now,
|
||||
userPriorityJson,
|
||||
});
|
||||
|
||||
// v1.8.6 — the same threshold-gated narrative feed the user sees on
|
||||
// the mood page, computed from the same aggregates so the prose the
|
||||
// model writes never contradicts the takeaways on screen (shown ==
|
||||
@@ -382,6 +477,13 @@ export async function generateMoodStatusForUser(
|
||||
// v1.11.5 (F2) — the ranked "better days" board the user sees, so the
|
||||
// model's relations prose matches the on-screen list (shown == sent).
|
||||
betterDays: betterDays.length > 0 ? betterDays : null,
|
||||
// v1.14.0 — RATED-factor → vital deviation rows (low-vs-high-day Welch
|
||||
// delta, FDR-gated). Lets the model say "your data shows sleep runs ~X
|
||||
// shorter on days you rate work low (n=N, association not cause)". Only
|
||||
// the surviving rows reach the prompt; an empty board emits null.
|
||||
factorCrosstab: factorCrosstab.length > 0
|
||||
? factorCrosstab.map(stripFactorCrosstab)
|
||||
: null,
|
||||
narratives: narratives.length > 0 ? narratives : null,
|
||||
// v1.9.0 — only emit the daypart pattern when it cleared its
|
||||
// spread/sample floors, so the model never reasons over a once-a-day
|
||||
|
||||
@@ -213,6 +213,41 @@ describe("assemblePeriodNarrativeContext — drivers (FDR-controlled)", () => {
|
||||
const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m)));
|
||||
expect(ctx.drivers).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces a RATED-factor channel as a driver with humanised prose", () => {
|
||||
// A `FACTOR:work` daily score whose next-day sleep tracks it linearly.
|
||||
const fVals = Array.from({ length: 31 }, (_, i) => (i % 5) + 1);
|
||||
const sVals = Array.from({ length: 31 }, (_, i) =>
|
||||
i === 0 ? 400 : ((i - 1) % 5) * 40 + 360 + (i % 2 === 0 ? 1 : -1),
|
||||
);
|
||||
const factor = seriesEndingAt(fVals, "2026-04-30");
|
||||
const sleep = seriesEndingAt(sVals, "2026-04-30");
|
||||
// Two real vitals with current-period coverage so the availability gate
|
||||
// (≥ 2 DELTA metrics covered) is satisfied — a factor channel is not a
|
||||
// vital delta and does not count toward coverage by design.
|
||||
const weight = seriesEndingAt(Array.from({ length: 31 }, () => 80), "2026-04-30");
|
||||
const steps = seriesEndingAt(Array.from({ length: 31 }, (_, i) => 8000 + i), "2026-04-30");
|
||||
const discoverySeries: NamedSeries[] = [
|
||||
{ key: "FACTOR:work", role: "behaviour", points: factor },
|
||||
{ key: "SLEEP_DURATION", role: "outcome", points: sleep },
|
||||
];
|
||||
const m = new Map<string, DailySeriesPoint[]>();
|
||||
m.set("FACTOR:work", factor);
|
||||
m.set("SLEEP_DURATION", sleep);
|
||||
m.set("WEIGHT", weight);
|
||||
m.set("ACTIVITY_STEPS", steps);
|
||||
|
||||
const ctx = assertReady(
|
||||
assemblePeriodNarrativeContext(monthInput(m, discoverySeries)),
|
||||
);
|
||||
const driver = ctx.drivers.find((d) => d.behaviour === "FACTOR:work");
|
||||
expect(driver).toBeDefined();
|
||||
// The prose strips the namespace prefix and reads "rated work", stays
|
||||
// causation-banned.
|
||||
expect(driver!.interpretation).toContain("rated work");
|
||||
expect(driver!.interpretation).not.toContain("FACTOR:");
|
||||
expect(driver!.interpretation.toLowerCase()).toContain("not a cause");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assemblePeriodNarrativeContext — coincident flags", () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
discoverCorrelations,
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
FACTOR_CHANNEL_PREFIX,
|
||||
type DailySeriesPoint,
|
||||
type NamedSeries,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
@@ -199,6 +200,57 @@ function toDailyMeans(
|
||||
.sort((a, b) => (a.day < b.day ? -1 : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* A RATED mood factor link the period read pulls alongside the score.
|
||||
* Carries the scale + `inverse` so the series build can apply the
|
||||
* documented sign-flip once, at the boundary.
|
||||
*/
|
||||
interface FactorLink {
|
||||
key: string;
|
||||
rating: number;
|
||||
scaleMin: number;
|
||||
scaleMax: number;
|
||||
inverse: boolean;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.14.0 — collapse RATED-factor links to one inverse-flipped daily-mean
|
||||
* series per factor, tz-day-keyed exactly like `toDailyMeans` so a factor
|
||||
* channel joins the discovery matrix on the same day grid as every vital.
|
||||
* An inverse factor's rating `r` maps to `(scaleMin + scaleMax) - r` BEFORE
|
||||
* averaging so "up" always reads as a better day — the same flip the mood
|
||||
* aggregates apply, kept in lock-step. Returns one `FACTOR:<key>` series per
|
||||
* factor the user actually rated. Pure.
|
||||
*/
|
||||
function factorDailyMeans(
|
||||
links: FactorLink[],
|
||||
tz: string,
|
||||
): Map<string, DailySeriesPoint[]> {
|
||||
const byFactor = new Map<string, Map<string, { sum: number; count: number }>>();
|
||||
for (const l of links) {
|
||||
if (!Number.isFinite(l.rating)) continue;
|
||||
const value = l.inverse ? l.scaleMin + l.scaleMax - l.rating : l.rating;
|
||||
const day = tzDayKey(l.at, tz);
|
||||
const days = byFactor.get(l.key) ?? new Map<string, { sum: number; count: number }>();
|
||||
const acc = days.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += value;
|
||||
acc.count += 1;
|
||||
days.set(day, acc);
|
||||
byFactor.set(l.key, days);
|
||||
}
|
||||
const out = new Map<string, DailySeriesPoint[]>();
|
||||
for (const [key, days] of byFactor) {
|
||||
out.set(
|
||||
`${FACTOR_CHANNEL_PREFIX}${key}`,
|
||||
[...days.entries()]
|
||||
.map(([day, acc]) => ({ day, value: acc.sum / acc.count }))
|
||||
.sort((a, b) => (a.day < b.day ? -1 : 1)),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Round to 2 decimals. */
|
||||
function round2(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
@@ -501,7 +553,29 @@ export async function buildPeriodNarrativeContext(
|
||||
where: { userId, deletedAt: null, moodLoggedAt: { gte: since } },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
take: 5000,
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
select: {
|
||||
score: true,
|
||||
moodLoggedAt: true,
|
||||
// v1.14.0 — pull RATED-factor links so each factor the user scores
|
||||
// (work / sleep-quality / stress …) joins the discovery matrix as a
|
||||
// `FACTOR:<key>` channel. One extra select on the existing mood read
|
||||
// — no new round-trip. BINARY links carry a null `rating` and are
|
||||
// dropped below.
|
||||
tagLinks: {
|
||||
where: { moodTag: { kind: "RATED" }, rating: { not: null } },
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: {
|
||||
select: {
|
||||
key: true,
|
||||
scaleMin: true,
|
||||
scaleMax: true,
|
||||
inverse: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -522,6 +596,25 @@ export async function buildPeriodNarrativeContext(
|
||||
);
|
||||
if (moodPoints.length > 0) seriesByMetric.set("MOOD", moodPoints);
|
||||
|
||||
// v1.14.0 — RATED-factor channels. Flatten every entry's RATED links into
|
||||
// one inverse-flipped daily-mean series per factor, keyed `FACTOR:<key>`.
|
||||
const factorLinks: FactorLink[] = [];
|
||||
for (const e of moodEntries) {
|
||||
for (const link of e.tagLinks) {
|
||||
if (link.rating == null) continue;
|
||||
factorLinks.push({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating,
|
||||
scaleMin: link.moodTag.scaleMin,
|
||||
scaleMax: link.moodTag.scaleMax,
|
||||
inverse: link.moodTag.inverse,
|
||||
at: e.moodLoggedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
const factorSeries = factorDailyMeans(factorLinks, tz);
|
||||
for (const [key, points] of factorSeries) seriesByMetric.set(key, points);
|
||||
|
||||
// Discovery matrix over the same window.
|
||||
const discoverySeries: NamedSeries[] = [];
|
||||
for (const key of DISCOVERY_BEHAVIOURS) {
|
||||
@@ -538,6 +631,15 @@ export async function buildPeriodNarrativeContext(
|
||||
points: seriesByMetric.get(key) ?? [],
|
||||
});
|
||||
}
|
||||
// A factor is plausibly both a lag source ("rated work today → next-day
|
||||
// sleep") and a lag target ("steps today → next-day rated energy"), so it
|
||||
// enters as both roles. The engine skips self-pairs and BH-FDR-controls the
|
||||
// wider family this opens. Factors the user rated on < the paired-day floor
|
||||
// simply never get tested — no new statistical machinery, same honesty.
|
||||
for (const [key, points] of factorSeries) {
|
||||
discoverySeries.push({ key, role: "behaviour", points });
|
||||
discoverySeries.push({ key, role: "outcome", points });
|
||||
}
|
||||
|
||||
return assemblePeriodNarrativeContext({
|
||||
period,
|
||||
|
||||
@@ -56,7 +56,18 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray<InsightStatusMetric> = [
|
||||
* specialised one. A specialised scope is one of `InsightStatusMetric`;
|
||||
* a generic scope is any `metric:`-prefixed string.
|
||||
*/
|
||||
export type InsightStatusScope = InsightStatusMetric | `metric:${string}`;
|
||||
export type InsightStatusScope =
|
||||
| InsightStatusMetric
|
||||
| `metric:${string}`
|
||||
/**
|
||||
* v1.13.2 — per-derived-SCORE assessment scopes (READINESS, SLEEP_SCORE,
|
||||
* RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE) ride the same on-demand queue.
|
||||
* Their scope id is `derived-score:<DERIVED_METRIC_ID>`; the worker routes
|
||||
* the prefix to the per-score assessment generator. The deterministic text
|
||||
* always fills the field synchronously — this queue only warms the AI prose
|
||||
* that overrides it on the next read.
|
||||
*/
|
||||
| `derived-score:${string}`;
|
||||
|
||||
export interface InsightStatusGeneratePayload {
|
||||
userId: string;
|
||||
|
||||
@@ -36,6 +36,16 @@ import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
|
||||
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
|
||||
import { generateMetricStatus } from "@/lib/insights/metric-status";
|
||||
import { isMetricStatusId } from "@/lib/insights/metric-status-registry";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
computeDerivedMetric,
|
||||
loadBaselineProfile,
|
||||
} from "@/lib/insights/derived";
|
||||
import {
|
||||
generateDerivedScoreAssessment,
|
||||
parseDerivedScoreScope,
|
||||
} from "@/lib/insights/derived/derived-assessment-ai";
|
||||
import { isDerivedMetricId } from "@/lib/insights/derived/registry";
|
||||
import {
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
INSIGHT_STATUS_GENERATE_CONCURRENCY,
|
||||
@@ -105,6 +115,36 @@ export async function runInsightStatusGenerate(
|
||||
return;
|
||||
}
|
||||
|
||||
// v1.13.2 — a `derived-score:<ID>` scope warms the per-score assessment AI
|
||||
// prose. The route already filled the deterministic text synchronously; this
|
||||
// recomputes the derived value and persists the warmer prose so the next
|
||||
// read upgrades. A score that lost its data resolves to insufficient and the
|
||||
// generator no-ops without an LLM call.
|
||||
if (payload.metric.startsWith("derived-score:")) {
|
||||
const metricId = parseDerivedScoreScope(payload.metric);
|
||||
if (!metricId || !isDerivedMetricId(metricId)) {
|
||||
annotate({
|
||||
action: { name: "insights.status.generate.unknown_metric" },
|
||||
meta: { metric: payload.metric },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const profile = await loadBaselineProfile(prisma, payload.userId);
|
||||
const derived = await computeDerivedMetric({
|
||||
metric: metricId,
|
||||
userId: payload.userId,
|
||||
profile,
|
||||
type: null,
|
||||
});
|
||||
await generateDerivedScoreAssessment({
|
||||
metric: metricId,
|
||||
userId: payload.userId,
|
||||
derived,
|
||||
locale: payload.locale,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const generate = generators[payload.metric as InsightStatusMetric];
|
||||
if (!generate) {
|
||||
annotate({
|
||||
|
||||
@@ -1445,6 +1445,26 @@ const derivedProvenance = z
|
||||
})
|
||||
.meta({ id: "DerivedProvenance" });
|
||||
|
||||
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
|
||||
// on the derived response; the iOS field-name contract is LOCKED.
|
||||
const derivedAssessment = z
|
||||
.object({
|
||||
text: z
|
||||
.string()
|
||||
.describe(
|
||||
"Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.",
|
||||
),
|
||||
source: z
|
||||
.string()
|
||||
.describe(
|
||||
"'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.",
|
||||
),
|
||||
updatedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("When the text was produced / last warmed."),
|
||||
})
|
||||
.meta({ id: "DerivedAssessment" });
|
||||
|
||||
const derivedMetricResponse = z
|
||||
.object({
|
||||
metric: z
|
||||
@@ -1470,6 +1490,11 @@ const derivedMetricResponse = z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Why the value could not be produced; null when status is 'ok'."),
|
||||
assessment: derivedAssessment
|
||||
.nullable()
|
||||
.describe(
|
||||
"v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "DerivedMetricResponse",
|
||||
|
||||
Reference in new issue
Block a user