diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b0fdb88c..c4c3e0b84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## [Unreleased] +## [1.30.10] — 2026-07-18 + +Localized insight text, and a richer workout view. + +- **Correlation insights now read in your language.** The "this tends to go with that — a pattern worth watching, not a cause" lines under a metric were always written in English; they now render in your interface language (German, Spanish, French, Italian, Polish), with the association-not-causation framing preserved in each. +- **A workout you open shows more of what was recorded.** The detail view now draws the session's heart-rate curve over time, its route as a clean line, the time spent in each effort zone, per-kilometre splits, and how it compares to your own average for that sport — all from data already synced, with no new connection. Anything a given workout didn't record simply isn't shown. + +No migration. No breaking changes. + ## [1.30.9] — 2026-07-18 Faster dashboard first load. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf15a1967..b7bf5f4eb 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.30.9 + version: 1.30.10 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2668,14 +2668,23 @@ paths: - Measurements summary: Workout detail (v1.4.32) description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that - resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows - surface as 404 (existence channel sealed). + resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive + enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed + server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and + `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 + contract. Cross-user rows surface as 404 (existence channel sealed). parameters: - in: path name: id schema: type: string required: true + - in: query + name: compact + schema: + type: string + enum: + - "1" responses: "200": description: Workout detail. @@ -17429,6 +17438,10 @@ components: anyOf: - type: string - type: "null" + hasRoute: + type: boolean + hasHrSeries: + type: boolean required: - id - sportType @@ -17441,6 +17454,8 @@ components: - maxHr - source - externalId + - hasRoute + - hasHrSeries additionalProperties: false GetWorkoutResponse: type: object @@ -17553,6 +17568,26 @@ components: anyOf: - $ref: "#/components/schemas/WorkoutHrSeries" - type: "null" + hrSeries: + anyOf: + - $ref: "#/components/schemas/WorkoutHrCurve" + - type: "null" + zones: + anyOf: + - $ref: "#/components/schemas/WorkoutZones" + - type: "null" + splits: + anyOf: + - type: array + items: + $ref: "#/components/schemas/WorkoutSplit" + - type: "null" + sportContext: + anyOf: + - $ref: "#/components/schemas/WorkoutSportContext" + - type: "null" + aiInsight: + type: "null" canonicalId: type: string required: @@ -17574,6 +17609,11 @@ components: - metadata - route - samples + - hrSeries + - zones + - splits + - sportContext + - aiInsight - canonicalId additionalProperties: false WorkoutRouteGeometry: @@ -17602,6 +17642,150 @@ components: - sampleCount - samples additionalProperties: false + WorkoutHrCurve: + type: object + properties: + source: + type: string + enum: + - workout_series + - pulse_window + bucketSec: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + points: + type: array + items: + type: object + properties: + tSec: + type: integer + minimum: 0 + maximum: 9007199254740991 + mean: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + min: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + max: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - tSec + - mean + - min + - max + additionalProperties: false + envelope: + type: boolean + required: + - source + - bucketSec + - points + - envelope + additionalProperties: false + WorkoutZones: + type: object + properties: + model: + type: string + enum: + - whoop + - tanaka + hrMax: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + zones: + type: array + items: + type: object + properties: + zone: + type: integer + minimum: 1 + maximum: 5 + lowBpm: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + highBpm: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + seconds: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - zone + - lowBpm + - highBpm + - seconds + additionalProperties: false + required: + - model + - hrMax + - zones + additionalProperties: false + WorkoutSplit: + type: object + properties: + km: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + durationSec: + type: integer + minimum: 0 + maximum: 9007199254740991 + paceSecPerKm: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - km + - durationSec + - paceSecPerKm + additionalProperties: false + WorkoutSportContext: + type: object + properties: + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + avgDurationSec: + type: integer + minimum: 0 + maximum: 9007199254740991 + avgDistanceM: + anyOf: + - type: number + - type: "null" + avgAvgHr: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + required: + - count + - avgDurationSec + - avgDistanceM + - avgAvgHr + additionalProperties: false BatchWorkoutsResponse: type: object properties: diff --git a/eslint.config.mjs b/eslint.config.mjs index 9d66c3ee6..ec45d0f14 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -109,6 +109,8 @@ const eslintConfig = defineConfig([ "src/components/medications/dose-strength-curve.tsx", "src/components/medications/drug-level-chart.tsx", "src/components/mental-health/assessment-history-chart.tsx", + "src/components/charts/workout-hr-chart.tsx", + "src/components/charts/workout-elevation-chart.tsx", ], rules: { "no-restricted-imports": [ diff --git a/messages/de.json b/messages/de.json index 743eecfdc..c638bd350 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1979,6 +1979,61 @@ "notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar" }, "insights": { + "correlation": { + "direction": { + "higher": "höher", + "lower": "niedriger" + }, + "weekdayDirection": { + "above": "über", + "below": "unter" + }, + "channelFactor": "die Bewertung von {factor}", + "channel": { + "WEIGHT": "das Gewicht", + "BLOOD_GLUCOSE": "der Blutzucker", + "BLOOD_PRESSURE_SYS": "der systolische Blutdruck", + "BLOOD_PRESSURE_DIA": "der diastolische Blutdruck", + "ACTIVITY_STEPS": "die tägliche Schrittzahl", + "SLEEP_DURATION": "die Schlafdauer", + "HEART_RATE_VARIABILITY": "die Herzratenvariabilität", + "RESTING_HEART_RATE": "die Ruheherzfrequenz", + "TIME_IN_DAYLIGHT": "die Zeit im Tageslicht", + "MOOD": "die Stimmung", + "PULSE": "der Ruhepuls", + "MEDICATION_COMPLIANCE": "die Medikamententreue", + "SYMPTOM_SEVERITY": "die Symptomstärke", + "ENV_TEMP_MEAN": "die Tagestemperatur", + "ENV_TEMP_MIN": "die nächtliche Tiefsttemperatur", + "ENV_SUNSHINE": "der Sonnenschein", + "ENV_DAYLIGHT": "das Tageslicht", + "ENV_PRECIP": "der Niederschlag", + "ENV_PRESSURE_MEAN": "der Luftdruck", + "ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag" + }, + "daily": { + "high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.", + "moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.", + "faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache." + }, + "lab": { + "default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.", + "faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache." + }, + "hypothesis": { + "bpCompliance": { + "support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.", + "surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.", + "none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam." + }, + "moodPulse": { + "support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.", + "surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.", + "none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam." + }, + "weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient." + } + }, "emptyTitle": "Noch nicht genug Daten", "emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.", "emptyAddMeasurement": "Messwert erfassen", @@ -2934,7 +2989,24 @@ "hrChartTitle": "Herzfrequenz", "hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.", "notFound": "Workout nicht gefunden.", - "loading": "Workout wird geladen…" + "loading": "Workout wird geladen…", + "hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit", + "hrAxisMinutes": "Min.", + "sportAverageLabel": "Dein aktueller Durchschnitt:", + "zonesTitle": "Belastungszonen", + "zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.", + "zonesCaptionDevice": "Zeit pro Zone laut deinem Gerät.", + "exportGpx": "GPX exportieren", + "elevationTitle": "Höhe", + "splitsTitle": "Kilometerzeiten", + "splitsKm": "km", + "splitsTime": "Zeit", + "splitsPace": "Tempo", + "thatDayTitle": "An diesem Tag", + "dayPulse": "Puls", + "daySleep": "Schlaf", + "dayMood": "Stimmung", + "insightTitle": "Aktivitäts-Einblick" }, "sport": { "walking": "Gehen", diff --git a/messages/en.json b/messages/en.json index 60fa17d56..6ea7a84ed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1979,6 +1979,61 @@ "notAvailableForCompliance": "Comparison N/A for adherence" }, "insights": { + "correlation": { + "direction": { + "higher": "higher", + "lower": "lower" + }, + "weekdayDirection": { + "above": "above", + "below": "below" + }, + "channelFactor": "rated {factor}", + "channel": { + "WEIGHT": "weight", + "BLOOD_GLUCOSE": "blood glucose", + "BLOOD_PRESSURE_SYS": "systolic blood pressure", + "BLOOD_PRESSURE_DIA": "diastolic blood pressure", + "ACTIVITY_STEPS": "daily steps", + "SLEEP_DURATION": "sleep duration", + "HEART_RATE_VARIABILITY": "heart-rate variability", + "RESTING_HEART_RATE": "resting heart rate", + "TIME_IN_DAYLIGHT": "time in daylight", + "MOOD": "mood", + "PULSE": "resting pulse", + "MEDICATION_COMPLIANCE": "medication compliance", + "SYMPTOM_SEVERITY": "symptom severity", + "ENV_TEMP_MEAN": "daily temperature", + "ENV_TEMP_MIN": "overnight low temperature", + "ENV_SUNSHINE": "sunshine", + "ENV_DAYLIGHT": "daylight", + "ENV_PRECIP": "precipitation", + "ENV_PRESSURE_MEAN": "barometric pressure", + "ENV_PRESSURE_DELTA": "intraday pressure swing" + }, + "daily": { + "high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.", + "moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.", + "faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause." + }, + "lab": { + "default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.", + "faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause." + }, + "hypothesis": { + "bpCompliance": { + "support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.", + "surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.", + "none": "Compliance and systolic do not move together strongly in this window." + }, + "moodPulse": { + "support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.", + "surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.", + "none": "Mood and resting pulse do not move together strongly in this window." + }, + "weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching." + } + }, "noAnalysisYet": "No assessment yet.", "assessmentPreparing": "Preparing your assessment…", "emptyTitle": "Not enough data yet", @@ -2934,7 +2989,24 @@ "hrChartTitle": "Heart rate", "hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.", "notFound": "Workout not found.", - "loading": "Loading workout…" + "loading": "Loading workout…", + "hrFromPulse": "From your heart-rate data around this session", + "hrAxisMinutes": "min", + "sportAverageLabel": "Your recent average:", + "zonesTitle": "Effort zones", + "zonesCaption": "How your effort was distributed across heart-rate zones.", + "zonesCaptionDevice": "Time in zone as reported by your device.", + "exportGpx": "Export GPX", + "elevationTitle": "Elevation", + "splitsTitle": "Splits", + "splitsKm": "km", + "splitsTime": "Time", + "splitsPace": "Pace", + "thatDayTitle": "That day", + "dayPulse": "Pulse", + "daySleep": "Sleep", + "dayMood": "Mood", + "insightTitle": "Activity insight" }, "sport": { "walking": "Walking", diff --git a/messages/es.json b/messages/es.json index 30ed1b10d..a9b18f535 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1979,6 +1979,61 @@ "notAvailableForCompliance": "Comparación no disponible para el cumplimiento" }, "insights": { + "correlation": { + "direction": { + "higher": "subir", + "lower": "bajar" + }, + "weekdayDirection": { + "above": "por encima de", + "below": "por debajo de" + }, + "channelFactor": "la valoración de {factor}", + "channel": { + "WEIGHT": "el peso", + "BLOOD_GLUCOSE": "la glucosa en sangre", + "BLOOD_PRESSURE_SYS": "la presión sistólica", + "BLOOD_PRESSURE_DIA": "la presión diastólica", + "ACTIVITY_STEPS": "los pasos diarios", + "SLEEP_DURATION": "la duración del sueño", + "HEART_RATE_VARIABILITY": "la variabilidad de la frecuencia cardíaca", + "RESTING_HEART_RATE": "la frecuencia cardíaca en reposo", + "TIME_IN_DAYLIGHT": "el tiempo con luz natural", + "MOOD": "el estado de ánimo", + "PULSE": "el pulso en reposo", + "MEDICATION_COMPLIANCE": "la adherencia a la medicación", + "SYMPTOM_SEVERITY": "la intensidad de los síntomas", + "ENV_TEMP_MEAN": "la temperatura diaria", + "ENV_TEMP_MIN": "la temperatura mínima nocturna", + "ENV_SUNSHINE": "las horas de sol", + "ENV_DAYLIGHT": "la luz natural", + "ENV_PRECIP": "las precipitaciones", + "ENV_PRESSURE_MEAN": "la presión atmosférica", + "ENV_PRESSURE_DELTA": "la variación de presión del día" + }, + "daily": { + "high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.", + "moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.", + "faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa." + }, + "lab": { + "default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.", + "faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa." + }, + "hypothesis": { + "bpCompliance": { + "support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.", + "surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.", + "none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo." + }, + "moodPulse": { + "support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.", + "surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.", + "none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo." + }, + "weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar." + } + }, "noAnalysisYet": "Aún no hay valoración.", "assessmentPreparing": "Preparando tu valoración…", "emptyTitle": "Aún no hay datos suficientes", @@ -2934,7 +2989,24 @@ "hrChartTitle": "Frecuencia cardíaca", "hrChartUnavailable": "Las muestras de frecuencia cardíaca por segundo no se almacenan para este entrenamiento.", "notFound": "Entrenamiento no encontrado.", - "loading": "Cargando entrenamiento…" + "loading": "Cargando entrenamiento…", + "hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión", + "hrAxisMinutes": "min", + "sportAverageLabel": "Tu promedio reciente:", + "zonesTitle": "Zonas de esfuerzo", + "zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.", + "zonesCaptionDevice": "Tiempo por zona según tu dispositivo.", + "exportGpx": "Exportar GPX", + "elevationTitle": "Altitud", + "splitsTitle": "Parciales", + "splitsKm": "km", + "splitsTime": "Tiempo", + "splitsPace": "Ritmo", + "thatDayTitle": "Ese día", + "dayPulse": "Pulso", + "daySleep": "Sueño", + "dayMood": "Ánimo", + "insightTitle": "Análisis de la actividad" }, "sport": { "walking": "Caminar", diff --git a/messages/fr.json b/messages/fr.json index 7187478d0..409c462fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1979,6 +1979,61 @@ "notAvailableForCompliance": "Comparaison indisponible pour l’observance" }, "insights": { + "correlation": { + "direction": { + "higher": "augmenter", + "lower": "diminuer" + }, + "weekdayDirection": { + "above": "au-dessus de", + "below": "en dessous de" + }, + "channelFactor": "l'évaluation de {factor}", + "channel": { + "WEIGHT": "le poids", + "BLOOD_GLUCOSE": "la glycémie", + "BLOOD_PRESSURE_SYS": "la pression systolique", + "BLOOD_PRESSURE_DIA": "la pression diastolique", + "ACTIVITY_STEPS": "le nombre de pas quotidien", + "SLEEP_DURATION": "la durée de sommeil", + "HEART_RATE_VARIABILITY": "la variabilité de la fréquence cardiaque", + "RESTING_HEART_RATE": "la fréquence cardiaque au repos", + "TIME_IN_DAYLIGHT": "le temps passé à la lumière du jour", + "MOOD": "l'humeur", + "PULSE": "le pouls au repos", + "MEDICATION_COMPLIANCE": "l'observance des médicaments", + "SYMPTOM_SEVERITY": "la sévérité des symptômes", + "ENV_TEMP_MEAN": "la température quotidienne", + "ENV_TEMP_MIN": "la température minimale nocturne", + "ENV_SUNSHINE": "l'ensoleillement", + "ENV_DAYLIGHT": "la lumière du jour", + "ENV_PRECIP": "les précipitations", + "ENV_PRESSURE_MEAN": "la pression atmosphérique", + "ENV_PRESSURE_DELTA": "la variation de pression sur la journée" + }, + "daily": { + "high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.", + "moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.", + "faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause." + }, + "lab": { + "default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.", + "faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause." + }, + "hypothesis": { + "bpCompliance": { + "support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.", + "surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.", + "none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période." + }, + "moodPulse": { + "support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.", + "surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.", + "none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période." + }, + "weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller." + } + }, "noAnalysisYet": "Pas encore d’évaluation.", "assessmentPreparing": "Préparation de votre évaluation…", "emptyTitle": "Pas encore assez de données", @@ -2934,7 +2989,24 @@ "hrChartTitle": "Fréquence cardiaque", "hrChartUnavailable": "Les échantillons de fréquence cardiaque à la seconde ne sont pas stockés pour cet entraînement.", "notFound": "Entraînement introuvable.", - "loading": "Chargement de l'entraînement…" + "loading": "Chargement de l'entraînement…", + "hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance", + "hrAxisMinutes": "min", + "sportAverageLabel": "Votre moyenne récente :", + "zonesTitle": "Zones d'effort", + "zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.", + "zonesCaptionDevice": "Temps par zone indiqué par votre appareil.", + "exportGpx": "Exporter le GPX", + "elevationTitle": "Dénivelé", + "splitsTitle": "Temps par kilomètre", + "splitsKm": "km", + "splitsTime": "Temps", + "splitsPace": "Allure", + "thatDayTitle": "Ce jour-là", + "dayPulse": "Pouls", + "daySleep": "Sommeil", + "dayMood": "Humeur", + "insightTitle": "Analyse de l'activité" }, "sport": { "walking": "Marche", diff --git a/messages/it.json b/messages/it.json index 308233826..f2a132f72 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1979,6 +1979,61 @@ "notAvailableForCompliance": "Confronto non disponibile per l’aderenza" }, "insights": { + "correlation": { + "direction": { + "higher": "salire", + "lower": "scendere" + }, + "weekdayDirection": { + "above": "sopra", + "below": "sotto" + }, + "channelFactor": "la valutazione di {factor}", + "channel": { + "WEIGHT": "il peso", + "BLOOD_GLUCOSE": "la glicemia", + "BLOOD_PRESSURE_SYS": "la pressione sistolica", + "BLOOD_PRESSURE_DIA": "la pressione diastolica", + "ACTIVITY_STEPS": "i passi giornalieri", + "SLEEP_DURATION": "la durata del sonno", + "HEART_RATE_VARIABILITY": "la variabilità della frequenza cardiaca", + "RESTING_HEART_RATE": "la frequenza cardiaca a riposo", + "TIME_IN_DAYLIGHT": "il tempo alla luce del giorno", + "MOOD": "l'umore", + "PULSE": "il polso a riposo", + "MEDICATION_COMPLIANCE": "l'aderenza ai farmaci", + "SYMPTOM_SEVERITY": "la gravità dei sintomi", + "ENV_TEMP_MEAN": "la temperatura giornaliera", + "ENV_TEMP_MIN": "la temperatura minima notturna", + "ENV_SUNSHINE": "il soleggiamento", + "ENV_DAYLIGHT": "la luce del giorno", + "ENV_PRECIP": "le precipitazioni", + "ENV_PRESSURE_MEAN": "la pressione atmosferica", + "ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata" + }, + "daily": { + "high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.", + "moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.", + "faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa." + }, + "lab": { + "default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.", + "faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa." + }, + "hypothesis": { + "bpCompliance": { + "support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.", + "surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.", + "none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo." + }, + "moodPulse": { + "support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.", + "surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.", + "none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo." + }, + "weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare." + } + }, "noAnalysisYet": "Ancora nessuna valutazione.", "assessmentPreparing": "Preparazione della valutazione…", "emptyTitle": "Dati ancora insufficienti", @@ -2934,7 +2989,24 @@ "hrChartTitle": "Frequenza cardiaca", "hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.", "notFound": "Allenamento non trovato.", - "loading": "Caricamento allenamento…" + "loading": "Caricamento allenamento…", + "hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione", + "hrAxisMinutes": "min", + "sportAverageLabel": "La tua media recente:", + "zonesTitle": "Zone di sforzo", + "zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.", + "zonesCaptionDevice": "Tempo per zona riportato dal tuo dispositivo.", + "exportGpx": "Esporta GPX", + "elevationTitle": "Dislivello", + "splitsTitle": "Parziali", + "splitsKm": "km", + "splitsTime": "Tempo", + "splitsPace": "Ritmo", + "thatDayTitle": "Quel giorno", + "dayPulse": "Battito", + "daySleep": "Sonno", + "dayMood": "Umore", + "insightTitle": "Analisi dell'attività" }, "sport": { "walking": "Camminata", diff --git a/messages/pl.json b/messages/pl.json index 0f6a7dd5d..c64233093 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1979,6 +1979,61 @@ "notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania" }, "insights": { + "correlation": { + "direction": { + "higher": "wzrostu", + "lower": "spadku" + }, + "weekdayDirection": { + "above": "powyżej", + "below": "poniżej" + }, + "channelFactor": "ocena czynnika {factor}", + "channel": { + "WEIGHT": "waga", + "BLOOD_GLUCOSE": "poziom cukru we krwi", + "BLOOD_PRESSURE_SYS": "ciśnienie skurczowe", + "BLOOD_PRESSURE_DIA": "ciśnienie rozkurczowe", + "ACTIVITY_STEPS": "dzienna liczba kroków", + "SLEEP_DURATION": "długość snu", + "HEART_RATE_VARIABILITY": "zmienność rytmu serca", + "RESTING_HEART_RATE": "spoczynkowe tętno", + "TIME_IN_DAYLIGHT": "czas w świetle dziennym", + "MOOD": "nastrój", + "PULSE": "tętno spoczynkowe", + "MEDICATION_COMPLIANCE": "przyjmowanie leków", + "SYMPTOM_SEVERITY": "nasilenie objawów", + "ENV_TEMP_MEAN": "temperatura dobowa", + "ENV_TEMP_MIN": "nocna temperatura minimalna", + "ENV_SUNSHINE": "nasłonecznienie", + "ENV_DAYLIGHT": "światło dzienne", + "ENV_PRECIP": "opady", + "ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne", + "ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia" + }, + "daily": { + "high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.", + "moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.", + "faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna." + }, + "lab": { + "default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.", + "faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna." + }, + "hypothesis": { + "bpCompliance": { + "support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.", + "surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.", + "none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie." + }, + "moodPulse": { + "support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.", + "surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.", + "none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie." + }, + "weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji." + } + }, "noAnalysisYet": "Brak oceny.", "assessmentPreparing": "Przygotowywanie oceny…", "emptyTitle": "Jeszcze za mało danych", @@ -2934,7 +2989,24 @@ "hrChartTitle": "Tętno", "hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.", "notFound": "Nie znaleziono treningu.", - "loading": "Wczytywanie treningu…" + "loading": "Wczytywanie treningu…", + "hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji", + "hrAxisMinutes": "min", + "sportAverageLabel": "Twoja ostatnia średnia:", + "zonesTitle": "Strefy wysiłku", + "zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.", + "zonesCaptionDevice": "Czas w strefach według Twojego urządzenia.", + "exportGpx": "Eksportuj GPX", + "elevationTitle": "Wznios", + "splitsTitle": "Międzyczasy", + "splitsKm": "km", + "splitsTime": "Czas", + "splitsPace": "Tempo", + "thatDayTitle": "Tego dnia", + "dayPulse": "Tętno", + "daySleep": "Sen", + "dayMood": "Nastrój", + "insightTitle": "Analiza aktywności" }, "sport": { "walking": "Marsz", diff --git a/package.json b/package.json index abf5b5906..04cdff980 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.9", + "version": "1.30.10", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 472661840..8ec0244fc 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.9"; + /* @sw-version-fallback */ "v1.30.10"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 7225c11d0..b33064a19 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -22,6 +22,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode"; import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs"; import { deriveBpWindow90 } from "@/lib/analytics/window-confidence"; import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { type Locale } from "@/lib/i18n/config"; import { computeGlucoseClinicalMetrics, GLUCOSE_PANEL_WINDOW_DAYS, @@ -96,12 +98,18 @@ export const GET = apiHandler(async (request?: Request) => { // thick body is the 30-query chain feeding the dashboard + insights // hero score; after a measurement sync the next mount serves the // prior body instantly while one background recompute refreshes it. + // v1.30.10 — the `correlations` block carries user-facing narration that is + // localised per reader, so the cache key MUST include the locale; otherwise a + // language switch would serve another locale's cached interpretation for up to + // the TTL. Every other block is locale-independent, so per-locale entries only + // cost a little duplication (most users have one locale). + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); const cachedBody = await cachedSwr( caches.analytics as ServerCache< Awaited> >, - `${user.id}|default`, - () => buildAnalyticsResponse(user), + `${user.id}|default|${locale}`, + () => buildAnalyticsResponse(user, locale), annotate, ); @@ -173,7 +181,7 @@ function enrichLastSeenDaysAgo< * matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` → * `buildCoachSnapshotImpl` shape. */ -async function buildAnalyticsResponse(user: AuthedUser) { +async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { // v1.4.37.1 hotfix — fire-and-forget the rollup-fresh check. // Awaiting it on the read path can stall the Node event loop for // 30–60 s on a power-user account whose iOS step samples keep the @@ -405,6 +413,7 @@ async function buildAnalyticsResponse(user: AuthedUser) { userTz, now: new Date(), coverage, + locale, }); // v1.4.20 phase B5 — Personal Health Score. Server-deterministic diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 63a4624fe..763ea82c8 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import type { MeasurementType } from "@/generated/prisma/client"; @@ -82,6 +83,11 @@ export const GET = apiHandler(async () => { select: { timezone: true }, }); const tz = profile?.timezone ?? "Europe/Berlin"; + // Reader's locale for the narrated `interpretation` — the correlation cards + // render this string verbatim, so it MUST be localised (cookie / User.locale / + // Accept-Language). Without it the never-causal sentence leaked English into a + // non-English UI. + const locale = await resolveServerLocale({ userLocale: user.locale ?? null }); const now = new Date(); const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); @@ -165,7 +171,7 @@ export const GET = apiHandler(async () => { series.push(envSeries); } - const result = discoverCorrelations(series); + const result = discoverCorrelations(series, { locale }); // v1.22 — rolling early-detection pass over the trailing window, re-using // the already-built series (no extra DB read). Emerging pairs exclude anything @@ -176,11 +182,14 @@ export const GET = apiHandler(async () => { ); const emerging = discoverEmergingCorrelations(series, result, { recentFromDayKey, + locale, }); // v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). Degrades // to absent when the user has too few draws to clear the per-pair floor. - const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series); + const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series, { + locale, + }); annotate({ action: { name: "insights.correlations.discover" }, diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index d4f55e537..4cf93eae1 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -30,13 +30,31 @@ import { apiError, apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { requireModuleEnabled } from "@/lib/modules/gate"; +import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets"; +import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series"; +import { + computeZones, + hrMaxFromAge, + parseWhoopZoneDurations, +} from "@/lib/workouts/zones"; +import { computeSplits } from "@/lib/workouts/splits"; +import type { RouteCoordinate } from "@/lib/workouts/route-svg"; type RouteParams = { params: Promise<{ id: string }> }; +/** Own-history lookback for the sport-average comparison line. */ +const SPORT_CONTEXT_LOOKBACK_DAYS = 180; +/** Slim-row cap for the sport-average read. */ +const SPORT_CONTEXT_MAX_ROWS = 500; + export const GET = apiHandler( - async (_request: NextRequest, { params }: RouteParams) => { + async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); const { id } = await params; + // Web always sends `compact=1` to drop the raw 30k-sample / route + // timestamp blobs. Absent the param the response is byte-identical to + // the v1.4.32 contract → iOS is untouched, no coordination ticket. + const compact = request.nextUrl.searchParams.get("compact") === "1"; annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } }); @@ -98,7 +116,7 @@ export const GET = apiHandler( const userRow = await prisma.user.findUnique({ where: { id: user.id }, - select: { sourcePriorityJson: true }, + select: { sourcePriorityJson: true, dateOfBirth: true }, }); const canonicalCluster = pickCanonicalWorkoutRows( clusterRows, @@ -112,20 +130,82 @@ export const GET = apiHandler( canonicalCluster[0] ?? null; + // ── Enrichment reads (all over existing tables — no migration) ──── + + // Heart-rate curve: stored series first, pulse-window fallback + // second, hide third. One server path, one DTO with provenance. + const hrSeries = await buildWorkoutHrSeries({ + userId: user.id, + startedAt: row.startedAt, + endedAt: row.endedAt, + durationSec: row.durationSec, + storedSamples: row.samples?.samples ?? null, + }); + annotate({ + action: { name: "workouts.detail.hr_series" }, + meta: { + source: hrSeries?.source ?? "none", + points: hrSeries?.points.length ?? 0, + }, + }); + + // Effort zones: WHOOP device durations win; else %HRmax fold from + // the series when profile age exists; else null. + const ageYears = getAgeFromDateOfBirth(userRow?.dateOfBirth ?? null); + const zones = computeZones({ + hrMax: hrMaxFromAge(ageYears), + series: hrSeries?.points ?? [], + bucketSec: hrSeries?.bucketSec ?? 0, + whoopZoneDurations: parseWhoopZoneDurations(row.metadata), + }); + + // Per-km splits computed server-side from the geometry + timestamps + // so the web client keeps dropping the raw timestamp blob under + // `compact=1` while still rendering splits (server-authoritative + // parity — iOS gets the same resolved figures). + const geometryCoords = + row.route && + row.route.geometry && + typeof row.route.geometry === "object" && + Array.isArray( + (row.route.geometry as { coordinates?: unknown }).coordinates, + ) + ? (row.route.geometry as { coordinates: RouteCoordinate[] }).coordinates + : null; + const splits = + geometryCoords && Array.isArray(row.route?.sampleTimestamps) + ? computeSplits(geometryCoords, row.route.sampleTimestamps as string[]) + : null; + + // Sport context: the user's own last-180-days average for this + // sport, cross-source-collapsed so twins don't double-count. + const sportContext = await buildSportContext( + user.id, + row.sportType, + userRow?.sourcePriorityJson ?? null, + ); + const route = row.route ? { geometry: row.route.geometry, - sampleTimestamps: row.route.sampleTimestamps ?? null, + // `compact=1` drops the (up to 20k-entry) timestamp array; the + // SVG needs geometry, not the per-sample timestamps, and the + // splits above are already derived from them server-side. + sampleTimestamps: compact + ? null + : (row.route.sampleTimestamps ?? null), } : null; // v1.10.0 — route-independent per-workout HR series. Present for // both indoor (no route) and outdoor workouts that shipped a - // `samples` array on ingest; null otherwise. + // `samples` array on ingest; null otherwise. `compact=1` keeps the + // denormalised count but drops the raw sample blob (the web curve + // reads `hrSeries` instead). const samples = row.samples ? { sampleCount: row.samples.sampleCount, - samples: row.samples.samples, + samples: compact ? null : row.samples.samples, } : null; @@ -148,6 +228,17 @@ export const GET = apiHandler( metadata: row.metadata, route, samples, + // #67 enrichment — all additive, all over existing tables. + hrSeries, + zones, + splits, + sportContext, + // Reserved Activity-Insight seam (strategic-concept Wave C, bet 5): + // always null today. The Phase-2 pg-boss job writes a cached + // paragraph onto the workout row and maps it here — the detail page + // already composes `{aiInsight ? : null}`, so the card + // mounts with zero layout rework. + aiInsight: null, // v1.4.32 — when the requested id is a non-canonical twin the // caller can redirect to `canonicalId` to land on the cluster // winner. `canonicalId === id` when the requested row already @@ -156,3 +247,64 @@ export const GET = apiHandler( }); }, ); + +/** + * The user's own recent average for a sport — rendered as one muted + * comparison line. Cross-source twins are collapsed through + * `pickCanonicalWorkoutRows()` first so a run recorded by two paired + * watches counts once. Comparisons are to the user's own history only + * (non-diagnostic standard). + */ +async function buildSportContext( + userId: string, + sportType: string, + sourcePriorityJson: unknown, +): Promise<{ + count: number; + avgDurationSec: number; + avgDistanceM: number | null; + avgAvgHr: number | null; +} | null> { + const since = new Date( + Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ); + const rows = await prisma.workout.findMany({ + where: { userId, sportType, startedAt: { gte: since } }, + orderBy: [{ startedAt: "asc" }, { id: "asc" }], + take: SPORT_CONTEXT_MAX_ROWS, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + durationSec: true, + totalDistanceM: true, + avgHeartRate: true, + }, + }); + const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson); + if (canonical.length === 0) return null; + + let durationSum = 0; + let distanceSum = 0; + let distanceCount = 0; + let hrSum = 0; + let hrCount = 0; + for (const r of canonical) { + durationSum += r.durationSec; + if (r.totalDistanceM != null) { + distanceSum += r.totalDistanceM; + distanceCount += 1; + } + if (r.avgHeartRate != null) { + hrSum += r.avgHeartRate; + hrCount += 1; + } + } + return { + count: canonical.length, + avgDurationSec: Math.round(durationSum / canonical.length), + avgDistanceM: distanceCount > 0 ? distanceSum / distanceCount : null, + avgAvgHr: hrCount > 0 ? Math.round(hrSum / hrCount) : null, + }; +} diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 71b045c1f..2ebbadb98 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -27,6 +27,9 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn(), }, + measurement: { + findMany: vi.fn(), + }, }, })); @@ -103,7 +106,14 @@ describe("GET /api/workouts/{id}", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ sourcePriorityJson: null, + dateOfBirth: null, } as never); + // #67 — the enrichment reads: the HR-curve pulse-window fallback + // (`measurement.findMany`) and the sport-context average (a SECOND + // `workout.findMany`). Default both to empty; each test's + // `mockResolvedValueOnce` still wins for the cluster call. + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); // clearAllMocks wipes the factory default; re-seed the gate to pass. vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true, @@ -208,6 +218,72 @@ describe("GET /api/workouts/{id}", () => { expect(body.data.canonicalId).toBe("w-apple"); }); + it("adds the #67 enrichment fields and strips raw blobs under compact=1", async () => { + const startMs = BASE_ROW.startedAt.getTime(); + const storedSamples = Array.from({ length: 40 }, (_, i) => ({ + t: new Date(startMs + i * 45_000).toISOString(), + hr: 140 + (i % 6), + })); + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + // WHOOP device zones win → zones render even without profile age. + metadata: { + zoneDurations: { + zone_one_milli: 60_000, + zone_two_milli: 300_000, + zone_three_milli: 600_000, + zone_four_milli: 200_000, + zone_five_milli: 40_000, + }, + }, + route: { + id: "wr-1", + geometry: { + type: "LineString", + coordinates: [ + [11, 49], + [11.01, 49.01], + ], + }, + sampleTimestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:01:00Z"], + createdAt: BASE_ROW.createdAt, + }, + samples: { sampleCount: storedSamples.length, samples: storedSamples }, + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: BASE_ROW.startedAt, + sportType: "RUNNING", + }, + ] as never); + + const res = await GET( + new NextRequest("http://localhost/api/workouts/w-1?compact=1", { + method: "GET", + }), + makeParams("w-1"), + ); + const body = await res.json(); + + expect(res.status).toBe(200); + // Stored series wins → provenance is the native workout series. + expect(body.data.hrSeries.source).toBe("workout_series"); + expect(body.data.hrSeries.points.length).toBeGreaterThanOrEqual(2); + // WHOOP device zones. + expect(body.data.zones.model).toBe("whoop"); + // Additive keys present; reserved seam is null. + expect(body.data).toHaveProperty("splits"); + expect(body.data).toHaveProperty("sportContext"); + expect(body.data.aiInsight).toBeNull(); + // compact=1 drops the raw blobs but keeps count + geometry. + expect(body.data.samples.sampleCount).toBe(40); + expect(body.data.samples.samples).toBeNull(); + expect(body.data.route.geometry).not.toBeNull(); + expect(body.data.route.sampleTimestamps).toBeNull(); + }); + it("exposes the WorkoutRoute geometry when present", async () => { const routeGeometry = { type: "LineString", diff --git a/src/app/api/workouts/route.ts b/src/app/api/workouts/route.ts index c58e37537..742f60c46 100644 --- a/src/app/api/workouts/route.ts +++ b/src/app/api/workouts/route.ts @@ -94,6 +94,10 @@ interface CanonicalWorkoutRow { avgHeartRate: number | null; maxHeartRate: number | null; createdAt: Date; + // #67 list glyphs — cheap relation-id selects so the list can flag + // which sessions open into a rich detail (map / HR curve). + route: { id: string } | null; + samples: { sampleCount: number } | null; } /** The cached, deduped, most-recent-first row set for one filter combination. */ @@ -115,6 +119,8 @@ interface WorkoutsResult { maxHr: number | null; source: string; externalId: string | null; + hasRoute: boolean; + hasHrSeries: boolean; }>; meta: { total: number; @@ -169,6 +175,8 @@ async function buildWorkoutsProjection( avgHeartRate: true, maxHeartRate: true, createdAt: true, + route: { select: { id: true } }, + samples: { select: { sampleCount: true } }, }, }), prisma.user.findUnique({ @@ -215,6 +223,8 @@ function sliceWorkoutsProjection( maxHr: row.maxHeartRate, source: row.source, externalId: row.externalId, + hasRoute: row.route != null, + hasHrSeries: row.samples != null && row.samples.sampleCount > 0, })); return { diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 2353ed164..c5216de72 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -11,22 +11,34 @@ import { BackLink } from "@/components/ui/back-link"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { WorkoutDetailHeader, - WorkoutDetailHRChart, - WorkoutDetailRoute, WorkoutDetailStats, + WorkoutDetailHrSection, + WorkoutDetailZones, + WorkoutDetailRoute, + WorkoutDetailSplits, + WorkoutDetailDayLinks, + WorkoutInsightCard, } from "@/components/insights/workout-detail"; /** - * v1.4.32 — `/insights/workouts/[id]`. + * `/insights/workouts/[id]` — workout detail surface. * - * Workout detail surface. Mounts the header + stats + optional GPS - * route + heart-rate chart slot. The route data flows from - * `GET /api/workouts/{id}` (v1.4.32) and includes the cross-source - * canonical-id pointer so a deep-link into a non-canonical twin - * surfaces a graceful redirect cue (handled inline on the header for - * v1.4.32; iOS-side redirect arrives in v1.5). + * Layout, top to bottom (mobile-first single column): + * hero header → reserved Activity-Insight seam (renders nothing today) + * → stats grid + sport-average line → HR curve → effort zones → GPS + * route → per-km splits → "that day" cross-links → coach launch. + * + * Every data-less section returns `null` (hide, don't render empty), so + * an aggregates-only workout (a Strava ride with no wearable, a manual + * entry) reads as hero + stats + "that day" + coach — compact but + * honest, no empty shells. + * + * Data flows from `GET /api/workouts/{id}?compact=1` (v1.4.32 + the #67 + * enrichment fields). The `canonicalId` pointer still resolves a + * deep-link into a non-canonical twin; the header carries the source. */ export default function InsightsWorkoutDetailPage({ params, @@ -36,7 +48,7 @@ export default function InsightsWorkoutDetailPage({ const { t } = useTranslations(); const { id } = use(params); const { ready } = useModulePageGuard("workouts"); - const { data, isLoading, error } = useWorkoutDetail(id); + const { data, isLoading, error, refetch } = useWorkoutDetail(id); // v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account. if (!ready) { @@ -63,9 +75,13 @@ export default function InsightsWorkoutDetailPage({
+
- ) : error || !data ? ( + ) : error ? ( + // A failed query must never read as "no data" (UI-STANDARDS §6). + refetch()} /> + ) : !data ? (

+ {/* Reserved Activity-Insight seam — `aiInsight` is always null + today, so this renders nothing. When the Phase-2 job + populates it, the card mounts here with zero layout rework. */} + {data.aiInsight ? ( + + ) : null} + + - - {/* 2026-07-17 UX-flows audit F6-1 — the button used to open an - unscoped chat; the user had to restate which session they - meant. `workouts` narrows the snapshot the first turn reads - (pairs with the coach-scope param added for F4-1). */} + + + {/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the + snapshot the first coach turn reads. */} )} diff --git a/src/components/charts/chart-runtime.ts b/src/components/charts/chart-runtime.ts index 4368e24d3..6fc198934 100644 --- a/src/components/charts/chart-runtime.ts +++ b/src/components/charts/chart-runtime.ts @@ -44,3 +44,5 @@ export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart"; export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart"; export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart"; +export { WorkoutHrChart } from "./workout-hr-chart"; +export { WorkoutElevationChart } from "./workout-elevation-chart"; diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx new file mode 100644 index 000000000..8c565bd11 --- /dev/null +++ b/src/components/charts/workout-elevation-chart.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Workout elevation profile — a small cumulative-distance / altitude + * area under the route map. Rendered ONLY through `chart-runtime.ts`. + * Present only when ≥ 60 % of route coordinates carry altitude (the + * caller gates it); partial altimeter data would draw a lie. + */ + +export interface WorkoutElevationPoint { + distanceM: number; + altitude: number; +} + +export function WorkoutElevationChart({ + points, +}: { + points: WorkoutElevationPoint[]; +}) { + const { t } = useTranslations(); + const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude })); + + return ( +

