diff --git a/CHANGELOG.md b/CHANGELOG.md
index d66557cfc..30d833228 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b0120b86a..4b2c1c1db 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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` 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:
diff --git a/messages/de.json b/messages/de.json
index b6e9eff2d..8497c0d13 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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."
}
-}
\ No newline at end of file
+}
diff --git a/messages/en.json b/messages/en.json
index 5caff21fa..6fb5b62e9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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."
}
-}
\ No newline at end of file
+}
diff --git a/messages/es.json b/messages/es.json
index 16245afac..513ca1b2e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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."
}
-}
\ No newline at end of file
+}
diff --git a/messages/fr.json b/messages/fr.json
index 685bee44f..b2543a04a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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é."
}
-}
\ No newline at end of file
+}
diff --git a/messages/it.json b/messages/it.json
index e9f5270e6..e94caef04 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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."
}
-}
\ No newline at end of file
+}
diff --git a/messages/pl.json b/messages/pl.json
index 7166f54e0..6e87401a3 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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."
}
-}
\ No newline at end of file
+}
diff --git a/package.json b/package.json
index 784508b61..a7bd35db9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts
index ea0c8e22d..51d5802b9 100644
--- a/src/app/api/insights/derived/batch/route.ts
+++ b/src/app/api/insights/derived/batch/route.ts
@@ -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,
},
};
}),
diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts
index c14536150..0cc883acf 100644
--- a/src/app/api/insights/derived/route.ts
+++ b/src/app/api/insights/derived/route.ts
@@ -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,
});
});
diff --git a/src/app/globals.css b/src/app/globals.css
index edb59abff..4806a0bed 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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: "";
+ 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;
+ }
}
/*
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx
index 251020397..23c4c756a 100644
--- a/src/app/medications/page.tsx
+++ b/src/app/medications/page.tsx
@@ -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() {
// `` mounted below, keyed to the selected
// medication (reused, not duplicated per card).
const [advancedMed, setAdvancedMed] = useState(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")}
- {/* 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. */}
-
+ {/* 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. */}
+
+
+
+
+
+ setLogIntakeOpen(true)}
+ disabled={activeMeds.length === 0}
+ >
+
+ {t("medications.addChoice.logIntake")}
+
+
+
+ {t("medications.addChoice.newMedication")}
+
+
+
{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 && (
+ ({
+ 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
diff --git a/src/components/charts/mood-chart.tsx b/src/components/charts/mood-chart.tsx
index 904b2d607..50605a899 100644
--- a/src/components/charts/mood-chart.tsx
+++ b/src/components/charts/mood-chart.tsx
@@ -358,7 +358,8 @@ export function MoodChart({
const rawCount = useMemo(() => {
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 (
- ` paints `py-6 gap-6` —
- // ~48 px of vertical chrome that pulled the mood mini chart
- // band ~52 px lower than the `` 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 `
` 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 `` uses (`bg-card border-border rounded-md border p-2` with a
+ // plain `
` title), instead of the `//
+ // ` 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 ? (
+
+
+
+ ) : !chartData?.length ? (
+
+ {t("charts.noData")}
+
+ ) : 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 ? (
+
+ ) : (
+
+ )
+ ) : (
+
-
+
+ {/* 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. */}
+ {
+ // `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) => (
+
+ ))}
+ {/* v1.4.18 — personal-baseline gated behind the
+ Trend toggle (maintainer: "only when a trend is being
+ displayed"). Default OFF. */}
+ {showTrend && personalBaseline != null && (
+
+ )}
+
+ 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}
+ />
+
+ {
+ 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 ;
+ }}
+ />
+
+ {showMA && (
+
+ )}
+ {showTrend && (
+
+ )}
+ {/* 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 && (
+
+ )}
+
+
+
` title), so the
+ // three trend cards share one title baseline and one height.
+ if (mini) {
+ return (
+
+
+ {displayTitle}
+
+ {chartBody}
+
+ );
+ }
+
+ return (
+
+
{/* 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. */}
+ {TIME_RANGES_KEYS.map((r) => (
+
+ ))}
+ {/* 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 ? (
+
+ ) : null}
- {!mini && (
-
- {TIME_RANGES_KEYS.map((r) => (
-
- ))}
- {/* 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 ? (
-
- ) : null}
-
- )}
-
- {isLoading ? (
-
-
-
- ) : !chartData?.length ? (
-
- {t("charts.noData")}
-
- ) : 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 ? (
-
- ) : (
-
- )
- ) : (
-
-
-
- {/* 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. */}
- {
- // `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) => (
-
- ))}
- {/* v1.4.18 — personal-baseline gated behind the
- Trend toggle (maintainer: "only when a trend is being
- displayed"). Default OFF. */}
- {showTrend && personalBaseline != null && (
-
- )}
-
- 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}
- />
-
- {
- 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 (
-
- );
- }}
- />
-
- {showMA && (
-
- )}
- {showTrend && (
-
- )}
- {/* 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 && (
-
- )}
-
-
-
@@ -270,109 +273,121 @@ export function DailyBriefing({
const { t } = useTranslations();
return (
-
-
-
-
-
-
- {t("insights.dailyBriefing.title")}
-
-
- {metaSlot && (
-
- {metaSlot}
-
- )}
+ // v1.13.1 — heading-above-card pattern. The "Tagesbriefing" title
+ // moves OUT of the card into an `
` 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.
+
+
+ {/* 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 && (
-
- ) : (
- }
- 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.
-
- ) : null
- }
- />
- )}
-
-
+ {t("insights.heroGenerated", {
+ time: formatRelativeTime(updatedAt, t),
+ })}
+
+ )}
+
+ ) : (
+ }
+ 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.
+
+ ) : null
+ }
+ />
+ )}
+
+
+
);
}
diff --git a/src/components/insights/derived/ring-hues.ts b/src/components/insights/derived/ring-hues.ts
index 01f8aff83..e94d457c8 100644
--- a/src/components/insights/derived/ring-hues.ts
+++ b/src/components/insights/derived/ring-hues.ts
@@ -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--from` / `--ring--to` / `--tile-`) 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 `` 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
+ * `` stops.
*/
export const RING_GRADIENT: Record = {
- // 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 = {
+ 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 = {
* mixes (gently) over the theme `--card`. Set inline by each `RingTile`.
*/
export const TILE_HUE: Record = {
- 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)",
};
diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx
index 98f88857f..7cbd46b4a 100644
--- a/src/components/insights/derived/score-ring.tsx
+++ b/src/components/insights/derived/score-ring.tsx
@@ -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 `` swept by `stroke-dashoffset`,
- * filled with a JSX `` (NOT a `dangerouslySetInnerHTML` —
- * ``/`` are real SVG JSX). The `
- {/* 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. */}
- {/* The progress arc — single-hue gradient, round cap. */}
+ {/* "vs your baseline" ghost arc — thinner, faint, under the live arc. */}
+ {hasBaseline ? (
+
+ ) : null}
+ {/* The progress arc — single-hue gradient, round cap, spring sweep, bloom. */}
- {/* 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. */}
+
+ {/* Centred number as real DOM text — design tokens + tabular-nums. */}
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 (
@@ -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. */}
-
+ {
+ 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 = {
+ 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 (
{tiles}
diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx
index 5df13c204..2a8b4487f 100644
--- a/src/components/insights/insight-status-card.tsx
+++ b/src/components/insights/insight-status-card.tsx
@@ -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"
>
-
+
diff --git a/src/components/insights/mood/mood-factor-metric-crosstab.tsx b/src/components/insights/mood/mood-factor-metric-crosstab.tsx
new file mode 100644
index 000000000..bec66ed6f
--- /dev/null
+++ b/src/components/insights/mood/mood-factor-metric-crosstab.tsx
@@ -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
+ * 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 = {
+ 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 = {
+ 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 = {
+ low: "insights.mood.influence.confidenceLow",
+ medium: "insights.mood.influence.confidenceMedium",
+ high: "insights.mood.influence.confidenceHigh",
+};
+
+const CONFIDENCE_CLASS: Record = {
+ 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 (
+
+ {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 (
+
diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx
index 73744fd5f..708362399 100644
--- a/src/components/medications/__tests__/card-parts.test.tsx
+++ b/src/components/medications/__tests__/card-parts.test.tsx
@@ -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(
+ ,
+ );
+ expect(html).toBe("");
+ });
+
+ it("renders the neutral no-closed-cycles state with the dashed glyph", () => {
+ const html = render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(html).toContain("Next dose in 4 days");
+ });
+
+ it("on_track one day out reads tomorrow", () => {
+ const html = render(
+ ,
+ );
+ 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(
+ ,
+ );
+ expect(html).toContain("Next dose today");
+ });
+
+ it("due reads the amber due-today line", () => {
+ const html = render(
+ ,
+ );
+ 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(
+ ,
+ );
+ 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
diff --git a/src/components/medications/__tests__/use-medication-intake.test.ts b/src/components/medications/__tests__/use-medication-intake.test.ts
index 45be74e1a..48274afe0 100644
--- a/src/components/medications/__tests__/use-medication-intake.test.ts
+++ b/src/components/medications/__tests__/use-medication-intake.test.ts
@@ -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 });
diff --git a/src/components/medications/card-parts/medication-cycle-status.tsx b/src/components/medications/card-parts/medication-cycle-status.tsx
new file mode 100644
index 000000000..8ff46e633
--- /dev/null
+++ b/src/components/medications/card-parts/medication-cycle-status.tsx
@@ -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 (
+
+
+ {t("medications.cycleNoClosedCycles")}
+
+ );
+ }
+
+ let tone: string;
+ let icon: React.ReactNode;
+ let label: string;
+
+ if (cycle.state === "missed") {
+ tone = "text-destructive";
+ icon = ;
+ label = t("medications.cycleOverdue");
+ } else if (cycle.state === "due") {
+ tone = "text-warning";
+ icon = ;
+ label = t("medications.cycleDueToday");
+ } else {
+ // on_track — the next dose simply hasn't come round yet.
+ tone = "text-success";
+ icon = ;
+ 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 (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx
index 4f9462279..1f81a4d12 100644
--- a/src/components/medications/glp1-medication-card.tsx
+++ b/src/components/medications/glp1-medication-card.tsx
@@ -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({
))}
+ {/* 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 && (
+
+ )}
+
{/* 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
diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx
new file mode 100644
index 000000000..b94fdbd30
--- /dev/null
+++ b/src/components/medications/log-intake-dialog.tsx
@@ -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__";
+
+/**
+ * `` 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();
+ 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(
+ () => medications[0]?.id ?? "",
+ );
+ const [slot, setSlot] = useState(NO_SLOT);
+ const [takenAt, setTakenAt] = useState(() =>
+ 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 (
+
+ );
+}
diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx
index 66abd7be5..3f3a85607 100644
--- a/src/components/medications/medication-card.tsx
+++ b/src/components/medications/medication-card.tsx
@@ -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({
))}
+ {/* 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 && (
+
+ )}
+
{/* 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
diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts
index acafdbec3..08cb2b21d 100644
--- a/src/components/medications/use-medication-intake.ts
+++ b/src/components/medications/use-medication-intake.ts
@@ -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 {
+ const { medication, skipped, takenAt, scheduledFor, t, queryClient } = deps;
+ try {
+ const body: Record = { 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}.
diff --git a/src/hooks/use-count-up.ts b/src/hooks/use-count-up.ts
index ab4b6c1b0..57991d621 100644
--- a/src/hooks/use-count-up.ts
+++ b/src/hooks/use-count-up.ts
@@ -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 | 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;
}
diff --git a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts
index 8d0271b2c..8f9178570 100644
--- a/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts
+++ b/src/lib/ai/prompts/__tests__/assessment-prompt-contract.test.ts
@@ -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);
}
});
diff --git a/src/lib/ai/prompts/base-system.ts b/src/lib/ai/prompts/base-system.ts
index 010d312f4..9de0f1ca9 100644
--- a/src/lib/ai/prompts/base-system.ts
+++ b/src/lib/ai/prompts/base-system.ts
@@ -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.`,
},
{
diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts
index 131168053..012c1b9fa 100644
--- a/src/lib/insights/__tests__/correlation-discovery.test.ts
+++ b/src/lib/insights/__tests__/correlation-discovery.test.ts
@@ -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 ─────────────────────
diff --git a/src/lib/insights/__tests__/metric-signal.test.ts b/src/lib/insights/__tests__/metric-signal.test.ts
new file mode 100644
index 000000000..ef3f2a8ac
--- /dev/null
+++ b/src/lib/insights/__tests__/metric-signal.test.ts
@@ -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 {
+ 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");
+ });
+});
diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts
index 47bf54902..4acf39706 100644
--- a/src/lib/insights/__tests__/mood-aggregates.test.ts
+++ b/src/lib/insights/__tests__/mood-aggregates.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts
index 5ed1cd7aa..164ed9cde 100644
--- a/src/lib/insights/__tests__/mood-status.test.ts
+++ b/src/lib/insights/__tests__/mood-status.test.ts
@@ -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", () => {
diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts
index 959cb809e..318dc23c4 100644
--- a/src/lib/insights/correlation-discovery.ts
+++ b/src/lib/insights/correlation-discovery.ts
@@ -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:` channel (a RATED mood factor folded into the
+ * matrix) strips its namespace prefix so the phrasing reads "rated "
+ * 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.
*
diff --git a/src/lib/insights/derived/__tests__/derived-assessment.test.ts b/src/lib/insights/derived/__tests__/derived-assessment.test.ts
new file mode 100644
index 000000000..9aff6f211
--- /dev/null
+++ b/src/lib/insights/derived/__tests__/derived-assessment.test.ts
@@ -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(value: T): Derived {
+ 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 = {
+ 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");
+ });
+});
diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts
new file mode 100644
index 000000000..b683a50ea
--- /dev/null
+++ b/src/lib/insights/derived/derived-assessment-ai.ts
@@ -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:-status.` 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:`. */
+export function derivedScoreScope(
+ metric: DerivedMetricId,
+): `derived-score:${DerivedMetricId}` {
+ return `derived-score:${metric}`;
+}
+
+/** Read a `derived-score:` 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;
+ locale: string | null | undefined;
+ now?: Date;
+}): Promise {
+ 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;
+ locale: string | null | undefined;
+ now?: Date;
+}): Promise {
+ 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 },
+ });
+}
diff --git a/src/lib/insights/derived/derived-assessment.ts b/src/lib/insights/derived/derived-assessment.ts
new file mode 100644
index 000000000..c09c08ae4
--- /dev/null
+++ b/src/lib/insights/derived/derived-assessment.ts
@@ -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:-status.`. 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 = 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 = {
+ 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,
+ 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";
+}
diff --git a/src/lib/insights/metric-signal.ts b/src/lib/insights/metric-signal.ts
new file mode 100644
index 000000000..6642ad3d4
--- /dev/null
+++ b/src/lib/insights/metric-signal.ts
@@ -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;
+}
diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts
index 4855464d8..631aa5472 100644
--- a/src/lib/insights/metric-status.ts
+++ b/src/lib/insights/metric-status.ts
@@ -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
diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts
index 3570fe17c..cf43fabff 100644
--- a/src/lib/insights/mood-aggregates.ts
+++ b/src/lib/insights/mood-aggregates.ts
@@ -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
+ * low, your 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 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;
+ /** Day key → factor's daily-mean score, inverse-flipped if needed. */
+ byDay: Map;
+}
+
+/**
+ * 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 {
+ const cutoff = now.getTime() - windowDays * MS_PER_DAY;
+ const acc = new Map<
+ string,
+ {
+ ref: FactorDailySeries["ref"];
+ byDay: Map;
+ }
+ >();
+ 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(),
+ };
+ 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();
+ for (const [key, slot] of acc) {
+ const byDay = new Map();
+ 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;
+}
+
+/**
+ * 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([
...(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,
]),
),
},
diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts
index 02ab43694..0da269f7c 100644
--- a/src/lib/insights/mood-status.ts
+++ b/src/lib/insights/mood-status.ts
@@ -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 {
+ 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 low,
+ // your 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
diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts
index d0cd5c65a..3e0b7aa26 100644
--- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts
+++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts
@@ -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();
+ 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", () => {
diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts
index 1641b2314..e363d4852 100644
--- a/src/lib/insights/narrative/period-narrative.ts
+++ b/src/lib/insights/narrative/period-narrative.ts
@@ -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:` series per
+ * factor the user actually rated. Pure.
+ */
+function factorDailyMeans(
+ links: FactorLink[],
+ tz: string,
+): Map {
+ const byFactor = new Map>();
+ 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();
+ 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();
+ 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:` 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:`.
+ 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,
diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts
index bbe2a95ad..42ac9c741 100644
--- a/src/lib/jobs/insight-status-generate-shared.ts
+++ b/src/lib/jobs/insight-status-generate-shared.ts
@@ -56,7 +56,18 @@ export const INSIGHT_STATUS_METRICS: ReadonlyArray = [
* 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:`; 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;
diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts
index 9ee562b2a..1ca8d2a28 100644
--- a/src/lib/jobs/insight-status-generate.ts
+++ b/src/lib/jobs/insight-status-generate.ts
@@ -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:` 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({
diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts
index 276e31402..ac8bd49a2 100644
--- a/src/lib/openapi/routes.ts
+++ b/src/lib/openapi/routes.ts
@@ -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",