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 && ( + + )} + + +
+ ); + + // v1.13.1 — mini mode: flat shell that exactly mirrors `` (bg-card border rounded-md p-2 + plain `
` 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. */}
- + {displayTitle} - {activeBucket !== "day" && !mini && ( + {activeBucket !== "day" && ( {t( activeBucket === "week" @@ -628,389 +924,63 @@ export function MoodChart({ )} {/* v1.4.16 B8 — comparison caption (mood). v1.4.19 A2 — hidden on mobile to free up the title row. */} - {!mini && - effectiveCompareBaseline !== "none" && - hasComparisonData && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.captionLastMonth" - : "comparison.captionLastYear", - )} - - )} - {!mini && - effectiveCompareBaseline !== "none" && - !hasComparisonData && ( - - {t( - effectiveCompareBaseline === "lastMonth" - ? "comparison.unavailable.lastMonth" - : "comparison.unavailable.lastYear", - )} - - )} + {effectiveCompareBaseline !== "none" && hasComparisonData && ( + + {t( + effectiveCompareBaseline === "lastMonth" + ? "comparison.captionLastMonth" + : "comparison.captionLastYear", + )} + + )} + {effectiveCompareBaseline !== "none" && !hasComparisonData && ( + + {t( + effectiveCompareBaseline === "lastMonth" + ? "comparison.unavailable.lastMonth" + : "comparison.unavailable.lastYear", + )} + + )} +
+
+ {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 && ( - - )} - - -
- )} -
+ {chartBody}
); } diff --git a/src/components/insights/coach-panel/coach-settings-sheet.tsx b/src/components/insights/coach-panel/coach-settings-sheet.tsx index 909304064..180612100 100644 --- a/src/components/insights/coach-panel/coach-settings-sheet.tsx +++ b/src/components/insights/coach-panel/coach-settings-sheet.tsx @@ -240,72 +240,80 @@ export function CoachSettingsSheet({
) : (
- {/* Tone */} -
- - -
+ {t("insights.coach.settingsToneLabel")} + + +
- {/* Verbosity */} -
- - + {t("insights.coach.settingsVerbosityLabel")} + + +
{/* v1.4.25 W5 — default analysis window. Sets the snapshot @@ -496,7 +504,10 @@ export function CoachSettingsSheet({ data-slot="coach-prefs-save" > {save.isPending ? ( -