+ + + + + + + + + + Number(v).toFixed(1)} + tickLine={false} + axisLine={false} + /> + + `${Number(km).toFixed(2)} km`} + formatter={(value) => [ + `${Math.round(Number(value))} m`, + t("insights.workouts.detail.elevationTitle"), + ]} + /> + + + +
+ ); +} diff --git a/src/components/charts/workout-hr-chart.tsx b/src/components/charts/workout-hr-chart.tsx new file mode 100644 index 000000000..9a5fe387e --- /dev/null +++ b/src/components/charts/workout-hr-chart.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useMemo } from "react"; +import { + ComposedChart, + Area, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ReferenceLine, + ReferenceArea, + ResponsiveContainer, +} from "recharts"; + +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Per-workout heart-rate curve. Rendered ONLY through the shared + * `chart-runtime.ts` barrel (the one recharts async boundary) so the + * library stays a single shared chunk — never import this file directly + * at a call site. + * + * `ComposedChart`: an `Area` for the min→max envelope (drawn only when + * the series is dense enough to be honest), a `Line` for the bucket + * mean, optional %HRmax zone bands behind them, and a dashed reference + * line at the workout's average HR. Gaps stay gaps (`connectNulls` + * false) — a hole in the recording never reads as an interpolated + * curve. + */ + +export interface WorkoutHrChartPoint { + tSec: number; + mean: number; + min: number; + max: number; +} + +export interface WorkoutHrZoneBand { + zone: number; + lowBpm: number | null; + highBpm: number | null; +} + +export interface WorkoutHrChartProps { + points: WorkoutHrChartPoint[]; + bucketSec: number; + /** Draw the min→max envelope band behind the mean line. */ + envelope: boolean; + /** Workout average HR for the reference line. */ + avgHr: number | null; + /** Optional %HRmax zone bands for the shaded background. */ + zones?: WorkoutHrZoneBand[] | null; +} + +const ZONE_FILLS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + +function minuteTick(seconds: number): string { + return String(Math.round(seconds / 60)); +} + +export function WorkoutHrChart({ + points, + bucketSec, + envelope, + avgHr, + zones, +}: WorkoutHrChartProps) { + const { t } = useTranslations(); + + // Build a full elapsed-time grid so a gap of missing buckets breaks + // the line rather than interpolating across it. + const data = useMemo(() => { + if (points.length === 0) return []; + const byT = new Map(points.map((p) => [p.tSec, p])); + const maxT = points[points.length - 1].tSec; + const grid: Array<{ + tSec: number; + mean: number | null; + lo: number | null; + hi: number | null; + }> = []; + for (let tSec = 0; tSec <= maxT; tSec += bucketSec) { + const p = byT.get(tSec); + grid.push({ + tSec, + mean: p ? p.mean : null, + lo: p ? p.min : null, + hi: p && envelope ? p.max - p.min : null, + }); + } + return grid; + }, [points, bucketSec, envelope]); + + return ( +
+ + + + {zones?.map((z) => + z.lowBpm != null ? ( + + ) : null, + )} + + + + `${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}` + } + formatter={(value, name) => + name === "mean" + ? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")] + : [null, null] + } + /> + {/* Envelope band: a transparent base at `lo` plus a stacked + `hi` (= max − min) renders the min→max ribbon. Only present + when `envelope` gated it in. */} + {envelope ? ( + <> + + + + ) : null} + {avgHr != null ? ( + + ) : null} + + + +
+ ); +} diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index c850e5cb9..96e0020de 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -4,19 +4,21 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { WorkoutDetailHeader, - WorkoutDetailHRChart, - WorkoutDetailRoute, WorkoutDetailStats, + WorkoutDetailRoute, + WorkoutDetailHrSection, + WorkoutDetailZones, + WorkoutDetailSplits, + WorkoutDetailDayLinks, } from "../workout-detail"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; +import type { RouteCoordinate } from "@/lib/workouts/route-svg"; /** - * v1.4.32 — `` unit tests. - * - * Each primitive runs through SSR with a single canonical fixture. - * The tests pin the visible labels + the graceful-fallback shapes so - * the page renders something useful even when the optional fields - * (route, HR samples, energy) are absent. + * #67 — `` unit tests over the split `workout-detail/` + * directory. Each primitive runs through SSR with a canonical fixture; + * the tests pin the visible labels and the hide-don't-render contract + * (every data-less section returns null). */ function render(node: React.ReactNode) { @@ -43,6 +45,12 @@ const FIXTURE: WorkoutDetailPayload = { externalId: "ext-w-1", metadata: null, route: null, + samples: null, + hrSeries: null, + zones: null, + splits: null, + sportContext: null, + aiInsight: null, canonicalId: "w-1", }; @@ -62,7 +70,6 @@ describe("", () => { expect(html).toContain("Duration"); expect(html).toContain("Distance"); expect(html).toContain("Active energy"); - expect(html).toContain("Average HR"); expect(html).toContain("145 bpm"); expect(html).toContain("Steps"); expect(html).toContain("Pace"); @@ -82,68 +89,176 @@ describe("", () => { const html = render(); expect(html).toContain("Duration"); expect(html).not.toContain("Distance"); - expect(html).not.toContain("Active energy"); - expect(html).not.toContain("Steps"); expect(html).not.toContain("Average HR"); }); + + it("renders the own-history average line when ≥ 2 sessions exist", () => { + const withCtx: WorkoutDetailPayload = { + ...FIXTURE, + sportContext: { + count: 8, + avgDurationSec: 2040, + avgDistanceM: 5800, + avgAvgHr: 148, + }, + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-sport-average"'); + expect(html).toContain("148 bpm"); + }); + + it("hides the average line for a lone session", () => { + const lone: WorkoutDetailPayload = { + ...FIXTURE, + sportContext: { + count: 1, + avgDurationSec: 1800, + avgDistanceM: 5200, + avgAvgHr: 145, + }, + }; + const html = render(); + expect(html).not.toContain('data-slot="workout-detail-sport-average"'); + }); }); describe("", () => { - it("renders the empty-state when the workout has no route", () => { + it("returns null when the workout has no route", () => { const html = render(); - expect(html).toContain('data-slot="workout-detail-route-empty"'); - expect(html).toContain("No GPS route"); + expect(html).toBe(""); }); - it("renders an SVG polyline when GeoJSON LineString geometry is present", () => { + it("renders an SVG path for a real route", () => { + const coords: RouteCoordinate[] = []; + for (let i = 0; i < 20; i++) + coords.push([11.0 + i * 0.0004, 50.0 + i * 0.0003]); const withRoute: WorkoutDetailPayload = { ...FIXTURE, route: { - geometry: { - type: "LineString", - coordinates: [ - [11.0, 49.0], - [11.01, 49.005], - [11.02, 49.01], - ], - }, + geometry: { type: "LineString", coordinates: coords }, sampleTimestamps: null, }, }; const html = render(); expect(html).toContain('data-slot="workout-detail-route"'); - expect(html).toContain(" { + it("returns null for a degenerate point-shaped route", () => { const stub: WorkoutDetailPayload = { ...FIXTURE, route: { - geometry: { type: "LineString", coordinates: [[11, 49]] }, + geometry: { + type: "LineString", + coordinates: [ + [11, 49], + [11.0001, 49.0001], + ], + }, sampleTimestamps: null, }, }; - const html = render(); - expect(html).toContain('data-slot="workout-detail-route-empty"'); + expect(render()).toBe(""); }); }); -describe("", () => { - it("renders the unavailable notice with aggregate HR values when present", () => { - const html = render(); - expect(html).toContain('data-slot="workout-detail-hr-chart"'); - expect(html).toContain("145 bpm"); - expect(html).toContain("170 bpm"); +describe("", () => { + it("returns null without an HR series", () => { + expect(render()).toBe(""); }); - it("renders just the unavailable notice when no HR aggregates are present", () => { - const noHr: WorkoutDetailPayload = { + it("renders the card and a provenance chip for a pulse-window series", () => { + const withHr: WorkoutDetailPayload = { ...FIXTURE, - avgHr: null, - maxHr: null, + hrSeries: { + source: "pulse_window", + bucketSec: 8, + points: [ + { tSec: 0, mean: 130, min: 125, max: 135 }, + { tSec: 8, mean: 140, min: 135, max: 145 }, + ], + envelope: false, + }, }; - const html = render(); - expect(html).toContain("Per-second heart-rate"); - expect(html).not.toContain("145 bpm"); + const html = render(); + expect(html).toContain('data-slot="workout-detail-hr"'); + expect(html).toContain('data-slot="workout-detail-hr-provenance"'); + expect(html).toContain("From your heart-rate data"); + }); + + it("omits the provenance chip for a stored series", () => { + const withHr: WorkoutDetailPayload = { + ...FIXTURE, + hrSeries: { + source: "workout_series", + bucketSec: 8, + points: [ + { tSec: 0, mean: 130, min: 125, max: 135 }, + { tSec: 8, mean: 140, min: 135, max: 145 }, + ], + envelope: true, + }, + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-hr"'); + expect(html).not.toContain('data-slot="workout-detail-hr-provenance"'); + }); +}); + +describe("", () => { + it("returns null without zone data", () => { + expect(render()).toBe(""); + }); + + it("renders the stacked bar and per-zone minutes", () => { + const withZones: WorkoutDetailPayload = { + ...FIXTURE, + zones: { + model: "tanaka", + hrMax: 180, + zones: [ + { zone: 1, lowBpm: 90, highBpm: 108, seconds: 300 }, + { zone: 2, lowBpm: 108, highBpm: 126, seconds: 600 }, + { zone: 3, lowBpm: 126, highBpm: 144, seconds: 500 }, + { zone: 4, lowBpm: 144, highBpm: 162, seconds: 200 }, + { zone: 5, lowBpm: 162, highBpm: null, seconds: 60 }, + ], + }, + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-zones"'); + expect(html).toContain("Effort zones"); + expect(html).toContain("Z3"); + }); +}); + +describe("", () => { + it("returns null without splits", () => { + expect(render()).toBe(""); + }); + + it("renders a row per kilometre", () => { + const withSplits: WorkoutDetailPayload = { + ...FIXTURE, + splits: [ + { km: 1, durationSec: 300, paceSecPerKm: 300 }, + { km: 2, durationSec: 288, paceSecPerKm: 288 }, + ], + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-splits"'); + expect(html).toContain("5:00"); + expect(html).toContain("4:48 /km"); + }); +}); + +describe("", () => { + it("renders the that-day navigation links", () => { + const html = render(); + expect(html).toContain('data-slot="workout-detail-day-links"'); + expect(html).toContain('href="/insights/pulse"'); + expect(html).toContain('href="/insights/sleep"'); + expect(html).toContain('href="/insights/mood"'); }); }); diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx deleted file mode 100644 index 74e8169c0..000000000 --- a/src/components/insights/workout-detail.tsx +++ /dev/null @@ -1,438 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { - Flame, - Footprints, - HeartPulse, - Map, - Mountain, - Timer, -} from "lucide-react"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; -import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; -import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; -import { - hourCycleOptions, - type TimeFormatPreference, -} from "@/lib/format-locale"; -import { iconForSport } from "@/lib/workouts/sport-icons"; -import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; - -/** - * v1.4.32 — workout-detail components: header, stats, optional route, - * optional HR chart. - * - * Mounted by `/insights/workouts/[id]/page.tsx`. The page itself owns - * the data fetch + the empty / loading / error branches; each - * component below is a pure render primitive over a non-null - * `WorkoutDetailPayload`. - */ - -function formatDuration(seconds: number): string { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) { - return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`; - } - return `${m}m ${s.toString().padStart(2, "0")}s`; -} - -function formatDistanceKm(meters: number, locale: string): string { - const km = meters / 1000; - return getNumberFormat(locale, { - maximumFractionDigits: 2, - minimumFractionDigits: km < 10 ? 2 : 1, - }).format(km); -} - -function formatNumber( - value: number, - locale: string, - fractionDigits = 0, -): string { - return getNumberFormat(locale, { - maximumFractionDigits: fractionDigits, - minimumFractionDigits: fractionDigits, - }).format(value); -} - -function formatPaceMinPerKm( - durationSec: number, - meters: number, - locale: string, -): string { - // Pace is the seconds-per-kilometre value. Only meaningful for - // run / walk / hike. The caller already gated on `meters > 0`. - const secPerKm = (durationSec / meters) * 1000; - const m = Math.floor(secPerKm / 60); - const s = Math.round(secPerKm % 60); - return `${m}:${s.toString().padStart(2, "0")} ${locale === "en" ? "/km" : "/km"}`; -} - -function formatDateRange( - startedAt: string, - endedAt: string, - locale: string, - timeFormat: TimeFormatPreference, -): string { - const start = new Date(startedAt); - const end = new Date(endedAt); - const dateFmt = getDateTimeFormat(locale, { - weekday: "long", - day: "2-digit", - month: "long", - year: "numeric", - }); - const timeFmt = getDateTimeFormat(locale, { - hour: "2-digit", - minute: "2-digit", - ...hourCycleOptions(timeFormat), - }); - return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`; -} - -// ── Header ─────────────────────────────────────────────────────────── - -export interface WorkoutDetailHeaderProps { - workout: WorkoutDetailPayload; -} - -function renderSportIconBadge(sportType: string) { - const Icon = iconForSport(sportType); - return ( - - ); -} - -export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { - const { t, locale } = useTranslations(); - const timeFormat = useTimeFormatPreference(); - const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; - const sportLabel = t(sportLabelKey); - const sportName = - sportLabel === sportLabelKey ? workout.sportType : sportLabel; - - return ( -
-
- {renderSportIconBadge(workout.sportType)} -
-

- {workout.source} -

-

- {sportName} -

-

- {formatDateRange( - workout.startedAt, - workout.endedAt, - locale, - timeFormat, - )} -

-
-
-
- - {formatDuration(workout.durationSec)} - - {workout.distanceM != null ? ( - - {formatDistanceKm(workout.distanceM, locale)} km - - ) : null} -
-
- ); -} - -// ── Stats ──────────────────────────────────────────────────────────── - -interface StatTileProps { - icon: ReactNode; - label: string; - value: string; - hint?: string; -} - -function StatTile({ icon, label, value, hint }: StatTileProps) { - return ( -
- - - {label} - - {value} - {hint ? ( - {hint} - ) : null} -
- ); -} - -export interface WorkoutDetailStatsProps { - workout: WorkoutDetailPayload; -} - -export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { - const { t, locale } = useTranslations(); - const tiles: StatTileProps[] = []; - - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsDuration"), - value: formatDuration(workout.durationSec), - }); - - if (workout.distanceM != null && workout.distanceM > 0) { - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsDistance"), - value: `${formatDistanceKm(workout.distanceM, locale)} km`, - }); - } - - if (workout.activeEnergyKcal != null) { - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsActiveEnergy"), - value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`, - }); - } - - if (workout.avgHr != null) { - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsAvgHr"), - value: `${workout.avgHr} bpm`, - hint: - workout.maxHr != null - ? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm` - : undefined, - }); - } - - if (workout.minHr != null) { - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsMinHr"), - value: `${workout.minHr} bpm`, - }); - } - - if (workout.stepCount != null && workout.stepCount > 0) { - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsStepCount"), - value: formatNumber(workout.stepCount, locale), - }); - } - - if (workout.elevationM != null && workout.elevationM !== 0) { - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsElevation"), - value: `${formatNumber(workout.elevationM, locale, 1)} m`, - }); - } - - // Pace is only meaningful for foot- and ride-paced sports. Surface - // it when the workout has both a distance and a sport with a - // canonical pace unit; the fallback covers the long-tail HK enum - // values without lighting up a misleading number. - const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]); - if ( - workout.distanceM != null && - workout.distanceM > 100 && - PACE_SPORTS.has(workout.sportType) - ) { - tiles.push({ - icon: , - label: t("insights.workouts.detail.statsPace"), - value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale), - }); - } - - return ( - - - - {t("insights.workouts.detail.statsTitle")} - - - - {tiles.map((tile) => ( - - ))} - - - ); -} - -// ── Route ──────────────────────────────────────────────────────────── - -export interface WorkoutDetailRouteProps { - workout: WorkoutDetailPayload; -} - -interface LineStringGeometry { - type: "LineString"; - coordinates: Array<[number, number] | [number, number, number]>; -} - -function isLineString(value: unknown): value is LineStringGeometry { - if (!value || typeof value !== "object") return false; - const v = value as Record; - return v.type === "LineString" && Array.isArray(v.coordinates); -} - -export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) { - const { t } = useTranslations(); - const geometry = workout.route?.geometry; - - if (!isLineString(geometry) || geometry.coordinates.length < 2) { - return ( - - - - {t("insights.workouts.detail.routeTitle")} - - - - {t("insights.workouts.detail.routeUnavailable")} - - - ); - } - - // The map widget itself defers to v1.5.x (MapLibre / Leaflet not yet - // pulled in). For v1.4.32 we paint a lightweight SVG polyline of the - // route geometry — projection-free, normalised against the bounding - // box, no third-party dependency. Resolution is fine on screen - // because routes are bounded to 20 000 points by the ingest cap. - const coordinates = geometry.coordinates; - let minLon = Infinity; - let maxLon = -Infinity; - let minLat = Infinity; - let maxLat = -Infinity; - for (const [lon, lat] of coordinates) { - if (lon < minLon) minLon = lon; - if (lon > maxLon) maxLon = lon; - if (lat < minLat) minLat = lat; - if (lat > maxLat) maxLat = lat; - } - const padLon = (maxLon - minLon) * 0.05 || 0.0001; - const padLat = (maxLat - minLat) * 0.05 || 0.0001; - minLon -= padLon; - maxLon += padLon; - minLat -= padLat; - maxLat += padLat; - const VIEW_W = 400; - const VIEW_H = 240; - const points = coordinates - .map(([lon, lat]) => { - const x = ((lon - minLon) / (maxLon - minLon)) * VIEW_W; - // SVG y-axis grows downward; flip lat → y. - const y = VIEW_H - ((lat - minLat) / (maxLat - minLat)) * VIEW_H; - return `${x.toFixed(1)},${y.toFixed(1)}`; - }) - .join(" "); - - return ( - - - - {t("insights.workouts.detail.routeTitle")} - - - - - - - - - ); -} - -// ── Heart-rate chart ───────────────────────────────────────────────── - -export function WorkoutDetailHRChart({ - workout, -}: { - workout: WorkoutDetailPayload; -}) { - const { t } = useTranslations(); - - // Per-second HR samples are not persisted on the Workout row today — - // the W8d ingest schema stores aggregates only (avg / max / min). - // The per-second series lives on the corresponding `Measurement` - // rows posted alongside the workout via `POST /api/measurements/batch`. - // Wiring that into the detail page consumes a future v1.5.x sprint - // (see R-F T2 + W8d outline); for v1.4.32 the slot renders a - // graceful empty state with the aggregates already surfaced on - // ``. - return ( - - - - {t("insights.workouts.detail.hrChartTitle")} - - - -

{t("insights.workouts.detail.hrChartUnavailable")}

- {workout.avgHr != null || workout.maxHr != null ? ( -

- {workout.avgHr != null - ? `${t("insights.workouts.detail.statsAvgHr")}: ${workout.avgHr} bpm` - : null} - {workout.avgHr != null && workout.maxHr != null ? " · " : ""} - {workout.maxHr != null - ? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm` - : null} -

- ) : null} -
-
- ); -} diff --git a/src/components/insights/workout-detail/day-links.tsx b/src/components/insights/workout-detail/day-links.tsx new file mode 100644 index 000000000..ff1f19c11 --- /dev/null +++ b/src/components/insights/workout-detail/day-links.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; +import { Activity, Moon, Smile } from "lucide-react"; +import type { ComponentType } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { CalendarDays } from "lucide-react"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +interface DayLink { + href: string; + labelKey: string; + icon: ComponentType<{ className?: string }>; +} + +const LINKS: DayLink[] = [ + { + href: "/insights/pulse", + labelKey: "insights.workouts.detail.dayPulse", + icon: Activity, + }, + { + href: "/insights/sleep", + labelKey: "insights.workouts.detail.daySleep", + icon: Moon, + }, + { + href: "/insights/mood", + labelKey: "insights.workouts.detail.dayMood", + icon: Smile, + }, +]; + +export interface WorkoutDetailDayLinksProps { + workout: WorkoutDetailPayload; +} + +/** + * "That day" — a plain inline link row to the day's other signals + * (intraday pulse, that night's sleep, mood). No reads; just navigation + * so the workout sits in the context of the rest of the day. + */ +export function WorkoutDetailDayLinks({ workout }: WorkoutDetailDayLinksProps) { + const { t } = useTranslations(); + void workout; + + return ( + + + + + +
+ {LINKS.map(({ href, labelKey, icon: Icon }) => ( + +
+
+
+ ); +} diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts new file mode 100644 index 000000000..50fc6f775 --- /dev/null +++ b/src/components/insights/workout-detail/format.ts @@ -0,0 +1,86 @@ +import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { + hourCycleOptions, + type TimeFormatPreference, +} from "@/lib/format-locale"; + +/** + * Shared formatters for the workout-detail surface. Pure helpers pulled + * out of the former single `workout-detail.tsx` when it was split into a + * `workout-detail/` directory (#67), so header / stats / splits format + * durations and distances identically. + */ + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) { + return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`; + } + return `${m}m ${s.toString().padStart(2, "0")}s`; +} + +/** Compact "34 min" style duration for the sport-average comparison line. */ +export function formatDurationMinutes(seconds: number, locale: string): string { + const minutes = Math.round(seconds / 60); + return formatNumber(minutes, locale); +} + +export function formatDistanceKm(meters: number, locale: string): string { + const km = meters / 1000; + return getNumberFormat(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: km < 10 ? 2 : 1, + }).format(km); +} + +export function formatNumber( + value: number, + locale: string, + fractionDigits = 0, +): string { + return getNumberFormat(locale, { + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits, + }).format(value); +} + +export function formatPaceMinPerKm( + durationSec: number, + meters: number, +): string { + // Seconds-per-kilometre. Only meaningful for run / walk / hike / ride; + // the caller gates on distance + sport. + const secPerKm = (durationSec / meters) * 1000; + return formatPaceSeconds(secPerKm); +} + +/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */ +export function formatPaceSeconds(secPerKm: number): string { + const m = Math.floor(secPerKm / 60); + const s = Math.round(secPerKm % 60); + return `${m}:${s.toString().padStart(2, "0")} /km`; +} + +export function formatDateRange( + startedAt: string, + endedAt: string, + locale: string, + timeFormat: TimeFormatPreference, +): string { + const start = new Date(startedAt); + const end = new Date(endedAt); + const dateFmt = getDateTimeFormat(locale, { + weekday: "long", + day: "2-digit", + month: "long", + year: "numeric", + }); + const timeFmt = getDateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + ...hourCycleOptions(timeFormat), + }); + return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`; +} diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx new file mode 100644 index 000000000..172316af1 --- /dev/null +++ b/src/components/insights/workout-detail/header.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; +import { iconForSport } from "@/lib/workouts/sport-icons"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +import { formatDuration, formatDistanceKm, formatDateRange } from "./format"; + +/** + * Hero header — the at-a-glance verdict row. Sport icon badge, source + * provenance line, sport name, date/time range, big duration + distance. + */ + +export interface WorkoutDetailHeaderProps { + workout: WorkoutDetailPayload; +} + +function renderSportIconBadge(sportType: string) { + const Icon = iconForSport(sportType); + return ( + + ); +} + +export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { + const { t, locale } = useTranslations(); + const timeFormat = useTimeFormatPreference(); + const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; + const sportLabel = t(sportLabelKey); + const sportName = + sportLabel === sportLabelKey ? workout.sportType : sportLabel; + + return ( +
+
+ {renderSportIconBadge(workout.sportType)} +
+

+ {workout.source} +

+

+ {sportName} +

+

+ {formatDateRange( + workout.startedAt, + workout.endedAt, + locale, + timeFormat, + )} +

+
+
+
+ + {formatDuration(workout.durationSec)} + + {workout.distanceM != null ? ( + + {formatDistanceKm(workout.distanceM, locale)} km + + ) : null} +
+
+ ); +} diff --git a/src/components/insights/workout-detail/hr-section.tsx b/src/components/insights/workout-detail/hr-section.tsx new file mode 100644 index 000000000..1bc9701ec --- /dev/null +++ b/src/components/insights/workout-detail/hr-section.tsx @@ -0,0 +1,77 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { HeartPulse } from "lucide-react"; +import type { ComponentProps } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { importWithRetry } from "@/lib/retry-import"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +// Route the HR curve through the ONE recharts async boundary so the +// library stays a single shared chunk (chart-runtime.ts header rule). +const WorkoutHrChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.WorkoutHrChart }), + ), + { ssr: false, loading: () => }, +); +function WorkoutHrChart(props: ComponentProps) { + return ( + + + + ); +} + +export interface WorkoutDetailHrSectionProps { + workout: WorkoutDetailPayload; +} + +/** + * Heart-rate curve card — mean line + optional min→max envelope + %HRmax + * zone bands + an average-HR reference line. A muted provenance chip + * discloses when the curve was reconstructed from pulse data around the + * session rather than the workout's own sensor stream. Returns `null` + * when no series is available (hide, don't render empty). + */ +export function WorkoutDetailHrSection({ + workout, +}: WorkoutDetailHrSectionProps) { + const { t } = useTranslations(); + const series = workout.hrSeries; + if (!series || series.points.length < 2) return null; + + return ( + + + + {series.source === "pulse_window" ? ( +

+ {t("insights.workouts.detail.hrFromPulse")} +

+ ) : null} +
+ + + +
+ ); +} diff --git a/src/components/insights/workout-detail/index.ts b/src/components/insights/workout-detail/index.ts new file mode 100644 index 000000000..4b6e0938e --- /dev/null +++ b/src/components/insights/workout-detail/index.ts @@ -0,0 +1,17 @@ +/** + * Barrel for the workout-detail surface (#67). The former single + * `workout-detail.tsx` grew past a comfortable size once the HR curve, + * zones, route SVG, splits and day-links landed, so it was split into + * this directory. Import path stays `@/components/insights/workout-detail`. + */ +export { WorkoutDetailHeader } from "./header"; +export { WorkoutDetailStats } from "./stats"; +export { WorkoutDetailHrSection } from "./hr-section"; +export { WorkoutDetailZones } from "./zones"; +export { WorkoutDetailRoute } from "./route-map"; +export { WorkoutDetailSplits } from "./splits"; +export { WorkoutDetailDayLinks } from "./day-links"; +export { + WorkoutInsightCard, + type WorkoutActivityInsightData, +} from "./insight-slot"; diff --git a/src/components/insights/workout-detail/insight-slot.tsx b/src/components/insights/workout-detail/insight-slot.tsx new file mode 100644 index 000000000..3dbb954db --- /dev/null +++ b/src/components/insights/workout-detail/insight-slot.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * Reserved Activity-Insight seam (strategic-concept Wave C, bet 5). + * + * NOTHING renders here today: the workout payload's `aiInsight` field is + * always `null`, so the page composes `{aiInsight ? : null}` and this component never mounts. The + * shape below is what the Phase-2 pg-boss job will write onto the + * workout row — when it lands, the server maps the row to `aiInsight`, + * the union in `use-workouts.ts` widens from `null` to + * `WorkoutActivityInsightData | null`, and the card mounts right under + * the hero header with zero layout rework. Non-diagnostic by + * construction: a descriptive paragraph, device-attributed, never a + * verdict. + */ +export interface WorkoutActivityInsightData { + /** The cached, plain-text insight paragraph. Rendered as text children. */ + paragraph: string; + /** ISO timestamp the paragraph was generated. */ + generatedAt: string; +} + +export function WorkoutInsightCard({ + insight, +}: { + insight: WorkoutActivityInsightData; +}) { + const { t } = useTranslations(); + return ( + + + + + +

