Merge pull request #534 from MBombeck/release/v1.30.10

release/v1.30.10 — localized correlation insights + richer workout detail
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-18 16:25:51 +02:00
commit 74891e95f4
48 files changed
+4044 -593

No files matched your search

+9
View File
@@ -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.
+187 -3
View File
@@ -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:
+2
View File
@@ -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": [
+73 -1
View File
@@ -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",
+73 -1
View File
@@ -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",
+73 -1
View File
@@ -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",
+73 -1
View File
@@ -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",
+73 -1
View File
@@ -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",
+73 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
+12 -3
View File
@@ -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<ReturnType<typeof buildAnalyticsResponse>>
>,
`${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
+11 -2
View File
@@ -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" },
+157 -5
View File
@@ -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 ? <card/> : 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,
};
}
@@ -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",
+10
View File
@@ -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 {
+39 -16
View File
@@ -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({
<div data-slot="workout-detail-loading" className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-56 w-full rounded-lg" />
<Skeleton className="h-60 w-full rounded-lg" />
</div>
) : error || !data ? (
) : error ? (
// A failed query must never read as "no data" (UI-STANDARDS §6).
<QueryErrorCard onRetry={() => refetch()} />
) : !data ? (
<p
data-slot="workout-detail-error"
className="text-muted-foreground text-sm"
@@ -75,13 +91,20 @@ export default function InsightsWorkoutDetailPage({
) : (
<>
<WorkoutDetailHeader workout={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 ? (
<WorkoutInsightCard insight={data.aiInsight} />
) : null}
<WorkoutDetailStats workout={data} />
<WorkoutDetailHrSection workout={data} />
<WorkoutDetailZones workout={data} />
<WorkoutDetailRoute workout={data} />
<WorkoutDetailHRChart workout={data} />
{/* 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). */}
<WorkoutDetailSplits workout={data} />
<WorkoutDetailDayLinks workout={data} />
{/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the
snapshot the first coach turn reads. */}
<CoachLaunchButton scope={{ metric: "workouts" }} />
</>
)}
+2
View File
@@ -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";
@@ -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 (
<div className="h-24 w-full" data-slot="workout-elevation-chart">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 4, right: 8, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="workoutElevationFill"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.4}
/>
<XAxis
dataKey="km"
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickFormatter={(v) => Number(v).toFixed(1)}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={["dataMin - 5", "dataMax + 5"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={36}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
formatter={(value) => [
`${Math.round(Number(value))} m`,
t("insights.workouts.detail.elevationTitle"),
]}
/>
<Area
type="monotone"
dataKey="alt"
stroke="var(--chart-2)"
strokeWidth={2}
fill="url(#workoutElevationFill)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
+211
View File
@@ -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 (
<div
className="h-48 w-full touch-pan-y sm:h-64"
data-slot="workout-hr-chart"
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
{zones?.map((z) =>
z.lowBpm != null ? (
<ReferenceArea
key={z.zone}
y1={z.lowBpm}
y2={z.highBpm ?? undefined}
fill={ZONE_FILLS[z.zone - 1]}
fillOpacity={0.06}
ifOverflow="hidden"
/>
) : null,
)}
<XAxis
dataKey="tSec"
type="number"
domain={[0, "dataMax"]}
tickFormatter={minuteTick}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
unit=""
/>
<YAxis
domain={["dataMin - 10", "dataMax + 10"]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={44}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(tSec) =>
`${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 ? (
<>
<Area
type="monotone"
dataKey="lo"
stackId="band"
stroke="none"
fill="transparent"
connectNulls={false}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="hi"
stackId="band"
stroke="none"
fill="var(--chart-1)"
fillOpacity={0.15}
connectNulls={false}
isAnimationActive={false}
/>
</>
) : null}
{avgHr != null ? (
<ReferenceLine
y={avgHr}
stroke="var(--muted-foreground)"
strokeDasharray="5 5"
strokeOpacity={0.7}
/>
) : null}
<Line
type="monotone"
dataKey="mean"
stroke="var(--chart-1)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
@@ -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 — `<WorkoutDetail*>` 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 — `<WorkoutDetail*>` 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("<WorkoutDetailStats>", () => {
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("<WorkoutDetailStats>", () => {
const html = render(<WorkoutDetailStats workout={minimal} />);
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(<WorkoutDetailStats workout={withCtx} />);
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(<WorkoutDetailStats workout={lone} />);
expect(html).not.toContain('data-slot="workout-detail-sport-average"');
});
});
describe("<WorkoutDetailRoute>", () => {
it("renders the empty-state when the workout has no route", () => {
it("returns null when the workout has no route", () => {
const html = render(<WorkoutDetailRoute workout={FIXTURE} />);
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(<WorkoutDetailRoute workout={withRoute} />);
expect(html).toContain('data-slot="workout-detail-route"');
expect(html).toContain("<polyline");
expect(html).toContain("<path");
expect(html).toContain("Export GPX");
});
it("falls back to the empty state for a LineString with fewer than 2 points", () => {
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(<WorkoutDetailRoute workout={stub} />);
expect(html).toContain('data-slot="workout-detail-route-empty"');
expect(render(<WorkoutDetailRoute workout={stub} />)).toBe("");
});
});
describe("<WorkoutDetailHRChart>", () => {
it("renders the unavailable notice with aggregate HR values when present", () => {
const html = render(<WorkoutDetailHRChart workout={FIXTURE} />);
expect(html).toContain('data-slot="workout-detail-hr-chart"');
expect(html).toContain("145 bpm");
expect(html).toContain("170 bpm");
describe("<WorkoutDetailHrSection>", () => {
it("returns null without an HR series", () => {
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).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(<WorkoutDetailHRChart workout={noHr} />);
expect(html).toContain("Per-second heart-rate");
expect(html).not.toContain("145 bpm");
const html = render(<WorkoutDetailHrSection workout={withHr} />);
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(<WorkoutDetailHrSection workout={withHr} />);
expect(html).toContain('data-slot="workout-detail-hr"');
expect(html).not.toContain('data-slot="workout-detail-hr-provenance"');
});
});
describe("<WorkoutDetailZones>", () => {
it("returns null without zone data", () => {
expect(render(<WorkoutDetailZones workout={FIXTURE} />)).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(<WorkoutDetailZones workout={withZones} />);
expect(html).toContain('data-slot="workout-detail-zones"');
expect(html).toContain("Effort zones");
expect(html).toContain("Z3");
});
});
describe("<WorkoutDetailSplits>", () => {
it("returns null without splits", () => {
expect(render(<WorkoutDetailSplits workout={FIXTURE} />)).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(<WorkoutDetailSplits workout={withSplits} />);
expect(html).toContain('data-slot="workout-detail-splits"');
expect(html).toContain("5:00");
expect(html).toContain("4:48 /km");
});
});
describe("<WorkoutDetailDayLinks>", () => {
it("renders the that-day navigation links", () => {
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
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"');
});
});
-438
View File
@@ -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 (
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-6" />
</span>
);
}
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 (
<header
data-slot="workout-detail-header"
// v1.30 mobile audit (W-1) — the three columns (icon + title block +
// duration) held at every width, so at 360px the middle column got
// ~120px and the long-format date line wrapped 4-5 lines beside a
// text-2xl duration. Stack below sm: duration drops under the title
// block as its own row instead of squeezing beside it.
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
>
<div className="flex min-w-0 items-start gap-4">
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
<span className="text-2xl font-semibold tabular-nums">
{formatDuration(workout.durationSec)}
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
</span>
) : null}
</div>
</header>
);
}
// ── Stats ────────────────────────────────────────────────────────────
interface StatTileProps {
icon: ReactNode;
label: string;
value: string;
hint?: string;
}
function StatTile({ icon, label, value, hint }: StatTileProps) {
return (
<div
data-slot="workout-detail-stat"
className="flex flex-col gap-1 rounded-lg border p-3"
>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<span aria-hidden="true" className="size-4">
{icon}
</span>
{label}
</span>
<span className="text-base font-semibold tabular-nums">{value}</span>
{hint ? (
<span className="text-muted-foreground text-xs">{hint}</span>
) : null}
</div>
);
}
export interface WorkoutDetailStatsProps {
workout: WorkoutDetailPayload;
}
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const tiles: StatTileProps[] = [];
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsDuration"),
value: formatDuration(workout.durationSec),
});
if (workout.distanceM != null && workout.distanceM > 0) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
});
}
if (workout.activeEnergyKcal != null) {
tiles.push({
icon: <Flame className="size-4" />,
label: t("insights.workouts.detail.statsActiveEnergy"),
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
});
}
if (workout.avgHr != null) {
tiles.push({
icon: <HeartPulse className="size-4" />,
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: <HeartPulse className="size-4" />,
label: t("insights.workouts.detail.statsMinHr"),
value: `${workout.minHr} bpm`,
});
}
if (workout.stepCount != null && workout.stepCount > 0) {
tiles.push({
icon: <Footprints className="size-4" />,
label: t("insights.workouts.detail.statsStepCount"),
value: formatNumber(workout.stepCount, locale),
});
}
if (workout.elevationM != null && workout.elevationM !== 0) {
tiles.push({
icon: <Mountain className="size-4" />,
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: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale),
});
}
return (
<Card data-slot="workout-detail-stats">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.statsTitle")}
</CardTitle>
</CardHeader>
<CardContent
className={cn(
"grid gap-2",
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{tiles.map((tile) => (
<StatTile key={tile.label} {...tile} />
))}
</CardContent>
</Card>
);
}
// ── 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<string, unknown>;
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 (
<Card data-slot="workout-detail-route-empty">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.routeTitle")}
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
{t("insights.workouts.detail.routeUnavailable")}
</CardContent>
</Card>
);
}
// 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 (
<Card data-slot="workout-detail-route">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.routeTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="bg-muted/40 h-60 w-full rounded-md"
role="img"
aria-label={t("insights.workouts.detail.routeTitle")}
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
/>
</svg>
</CardContent>
</Card>
);
}
// ── 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
// `<WorkoutDetailStats>`.
return (
<Card data-slot="workout-detail-hr-chart">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.hrChartTitle")}
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<p>{t("insights.workouts.detail.hrChartUnavailable")}</p>
{workout.avgHr != null || workout.maxHr != null ? (
<p>
{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}
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -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 (
<Card data-slot="workout-detail-day-links">
<CardHeader>
<TileHeader
icon={CalendarDays}
title={t("insights.workouts.detail.thatDayTitle")}
/>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{LINKS.map(({ href, labelKey, icon: Icon }) => (
<Link
key={href}
href={href}
className="border-border hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<Icon
className="text-muted-foreground size-4"
aria-hidden="true"
/>
{t(labelKey)}
</Link>
))}
</div>
</CardContent>
</Card>
);
}
@@ -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)}`;
}
@@ -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 (
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-6" />
</span>
);
}
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 (
<header
data-slot="workout-detail-header"
// v1.30 mobile audit (W-1) — stack below sm so the long-format date
// line never squeezes beside a text-2xl duration at 360px.
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
>
<div className="flex min-w-0 items-start gap-4">
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
<span className="text-2xl font-semibold tabular-nums">
{formatDuration(workout.durationSec)}
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
</span>
) : null}
</div>
</header>
);
}
@@ -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: () => <ChartSkeleton /> },
);
function WorkoutHrChart(props: ComponentProps<typeof WorkoutHrChartLazy>) {
return (
<ChartErrorBoundary>
<WorkoutHrChartLazy {...props} />
</ChartErrorBoundary>
);
}
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 (
<Card data-slot="workout-detail-hr">
<CardHeader className="gap-1">
<TileHeader
icon={HeartPulse}
title={t("insights.workouts.detail.hrChartTitle")}
/>
{series.source === "pulse_window" ? (
<p
data-slot="workout-detail-hr-provenance"
className="text-muted-foreground text-xs"
>
{t("insights.workouts.detail.hrFromPulse")}
</p>
) : null}
</CardHeader>
<CardContent>
<WorkoutHrChart
points={series.points}
bucketSec={series.bucketSec}
envelope={series.envelope}
avgHr={workout.avgHr}
zones={workout.zones?.zones ?? null}
/>
</CardContent>
</Card>
);
}
@@ -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";
@@ -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 ? <WorkoutInsightCard
* insight={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 (
<Card data-slot="workout-detail-insight">
<CardHeader>
<TileHeader
icon={Sparkles}
title={t("insights.workouts.detail.insightTitle")}
/>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed">{insight.paragraph}</p>
</CardContent>
</Card>
);
}
@@ -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: () => <div className="h-24 w-full" /> },
);
function WorkoutElevationChart(
props: ComponentProps<typeof WorkoutElevationChartLazy>,
) {
return (
<ChartErrorBoundary>
<WorkoutElevationChartLazy {...props} />
</ChartErrorBoundary>
);
}
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<string, unknown>;
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 (
<Card data-slot="workout-detail-route">
<CardHeader>
<TileHeader
icon={MapIcon}
title={t("insights.workouts.detail.routeTitle")}
right={
<Button
type="button"
variant="ghost"
size="sm"
onClick={exportGpx}
data-slot="workout-detail-gpx-export"
>
<Download className="size-4" aria-hidden="true" />
{t("insights.workouts.detail.exportGpx")}
</Button>
}
/>
</CardHeader>
<CardContent className="space-y-3">
<svg
viewBox={projected.viewBox}
preserveAspectRatio="xMidYMid meet"
className="bg-muted/40 max-h-72 w-full rounded-md"
role="img"
aria-label={t("insights.workouts.detail.routeTitle")}
>
<path
d={projected.path}
fill="none"
stroke="var(--chart-1)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{/* End ring first so the start dot always sits on top on a loop. */}
<circle
cx={projected.end.x}
cy={projected.end.y}
r={markerR}
fill="none"
stroke="var(--foreground)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={projected.start.x}
cy={projected.start.y}
r={markerR}
fill="var(--success)"
/>
</svg>
{elevation ? (
<div data-slot="workout-detail-elevation">
<p className="text-muted-foreground mb-1 text-xs">
{t("insights.workouts.detail.elevationTitle")}
</p>
<WorkoutElevationChart points={elevation} />
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -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 (
<Card data-slot="workout-detail-splits">
<CardHeader>
<TileHeader
icon={Flag}
title={t("insights.workouts.detail.splitsTitle")}
/>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-left text-xs">
<th className="pb-2 font-medium">
{t("insights.workouts.detail.splitsKm")}
</th>
<th className="pb-2 text-right font-medium">
{t("insights.workouts.detail.splitsTime")}
</th>
<th className="pb-2 text-right font-medium">
{t("insights.workouts.detail.splitsPace")}
</th>
</tr>
</thead>
<tbody>
{splits.map((s) => (
<tr key={s.km} className="border-t">
<td className="py-1.5 tabular-nums">{s.km}</td>
<td className="py-1.5 text-right tabular-nums">
{formatSplitTime(s.durationSec)}
</td>
<td
className={
s.paceSecPerKm === fastest
? "py-1.5 text-right font-semibold tabular-nums"
: "py-1.5 text-right tabular-nums"
}
>
{formatPaceSeconds(s.paceSecPerKm)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);
}
@@ -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 (
<div
data-slot="workout-detail-stat"
className="flex flex-col gap-1 rounded-lg border p-3"
>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<span aria-hidden="true" className="size-4">
{icon}
</span>
{label}
</span>
<span className="text-base font-semibold tabular-nums">{value}</span>
{hint ? (
<span className="text-muted-foreground text-xs">{hint}</span>
) : null}
</div>
);
}
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: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsDuration"),
value: formatDuration(workout.durationSec),
});
if (workout.distanceM != null && workout.distanceM > 0) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
});
}
if (workout.activeEnergyKcal != null) {
tiles.push({
icon: <Flame className="size-4" />,
label: t("insights.workouts.detail.statsActiveEnergy"),
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
});
}
if (workout.avgHr != null) {
tiles.push({
icon: <HeartPulse className="size-4" />,
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: <HeartPulse className="size-4" />,
label: t("insights.workouts.detail.statsMinHr"),
value: `${workout.minHr} bpm`,
});
}
if (workout.stepCount != null && workout.stepCount > 0) {
tiles.push({
icon: <Footprints className="size-4" />,
label: t("insights.workouts.detail.statsStepCount"),
value: formatNumber(workout.stepCount, locale),
});
}
if (workout.elevationM != null && workout.elevationM !== 0) {
tiles.push({
icon: <Mountain className="size-4" />,
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: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
});
}
const averageLine = sportAverageLine(workout, locale);
return (
<Card data-slot="workout-detail-stats">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.statsTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div
className={cn(
"grid gap-2",
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{tiles.map((tile) => (
<StatTile key={tile.label} {...tile} />
))}
</div>
{averageLine ? (
<p
data-slot="workout-detail-sport-average"
className="text-muted-foreground text-xs"
>
{t("insights.workouts.detail.sportAverageLabel")}{" "}
<span className="tabular-nums">{averageLine}</span>
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -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 (
<Card data-slot="workout-detail-zones">
<CardHeader className="gap-1">
<TileHeader
icon={Gauge}
title={t("insights.workouts.detail.zonesTitle")}
/>
<p className="text-muted-foreground text-xs">
{zones.model === "whoop"
? t("insights.workouts.detail.zonesCaptionDevice")
: t("insights.workouts.detail.zonesCaption")}
</p>
</CardHeader>
<CardContent className="space-y-3">
<div
className="flex h-3 w-full overflow-hidden rounded-full"
role="img"
aria-label={t("insights.workouts.detail.zonesTitle")}
>
{zones.zones.map((z, i) =>
z.seconds > 0 ? (
<div
key={z.zone}
style={{
width: `${(z.seconds / total) * 100}%`,
backgroundColor: ZONE_COLORS[i],
}}
/>
) : null,
)}
</div>
<div className="grid grid-cols-5 gap-1 text-center">
{zones.zones.map((z, i) => (
<div key={z.zone} className="flex flex-col items-center gap-1">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ backgroundColor: ZONE_COLORS[i] }}
/>
<span className="text-xs font-medium">Z{z.zone}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{formatMinutes(z.seconds)}
</span>
{z.lowBpm != null ? (
<span className="text-muted-foreground text-[10px] tabular-nums">
{z.lowBpm}
{z.highBpm != null ? `–${z.highBpm}` : "+"}
</span>
) : null}
</div>
))}
</div>
</CardContent>
</Card>
);
}
+21 -1
View File
@@ -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) {
<Icon className="size-4" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{sportName}</span>
<span className="flex items-center gap-1.5 truncate font-medium">
{sportName}
{/* #67 — discreet glyphs flag which sessions open rich
(map / HR curve). Muted so they never compete with
the sport name. */}
{workout.hasRoute ? (
<MapIcon
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
data-slot="workout-list-glyph-route"
/>
) : null}
{workout.hasHrSeries ? (
<HeartPulse
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
data-slot="workout-list-glyph-hr"
/>
) : null}
</span>
<span className="text-muted-foreground truncate text-xs">
{formatDate(workout.startedAt, locale, timeFormat)}
</span>
+74 -1
View File
@@ -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<WorkoutDetailPayload> {
// 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<WorkoutDetailPayload>(
`/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(),
};
}
+15 -3
View File
@@ -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<CorrelationHypothesesResult> {
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<string, number[]>();
@@ -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: {
-12
View File
@@ -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;
}
@@ -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<typeof discoverCorrelations>) =>
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 ─────
+91 -38
View File
@@ -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, string | number>,
) => 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:<key>` channel (a RATED mood factor folded into the
* matrix) strips its namespace prefix so the phrasing reads "rated <factor>"
* rather than leaking the raw key. The prefix exists only to keep a factor
* key from colliding with a `MeasurementType` in the channel set.
* 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.<KEY>` 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<string, LabDrawPoint[]>();
@@ -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);
}
+60 -21
View File
@@ -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, string | number>,
) => 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",
+71 -1
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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": {
@@ -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<typeof storedSample>[] = [];
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();
});
});
@@ -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('<gpx version="1.1"');
expect(gpx).toContain('lat="50" lon="11"');
expect(gpx).toContain("<ele>300</ele>");
expect(gpx).toContain("<time>2026-05-15T07:00:05Z</time>");
});
});
+55
View File
@@ -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();
});
});
+122
View File
@@ -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();
});
});
+239
View File
@@ -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<string, unknown>;
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<WorkoutHrSeries | null> {
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,
};
}
+353
View File
@@ -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<T>(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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* 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 = [`<trkpt lat="${lat}" lon="${lon}">`];
if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`<ele>${c[2]}</ele>`);
const ts = timestamps?.[i];
if (typeof ts === "string" && ts.length > 0) {
parts.push(`<time>${escapeXml(ts)}</time>`);
}
parts.push(`</trkpt>`);
segments.push(parts.join(""));
});
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<gpx version="1.1" creator="HealthLog" xmlns="http://www.topografix.com/GPX/1/1">`,
`<metadata><time>${escapeXml(startedAt)}</time></metadata>`,
`<trk><name>${escapeXml(sportType)}</name><trkseg>`,
...segments,
`</trkseg></trk>`,
`</gpx>`,
].join("\n");
}
+84
View File
@@ -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;
}
+162
View File
@@ -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<string, unknown>).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;
}