{insight.paragraph}

+
+
+ ); +} diff --git a/src/components/insights/workout-detail/route-map.tsx b/src/components/insights/workout-detail/route-map.tsx new file mode 100644 index 000000000..cd37736c6 --- /dev/null +++ b/src/components/insights/workout-detail/route-map.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useMemo, type ComponentProps } from "react"; +import dynamic from "next/dynamic"; +import { Map as MapIcon, Download } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { TileHeader } from "@/components/insights/tile-header"; +import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; +import { importWithRetry } from "@/lib/retry-import"; +import { useTranslations } from "@/lib/i18n/context"; +import { + projectRoute, + buildElevationProfile, + buildGpxDocument, + type RouteCoordinate, +} from "@/lib/workouts/route-svg"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +const WorkoutElevationChartLazy = dynamic( + () => + importWithRetry(() => import("@/components/charts/chart-runtime")).then( + (mod) => ({ default: mod.WorkoutElevationChart }), + ), + { ssr: false, loading: () =>
}, +); +function WorkoutElevationChart( + props: ComponentProps, +) { + return ( + + + + ); +} + +interface LineStringGeometry { + type: "LineString"; + coordinates: RouteCoordinate[]; +} + +function isLineString(value: unknown): value is LineStringGeometry { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return v.type === "LineString" && Array.isArray(v.coordinates); +} + +export interface WorkoutDetailRouteProps { + workout: WorkoutDetailPayload; +} + +/** + * GPS route card — a self-rendered, aspect-true SVG polyline over the + * user's own stored geometry. Zero external requests: no tile server, no + * geocoder, nothing leaves the origin (binding privacy/CSP property). + * Start = filled `--success` dot, end = `--foreground` ring. Optional + * elevation profile below when the track carries altitude. A GPX export + * lets the user take their track with them — built client-side, zero + * egress. Returns `null` when there is no usable geometry (hide, don't + * render empty). + */ +export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) { + const { t } = useTranslations(); + const geometry = workout.route?.geometry; + const coordinates = isLineString(geometry) ? geometry.coordinates : null; + + const projected = useMemo( + () => (coordinates ? projectRoute(coordinates) : null), + [coordinates], + ); + const elevation = useMemo( + () => (coordinates ? buildElevationProfile(coordinates) : null), + [coordinates], + ); + + if (!coordinates || !projected) return null; + + const exportGpx = () => { + const gpx = buildGpxDocument({ + coordinates, + timestamps: workout.route?.sampleTimestamps ?? null, + sportType: workout.sportType, + startedAt: workout.startedAt, + }); + const blob = new Blob([gpx], { type: "application/gpx+xml" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `workout-${workout.id}.gpx`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }; + + const markerR = 1.6; + + return ( + + + + + + + + {/* End ring first so the start dot always sits on top on a loop. */} + + + + {elevation ? ( +
+

+ {t("insights.workouts.detail.elevationTitle")} +

+ +
+ ) : null} +
+
+ ); +} diff --git a/src/components/insights/workout-detail/splits.tsx b/src/components/insights/workout-detail/splits.tsx new file mode 100644 index 000000000..267ac13ce --- /dev/null +++ b/src/components/insights/workout-detail/splits.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Flag } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +import { formatPaceSeconds } from "./format"; + +function formatSplitTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export interface WorkoutDetailSplitsProps { + workout: WorkoutDetailPayload; +} + +/** + * Per-kilometre splits table, derived server-side from the route + * geometry + timestamps. Returns `null` when the workout has no + * split data (hide, don't render empty). The fastest km is marked + * with a discreet muted accent — descriptive, not a verdict. + */ +export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) { + const { t } = useTranslations(); + const splits = workout.splits; + if (!splits || splits.length === 0) return null; + + const fastest = Math.min(...splits.map((s) => s.paceSecPerKm)); + + return ( + + + + + + + + + + + + + + + {splits.map((s) => ( + + + + + + ))} + +
+ {t("insights.workouts.detail.splitsKm")} + + {t("insights.workouts.detail.splitsTime")} + + {t("insights.workouts.detail.splitsPace")} +
{s.km} + {formatSplitTime(s.durationSec)} + + {formatPaceSeconds(s.paceSecPerKm)} +
+
+
+ ); +} diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx new file mode 100644 index 000000000..408f61a34 --- /dev/null +++ b/src/components/insights/workout-detail/stats.tsx @@ -0,0 +1,187 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Flame, + Footprints, + HeartPulse, + Map, + Mountain, + Timer, +} from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +import { + formatDuration, + formatDistanceKm, + formatNumber, + formatPaceMinPerKm, + formatDurationMinutes, +} from "./format"; + +interface StatTileProps { + icon: ReactNode; + label: string; + value: string; + hint?: string; +} + +function StatTile({ icon, label, value, hint }: StatTileProps) { + return ( +
+ + + {label} + + {value} + {hint ? ( + {hint} + ) : null} +
+ ); +} + +export interface WorkoutDetailStatsProps { + workout: WorkoutDetailPayload; +} + +const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]); + +/** + * Build the muted own-history comparison line ("Your recent average: + * 5.8 km · 34 min · 148 bpm"). Own-history only — never a population + * comparison (non-diagnostic standard). Rendered only when at least two + * sessions of the sport exist, so it never restates the single workout + * you are already looking at. + */ +function sportAverageLine( + workout: WorkoutDetailPayload, + locale: string, +): string | null { + const ctx = workout.sportContext; + if (!ctx || ctx.count < 2) return null; + const parts: string[] = []; + if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) { + parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`); + } + parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`); + if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`); + return parts.join(" · "); +} + +export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { + const { t, locale } = useTranslations(); + const tiles: StatTileProps[] = []; + + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsDuration"), + value: formatDuration(workout.durationSec), + }); + + if (workout.distanceM != null && workout.distanceM > 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsDistance"), + value: `${formatDistanceKm(workout.distanceM, locale)} km`, + }); + } + + if (workout.activeEnergyKcal != null) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsActiveEnergy"), + value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`, + }); + } + + if (workout.avgHr != null) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsAvgHr"), + value: `${workout.avgHr} bpm`, + hint: + workout.maxHr != null + ? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm` + : undefined, + }); + } + + if (workout.minHr != null) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsMinHr"), + value: `${workout.minHr} bpm`, + }); + } + + if (workout.stepCount != null && workout.stepCount > 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsStepCount"), + value: formatNumber(workout.stepCount, locale), + }); + } + + if (workout.elevationM != null && workout.elevationM !== 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsElevation"), + value: `${formatNumber(workout.elevationM, locale, 1)} m`, + }); + } + + if ( + workout.distanceM != null && + workout.distanceM > 100 && + PACE_SPORTS.has(workout.sportType) + ) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsPace"), + value: formatPaceMinPerKm(workout.durationSec, workout.distanceM), + }); + } + + const averageLine = sportAverageLine(workout, locale); + + return ( + + + + {t("insights.workouts.detail.statsTitle")} + + + +
+ {tiles.map((tile) => ( + + ))} +
+ {averageLine ? ( +

+ {t("insights.workouts.detail.sportAverageLabel")}{" "} + {averageLine} +

+ ) : null} +
+
+ ); +} diff --git a/src/components/insights/workout-detail/zones.tsx b/src/components/insights/workout-detail/zones.tsx new file mode 100644 index 000000000..bc451d191 --- /dev/null +++ b/src/components/insights/workout-detail/zones.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Gauge } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +const ZONE_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +]; + +function formatMinutes(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export interface WorkoutDetailZonesProps { + workout: WorkoutDetailPayload; +} + +/** + * Effort-zone distribution — a single horizontal stacked bar (%HRmax + * zones) with minutes per zone underneath. Descriptive only: "how the + * effort was distributed", never a training prescription or cardiac + * read (non-diagnostic standard). Returns `null` when zones could not + * be derived (no profile age and no device zones). + */ +export function WorkoutDetailZones({ workout }: WorkoutDetailZonesProps) { + const { t } = useTranslations(); + const zones = workout.zones; + if (!zones) return null; + + const total = zones.zones.reduce((sum, z) => sum + z.seconds, 0); + if (total <= 0) return null; + + return ( + + + +

+ {zones.model === "whoop" + ? t("insights.workouts.detail.zonesCaptionDevice") + : t("insights.workouts.detail.zonesCaption")} +

+
+ +
+ {zones.zones.map((z, i) => + z.seconds > 0 ? ( +
+ ) : null, + )} +
+
+ {zones.zones.map((z, i) => ( +
+
+ ))} +
+ + + ); +} diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 18cbaaec8..551e1a699 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -1,6 +1,7 @@ "use client"; import Link from "next/link"; +import { Map as MapIcon, HeartPulse } from "lucide-react"; import { cn } from "@/lib/utils"; import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; @@ -106,7 +107,26 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
- {sportName} + + {sportName} + {/* #67 — discreet glyphs flag which sessions open rich + (map / HR curve). Muted so they never compete with + the sport name. */} + {workout.hasRoute ? ( + {formatDate(workout.startedAt, locale, timeFormat)} diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 82a490a42..f33cbece8 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -34,6 +34,14 @@ export interface WorkoutListEntry { maxHr: number | null; source: string; externalId: string | null; + /** + * #67 list glyphs — whether the workout opens into a rich detail with + * a map / HR curve. Optional so pre-#67 fixtures (and the dashboard + * recent-tile) still satisfy the type; the list endpoint always sends + * them. + */ + hasRoute?: boolean; + hasHrSeries?: boolean; } export interface WorkoutListMeta { @@ -118,6 +126,55 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult { }; } +export interface WorkoutHrSeriesPoint { + tSec: number; + mean: number; + min: number; + max: number; +} + +export interface WorkoutHrSeriesDto { + source: "workout_series" | "pulse_window"; + bucketSec: number; + points: WorkoutHrSeriesPoint[]; + envelope: boolean; +} + +export interface WorkoutZoneBandDto { + zone: number; + lowBpm: number | null; + highBpm: number | null; + seconds: number; +} + +export interface WorkoutZonesDto { + model: "whoop" | "tanaka"; + hrMax: number | null; + zones: WorkoutZoneBandDto[]; +} + +export interface WorkoutSplitDto { + km: number; + durationSec: number; + paceSecPerKm: number; +} + +export interface WorkoutSportContextDto { + count: number; + avgDurationSec: number; + avgDistanceM: number | null; + avgAvgHr: number | null; +} + +/** + * Reserved Activity-Insight payload (strategic-concept Wave C, bet 5). + * The wire field is `null` today; the Phase-2 job will populate it and + * the detail page's seam mounts a card with zero rework. Declared here + * so the type is stable in advance — widen the union (never change the + * seam shape) when the job ships. + */ +export type WorkoutActivityInsight = null; + export interface WorkoutDetailPayload extends WorkoutListEntry { minHr: number | null; stepCount: number | null; @@ -128,12 +185,25 @@ export interface WorkoutDetailPayload extends WorkoutListEntry { geometry: unknown; sampleTimestamps: string[] | null; } | null; + /** + * Raw per-workout HR sample envelope. `samples.samples` is dropped + * (null) under `compact=1` — the web curve reads `hrSeries` instead — + * but `sampleCount` is retained. Finally declared as of #67. + */ + samples: { sampleCount: number; samples: unknown } | null; + hrSeries: WorkoutHrSeriesDto | null; + zones: WorkoutZonesDto | null; + splits: WorkoutSplitDto[] | null; + sportContext: WorkoutSportContextDto | null; + aiInsight: WorkoutActivityInsight; canonicalId: string; } async function fetchWorkoutDetail(id: string): Promise { + // Web always sends `compact=1`: the SVG needs geometry, not the raw + // 30k-sample / route-timestamp blobs (those ride the iOS path only). return apiGet( - `/api/workouts/${encodeURIComponent(id)}`, + `/api/workouts/${encodeURIComponent(id)}?compact=1`, ); } @@ -141,6 +211,8 @@ export interface UseWorkoutDetailResult { data: WorkoutDetailPayload | undefined; isLoading: boolean; error: Error | null; + /** Re-runs the query — the retry action for the detail error card. */ + refetch: () => void; } export function useWorkoutDetail(id: string): UseWorkoutDetailResult { @@ -155,5 +227,6 @@ export function useWorkoutDetail(id: string): UseWorkoutDetailResult { data: query.data, isLoading: query.isLoading, error: query.error as Error | null, + refetch: () => void query.refetch(), }; } diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index 79370b404..b2a412044 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -63,6 +63,8 @@ import { import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { isNearUtc, userDayKey } from "@/lib/tz/resolver"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; /** * v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days @@ -120,6 +122,12 @@ export interface CorrelationsFastPathInput { userTz: string; now: Date; coverage?: RollupCoverageMap; + /** + * Reader's locale for the three hypotheses' narrated `interpretation`. The + * correlation cards render these verbatim, so they must be localised. Defaults + * to English. + */ + locale?: Locale; } export async function computeCorrelationHypothesesFastPath( @@ -127,6 +135,7 @@ export async function computeCorrelationHypothesesFastPath( ): Promise { const DAY_MS = 24 * 60 * 60 * 1000; const { userId, userTz, now } = input; + const { t } = getServerTranslator(input.locale ?? defaultLocale); const since = new Date(now.getTime() - CORRELATION_WINDOW_DAYS * DAY_MS); const coverage = input.coverage ?? (await probeRollupCoverage(userId)); @@ -303,7 +312,7 @@ export async function computeCorrelationHypothesesFastPath( compliancePct, }); } - const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs }); + const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs }, t); // ── Hypothesis 2: Mood × resting pulse ────────────────────── const dailyMood = new Map(); @@ -328,7 +337,7 @@ export async function computeCorrelationHypothesesFastPath( restingPulse, }); } - const moodPulse = correlateMoodPulse({ daily: moodPulsePairs }); + const moodPulse = correlateMoodPulse({ daily: moodPulsePairs }, t); // ── Hypothesis 3: Weight × weekday ────────────────────────── const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = []; @@ -339,7 +348,10 @@ export async function computeCorrelationHypothesesFastPath( weight: row.value, }); } - const weightWeekday = correlateWeightWeekday({ daily: weightWeekdayPairs }); + const weightWeekday = correlateWeightWeekday( + { daily: weightWeekdayPairs }, + t, + ); annotate({ meta: { diff --git a/src/lib/environment/fields.ts b/src/lib/environment/fields.ts index 70e94d6cd..bb313fbe3 100644 --- a/src/lib/environment/fields.ts +++ b/src/lib/environment/fields.ts @@ -117,15 +117,3 @@ export const ENVIRONMENT_FIELDS: readonly EnvironmentField[] = [ ] as const; /** The correlation channel keys, in declaration order. */ -export const ENVIRONMENT_CHANNEL_KEYS: readonly string[] = - ENVIRONMENT_FIELDS.map((f) => f.key); - -/** True for a registered environmental channel key. */ -export function isEnvironmentChannelKey(key: string): boolean { - return ENVIRONMENT_CHANNEL_KEYS.includes(key); -} - -/** Resolve a field by its key, or null when unregistered. */ -export function getEnvironmentField(key: string): EnvironmentField | null { - return ENVIRONMENT_FIELDS.find((f) => f.key === key) ?? null; -} diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index 02c7c9060..0e68d24bc 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -365,6 +365,53 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { expect(pair!.interpretation).not.toMatch(/tends to go with/); expect(pair!.interpretation).toMatch(/never a cause/); }); + + // ── i18n regression guard (v1.30.10) ───────────────────────────────── + // + // The generated-insight narrative layer built its user-facing sentences by + // TypeScript string interpolation and never routed through the i18n bundle, + // so a German UI rendered an English sentence spliced into German text. This + // pins the fix: with `locale: "de"` the interpretation is the German template + // (localised metric label + correctly-negated "keine Ursache"), NOT English. + it("narrates in the reader's locale — German, never the English string", () => { + const n = 90; + const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4)); + const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)]; + const seriesInput: NamedSeries[] = [ + { + key: "TIME_IN_DAYLIGHT", + role: "behaviour", + points: longSeries(behaviour), + }, + { key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) }, + ]; + const find = (r: ReturnType) => + r.discovered.find( + (p) => + p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION", + ); + + const de = find(discoverCorrelations(seriesInput, { locale: "de" })); + const en = find(discoverCorrelations(seriesInput, { locale: "en" })); + expect(de).toBeDefined(); + expect(en).toBeDefined(); + + // German narration: localised metric labels + the German "high" template. + expect(de!.interpretation).toContain("die Zeit im Tageslicht"); + expect(de!.interpretation).toContain("die Schlafdauer"); + // The load-bearing, correctly-negated "not a cause" survives in German. + expect(de!.interpretation).toMatch(/keine Ursache/); + // And it is NOT the English leak the maintainer saw. + expect(de!.interpretation).not.toMatch(/tends to go with/); + expect(de!.interpretation).not.toMatch( + /a pattern worth watching, not a cause/, + ); + expect(de!.interpretation).not.toEqual(en!.interpretation); + + // English default stays exactly the established wording. + expect(en!.interpretation).toMatch(/tends to go with/); + expect(en!.interpretation).toMatch(/not a cause/); + }); }); // ── FDREXTEND — medication compliance + symptom severity channels ───── diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index b7bd338b4..77db6f0d3 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -39,10 +39,19 @@ * link. The series builders live in `correlation-series-builders.ts`. */ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; -import { - getEnvironmentField, - isEnvironmentChannelKey, -} from "@/lib/environment/fields"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +/** + * Locale-aware string builder — the same signature the server translator + * exposes. Every user-facing narration in this module routes through one of + * these so the sentence is emitted in the reader's language, not hardcoded + * English. Falls back to English (then the raw key) inside the translator. + */ +type Translate = ( + key: string, + params?: Record, +) => string; /** Default Benjamini-Hochberg target false-discovery rate. */ export const FDR_Q = 0.1; @@ -288,46 +297,64 @@ export function benjaminiHochberg( * Sign of `r` flips the direction; the `tier` (RECON1 D2-2 / D2-6) flips the * CONFIDENCE of the phrasing so a faint effect reads as "a faint hint, if * anything" rather than a confident "tends to go with". Never claims causation. + * + * The sentence is assembled from locale-keyed templates under + * `insights.correlation.daily.*` (+ a per-locale direction word) so the calm, + * never-causal framing survives verbatim in every language rather than leaking + * English into a non-English UI. */ function interpret( behaviour: string, outcome: string, r: number, tier: ConfidenceTier, + t: Translate, ): string { - const b = humanise(behaviour); - const o = humanise(outcome); - const lower = r < 0 ? "lower" : "higher"; - if (tier === "faint") { - // Below the confident effect-size threshold: a real-but-small signal, - // hedged so the narrated confidence never outruns the effect. - return `Higher ${b} shows a faint hint, if anything, of ${lower} next-day ${o} in your data — too small to lean on, never a cause.`; - } - const lead = - tier === "high" - ? `Higher ${b} tends to go with ${lower} next-day ${o} in your data` - : `Higher ${b} looks like it goes with ${lower} next-day ${o} in your data, on the evidence so far`; - return `${lead} — a pattern worth watching, not a cause.`; + const params = { + behaviour: humanise(behaviour, t), + outcome: humanise(outcome, t), + direction: t( + `insights.correlation.direction.${r < 0 ? "lower" : "higher"}`, + ), + }; + // faint = below the confident effect-size threshold (a real-but-small signal, + // hedged); high = confident "tends to go with"; moderate = the in-between + // "on the evidence so far" phrasing. Each tier keeps its own template. + const tierKey = + tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate"; + return t(`insights.correlation.daily.${tierKey}`, params); } /** - * Lower-case, space-separated label from a channel key. + * Localised, human-readable label for 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. + * key from colliding with a `MeasurementType` in the channel set. The factor + * name itself is user data, kept verbatim (lower-cased) inside the localised + * `channelFactor` frame. + * + * Every other channel (measurement types, MOOD, medication compliance, symptom + * severity, and the ENV_* exposure channels) resolves through a stable + * `insights.correlation.channel.` label, translated in all six locales. + * An unknown key falls back to its prettified form so a newly-added channel + * never blanks. */ -function humanise(key: string): string { - if (key.startsWith("FACTOR:")) { - return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`; +function humanise(key: string, t: Translate): string { + if (key.startsWith(FACTOR_CHANNEL_PREFIX)) { + const factor = key + .slice(FACTOR_CHANNEL_PREFIX.length) + .replace(/[_-]/g, " ") + .toLowerCase(); + return t("insights.correlation.channelFactor", { factor }); } - // v1.25 (W-ENV) — env channels read with their descriptive label ("daily - // temperature", "daylight") rather than the raw "env temp mean" key. - if (isEnvironmentChannelKey(key)) { - return getEnvironmentField(key)?.narrationLabel ?? key.toLowerCase(); - } - return key.replace(/_/g, " ").toLowerCase(); + const labelKey = `insights.correlation.channel.${key}`; + const label = t(labelKey); + // The server translator echoes the key on a miss; fall back to a readable + // prettified form so an unmapped channel degrades gracefully, never English- + // leaks a raw key. + return label === labelKey ? key.replace(/_/g, " ").toLowerCase() : label; } /** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */ @@ -402,11 +429,26 @@ export function discoveryMeasurementTypes(keys: readonly string[]): string[] { */ export function discoverCorrelations( series: NamedSeries[], - opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {}, + opts: { + lagDays?: number; + minPairs?: number; + fdrQ?: number; + /** + * Reader's locale for the narrated `interpretation`. Defaults to English + * so the many server callers that feed the string into an LLM prompt (which + * re-localises) keep their existing behaviour; the user-facing correlations + * route passes the resolved locale so the card renders in the reader's + * language. + */ + locale?: Locale; + } = {}, ): CorrelationDiscoveryResult { const lagDays = opts.lagDays ?? 1; const minPairs = opts.minPairs ?? MIN_PAIRED_N; const fdrQ = opts.fdrQ ?? FDR_Q; + // Named `translate` (not `t`) — the pair-mapping callbacks below bind `t` to + // the RawPair element, so the translator must not shadow-collide with them. + const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale); const behaviours = series.filter((s) => s.role === "behaviour"); const outcomes = series.filter((s) => s.role === "outcome"); @@ -469,7 +511,7 @@ export function discoverCorrelations( qValue: Math.round(t.qValue * 1000) / 1000, shrunkR: Math.round(t.shrunkR * 1000) / 1000, tier: t.tier, - interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier), + interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier, translate), lagDays, })) // RECON1 (D2-2 / D4) — rank by the SHRUNK effect magnitude (a deep, strong @@ -650,6 +692,8 @@ export function discoverEmergingCorrelations( lagDays?: number; minPairs?: number; fdrQ?: number; + /** Reader's locale, threaded to the inner scan's narration. */ + locale?: Locale; }, ): EmergingCorrelationResult { const windowDays = opts.windowDays ?? EARLY_WINDOW_DAYS; @@ -661,6 +705,7 @@ export function discoverEmergingCorrelations( lagDays: opts.lagDays, minPairs, fdrQ, + locale: opts.locale, }); const established = new Set( @@ -810,12 +855,16 @@ export function discoverLabOutcomeCorrelations( minDraws?: number; minWindowPoints?: number; fdrQ?: number; + /** Reader's locale for the narrated `interpretation`. Defaults to English. */ + locale?: Locale; } = {}, ): LabCorrelationResult { const windowDays = opts.windowDays ?? LAB_OUTCOME_WINDOW_DAYS; const minDraws = opts.minDraws ?? LAB_MIN_DRAWS; const minWindowPoints = opts.minWindowPoints ?? LAB_MIN_WINDOW_POINTS; const fdrQ = opts.fdrQ ?? FDR_Q; + // Named `translate` — the pair-mapping callback below binds `t` to its element. + const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale); // Group draws by biomarker key (newest-irrelevant; Pearson is order-free). const drawsByLab = new Map(); @@ -902,7 +951,7 @@ export function discoverLabOutcomeCorrelations( pValue: t.pValue, qValue: Math.round(t.qValue * 1000) / 1000, windowDays, - interpretation: interpretLab(t.lab, t.outcome, t.r, t.tier), + interpretation: interpretLab(t.lab, t.outcome, t.r, t.tier, translate), })) .sort((a, b) => Math.abs(b.r) - Math.abs(a.r) || a.qValue - b.qValue); @@ -928,12 +977,16 @@ function interpretLab( outcome: string, r: number, tier: ConfidenceTier, + t: Translate, ): string { - const l = humaniseLab(lab); - const o = humanise(outcome); - const dir = r < 0 ? "lower" : "higher"; - if (tier === "faint") { - return `Higher ${l} readings show a faint hint, if anything, of ${dir} ${o} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause.`; - } - return `Higher ${l} readings line up with ${dir} ${o} over the same periods in your data — an association worth watching with your clinician, never a cause.`; + const params = { + // The analyte name is user data — kept verbatim inside the localised frame. + lab: humaniseLab(lab), + outcome: humanise(outcome, t), + direction: t( + `insights.correlation.direction.${r < 0 ? "lower" : "higher"}`, + ), + }; + const tierKey = tier === "faint" ? "faint" : "default"; + return t(`insights.correlation.lab.${tierKey}`, params); } diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index a4dc3b366..dffe8766b 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -34,8 +34,44 @@ * * Conservative phrasing — interpretations always read as "a pattern worth * watching" or similar, never "X causes Y". Causation language is banned - * from this module by code-review convention. + * from this module by code-review convention. Every user-facing interpretation + * is assembled from locale-keyed templates under `insights.correlation.*` so + * the never-causal framing survives verbatim in all six locales rather than + * leaking English into a non-English UI. */ +import { defaultLocale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +/** Locale-aware string builder — mirrors the server translator's `t`. */ +type Translate = ( + key: string, + params?: Record, +) => string; + +/** + * English by default so the pure callers + unit tests that pass no locale keep + * their existing (English) output; the analytics route threads the reader's + * locale through the fast-path so the cards render in the reader's language. + */ +function defaultTranslate(): Translate { + return getServerTranslator(defaultLocale).t; +} + +/** + * Weekday index (0 = Monday … 6 = Sunday, the order the ANOVA outlier index + * uses) → the shared `charts.weekdaysFull.*` label key, reused so the weekday + * name is already localised in every bundle. + */ +const WEEKDAY_LABEL_KEYS = [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", +] as const; + export type CorrelationKind = "bp-compliance" | "mood-pulse" | "weight-weekday"; export interface CorrelationConfidenceBand { @@ -612,6 +648,7 @@ export interface BpComplianceInput { */ export function correlateBpCompliance( input: BpComplianceInput, + t: Translate = defaultTranslate(), ): CorrelationResult { const points = input.daily.map((d) => ({ x: d.compliancePct, @@ -640,10 +677,10 @@ export function correlateBpCompliance( } const interpretation = r.r < -0.1 - ? "Higher medication compliance lines up with lower systolic readings — a pattern worth watching." + ? t("insights.correlation.hypothesis.bpCompliance.support") : r.r > 0.1 - ? "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything." - : "Compliance and systolic do not move together strongly in this window."; + ? t("insights.correlation.hypothesis.bpCompliance.surprising") + : t("insights.correlation.hypothesis.bpCompliance.none"); return { kind: "bp-compliance", status: "ok", @@ -673,7 +710,10 @@ export interface MoodPulseInput { * Runs Pearson on (mood, restingPulse). A negative r supports the * hypothesis (mood down → pulse up). */ -export function correlateMoodPulse(input: MoodPulseInput): CorrelationResult { +export function correlateMoodPulse( + input: MoodPulseInput, + t: Translate = defaultTranslate(), +): CorrelationResult { const points = input.daily.map((d) => ({ x: d.mood, y: d.restingPulse })); const xs = points.map((p) => p.x); const ys = points.map((p) => p.y); @@ -698,10 +738,10 @@ export function correlateMoodPulse(input: MoodPulseInput): CorrelationResult { } const interpretation = r.r < -0.1 - ? "Lower-mood days line up with higher resting pulse — a pattern worth watching." + ? t("insights.correlation.hypothesis.moodPulse.support") : r.r > 0.1 - ? "Higher-mood days line up with higher resting pulse in your data — surprising." - : "Mood and resting pulse do not move together strongly in this window."; + ? t("insights.correlation.hypothesis.moodPulse.surprising") + : t("insights.correlation.hypothesis.moodPulse.none"); return { kind: "mood-pulse", status: "ok", @@ -726,16 +766,6 @@ export interface WeightWeekdayInput { daily: ReadonlyArray<{ weekday: number; weight: number }>; } -const WEEKDAY_LABELS = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", -] as const; - /** * Hypothesis #3 — weight has a weekly pattern (e.g. Monday spikes * after weekends). @@ -745,6 +775,7 @@ const WEEKDAY_LABELS = [ */ export function correlateWeightWeekday( input: WeightWeekdayInput, + t: Translate = defaultTranslate(), ): CorrelationResult { const points = input.daily.map((d) => ({ x: d.weekday, y: d.weight })); const anova = weekdayAnova( @@ -768,13 +799,21 @@ export function correlateWeightWeekday( points, }; } - const dayLabel = WEEKDAY_LABELS[anova.outlierIndex]; + const dayLabel = t( + `charts.weekdaysFull.${WEEKDAY_LABEL_KEYS[anova.outlierIndex]}`, + ); const grandMean = input.daily.reduce((s, p) => s + p.weight, 0) / input.daily.length; const outlierMean = anova.means[anova.outlierIndex] ?? grandMean; - const direction = outlierMean > grandMean ? "above" : "below"; + const direction = t( + `insights.correlation.weekdayDirection.${outlierMean > grandMean ? "above" : "below"}`, + ); const delta = Math.abs(outlierMean - grandMean).toFixed(1); - const interpretation = `${dayLabel} weights run ${delta} kg ${direction} your other-day average — a pattern worth watching.`; + const interpretation = t("insights.correlation.hypothesis.weightWeekday", { + weekday: dayLabel, + delta, + direction, + }); return { kind: "weight-weekday", diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 0e38a58bb..abf96cde6 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -56,6 +56,9 @@ const workoutListEntry = z maxHr: z.number().int().nullable(), source: measurementSourceEnum, externalId: z.string().nullable(), + // #67 list glyphs — flags which sessions open into a rich detail. + hasRoute: z.boolean(), + hasHrSeries: z.boolean(), }) .meta({ id: "WorkoutListEntry" }); @@ -89,6 +92,61 @@ const workoutHrSeries = z }) .meta({ id: "WorkoutHrSeries" }); +// #67 — per-workout HR curve, one DTO with explicit provenance. Stored +// `WorkoutSamples` series first, raw PULSE-window reconstruction second. +const workoutHrCurve = z + .object({ + source: z.enum(["workout_series", "pulse_window"]), + bucketSec: z.number().int().positive(), + points: z.array( + z.object({ + tSec: z.number().int().nonnegative(), + mean: z.number().int(), + min: z.number().int(), + max: z.number().int(), + }), + ), + envelope: z.boolean(), + }) + .meta({ id: "WorkoutHrCurve" }); + +// #67 — effort-zone distribution. WHOOP device durations win; else a +// %HRmax fold from the HR curve when profile age exists. +const workoutZones = z + .object({ + model: z.enum(["whoop", "tanaka"]), + hrMax: z.number().int().nullable(), + zones: z.array( + z.object({ + zone: z.number().int().min(1).max(5), + lowBpm: z.number().int().nullable(), + highBpm: z.number().int().nullable(), + seconds: z.number().int().nonnegative(), + }), + ), + }) + .meta({ id: "WorkoutZones" }); + +// #67 — per-kilometre splits, derived server-side from geometry + +// timestamps so the raw timestamp blob can be dropped under `compact=1`. +const workoutSplit = z + .object({ + km: z.number().int().positive(), + durationSec: z.number().int().nonnegative(), + paceSecPerKm: z.number().int().nonnegative(), + }) + .meta({ id: "WorkoutSplit" }); + +// #67 — own-history average for the sport (comparison line). +const workoutSportContext = z + .object({ + count: z.number().int().nonnegative(), + avgDurationSec: z.number().int().nonnegative(), + avgDistanceM: z.number().nullable(), + avgAvgHr: z.number().int().nullable(), + }) + .meta({ id: "WorkoutSportContext" }); + const workoutDetailResponse = z .object({ id: z.string(), @@ -109,6 +167,15 @@ const workoutDetailResponse = z metadata: z.unknown().nullable(), route: workoutRouteGeometry.nullable(), samples: workoutHrSeries.nullable(), + // #67 enrichment — additive; absent from a pre-enrichment client's + // expectations, opt-in `compact=1` only trims the raw blobs above. + hrSeries: workoutHrCurve.nullable(), + zones: workoutZones.nullable(), + splits: z.array(workoutSplit).nullable(), + sportContext: workoutSportContext.nullable(), + // Reserved Activity-Insight seam — always null until the Phase-2 job + // populates it. Typed here so the wire contract is stable in advance. + aiInsight: z.null(), canonicalId: z.string(), }) .meta({ id: "WorkoutDetailResponse" }); @@ -147,9 +214,12 @@ export const workoutPaths: NonNullable = { tags: ["Measurements"], summary: "Workout detail (v1.4.32)", description: - "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).", + "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed).", requestParams: { path: z.object({ id: z.string() }), + query: z.object({ + compact: z.enum(["1"]).optional(), + }), }, responses: { "200": { diff --git a/src/lib/workouts/__tests__/hr-series.test.ts b/src/lib/workouts/__tests__/hr-series.test.ts new file mode 100644 index 000000000..98e43e060 --- /dev/null +++ b/src/lib/workouts/__tests__/hr-series.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findMany = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } }, +})); + +import { + adaptiveBucketSec, + foldHrBuckets, + buildWorkoutHrSeries, +} from "@/lib/workouts/hr-series"; + +const START = new Date("2026-05-15T07:00:00Z"); +const startMs = START.getTime(); + +/** Stored-sample entries at `+seconds` offsets from START. */ +function storedSample(seconds: number, hr: number) { + return { t: new Date(startMs + seconds * 1000).toISOString(), hr }; +} + +beforeEach(() => { + findMany.mockReset(); +}); + +describe("adaptiveBucketSec", () => { + it("clamps to [5, 60]", () => { + expect(adaptiveBucketSec(300)).toBe(5); // ceil(1.25)=2 → floored to 5 + expect(adaptiveBucketSec(1800)).toBe(8); + expect(adaptiveBucketSec(6 * 3600)).toBe(60); // ceil(90)=90 → capped 60 + }); +}); + +describe("foldHrBuckets", () => { + it("buckets by elapsed time and drops samples outside the session", () => { + const samples = [ + { tMs: startMs - 5000, hr: 200 }, // before start → dropped + { tMs: startMs + 1000, hr: 100 }, + { tMs: startMs + 2000, hr: 120 }, + { tMs: startMs + 11000, hr: 150 }, + { tMs: startMs + 60000, hr: 130 }, // == durationSec → dropped (half-open) + ]; + const { points, bucketCount } = foldHrBuckets(samples, startMs, 60, 10); + expect(bucketCount).toBe(6); + // bucket 0 holds the two early samples → mean 110, min 100, max 120. + expect(points[0]).toEqual({ tSec: 0, mean: 110, min: 100, max: 120 }); + // bucket 1 holds the +11s sample. + expect(points[1]).toEqual({ tSec: 10, mean: 150, min: 150, max: 150 }); + expect(points).toHaveLength(2); // gaps stay as gaps + }); + + it("reports median per-bucket density for the envelope decision", () => { + const samples = [ + { tMs: startMs + 0, hr: 100 }, + { tMs: startMs + 1000, hr: 110 }, + { tMs: startMs + 2000, hr: 120 }, + { tMs: startMs + 3000, hr: 130 }, + ]; + const { medianDensity } = foldHrBuckets(samples, startMs, 60, 10); + expect(medianDensity).toBe(4); + }); +}); + +describe("buildWorkoutHrSeries", () => { + const base = { + userId: "u1", + startedAt: START, + endedAt: new Date(startMs + 600_000), // 10 min + durationSec: 600, + now: new Date(startMs + 600_000 + 1000), + }; + + it("prefers the stored series and never touches the DB", async () => { + const stored = Array.from({ length: 40 }, (_, i) => + storedSample(i * 15, 120 + (i % 5)), + ); + const result = await buildWorkoutHrSeries({ + ...base, + storedSamples: stored, + }); + expect(result).not.toBeNull(); + expect(result!.source).toBe("workout_series"); + expect(result!.bucketSec).toBe(adaptiveBucketSec(600)); + expect(result!.points.length).toBeGreaterThanOrEqual(2); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("marks the envelope when buckets are dense", async () => { + // ~8 samples per bucket (1 Hz over an 8 s bucket) → well above the + // density floor, so the min→max envelope band renders. + const stored: ReturnType[] = []; + for (let s = 0; s < 600; s += 1) stored.push(storedSample(s, 140)); + const result = await buildWorkoutHrSeries({ + ...base, + storedSamples: stored, + }); + expect(result!.envelope).toBe(true); + }); + + it("falls back to the pulse window when no stored series exists", async () => { + // Dense PULSE rows across the whole session → passes the gate. + const rows = Array.from({ length: 60 }, (_, i) => ({ + value: 130 + (i % 7), + measuredAt: new Date(startMs + i * 10_000), + externalId: null, + })); + findMany.mockResolvedValue(rows); + const result = await buildWorkoutHrSeries({ ...base, storedSamples: null }); + expect(findMany).toHaveBeenCalledOnce(); + expect(result).not.toBeNull(); + expect(result!.source).toBe("pulse_window"); + }); + + it("hides (returns null) when the window has too few samples", async () => { + findMany.mockResolvedValue([ + { value: 120, measuredAt: new Date(startMs + 1000), externalId: null }, + { value: 122, measuredAt: new Date(startMs + 2000), externalId: null }, + ]); + const result = await buildWorkoutHrSeries({ ...base, storedSamples: null }); + expect(result).toBeNull(); + }); + + it("hides when bucket coverage is below 40 %", async () => { + // 10 samples all clustered in the first minute of a 10-minute run → + // enough raw samples, but coverage is ~1 bucket of ~75. + const rows = Array.from({ length: 10 }, (_, i) => ({ + value: 120 + i, + measuredAt: new Date(startMs + i * 500), + externalId: null, + })); + findMany.mockResolvedValue(rows); + const result = await buildWorkoutHrSeries({ ...base, storedSamples: null }); + expect(result).toBeNull(); + }); + + it("excludes consolidated stats: rows from the fallback", async () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ + value: 130, + measuredAt: new Date(startMs + i * 20_000), + externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-05-15T07", + })); + findMany.mockResolvedValue(rows); + const result = await buildWorkoutHrSeries({ ...base, storedSamples: null }); + expect(result).toBeNull(); + }); + + it("skips the fallback for workouts older than the retention window", async () => { + const old = { + ...base, + now: new Date(startMs + 100 * 24 * 60 * 60 * 1000), + }; + const result = await buildWorkoutHrSeries({ ...old, storedSamples: null }); + expect(result).toBeNull(); + expect(findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/workouts/__tests__/route-svg.test.ts b/src/lib/workouts/__tests__/route-svg.test.ts new file mode 100644 index 000000000..0061b74c2 --- /dev/null +++ b/src/lib/workouts/__tests__/route-svg.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; + +import { + projectRoute, + rdpSimplify, + buildElevationProfile, + haversineMeters, + buildGpxDocument, + type RouteCoordinate, +} from "@/lib/workouts/route-svg"; + +/** A ring of `n` points around (lon0, lat0) with per-axis degree radii. */ +function ring( + lon0: number, + lat0: number, + dLon: number, + dLat: number, + n = 40, +): RouteCoordinate[] { + const out: RouteCoordinate[] = []; + for (let i = 0; i <= n; i++) { + const a = (i / n) * 2 * Math.PI; + out.push([lon0 + dLon * Math.cos(a), lat0 + dLat * Math.sin(a)]); + } + return out; +} + +describe("projectRoute cos(midLat) correction", () => { + it("squashes the longitude axis by cos(midLat) at 50°N", () => { + // A box twice as wide in DEGREES of longitude as in latitude. In + // real metres a degree of longitude at 50°N is only cos(50°) as long + // as a degree of latitude, so the projected aspect (ySpan/xSpan) must + // be 1 / (2·cos(50°)) ≈ 0.778 — NOT the uncorrected 0.5 the old + // normalisation produced. Both stay inside the [0.4, 1.4] clamp band. + const box = ring(11.0, 50.0, 0.02, 0.01, 30); + const projected = projectRoute(box); + expect(projected).not.toBeNull(); + const aspect = projected!.height / projected!.width; + const corrected = 1 / (2 * Math.cos(50 * (Math.PI / 180))); // ≈ 0.778 + expect(aspect).toBeGreaterThan(corrected * 0.95); + expect(aspect).toBeLessThan(corrected * 1.05); + // And distinctly above the uncorrected 0.5 the stretch bug gave. + expect(aspect).toBeGreaterThan(0.6); + }); + + it("clamps an extreme out-and-back sliver to the minimum height", () => { + // A near-straight east-west line — ySpan ≈ 0 → the aspect clamp + // floors the height at 40 rather than a 0-tall sliver. + const coords: RouteCoordinate[] = []; + for (let i = 0; i < 30; i++) + coords.push([11.0 + i * 0.001, 50.0 + i * 1e-6]); + const projected = projectRoute(coords); + expect(projected).not.toBeNull(); + expect(projected!.height).toBe(40); + expect(projected!.viewBox).toBe("0 0 100 40.00"); + }); + + it("rejects a degenerate point-shaped route (< 10 points)", () => { + expect( + projectRoute([ + [11, 50], + [11.0001, 50.0001], + [11.0002, 50.0002], + ]), + ).toBeNull(); + }); + + it("rejects a route whose real-world span is under ~30 m", () => { + // 15 points but jittering inside a ~5 m box. + const coords: RouteCoordinate[] = []; + for (let i = 0; i < 15; i++) { + coords.push([11.0 + i * 0.00001, 50.0 + (i % 2) * 0.00001]); + } + expect(projectRoute(coords)).toBeNull(); + }); + + it("emits start and end markers and a valid path", () => { + const box = ring(11.0, 50.0, 0.02, 0.02, 30); + const projected = projectRoute(box)!; + expect(projected.path.startsWith("M")).toBe(true); + expect(projected.pointCount).toBeGreaterThanOrEqual(4); + expect(projected.start).toHaveProperty("x"); + expect(projected.end).toHaveProperty("y"); + }); +}); + +describe("rdpSimplify", () => { + it("collapses collinear points but keeps corners", () => { + const line: Array<[number, number]> = [ + [0, 0], + [1, 0], + [2, 0], + [3, 0], + [3, 3], + ]; + const out = rdpSimplify(line, 0.01); + // The four collinear x-axis points collapse to the two endpoints of + // that run; the corner at [3,0] and the terminus [3,3] survive. + expect(out).toEqual([ + [0, 0], + [3, 0], + [3, 3], + ]); + }); + + it("never drops the first or last point", () => { + const line: Array<[number, number]> = Array.from( + { length: 100 }, + (_, i) => [i, Math.sin(i)], + ); + const out = rdpSimplify(line, 0.5); + expect(out[0]).toEqual(line[0]); + expect(out[out.length - 1]).toEqual(line[line.length - 1]); + expect(out.length).toBeLessThan(line.length); + }); +}); + +describe("buildElevationProfile", () => { + it("returns a cumulative-distance profile when ≥ 60 % carry altitude", () => { + const coords: RouteCoordinate[] = [ + [11.0, 50.0, 300], + [11.001, 50.0, 305], + [11.002, 50.0, 310], + [11.003, 50.0, 315], + ]; + const profile = buildElevationProfile(coords)!; + expect(profile).toHaveLength(4); + expect(profile[0].distanceM).toBe(0); + expect(profile[3].distanceM).toBeGreaterThan(profile[0].distanceM); + expect(profile[3].altitude).toBe(315); + }); + + it("returns null below the altitude-coverage threshold", () => { + const coords: RouteCoordinate[] = [ + [11.0, 50.0, 300], + [11.001, 50.0], + [11.002, 50.0], + [11.003, 50.0], + ]; + expect(buildElevationProfile(coords)).toBeNull(); + }); +}); + +describe("haversineMeters", () => { + it("measures ~111 km for one degree of latitude", () => { + const d = haversineMeters(11, 50, 11, 51); + expect(d).toBeGreaterThan(110_000); + expect(d).toBeLessThan(112_000); + }); +}); + +describe("buildGpxDocument", () => { + it("emits trkpt elements with elevation and time when present", () => { + const gpx = buildGpxDocument({ + coordinates: [ + [11.0, 50.0, 300], + [11.001, 50.001, 305], + ], + timestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:00:05Z"], + sportType: "running", + startedAt: "2026-05-15T07:00:00Z", + }); + expect(gpx).toContain('300"); + expect(gpx).toContain(""); + }); +}); diff --git a/src/lib/workouts/__tests__/splits.test.ts b/src/lib/workouts/__tests__/splits.test.ts new file mode 100644 index 000000000..6b335053a --- /dev/null +++ b/src/lib/workouts/__tests__/splits.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; + +import { computeSplits } from "@/lib/workouts/splits"; +import type { RouteCoordinate } from "@/lib/workouts/route-svg"; + +/** + * Build a straight eastward track of `n` evenly spaced points spanning + * `totalKm`, with `secPerKm` pace. One degree of longitude at 50°N is + * ~71.7 km, but we anchor the math on the actual haversine distance the + * function computes, so we just space points and let the boundaries + * fall out. + */ +function track( + totalMeters: number, + n: number, + totalSeconds: number, +): { coords: RouteCoordinate[]; timestamps: string[] } { + const coords: RouteCoordinate[] = []; + const timestamps: string[] = []; + const lat = 0; // equator → a degree of lon ≈ 111.32 km, easy to reason about + const startMs = Date.parse("2026-05-15T07:00:00Z"); + const totalDeg = totalMeters / 111_320; // metres → degrees lon at equator + for (let i = 0; i <= n; i++) { + const f = i / n; + coords.push([f * totalDeg, lat]); + timestamps.push(new Date(startMs + f * totalSeconds * 1000).toISOString()); + } + return { coords, timestamps }; +} + +describe("computeSplits", () => { + it("returns one split per full kilometre with interpolated pace", () => { + // 3.2 km in 960 s → 300 s/km even pace; three full-km boundaries. + const { coords, timestamps } = track(3200, 320, 960); + const splits = computeSplits(coords, timestamps)!; + expect(splits).toHaveLength(3); + expect(splits[0].km).toBe(1); + for (const s of splits) { + expect(s.paceSecPerKm).toBeGreaterThan(285); + expect(s.paceSecPerKm).toBeLessThan(315); + expect(s.durationSec).toBe(s.paceSecPerKm); + } + }); + + it("returns null without aligned timestamps", () => { + const { coords } = track(3000, 300, 900); + expect(computeSplits(coords, null)).toBeNull(); + expect(computeSplits(coords, ["2026-05-15T07:00:00Z"])).toBeNull(); + }); + + it("returns null under one full kilometre", () => { + const { coords, timestamps } = track(400, 50, 120); + expect(computeSplits(coords, timestamps)).toBeNull(); + }); +}); diff --git a/src/lib/workouts/__tests__/zones.test.ts b/src/lib/workouts/__tests__/zones.test.ts new file mode 100644 index 000000000..f1ebaf9fe --- /dev/null +++ b/src/lib/workouts/__tests__/zones.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; + +import { + computeZones, + parseWhoopZoneDurations, + hrMaxFromAge, +} from "@/lib/workouts/zones"; +import type { HrSeriesPoint } from "@/lib/workouts/hr-series"; + +function pt(tSec: number, mean: number): HrSeriesPoint { + return { tSec, mean, min: mean, max: mean }; +} + +describe("hrMaxFromAge", () => { + it("applies Tanaka and rounds", () => { + expect(hrMaxFromAge(40)).toBe(180); // 208 − 0.7·40 + expect(hrMaxFromAge(null)).toBeNull(); + }); +}); + +describe("computeZones — %HRmax (tanaka)", () => { + const hrMax = 180; // age 40 → Z1 90-108, Z3 126-144, Z5 162+ + + it("folds series buckets into %HRmax zones by mean HR", () => { + const series = [ + pt(0, 100), // 55 % → Z1 + pt(10, 135), // 75 % → Z3 + pt(20, 140), // 78 % → Z3 + pt(30, 170), // 94 % → Z5 + ]; + const zones = computeZones({ + hrMax, + series, + bucketSec: 10, + whoopZoneDurations: null, + })!; + expect(zones.model).toBe("tanaka"); + expect(zones.hrMax).toBe(180); + const byZone = Object.fromEntries( + zones.zones.map((z) => [z.zone, z.seconds]), + ); + expect(byZone[1]).toBe(10); + expect(byZone[3]).toBe(20); + expect(byZone[5]).toBe(10); + expect(byZone[2]).toBe(0); + // Bounds carry bpm edges; the top zone is open-ended. + const z5 = zones.zones.find((z) => z.zone === 5)!; + expect(z5.lowBpm).toBe(162); + expect(z5.highBpm).toBeNull(); + }); + + it("excludes below-Z1 (rest) time and returns null when all rest", () => { + const series = [pt(0, 80), pt(10, 85)]; // < 50 % HRmax + expect( + computeZones({ hrMax, series, bucketSec: 10, whoopZoneDurations: null }), + ).toBeNull(); + }); + + it("returns null without profile age", () => { + expect( + computeZones({ + hrMax: null, + series: [pt(0, 140)], + bucketSec: 10, + whoopZoneDurations: null, + }), + ).toBeNull(); + }); +}); + +describe("computeZones — WHOOP wins", () => { + it("uses device-reported durations over the computed series", () => { + const zones = computeZones({ + hrMax: 180, + series: [pt(0, 140)], + bucketSec: 10, + whoopZoneDurations: [60, 120, 180, 240, 300], + })!; + expect(zones.model).toBe("whoop"); + const seconds = zones.zones.map((z) => z.seconds); + expect(seconds).toEqual([60, 120, 180, 240, 300]); + // Bounds still resolve from HRmax when age is known. + expect(zones.zones[0].lowBpm).toBe(90); + }); + + it("carries null bounds when HRmax is unknown but WHOOP zones exist", () => { + const zones = computeZones({ + hrMax: null, + series: [], + bucketSec: 10, + whoopZoneDurations: [10, 20, 30, 40, 50], + })!; + expect(zones.model).toBe("whoop"); + expect(zones.hrMax).toBeNull(); + expect(zones.zones[0].lowBpm).toBeNull(); + }); +}); + +describe("parseWhoopZoneDurations", () => { + it("maps zone_one..zone_five milliseconds to Z1..Z5 seconds", () => { + const parsed = parseWhoopZoneDurations({ + zoneDurations: { + zone_zero_milli: 900000, + zone_one_milli: 60000, + zone_two_milli: 120000, + zone_three_milli: 0, + zone_four_milli: 0, + zone_five_milli: 30000, + }, + }); + expect(parsed).toEqual([60, 120, 0, 0, 30]); + }); + + it("returns null for absent, malformed, or all-zero durations", () => { + expect(parseWhoopZoneDurations(null)).toBeNull(); + expect(parseWhoopZoneDurations({})).toBeNull(); + expect(parseWhoopZoneDurations({ zoneDurations: "nope" })).toBeNull(); + expect( + parseWhoopZoneDurations({ zoneDurations: { zone_one_milli: 0 } }), + ).toBeNull(); + }); +}); diff --git a/src/lib/workouts/hr-series.ts b/src/lib/workouts/hr-series.ts new file mode 100644 index 000000000..e1db75335 --- /dev/null +++ b/src/lib/workouts/hr-series.ts @@ -0,0 +1,239 @@ +/** + * Per-workout heart-rate curve — one server code path, one DTO with + * explicit provenance. + * + * Source priority (strict): + * 1. `WorkoutSamples` (stored series) — the session's own sensor + * stream, highest fidelity. Used whenever the parsed series yields + * ≥ 2 usable HR points. + * 2. Pulse-window reconstruction — raw `PULSE` measurement rows in + * `[startedAt − 5 min, endedAt + 5 min]`, bucketed at session + * grain. Only attempted for workouts younger than the dense + * retention window (beyond it only hourly folds survive, which are + * useless for a session curve), and only when the fallback quality + * gate passes. + * 3. Nothing — the caller hides the HR-curve card. The avg/max/min + * aggregates already live in the stats grid; an empty chart lies. + * + * The fallback gate keeps the same posture as the tension detector: + * silence over speculation. A watchless ride with two opportunistic BPM + * readings must not paint a two-point "curve". + */ +import { prisma } from "@/lib/db"; + +/** Dense raw-PULSE retention. Older days keep only hourly folds. */ +const DENSE_INTRADAY_RETENTION_DAYS = 90; +/** ± padding around the session for the fallback window. */ +const WINDOW_PAD_MS = 5 * 60 * 1000; +/** Defensive read cap — dense accounts emit ~1 PULSE sample/second. */ +const MAX_WINDOW_PULSE_ROWS = 6000; +/** Fallback gate: raw samples required in the padded window. */ +const MIN_FALLBACK_SAMPLES = 8; +/** Fallback gate: fraction of session buckets that must be non-empty. */ +const MIN_BUCKET_COVERAGE = 0.4; +/** Envelope band renders once buckets are dense enough to be meaningful. */ +const ENVELOPE_MIN_MEDIAN_DENSITY = 3; + +export type HrSeriesSource = "workout_series" | "pulse_window"; + +export interface HrSeriesPoint { + /** Elapsed seconds from the workout start (bucket left edge). */ + tSec: number; + mean: number; + min: number; + max: number; +} + +export interface WorkoutHrSeries { + source: HrSeriesSource; + bucketSec: number; + points: HrSeriesPoint[]; + /** + * True when per-bucket density supports a min→max envelope band — + * this is what makes intervals read as spiky instead of averaged + * away. Below the threshold the chart draws the mean line only. + */ + envelope: boolean; +} + +interface RawHrSample { + tMs: number; + hr: number; +} + +function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); +} + +/** + * Adaptive bucket width targeting a constant on-screen density: a + * 20-minute HIIT and a 6-hour ride both land at ≤ ~240–480 points. + */ +export function adaptiveBucketSec(durationSec: number): number { + return clamp(Math.ceil(durationSec / 240), 5, 60); +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + 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]; +} + +/** + * Fold raw HR samples into session-grain buckets. Samples outside the + * session window `[0, durationSec)` are dropped from the curve (they + * only widen the DB query so a boundary reading still lands in bucket + * 0 / the last bucket). Empty buckets stay as gaps — no interpolation. + * Pure; exported for unit testing. + */ +export function foldHrBuckets( + samples: readonly RawHrSample[], + startMs: number, + durationSec: number, + bucketSec: number, +): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } { + const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec)); + const acc = new Map< + number, + { sum: number; min: number; max: number; n: number } + >(); + for (const s of samples) { + const rel = (s.tMs - startMs) / 1000; + if (rel < 0 || rel >= durationSec) continue; + const idx = Math.floor(rel / bucketSec); + const bucket = acc.get(idx); + if (bucket) { + bucket.sum += s.hr; + bucket.n += 1; + if (s.hr < bucket.min) bucket.min = s.hr; + if (s.hr > bucket.max) bucket.max = s.hr; + } else { + acc.set(idx, { sum: s.hr, min: s.hr, max: s.hr, n: 1 }); + } + } + const points: HrSeriesPoint[] = []; + const densities: number[] = []; + for (const [idx, b] of [...acc.entries()].sort((a, c) => a[0] - c[0])) { + points.push({ + tSec: idx * bucketSec, + mean: Math.round(b.sum / b.n), + min: b.min, + max: b.max, + }); + densities.push(b.n); + } + return { points, bucketCount, medianDensity: median(densities) }; +} + +/** + * Parse the stored `WorkoutSamples.samples` JSONB into usable HR + * samples. The blob is validated at ingest against + * `workoutHrSamplesSchema`, so this stays a lean, defensive read rather + * than a full re-validation of up to 30 000 rows. + */ +function parseStoredSamples(raw: unknown): RawHrSample[] { + if (!Array.isArray(raw)) return []; + const out: RawHrSample[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== "object") continue; + const rec = entry as Record; + const hr = rec.hr; + const t = rec.t; + if (typeof hr !== "number" || !Number.isFinite(hr)) continue; + if (typeof t !== "string") continue; + const tMs = Date.parse(t); + if (!Number.isFinite(tMs)) continue; + out.push({ tMs, hr }); + } + return out; +} + +export interface WorkoutHrSeriesInput { + userId: string; + startedAt: Date; + endedAt: Date; + durationSec: number; + storedSamples: unknown; + /** Reference clock for the retention cut — defaults to `now`. */ + now?: Date; +} + +/** + * Resolve the workout's HR curve. Returns `null` when neither a stored + * series nor an adequate pulse window exists. + */ +export async function buildWorkoutHrSeries( + input: WorkoutHrSeriesInput, +): Promise { + const { userId, startedAt, endedAt, durationSec, storedSamples } = input; + const now = input.now ?? new Date(); + const startMs = startedAt.getTime(); + const bucketSec = adaptiveBucketSec(durationSec); + + // 1. Stored series — the native, highest-fidelity path. + const stored = parseStoredSamples(storedSamples); + if (stored.length >= 2) { + const { points, medianDensity } = foldHrBuckets( + stored, + startMs, + durationSec, + bucketSec, + ); + if (points.length >= 2) { + return { + source: "workout_series", + bucketSec, + points, + envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY, + }; + } + } + + // 2. Pulse-window fallback — only within the dense-retention horizon. + const ageDays = (now.getTime() - startMs) / (24 * 60 * 60 * 1000); + if (ageDays > DENSE_INTRADAY_RETENTION_DAYS) return null; + + const windowStart = new Date(startMs - WINDOW_PAD_MS); + const windowEnd = new Date(endedAt.getTime() + WINDOW_PAD_MS); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: "PULSE", + deletedAt: null, + measuredAt: { gte: windowStart, lte: windowEnd }, + }, + orderBy: { measuredAt: "asc" }, + take: MAX_WINDOW_PULSE_ROWS, + select: { value: true, measuredAt: true, externalId: true }, + }); + + // Raw per-sample rows only — a consolidated `stats:` fold is a day + // shape, not a session reading. + const raw: RawHrSample[] = rows + .filter((r) => !r.externalId?.startsWith("stats:")) + .map((r) => ({ tMs: r.measuredAt.getTime(), hr: r.value })); + + // Gate part 1: enough raw samples in the padded window. + if (raw.length < MIN_FALLBACK_SAMPLES) return null; + + const { points, bucketCount, medianDensity } = foldHrBuckets( + raw, + startMs, + durationSec, + bucketSec, + ); + + // Gate part 2: enough of the session is actually covered. + if (points.length / bucketCount < MIN_BUCKET_COVERAGE) return null; + if (points.length < 2) return null; + + return { + source: "pulse_window", + bucketSec, + points, + envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY, + }; +} diff --git a/src/lib/workouts/route-svg.ts b/src/lib/workouts/route-svg.ts new file mode 100644 index 000000000..23d6d831c --- /dev/null +++ b/src/lib/workouts/route-svg.ts @@ -0,0 +1,353 @@ +/** + * Client-safe route geometry math for the workout-detail map. + * + * PURE — no Prisma, no server imports, no Node APIs. It runs in the + * browser bundle (the v1.29 client/server-boundary lesson) and turns a + * stored GeoJSON `LineString` into an inline SVG polyline plus optional + * elevation profile and a GPX export blob. Nothing here reaches the + * network: the whole point of self-rendering the route is that the + * user's track never leaves the origin (binding privacy/CSP decision — + * no tile server, ever). + * + * Projection is equirectangular with a single `cos(midLat)` longitude + * scale factor. At workout scale (≤ a few tens of km) that is visually + * exact; the previous uncorrected normalisation stretched a route ~40 % + * horizontally at 50° N (a circular circuit read as an ellipse). The + * correction squashes the longitude axis by `cos(midLat)` so one screen + * unit east equals one screen unit north in real metres. + */ + +export type RouteCoordinate = + [number, number] | [number, number, number] | number[]; + +export interface RoutePoint { + x: number; + y: number; +} + +export interface ProjectedRoute { + /** SVG path data (`M … L …`) in viewBox units. */ + path: string; + /** `0 0 W H` — W is fixed at 100, H is the aspect-derived, clamped height. */ + viewBox: string; + width: number; + height: number; + /** Start marker position (viewBox units). */ + start: RoutePoint; + /** End marker position (viewBox units). */ + end: RoutePoint; + /** Number of points after RDP downsampling. */ + pointCount: number; +} + +export interface ElevationPoint { + /** Cumulative distance from the route start, metres. */ + distanceM: number; + /** Altitude, metres. */ + altitude: number; +} + +const DEG_TO_RAD = Math.PI / 180; +const METERS_PER_DEG_LAT = 111_320; + +/** viewBox width — fixed; height rides the projected aspect ratio. */ +const VIEW_W = 100; +/** Aspect clamp so a straight out-and-back can't render as a 4 px sliver. */ +const MIN_H = 40; +const MAX_H = 140; +/** Guards against a degenerate "route" (an indoor session's 3 GPS pings). */ +const MIN_POINTS = 10; +const MIN_SPAN_M = 30; +/** Shape-preserving downsample ceiling. */ +const MAX_RENDER_POINTS = 1200; + +function isFiniteNumber(v: unknown): v is number { + return typeof v === "number" && Number.isFinite(v); +} + +/** + * Pull the `[lon, lat]` pairs out of a GeoJSON `LineString`'s + * coordinates, dropping any malformed entry. Altitude (3rd element) is + * ignored here — `buildElevationProfile` reads it separately. + */ +export function extractLonLat( + coordinates: readonly RouteCoordinate[], +): Array<[number, number]> { + const out: Array<[number, number]> = []; + for (const c of coordinates) { + if (!Array.isArray(c) || c.length < 2) continue; + const lon = c[0]; + const lat = c[1]; + if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) continue; + if (Math.abs(lon) > 180 || Math.abs(lat) > 90) continue; + out.push([lon, lat]); + } + return out; +} + +/** Great-circle distance between two lon/lat points, metres. */ +export function haversineMeters( + aLon: number, + aLat: number, + bLon: number, + bLat: number, +): number { + const R = 6_371_000; + const dLat = (bLat - aLat) * DEG_TO_RAD; + const dLon = (bLon - aLon) * DEG_TO_RAD; + const lat1 = aLat * DEG_TO_RAD; + const lat2 = bLat * DEG_TO_RAD; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Perpendicular distance from `p` to the segment `a→b` (planar). */ +function perpDistance( + p: [number, number], + a: [number, number], + b: [number, number], +): number { + const [px, py] = p; + const [ax, ay] = a; + const [bx, by] = b; + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + if (lenSq === 0) return Math.hypot(px - ax, py - ay); + let t = ((px - ax) * dx + (py - ay) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(px - (ax + t * dx), py - (ay + t * dy)); +} + +/** + * Ramer–Douglas–Peucker line simplification. Keeps switchbacks that a + * naive stride would erase. Iterative (an explicit stack) so a + * 20 000-point ultra can't blow the call stack. + */ +export function rdpSimplify( + points: ReadonlyArray<[number, number]>, + tolerance: number, +): Array<[number, number]> { + if (points.length <= 2 || tolerance <= 0) return points.slice(); + const keep = new Uint8Array(points.length); + keep[0] = 1; + keep[points.length - 1] = 1; + const stack: Array<[number, number]> = [[0, points.length - 1]]; + while (stack.length > 0) { + const [first, last] = stack.pop() as [number, number]; + let maxDist = 0; + let index = -1; + for (let i = first + 1; i < last; i++) { + const d = perpDistance(points[i], points[first], points[last]); + if (d > maxDist) { + maxDist = d; + index = i; + } + } + if (maxDist > tolerance && index !== -1) { + keep[index] = 1; + stack.push([first, index]); + stack.push([index, last]); + } + } + const out: Array<[number, number]> = []; + for (let i = 0; i < points.length; i++) if (keep[i]) out.push(points[i]); + return out; +} + +/** Uniform stride down to at most `max` points (post-RDP safety cap). */ +function strideTo(points: readonly T[], max: number): T[] { + if (points.length <= max) return points.slice(); + const step = points.length / max; + const out: T[] = []; + for (let i = 0; i < max; i++) out.push(points[Math.floor(i * step)]); + const last = points[points.length - 1]; + if (out[out.length - 1] !== last) out.push(last); + return out; +} + +/** + * Project a GeoJSON LineString into an aspect-true, downsampled SVG + * path. Returns `null` for a degenerate route (too few points or a + * bounding box under ~30 m) — the caller then hides the route card + * rather than painting a meaningless dot. + */ +export function projectRoute( + coordinates: readonly RouteCoordinate[], +): ProjectedRoute | null { + const lonLat = extractLonLat(coordinates); + if (lonLat.length < MIN_POINTS) return null; + + let minLon = Infinity; + let maxLon = -Infinity; + let minLat = Infinity; + let maxLat = -Infinity; + for (const [lon, lat] of lonLat) { + if (lon < minLon) minLon = lon; + if (lon > maxLon) maxLon = lon; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + + const midLat = (minLat + maxLat) / 2; + const cosMid = Math.cos(midLat * DEG_TO_RAD); + + // Real-world span (metres) — the small-route guard rejects the indoor + // session that logged a couple of stationary GPS pings. + const spanXm = (maxLon - minLon) * cosMid * METERS_PER_DEG_LAT; + const spanYm = (maxLat - minLat) * METERS_PER_DEG_LAT; + if (Math.hypot(spanXm, spanYm) < MIN_SPAN_M) return null; + + // Projected coordinates: longitude squashed by cos(midLat); latitude + // flipped so SVG y grows downward. + const projected: Array<[number, number]> = lonLat.map(([lon, lat]) => [ + (lon - minLon) * cosMid, + maxLat - lat, + ]); + + const xSpanRaw = (maxLon - minLon) * cosMid || 1e-9; + // RDP tolerance derived from the projected bbox (spec: xSpan / 2000). + let simplified = rdpSimplify(projected, xSpanRaw / 2000); + if (simplified.length > MAX_RENDER_POINTS) { + simplified = strideTo(simplified, MAX_RENDER_POINTS); + } + + // Projected bbox of the (simplified) track + 5 % padding per side. + let pMinX = Infinity; + let pMaxX = -Infinity; + let pMinY = Infinity; + let pMaxY = -Infinity; + for (const [x, y] of simplified) { + if (x < pMinX) pMinX = x; + if (x > pMaxX) pMaxX = x; + if (y < pMinY) pMinY = y; + if (y > pMaxY) pMaxY = y; + } + const rawXSpan = pMaxX - pMinX || 1e-9; + const rawYSpan = pMaxY - pMinY || 1e-9; + const padX = rawXSpan * 0.05; + const padY = rawYSpan * 0.05; + pMinX -= padX; + pMaxX += padX; + pMinY -= padY; + pMaxY += padY; + const xSpan = pMaxX - pMinX; + const ySpan = pMaxY - pMinY; + + // Height rides the true aspect ratio (uniform scale when unclamped); + // the clamp only bites on extreme slivers, where the intentional + // distortion trades geographic fidelity for on-screen readability. + const height = Math.max(MIN_H, Math.min(MAX_H, (VIEW_W * ySpan) / xSpan)); + const scaleX = VIEW_W / xSpan; + const scaleY = height / ySpan; + + const toVB = ([x, y]: [number, number]): RoutePoint => ({ + x: (x - pMinX) * scaleX, + y: (y - pMinY) * scaleY, + }); + + const vbPoints = simplified.map(toVB); + const path = vbPoints + .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`) + .join(" "); + + return { + path, + viewBox: `0 0 ${VIEW_W} ${height.toFixed(2)}`, + width: VIEW_W, + height, + start: vbPoints[0], + end: vbPoints[vbPoints.length - 1], + pointCount: vbPoints.length, + }; +} + +/** Fraction of coordinates that carry a finite altitude (3rd element). */ +function altitudeCoverage(coordinates: readonly RouteCoordinate[]): number { + if (coordinates.length === 0) return 0; + let withAlt = 0; + for (const c of coordinates) { + if (Array.isArray(c) && c.length >= 3 && isFiniteNumber(c[2])) withAlt++; + } + return withAlt / coordinates.length; +} + +/** Minimum altitude coverage before an elevation profile is honest. */ +export const ELEVATION_COVERAGE_THRESHOLD = 0.6; + +/** + * Build a cumulative-distance elevation profile when at least 60 % of + * coordinates carry altitude. Below the threshold partial altimeter + * data would draw a lie, so this returns `null` and the caller omits + * the profile. + */ +export function buildElevationProfile( + coordinates: readonly RouteCoordinate[], +): ElevationPoint[] | null { + if (altitudeCoverage(coordinates) < ELEVATION_COVERAGE_THRESHOLD) return null; + const out: ElevationPoint[] = []; + let cumulative = 0; + let prev: [number, number] | null = null; + for (const c of coordinates) { + if (!Array.isArray(c) || c.length < 3) continue; + const lon = c[0]; + const lat = c[1]; + const alt = c[2]; + if (!isFiniteNumber(lon) || !isFiniteNumber(lat) || !isFiniteNumber(alt)) { + continue; + } + if (prev) cumulative += haversineMeters(prev[0], prev[1], lon, lat); + out.push({ distanceM: cumulative, altitude: alt }); + prev = [lon, lat]; + } + return out.length >= 2 ? out : null; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Serialise the route geometry to a GPX 1.1 document, entirely + * client-side over data already loaded — zero server work, zero egress. + * Timestamps (when present, parallel to the coordinates) are emitted so + * the exported track round-trips into any GPX consumer. + */ +export function buildGpxDocument(params: { + coordinates: readonly RouteCoordinate[]; + timestamps?: readonly string[] | null; + sportType: string; + startedAt: string; +}): string { + const { coordinates, timestamps, sportType, startedAt } = params; + const segments: string[] = []; + coordinates.forEach((c, i) => { + if (!Array.isArray(c) || c.length < 2) return; + const lon = c[0]; + const lat = c[1]; + if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) return; + const parts = [``]; + if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`${c[2]}`); + const ts = timestamps?.[i]; + if (typeof ts === "string" && ts.length > 0) { + parts.push(``); + } + parts.push(``); + segments.push(parts.join("")); + }); + return [ + ``, + ``, + ``, + `${escapeXml(sportType)}`, + ...segments, + ``, + ``, + ].join("\n"); +} diff --git a/src/lib/workouts/splits.ts b/src/lib/workouts/splits.ts new file mode 100644 index 000000000..3a8377c1b --- /dev/null +++ b/src/lib/workouts/splits.ts @@ -0,0 +1,84 @@ +/** + * Per-kilometre splits from a workout's route geometry + per-sample + * timestamps. Pure; computed server-side so the web client keeps + * dropping the raw `sampleTimestamps` blob under `compact=1` (the + * derived splits survive, the 20k-point timestamp array does not) and + * iOS gets the same resolved figures — server-authoritative parity. + * + * Only meaningful for distance/pace sports; the caller gates the sport. + * A workout without aligned timestamps (or under one full km) yields + * `null` and the caller hides the splits card. + */ +import { + extractLonLat, + haversineMeters, + type RouteCoordinate, +} from "@/lib/workouts/route-svg"; + +export interface WorkoutSplit { + /** 1-based kilometre index. */ + km: number; + /** Time to cover this kilometre, seconds. */ + durationSec: number; + /** Pace over the kilometre, seconds per km (== durationSec here). */ + paceSecPerKm: number; +} + +const KM = 1000; + +/** + * Compute whole-kilometre splits. Walks the polyline accumulating + * haversine distance; at each km boundary the crossing time is linearly + * interpolated between the two bounding samples by distance fraction, + * so a split boundary that falls mid-segment is timed honestly. + */ +export function computeSplits( + coordinates: readonly RouteCoordinate[], + timestamps: readonly string[] | null | undefined, +): WorkoutSplit[] | null { + if (!timestamps || timestamps.length !== coordinates.length) return null; + + // Keep coordinate/timestamp pairs that are both well-formed. + const pts: Array<{ lon: number; lat: number; tMs: number }> = []; + coordinates.forEach((c, i) => { + const [lonLat] = extractLonLat([c]); + if (!lonLat) return; + const tMs = Date.parse(timestamps[i] ?? ""); + if (!Number.isFinite(tMs)) return; + pts.push({ lon: lonLat[0], lat: lonLat[1], tMs }); + }); + if (pts.length < 2) return null; + + const splits: WorkoutSplit[] = []; + let cumulative = 0; + let nextBoundary = KM; + let prevBoundaryTimeMs = pts[0].tMs; + + for (let i = 1; i < pts.length; i++) { + const a = pts[i - 1]; + const b = pts[i]; + const segDist = haversineMeters(a.lon, a.lat, b.lon, b.lat); + if (segDist <= 0) continue; + const segStart = cumulative; + cumulative += segDist; + + // A single long segment can straddle several km boundaries. + while (cumulative >= nextBoundary) { + const frac = (nextBoundary - segStart) / segDist; + const boundaryTimeMs = a.tMs + (b.tMs - a.tMs) * frac; + const durationSec = Math.max( + 0, + Math.round((boundaryTimeMs - prevBoundaryTimeMs) / 1000), + ); + splits.push({ + km: splits.length + 1, + durationSec, + paceSecPerKm: durationSec, + }); + prevBoundaryTimeMs = boundaryTimeMs; + nextBoundary += KM; + } + } + + return splits.length > 0 ? splits : null; +} diff --git a/src/lib/workouts/zones.ts b/src/lib/workouts/zones.ts new file mode 100644 index 000000000..fb39e4f34 --- /dev/null +++ b/src/lib/workouts/zones.ts @@ -0,0 +1,162 @@ +/** + * Effort zones for the workout-detail page. Descriptive, never + * prescriptive — a distribution of time-in-zone, not a training + * verdict or a cardiac interpretation (non-diagnostic standard). + * + * Display model: five %HRmax zones (Z1 50–60 %, Z2 60–70 %, Z3 70–80 %, + * Z4 80–90 %, Z5 90 %+), HRmax from the grounded `tanakaHrMax()` + * (profile age). %HRmax over Karvonen because it needs only age and + * matches every consumer device's zone labelling — the strain engine + * owns the HRR/TRIMP math internally; the detail page merely describes. + * + * Resolution order: + * 1. WHOOP `metadata.zoneDurations` — device-authoritative time in + * zone, parsed through a narrow slice (JSON is never duck-typed). + * 2. Computed time-in-zone from the HR series, when profile age + * exists. + * 3. Neither → `null`, and the caller hides the zones card. + */ +import { z } from "zod/v4"; + +import { tanakaHrMax } from "@/lib/insights/strain-score"; +import type { HrSeriesPoint } from "@/lib/workouts/hr-series"; + +export type ZonesModel = "whoop" | "tanaka"; + +export interface ZoneBand { + /** 1–5. */ + zone: number; + /** Lower bpm bound (null when HRmax is unknown — WHOOP-only path). */ + lowBpm: number | null; + /** Upper bpm bound; null for the open-ended top zone. */ + highBpm: number | null; + /** Time spent in the zone, seconds. */ + seconds: number; +} + +export interface WorkoutZones { + model: ZonesModel; + hrMax: number | null; + zones: ZoneBand[]; +} + +/** %HRmax band edges. The top zone is open-ended (highPct = null). */ +const ZONE_EDGES: Array<{ + zone: number; + lowPct: number; + highPct: number | null; +}> = [ + { zone: 1, lowPct: 0.5, highPct: 0.6 }, + { zone: 2, lowPct: 0.6, highPct: 0.7 }, + { zone: 3, lowPct: 0.7, highPct: 0.8 }, + { zone: 4, lowPct: 0.8, highPct: 0.9 }, + { zone: 5, lowPct: 0.9, highPct: null }, +]; + +function bandBounds(hrMax: number | null) { + return ZONE_EDGES.map((e) => ({ + zone: e.zone, + lowBpm: hrMax != null ? Math.round(hrMax * e.lowPct) : null, + highBpm: + hrMax != null && e.highPct != null ? Math.round(hrMax * e.highPct) : null, + })); +} + +/** + * Narrow slice of WHOOP's `zone_durations` (milliseconds per zone). The + * API ships `zone_zero_milli` (below Z1, recovery) through + * `zone_five_milli`; we map zones one–five onto our Z1–Z5 and drop + * zone-zero from the five-zone display, mirroring the device's own + * five-zone labelling. + */ +const whoopZoneDurationsSchema = z + .object({ + zone_zero_milli: z.number().nonnegative().optional(), + zone_one_milli: z.number().nonnegative().optional(), + zone_two_milli: z.number().nonnegative().optional(), + zone_three_milli: z.number().nonnegative().optional(), + zone_four_milli: z.number().nonnegative().optional(), + zone_five_milli: z.number().nonnegative().optional(), + }) + .transform((v) => [ + v.zone_one_milli ?? 0, + v.zone_two_milli ?? 0, + v.zone_three_milli ?? 0, + v.zone_four_milli ?? 0, + v.zone_five_milli ?? 0, + ]); + +/** + * Pull WHOOP zone durations (seconds per Z1–Z5) out of a workout's + * metadata blob, or `null` when absent / malformed / all-zero. + */ +export function parseWhoopZoneDurations(metadata: unknown): number[] | null { + if (!metadata || typeof metadata !== "object") return null; + const raw = (metadata as Record).zoneDurations; + if (!raw || typeof raw !== "object") return null; + const parsed = whoopZoneDurationsSchema.safeParse(raw); + if (!parsed.success) return null; + const seconds = parsed.data.map((ms) => ms / 1000); + return seconds.some((s) => s > 0) ? seconds : null; +} + +/** %HRmax zone index (1–5) for a heart rate, or 0 for below-Z1 (rest). */ +function zoneForHr(hr: number, hrMax: number): number { + const pct = hr / hrMax; + for (const e of ZONE_EDGES) { + if (e.highPct == null) return e.zone; // top zone catches everything ≥ Z5 low + if (pct < e.highPct) return pct >= e.lowPct ? e.zone : 0; + } + return 0; +} + +export interface ComputeZonesInput { + hrMax: number | null; + series: readonly HrSeriesPoint[]; + bucketSec: number; + whoopZoneDurations: number[] | null; +} + +/** + * Resolve the effort-zone distribution. WHOOP's device-reported + * durations win when present; otherwise time-in-zone is folded from the + * HR series (each non-empty bucket contributes `bucketSec` seconds at + * its mean HR). Below-Z1 (rest) time is excluded from the five-zone + * display. Returns `null` when neither source is available. + */ +export function computeZones(input: ComputeZonesInput): WorkoutZones | null { + const { hrMax, series, bucketSec, whoopZoneDurations } = input; + + if (whoopZoneDurations) { + const bounds = bandBounds(hrMax); + return { + model: "whoop", + hrMax, + zones: bounds.map((b, i) => ({ + ...b, + seconds: Math.round(whoopZoneDurations[i] ?? 0), + })), + }; + } + + if (hrMax == null || series.length === 0) return null; + + const seconds = [0, 0, 0, 0, 0]; + for (const p of series) { + const zone = zoneForHr(p.mean, hrMax); + if (zone >= 1 && zone <= 5) seconds[zone - 1] += bucketSec; + } + if (seconds.every((s) => s === 0)) return null; + + const bounds = bandBounds(hrMax); + return { + model: "tanaka", + hrMax, + zones: bounds.map((b, i) => ({ ...b, seconds: seconds[i] })), + }; +} + +/** Convenience: HRmax from profile age (Tanaka), or null when no age. */ +export function hrMaxFromAge(ageYears: number | null): number | null { + return ageYears != null ? Math.round(tanakaHrMax(ageYears)) : null; +}