mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3743a46a40 | ||
|
|
9e2c445ad7 | ||
|
|
b75771a445 | ||
|
|
64e7c57734 | ||
|
|
c2420d7f02 | ||
|
|
16fd4966d0 | ||
|
|
ada147522f | ||
|
|
e6d9cc229a | ||
|
|
74891e95f4 | ||
|
|
57e855eeb3 | ||
|
|
dfa8675a89 | ||
|
|
906acee978 | ||
|
|
e61fe42bd1 | ||
|
|
04320c415d | ||
|
|
36f73233b0 | ||
|
|
f3d92520d1 | ||
|
|
5c60558d4f | ||
|
|
271abd7b64 | ||
|
|
427b5f0b7c | ||
|
|
d4f78811d9 | ||
|
|
6af0e36c88 |
No files matched your search
@@ -115,10 +115,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# hides password/passkey login entirely once set to "true" -- but only takes
|
||||
# effect when the three vars above are also fully set, so a typo can't lock
|
||||
# every user out. Enforced server-side, not just a hidden button. The native
|
||||
# iOS client authenticates via password/passkey only -- there is no OIDC
|
||||
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
|
||||
# entirely until a native SSO flow ships. Do not enable it on a deployment
|
||||
# with active iOS users.
|
||||
# iOS client signs in through the native OIDC SSO handoff (the app opens the
|
||||
# login URL in an in-app browser and completes at a custom-scheme callback), so
|
||||
# OIDC_ONLY="true" is supported for iOS -- but only on the app build that ships
|
||||
# that flow. An older password/passkey-only iOS build is locked out of sign-in
|
||||
# by OIDC_ONLY; confirm the app version before enabling it on a deployment with
|
||||
# active iOS users.
|
||||
# Serve the issuer over HTTPS wherever possible: sign-in material (the
|
||||
# authorization code, the token-endpoint response carrying the ID token)
|
||||
# crosses this connection. Plain HTTP is defensible only on a fully trusted
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.30.12] — 2026-07-18
|
||||
|
||||
Faster medication reads.
|
||||
|
||||
- **The dashboard's medication section stays fast as your log grows.** Its reads now use dedicated indexes instead of scanning the full intake history on every load, so a long history no longer slows down the daily view.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.11] — 2026-07-18
|
||||
|
||||
Native single sign-on.
|
||||
|
||||
- **The iOS app can sign in through your identity provider (OIDC SSO).** Until now, single sign-on only worked in the browser — the native app couldn't complete it, which locked native users out entirely on an SSO-only instance. The server now supports a native sign-in handoff (an in-app authorization flow that returns the app's normal token, never a cookie), so the app can offer SSO once it adopts the flow. Existing password and passkey sign-in are unchanged. Multi-factor sign-in still applies, and an SSO session never satisfies a step-up prompt — those still require your own second factor.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [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.
|
||||
|
||||
- **The top card and the charts paint with their data on first load** instead of flashing empty and then filling in. The daily summary and the charts' 31-day series are now read on the server and handed to the page already populated, so there's no wait for a second round of requests after the page appears. The welcome/summary card, likely the slowest thing to show before, now lands with the first paint.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.8] — 2026-07-18
|
||||
|
||||
Hardening for the new heart-rate upload validation.
|
||||
|
||||
- **A malformed bucket timestamp is rejected, not silently misfiled.** A bucket whose time rolled over (a 24:00, an impossible day) is now refused rather than stored under a non-standard key, where it would have failed to merge with a re-post and could land on the wrong day.
|
||||
- **The per-window low/high is sanity-checked before it counts.** A window's low and high are kept only when they are in a plausible range and sit either side of the average; a glitched reading no longer skews the day's high/low band. The average is always kept.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.7] — 2026-07-18
|
||||
|
||||
Groundwork for a tidier heart-rate history.
|
||||
|
||||
@@ -165,7 +165,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
|
||||
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
|
||||
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
|
||||
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
|
||||
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
|
||||
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
|
||||
|
||||
## DO-NOTs
|
||||
|
||||
|
||||
+251
-3
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.30.7
|
||||
version: 1.30.12
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -1562,6 +1562,38 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/oidc/native/token:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Exchange a native OIDC handoff code for the token bundle
|
||||
description: >-
|
||||
The cookie-less native leg of the OIDC SSO flow. The iOS app opens `GET
|
||||
/api/auth/oidc/login?client=native&code_challenge=<S256>` inside an `ASWebAuthenticationSession`; the callback
|
||||
returns a one-time handoff code on `healthlog://oidc-callback?code=hlh_…` (or an `mfa_ticket` when the account
|
||||
has a second factor, completed at /api/auth/mfa/verify). This endpoint exchanges the code + its PKCE
|
||||
`codeVerifier` for the SAME native bundle password login issues.
|
||||
|
||||
|
||||
Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and
|
||||
expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. A single generic
|
||||
401 covers every invalid/expired/used/PKCE-mismatch case.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OidcNativeTokenRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Handoff accepted — native access + refresh bundle.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/OidcNativeTokenResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/mfa/totp/setup:
|
||||
post:
|
||||
tags:
|
||||
@@ -2668,14 +2700,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.
|
||||
@@ -11006,6 +11047,21 @@ components:
|
||||
- mfaTicket
|
||||
- code
|
||||
description: Complete a second-factor login challenge and receive the same token bundle the password path issues.
|
||||
OidcNativeTokenRequest:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
pattern: ^hlh_[A-Za-z0-9_-]{43}$
|
||||
description: One-time handoff code from the healthlog://oidc-callback?code= redirect.
|
||||
codeVerifier:
|
||||
type: string
|
||||
pattern: ^[A-Za-z0-9\-._~]{43,128}$
|
||||
description: The app's PKCE code_verifier (RFC 7636).
|
||||
required:
|
||||
- code
|
||||
- codeVerifier
|
||||
description: Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.
|
||||
MfaDisableRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -14865,6 +14921,23 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
OidcNativeTokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AccessRefreshBundle"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
TotpSetupEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -17429,6 +17502,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
hasRoute:
|
||||
type: boolean
|
||||
hasHrSeries:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- sportType
|
||||
@@ -17441,6 +17518,8 @@ components:
|
||||
- maxHr
|
||||
- source
|
||||
- externalId
|
||||
- hasRoute
|
||||
- hasHrSeries
|
||||
additionalProperties: false
|
||||
GetWorkoutResponse:
|
||||
type: object
|
||||
@@ -17553,6 +17632,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 +17673,11 @@ components:
|
||||
- metadata
|
||||
- route
|
||||
- samples
|
||||
- hrSeries
|
||||
- zones
|
||||
- splits
|
||||
- sportContext
|
||||
- aiInsight
|
||||
- canonicalId
|
||||
additionalProperties: false
|
||||
WorkoutRouteGeometry:
|
||||
@@ -17602,6 +17706,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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.30.7",
|
||||
"version": "1.30.12",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Native OIDC SSO session handoff (iOS#49).
|
||||
--
|
||||
-- One-time handoff codes bridging the browser-context OIDC callback to the
|
||||
-- cookie-less native token exchange. The web OIDC flow ends in a session
|
||||
-- cookie a Bearer-transport native client can never consume; this table lets
|
||||
-- the callback mint an opaque, PKCE-locked, single-use code that the app
|
||||
-- exchanges for the standard native token bundle at
|
||||
-- POST /api/auth/oidc/native/token.
|
||||
--
|
||||
-- The raw code (`hlh_<…>`) rides ONLY the custom-scheme redirect; only its
|
||||
-- HMAC-SHA256 hash is stored, mirroring the refresh-token / MFA-challenge
|
||||
-- hash-at-rest posture. `issued_refresh_token_hash` records the pair minted at
|
||||
-- exchange so a replay (a second presentation after consumption) can revoke
|
||||
-- exactly that family member — refresh-token reuse-detection reach-back.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "oidc_native_handoffs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"code_hash" TEXT NOT NULL,
|
||||
"code_challenge" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"issued_refresh_token_hash" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
CONSTRAINT "oidc_native_handoffs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex — the exchange loads a row by the code's hash; unique so a hash
|
||||
-- collision can never resolve to two live codes.
|
||||
CREATE UNIQUE INDEX "oidc_native_handoffs_code_hash_key"
|
||||
ON "oidc_native_handoffs" ("code_hash");
|
||||
|
||||
-- CreateIndex — per-user audit scans + the FK-cascade delete path.
|
||||
CREATE INDEX "oidc_native_handoffs_user_id_idx"
|
||||
ON "oidc_native_handoffs" ("user_id");
|
||||
|
||||
-- CreateIndex — the daily cleanup sweep filters on `expires_at`.
|
||||
CREATE INDEX "oidc_native_handoffs_expires_at_idx"
|
||||
ON "oidc_native_handoffs" ("expires_at");
|
||||
|
||||
-- AddForeignKey — deleting a user cascades their handoff rows away.
|
||||
ALTER TABLE "oidc_native_handoffs"
|
||||
ADD CONSTRAINT "oidc_native_handoffs_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- v1.30.12 perf — index the two medication-intake read shapes the dashboard
|
||||
-- "meds today" builder fires that could not index-seek before: the sibling
|
||||
-- reads filtering (user_id, scheduled_for) without medication_id, and the
|
||||
-- per-medication last-taken group-by that scanned the whole intake history.
|
||||
-- Additive; no data change.
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_intake_events_user_id_scheduled_for_idx" ON "medication_intake_events"("user_id", "scheduled_for");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_intake_events_user_id_medication_id_taken_at_idx" ON "medication_intake_events"("user_id", "medication_id", "taken_at");
|
||||
@@ -667,6 +667,9 @@ model User {
|
||||
// authorization for an MCP client, audience-bound to health:read/health:write.
|
||||
mcpOAuthConnections McpOAuthConnection[]
|
||||
refreshTokens RefreshToken[]
|
||||
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
|
||||
// browser-context OIDC callback to the cookie-less native token exchange.
|
||||
oidcNativeHandoffs OidcNativeHandoff[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
|
||||
@@ -2730,6 +2733,12 @@ model MedicationIntakeEvent {
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
// v1.30.12 perf — the dashboard "meds today" read fires sibling queries that
|
||||
// filter `(userId, scheduledFor)` WITHOUT `medicationId` (the composite above
|
||||
// cannot range-seek those) and a per-medication `_max(takenAt)` group-by that
|
||||
// scanned the whole intake history. These two let both index-seek instead.
|
||||
@@index([userId, scheduledFor])
|
||||
@@index([userId, medicationId, takenAt])
|
||||
@@map("medication_intake_events")
|
||||
}
|
||||
|
||||
@@ -3214,6 +3223,37 @@ model RefreshToken {
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// v1.30.x — one-time native OIDC handoff code. Bridges the browser-context
|
||||
// OIDC callback to the cookie-less native token exchange. The raw value
|
||||
// `hlh_<…>` rides ONLY the custom-scheme redirect (healthlog://oidc-callback);
|
||||
// only its HMAC-SHA256 hash (hashToken, keyed by API_TOKEN_HMAC_KEY) is stored,
|
||||
// so a database dump reconstructs nothing. Single-use via the guarded consume
|
||||
// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes
|
||||
// the token pair the first exchange issued (refresh-token reuse-detection
|
||||
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
|
||||
model OidcNativeHandoff {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
|
||||
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
|
||||
/// s256(code_verifier) against it (constant-time) before consuming.
|
||||
codeChallenge String @map("code_challenge")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
/// hashToken of the refresh token issued at exchange — replay containment
|
||||
/// revokes exactly this family member (+ its paired access token).
|
||||
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("oidc_native_handoffs")
|
||||
}
|
||||
|
||||
// ─── Withings ──────────────────────────────────────────────
|
||||
|
||||
model WithingsConnection {
|
||||
|
||||
+1
-1
@@ -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.7";
|
||||
/* @sw-version-fallback */ "v1.30.12";
|
||||
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/*`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,6 +68,18 @@ vi.mock("@/lib/auth/oidc", () => ({
|
||||
deriveUniqueUsername: vi.fn(async (email: string) => email.split("@")[0]),
|
||||
}));
|
||||
|
||||
// The handoff lib is unit-tested separately; here we assert the callback
|
||||
// branches into it and redirects to the custom scheme (real
|
||||
// `buildNativeCallbackUrl`, mocked `mintNativeHandoff`).
|
||||
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
|
||||
buildNativeCallbackUrl: (params: Record<string, string>) =>
|
||||
`healthlog://oidc-callback?${new URLSearchParams(params).toString()}`,
|
||||
mintNativeHandoff: vi.fn(async () => ({
|
||||
code: "hlh_test_code",
|
||||
handoffId: "ho-1",
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -80,6 +92,7 @@ import {
|
||||
exchangeCodeForTokens,
|
||||
verifyIdToken,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { mintNativeHandoff } from "@/lib/auth/oidc-native-handoff";
|
||||
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
@@ -577,3 +590,127 @@ describe("GET /api/auth/oidc/callback", () => {
|
||||
expect(successCookie.toLowerCase()).toContain("path=/api/auth/oidc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/oidc/callback — native branch", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 20,
|
||||
reset: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue(METADATA);
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0);
|
||||
vi.mocked(mintNativeHandoff).mockResolvedValue({
|
||||
code: "hlh_test_code",
|
||||
handoffId: "ho-1",
|
||||
});
|
||||
});
|
||||
|
||||
function nativeStateCookie(overrides: Record<string, unknown> = {}): string {
|
||||
return stateCookie({
|
||||
state: "STATE",
|
||||
nonce: "nonce-1",
|
||||
codeVerifier: "verifier-1",
|
||||
next: "/",
|
||||
native: true,
|
||||
appCodeChallenge: "app-challenge-123",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function nativeRequest(): NextRequest {
|
||||
return makeRequest({
|
||||
query: "?code=abc&state=STATE",
|
||||
cookie: nativeStateCookie(),
|
||||
});
|
||||
}
|
||||
|
||||
it("mints a handoff code and 302s to the custom scheme — no session", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location.startsWith("healthlog://oidc-callback?code=")).toBe(true);
|
||||
expect(location).toContain("code=hlh_test_code");
|
||||
// The app's challenge (from the tamper-authenticated blob) is bound at mint.
|
||||
expect(mintNativeHandoff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
appCodeChallenge: "app-challenge-123",
|
||||
}),
|
||||
);
|
||||
// No web session is minted on the native branch.
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hands the MFA ticket to the custom scheme (no session, no MFA cookie)", async () => {
|
||||
mockIdentity();
|
||||
mockUserLookups({
|
||||
byIdentity: userRow({
|
||||
oidcIssuer: METADATA.issuer,
|
||||
oidcSub: "sub-1",
|
||||
totpConfirmedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location.startsWith("healthlog://oidc-callback?")).toBe(true);
|
||||
expect(location).toContain("mfa_ticket=mfa-ticket-1");
|
||||
expect(location).toContain("methods=totp%2Crecovery");
|
||||
// No handoff code on the MFA branch, and no session.
|
||||
expect(location).not.toContain("code=hlh_");
|
||||
expect(mintNativeHandoff).not.toHaveBeenCalled();
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
// The web-only MFA handoff cookie is never set on the native branch.
|
||||
const mfaCookie = res.headers
|
||||
.getSetCookie()
|
||||
.find((c) => c.startsWith("oidc_mfa_handoff="));
|
||||
expect(mfaCookie).toBeUndefined();
|
||||
});
|
||||
|
||||
it("delivers a post-identity error to the custom scheme, not a web page", async () => {
|
||||
mockIdentity();
|
||||
// An account already pinned to a different IdP identity → identity_conflict.
|
||||
mockUserLookups({
|
||||
byIdentity: null,
|
||||
byEmail: userRow({ oidcIssuer: "https://other.example", oidcSub: "x" }),
|
||||
});
|
||||
|
||||
const res = await GET(nativeRequest());
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_identity_conflict",
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers an IdP denial to the custom scheme", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?error=access_denied&state=STATE",
|
||||
cookie: nativeStateCookie(),
|
||||
}),
|
||||
);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the WEB oidc_state page on a CSRF mismatch (broken flow)", async () => {
|
||||
const res = await GET(
|
||||
makeRequest({
|
||||
query: "?code=abc&state=WRONG",
|
||||
cookie: nativeStateCookie(),
|
||||
}),
|
||||
);
|
||||
// A tampered/broken round-trip never hands the app a scheme redirect.
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"/auth/login?error=oidc_state",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,10 @@ import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
|
||||
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { resolveServerDefaultTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
buildNativeCallbackUrl,
|
||||
mintNativeHandoff,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -82,17 +86,38 @@ interface StoredOidcState {
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
next: string;
|
||||
/**
|
||||
* v1.30.x — set only when the login GET carried `client=native`. When true
|
||||
* the callback mints a handoff code (or MFA ticket) and redirects to the
|
||||
* custom scheme instead of minting a session. Both fields live ONLY inside
|
||||
* the AES-256-GCM blob, so they are tamper-authenticated: a network attacker
|
||||
* or the IdP cannot flip a web login into a native one or swap the app
|
||||
* challenge.
|
||||
*/
|
||||
native?: boolean;
|
||||
/** The app's S256 PKCE challenge, bound at login; verified at the exchange. */
|
||||
appCodeChallenge?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Record<string, unknown>).state === "string" &&
|
||||
typeof (value as Record<string, unknown>).nonce === "string" &&
|
||||
typeof (value as Record<string, unknown>).codeVerifier === "string" &&
|
||||
typeof (value as Record<string, unknown>).next === "string"
|
||||
);
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
value === null ||
|
||||
typeof v.state !== "string" ||
|
||||
typeof v.nonce !== "string" ||
|
||||
typeof v.codeVerifier !== "string" ||
|
||||
typeof v.next !== "string"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Optional native fields: when present they must be the right shape, and a
|
||||
// native start MUST carry the challenge the exchange verifies against.
|
||||
if (v.native !== undefined) {
|
||||
if (v.native !== true) return false;
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,34 +131,12 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.callback" } });
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return errorRedirect("oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return errorRedirect("oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
return errorRedirect("oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
if (!code || !state) return errorRedirect("oidc_state");
|
||||
|
||||
// Decrypt the single-use state blob FIRST. The `native` flag lives inside the
|
||||
// AES-256-GCM payload (tamper-authenticated), so decrypting it up front lets
|
||||
// every post-decrypt error branch redirect to the custom scheme instead of
|
||||
// the web login page — the app parses `?error=<reason>` and never lands on a
|
||||
// dead web page. A missing/undecryptable blob leaves `stored` null (isNative
|
||||
// false) and those branches fall back to the web redirect by construction.
|
||||
const rawCookie = req.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
let stored: StoredOidcState | null = null;
|
||||
if (rawCookie) {
|
||||
@@ -144,6 +147,54 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
stored = null;
|
||||
}
|
||||
}
|
||||
const isNative = stored?.native === true;
|
||||
|
||||
// Native-aware error redirect: the custom scheme for a decrypted native flow,
|
||||
// the web login page otherwise. Deletes the single-use state cookie either
|
||||
// way. An error redirect carries no code, ticket, or session, so routing it
|
||||
// to the scheme leaks nothing (spec §1).
|
||||
const failRedirect = (reason: string): NextResponse => {
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({ error: reason }),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect(reason);
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return failRedirect("oidc_disabled");
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
req,
|
||||
"auth:oidc:callback",
|
||||
20,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) return failRedirect("oidc_rate_limited");
|
||||
|
||||
const { searchParams } = req.nextUrl;
|
||||
const idpError = searchParams.get("error");
|
||||
if (idpError) {
|
||||
annotate({
|
||||
meta: {
|
||||
reason: "idp_denied",
|
||||
idpError: KNOWN_IDP_ERROR_CODES.has(idpError) ? idpError : "other",
|
||||
},
|
||||
});
|
||||
return failRedirect("oidc_denied");
|
||||
}
|
||||
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
// Broken round-trip / missing-or-unreadable state blob / CSRF mismatch all
|
||||
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
|
||||
// and there is nothing safe to hand the app (spec §1 web-fallback).
|
||||
if (!code || !state) return errorRedirect("oidc_state");
|
||||
|
||||
if (!stored) return errorRedirect("oidc_state");
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
@@ -166,7 +217,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri,
|
||||
});
|
||||
if (!tokens.id_token) return errorRedirect("oidc_failed");
|
||||
if (!tokens.id_token) return failRedirect("oidc_failed");
|
||||
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
@@ -229,11 +280,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// health record.
|
||||
if (!email) {
|
||||
annotate({ meta: { reason: "no_email" } });
|
||||
return errorRedirect("oidc_no_email");
|
||||
return failRedirect("oidc_no_email");
|
||||
}
|
||||
if (emailVerified !== true) {
|
||||
annotate({ meta: { reason: "email_unverified" } });
|
||||
return errorRedirect("oidc_email_unverified");
|
||||
return failRedirect("oidc_email_unverified");
|
||||
}
|
||||
|
||||
const byEmail = await prisma.user.findFirst({
|
||||
@@ -251,7 +302,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ meta: { reason: "identity_conflict" } });
|
||||
return errorRedirect("oidc_identity_conflict");
|
||||
return failRedirect("oidc_identity_conflict");
|
||||
}
|
||||
user = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
@@ -277,7 +328,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
}
|
||||
if (!registrationEnabled) {
|
||||
annotate({ meta: { reason: "registration_disabled" } });
|
||||
return errorRedirect("oidc_registration_disabled");
|
||||
return failRedirect("oidc_registration_disabled");
|
||||
}
|
||||
|
||||
const username = await deriveUniqueUsername(email, (candidate) =>
|
||||
@@ -337,11 +388,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// second factor. An account with a confirmed TOTP secret or a
|
||||
// registered security key gets the same MFA challenge step password
|
||||
// login gets — no session is minted here; the single-use ticket rides
|
||||
// the handoff cookie to the login page, which renders the same
|
||||
// challenge UI and completes at `/api/auth/mfa/verify`. (The
|
||||
// trusted-device skip cannot apply on this path: its cookie is
|
||||
// SameSite=Strict, which the browser withholds on an IdP-initiated
|
||||
// redirect chain.)
|
||||
// the handoff cookie to the login page (web) or the custom scheme
|
||||
// (native), which render the same challenge UI and complete at
|
||||
// `/api/auth/mfa/verify`. (The trusted-device skip cannot apply on this
|
||||
// path: its cookie is SameSite=Strict, which the browser withholds on an
|
||||
// IdP-initiated redirect chain.)
|
||||
const hasTotp = Boolean(user.totpConfirmedAt);
|
||||
const webauthnKeyCount = await prisma.webauthnMfaCredential.count({
|
||||
where: { userId: user.id },
|
||||
@@ -356,12 +407,31 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
await auditLog("auth.mfa.challenge", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { source: "login.oidc" },
|
||||
details: { source: isNative ? "login.oidc.native" : "login.oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.mfa.challenge" },
|
||||
meta: { mfa_required: true },
|
||||
});
|
||||
|
||||
// Native: hand the app the SAME single-use ticket via the custom scheme.
|
||||
// No `OIDC_MFA_COOKIE` (it exists only to ferry the ticket to the web
|
||||
// login page's script) and no session — the app completes at the
|
||||
// existing native-capable `/api/auth/mfa/verify{,/webauthn}` endpoints,
|
||||
// which end in `finishLogin` and return the native bundle directly. The
|
||||
// ticket alone is inert: single-use, hashed at rest, 5-minute TTL,
|
||||
// 5-attempt cap, factor-gated.
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({
|
||||
mfa_ticket: challenge.ticket,
|
||||
methods: methods.join(","),
|
||||
}),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const loginUrl = oidcAppUrl(LOGIN_ERROR_URL);
|
||||
if (stored.next !== "/") {
|
||||
loginUrl.searchParams.set("next", stored.next);
|
||||
@@ -385,6 +455,33 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
|
||||
const ua = req.headers.get("user-agent");
|
||||
|
||||
// Native, no MFA: mint a one-time handoff code and hand it to the app via
|
||||
// the custom scheme. NO session and NO cookie are minted here — the app
|
||||
// exchanges the code at `POST /api/auth/oidc/native/token` for the native
|
||||
// bundle. `userId` is the resolved identity; the app's S256 challenge
|
||||
// (bound at login, tamper-authenticated in the state blob) locks the code
|
||||
// to the app instance that started the flow. The session/device are
|
||||
// recorded at the exchange via `finishLogin`, not here.
|
||||
if (isNative) {
|
||||
const { code: handoffCode } = await mintNativeHandoff({
|
||||
userId: user.id,
|
||||
// Guaranteed present by `isStoredOidcState` when `native === true`.
|
||||
appCodeChallenge: stored.appCodeChallenge as string,
|
||||
ipAddress: ip,
|
||||
userAgent: ua,
|
||||
});
|
||||
await auditLog("auth.oidc.native.handoff_minted", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
});
|
||||
annotate({ action: { name: "auth.oidc.native.handoff_minted" } });
|
||||
const response = NextResponse.redirect(
|
||||
buildNativeCallbackUrl({ code: handoffCode }),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
// v1.23 parity with password login — the admin-enforced-MFA hint cookie
|
||||
// sends a single-factor account into forced enrollment after sign-in.
|
||||
await syncMfaEnrollCookie(user.id, {
|
||||
@@ -414,6 +511,6 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ meta: { reason: "exchange_or_verify_failed" } });
|
||||
return errorRedirect("oidc_failed");
|
||||
return failRedirect("oidc_failed");
|
||||
}
|
||||
});
|
||||
@@ -150,4 +150,67 @@ describe("GET /api/auth/oidc/login", () => {
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.headers.get("location")).toContain("error=oidc_failed");
|
||||
});
|
||||
|
||||
describe("native SSO start", () => {
|
||||
const CONFIG = {
|
||||
issuerUrl: "https://idp.example.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
scopes: "openid email profile",
|
||||
buttonLabel: "SSO",
|
||||
};
|
||||
const CHALLENGE = "a".repeat(43); // valid S256 length
|
||||
|
||||
function storedFromCookie(res: Awaited<ReturnType<typeof GET>>) {
|
||||
const cookie = res.cookies.get("oidc_auth_state");
|
||||
return JSON.parse(cookie!.value.replace(/^enc\(/, "").replace(/\)$/, ""));
|
||||
}
|
||||
|
||||
it("rejects a native start with a missing challenge to the custom scheme", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
const res = await GET(makeRequest("/api/auth/oidc/login?client=native"));
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_invalid_request",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes native login errors (disabled) to the custom scheme", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(null);
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}`,
|
||||
),
|
||||
);
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"healthlog://oidc-callback?error=oidc_disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("folds native:true + appCodeChallenge into the state blob and pins next to /", async () => {
|
||||
vi.mocked(getOidcConfig).mockReturnValue(CONFIG);
|
||||
vi.mocked(discoverOidcMetadata).mockResolvedValue({
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
jwks_uri: "https://idp.example.com/jwks",
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
makeRequest(
|
||||
`/api/auth/oidc/login?client=native&code_challenge=${CHALLENGE}&next=/dashboard`,
|
||||
),
|
||||
);
|
||||
// Still a normal redirect to the IdP — the native fact rides the blob.
|
||||
expect(res.headers.get("location")).toBe(
|
||||
"https://idp.example.com/authorize?x=1",
|
||||
);
|
||||
const stored = storedFromCookie(res);
|
||||
expect(stored.native).toBe(true);
|
||||
expect(stored.appCodeChallenge).toBe(CHALLENGE);
|
||||
// `next` is meaningless to the app — pinned to "/".
|
||||
expect(stored.next).toBe("/");
|
||||
// The server↔IdP verifier stays independent and present.
|
||||
expect(typeof stored.codeVerifier).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
@@ -29,9 +30,28 @@ import {
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.login" } });
|
||||
|
||||
// v1.30.x — native SSO. The iOS app opens this URL inside an
|
||||
// `ASWebAuthenticationSession` with `client=native` and its own PKCE
|
||||
// `code_challenge`. Both are query params here only; they are validated and
|
||||
// folded into the AES-256-GCM state blob so nothing native-specific rides the
|
||||
// IdP round-trip as a URL parameter. The `client` param is attacker-flippable
|
||||
// at this route, but routing THIS request's error to the custom scheme leaks
|
||||
// nothing — an error redirect carries no code, ticket, or session (spec §1).
|
||||
const isNative = req.nextUrl.searchParams.get("client") === "native";
|
||||
const appCodeChallenge = req.nextUrl.searchParams.get("code_challenge");
|
||||
|
||||
// Native-aware error redirect: the scheme for a native start, the web login
|
||||
// page otherwise. Machine-readable `error=<reason>` in both.
|
||||
const loginError = (reason: string): NextResponse => {
|
||||
if (isNative) {
|
||||
return NextResponse.redirect(buildNativeCallbackUrl({ error: reason }));
|
||||
}
|
||||
return NextResponse.redirect(oidcAppUrl(`/auth/login?error=${reason}`));
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) {
|
||||
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_disabled"));
|
||||
return loginError("oidc_disabled");
|
||||
}
|
||||
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
@@ -41,9 +61,14 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.redirect(
|
||||
oidcAppUrl("/auth/login?error=oidc_rate_limited"),
|
||||
);
|
||||
return loginError("oidc_rate_limited");
|
||||
}
|
||||
|
||||
// A native start MUST carry a valid S256 challenge (43–128 chars). `plain` is
|
||||
// structurally unsupported — the exchange only ever verifies S256.
|
||||
if (isNative && !isValidChallenge(appCodeChallenge)) {
|
||||
annotate({ meta: { reason: "invalid_native_request" } });
|
||||
return loginError("oidc_invalid_request");
|
||||
}
|
||||
|
||||
let metadata;
|
||||
@@ -51,7 +76,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return NextResponse.redirect(oidcAppUrl("/auth/login?error=oidc_failed"));
|
||||
return loginError("oidc_failed");
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
@@ -59,10 +84,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
const codeChallenge = s256Challenge(codeVerifier);
|
||||
|
||||
const safeNext = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
// `next` is a post-login WEB path, meaningless to the app — pin it to "/" on
|
||||
// the native branch (one less input to sanitize).
|
||||
const safeNext = isNative
|
||||
? "/"
|
||||
: sanitizeOidcNextPath(req.nextUrl.searchParams.get("next"), req.url);
|
||||
|
||||
const redirectUri = getOidcRedirectUri();
|
||||
const authorizationUrl = buildAuthorizationUrl({
|
||||
@@ -75,8 +101,20 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
});
|
||||
|
||||
const response = NextResponse.redirect(authorizationUrl);
|
||||
// The server↔IdP PKCE (`codeVerifier`) stays independent of the app↔server
|
||||
// PKCE (`appCodeChallenge`). The native flag + challenge live ONLY inside the
|
||||
// AES-256-GCM blob, so the callback branches on tamper-authenticated state a
|
||||
// network attacker or the IdP cannot flip.
|
||||
const cookiePayload = encrypt(
|
||||
JSON.stringify({ state, nonce, codeVerifier, next: safeNext }),
|
||||
JSON.stringify({
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
next: safeNext,
|
||||
...(isNative
|
||||
? { native: true as const, appCodeChallenge: appCodeChallenge! }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
response.cookies.set(OIDC_STATE_COOKIE, cookiePayload, {
|
||||
httpOnly: true,
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/native-client", () => ({
|
||||
isCookielessNativeCaller: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/oidc-native-handoff", () => ({
|
||||
consumeNativeHandoff: vi.fn(),
|
||||
stampIssuedRefreshToken: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/login-response", () => ({
|
||||
finishLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const VALID_BODY = {
|
||||
code: "hlh_" + "a".repeat(43),
|
||||
codeVerifier: "a".repeat(43),
|
||||
};
|
||||
|
||||
function makeRequest(
|
||||
body: unknown = VALID_BODY,
|
||||
headers: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/oidc/native/token", {
|
||||
method: "POST",
|
||||
// The genuine iOS client always marks itself native; the transport gate
|
||||
// requires it. A gate-rejection test overrides `x-client-type` explicitly.
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-client-type": "native",
|
||||
...headers,
|
||||
},
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function nativeBundleResponse() {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
user: { id: "user-1", username: "alice" },
|
||||
token: "hlk_access",
|
||||
tokenExpiresAt: new Date().toISOString(),
|
||||
refreshToken: "hlr_refresh",
|
||||
refreshTokenExpiresAt: new Date().toISOString(),
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
resetAt: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("POST /api/auth/oidc/native/token", () => {
|
||||
it("429 when rate-limited (before any state is touched)", async () => {
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: 0,
|
||||
ip: "1.2.3.4",
|
||||
} as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(429);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401 and never consumes when the transport is not cookie-less native", async () => {
|
||||
vi.mocked(isCookielessNativeCaller).mockReturnValue(false);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422 on a malformed body", async () => {
|
||||
const res = await POST(makeRequest({ code: "nope", codeVerifier: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(consumeNativeHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns ONE identical generic 401 across every invalid-code class", async () => {
|
||||
const statuses = [
|
||||
{ status: "not_found" },
|
||||
{ status: "replayed", userId: "user-1" },
|
||||
{ status: "expired" },
|
||||
{ status: "pkce_mismatch" },
|
||||
{ status: "race_lost" },
|
||||
] as const;
|
||||
|
||||
const bodies: string[] = [];
|
||||
for (const s of statuses) {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue(s as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
}
|
||||
// Also the deleted-user-mid-flight branch.
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
const res = await POST(makeRequest());
|
||||
expect(res.status).toBe(401);
|
||||
bodies.push(await res.text());
|
||||
|
||||
// Byte-identical envelope — a code-guesser learns nothing.
|
||||
expect(new Set(bodies).size).toBe(1);
|
||||
});
|
||||
|
||||
it("does not mint on the deleted-user branch", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
await POST(makeRequest());
|
||||
expect(finishLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("200 on success: mints via the native source, stamps the issued hash, audits", async () => {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(VALID_BODY, { "x-client-type": "native" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const finishArg = vi.mocked(finishLogin).mock.calls[0][0];
|
||||
expect(finishArg.source).toBe("login.oidc.native");
|
||||
// `mfaVerified` is never set — the SSO login can't satisfy step-up.
|
||||
expect(finishArg.mfaVerified).toBeUndefined();
|
||||
|
||||
// The issued refresh token's hash is stamped for replay reach-back.
|
||||
expect(stampIssuedRefreshToken).toHaveBeenCalledWith("ho-1", "hlr_refresh");
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.login",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { transport: "native" },
|
||||
}),
|
||||
);
|
||||
|
||||
// The client still receives the bundle (clone left the stream intact).
|
||||
const body = await res.json();
|
||||
expect(body.data.refreshToken).toBe("hlr_refresh");
|
||||
});
|
||||
|
||||
it("is NOT blocked by OIDC_ONLY (it completes an OIDC-initiated login)", async () => {
|
||||
const prev = process.env.OIDC_ONLY;
|
||||
process.env.OIDC_ONLY = "true";
|
||||
try {
|
||||
vi.mocked(consumeNativeHandoff).mockResolvedValue({
|
||||
status: "ok",
|
||||
userId: "user-1",
|
||||
handoffId: "ho-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
onboardingCompletedAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(finishLogin).mockResolvedValue(nativeBundleResponse());
|
||||
|
||||
const res = await POST(
|
||||
makeRequest(VALID_BODY, { "x-client-type": "native" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.OIDC_ONLY;
|
||||
else process.env.OIDC_ONLY = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* POST /api/auth/oidc/native/token
|
||||
*
|
||||
* The cookie-less native leg of the OIDC SSO flow (design spec §4). The app
|
||||
* presents the one-time handoff code it received on
|
||||
* `healthlog://oidc-callback?code=` plus its PKCE `codeVerifier`; on success it
|
||||
* receives the SAME native token bundle password login issues
|
||||
* (`{ token, tokenExpiresAt, refreshToken, refreshTokenExpiresAt }`).
|
||||
*
|
||||
* Security-critical invariants:
|
||||
* - It IS the credential mint (same class as `/api/auth/mfa/verify`): no
|
||||
* session or Bearer is required to reach it.
|
||||
* - The transport gate (`isCookielessNativeCaller`) makes the endpoint
|
||||
* structurally incapable of minting a web session or handing a 60-day refresh
|
||||
* token into a browser context — a Mozilla UA or an inbound session cookie is
|
||||
* rejected before any state is touched.
|
||||
* - `userId` is derived SOLELY from the server-side handoff row — the request
|
||||
* carries no identity field.
|
||||
* - A single generic 401 covers not-found / expired / used / PKCE-mismatch /
|
||||
* deleted-user so a code-guesser learns nothing; distinct `annotate` names
|
||||
* keep the wide events diagnosable.
|
||||
* - `mfaVerifiedAt` is never set — a Bearer transport can never satisfy
|
||||
* `requireFreshMfa` step-up, so an SSO login can never satisfy step-up.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiError, safeJson } from "@/lib/api-response";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { isCookielessNativeCaller } from "@/lib/auth/native-client";
|
||||
import { isNativeClientRequest } from "@/lib/auth/issue-token";
|
||||
import { finishLogin } from "@/lib/auth/login-response";
|
||||
import {
|
||||
consumeNativeHandoff,
|
||||
stampIssuedRefreshToken,
|
||||
} from "@/lib/auth/oidc-native-handoff";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** The single generic rejection for every invalid-code class (steps 4–9). */
|
||||
function invalidCode() {
|
||||
return apiError("Invalid or expired code", 401);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.native.token" } });
|
||||
|
||||
// (1) Rate limit — byte-for-byte the MFA-verify surface's posture, including
|
||||
// the trust-violation collapse semantics.
|
||||
const rl = await checkAuthSurfaceRateLimit(
|
||||
request,
|
||||
"auth:oidc:native-token",
|
||||
10,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
const ip = rl.ip ?? "unknown";
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many attempts. Please try again later.", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
// (2) Transport gate — this endpoint only ever hands a native token bundle.
|
||||
// A browser (Mozilla UA or an inbound session cookie) is rejected, AND the
|
||||
// caller must present an explicit native marker (`isNativeClientRequest`, the
|
||||
// SAME predicate `finishLogin` uses to pick its bearer branch) — otherwise a
|
||||
// blank-UA caller could slip past `isCookielessNativeCaller` into the cookie
|
||||
// branch and be handed a `Set-Cookie` session instead of the bundle. Both
|
||||
// together make the cookie path structurally unreachable here.
|
||||
if (
|
||||
!isCookielessNativeCaller(request.headers) ||
|
||||
!isNativeClientRequest(request.headers)
|
||||
) {
|
||||
annotate({ action: { name: "auth.oidc.native.token.wrong_transport" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
await ensureDbCompatibility();
|
||||
|
||||
// (3) Body — bounded, then the exact wire contract.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = oidcNativeTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
const { code, codeVerifier } = parsed.data;
|
||||
|
||||
// (4–8) Lookup + replay/expiry/PKCE + atomic single-use consume.
|
||||
const result = await consumeNativeHandoff(code, codeVerifier);
|
||||
if (result.status !== "ok") {
|
||||
switch (result.status) {
|
||||
case "not_found":
|
||||
annotate({ action: { name: "auth.oidc.native.token.not_found" } });
|
||||
break;
|
||||
case "replayed":
|
||||
// Containment + the audit row already ran inside `consumeNativeHandoff`;
|
||||
// report whether the issued pair was actually revoked (false only in the
|
||||
// sub-ms pre-stamp window).
|
||||
annotate({
|
||||
action: { name: "auth.oidc.native.handoff_replay" },
|
||||
meta: { revoked_issued_pair: result.revokedIssuedPair },
|
||||
});
|
||||
break;
|
||||
case "expired":
|
||||
annotate({ action: { name: "auth.oidc.native.token.expired" } });
|
||||
break;
|
||||
case "pkce_mismatch":
|
||||
annotate({ action: { name: "auth.oidc.native.pkce_failed" } });
|
||||
break;
|
||||
case "race_lost":
|
||||
annotate({ action: { name: "auth.oidc.native.token.race_lost" } });
|
||||
break;
|
||||
}
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (9) Load the user (may have been deleted mid-flight).
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: result.userId },
|
||||
select: { id: true, username: true, onboardingCompletedAt: true },
|
||||
});
|
||||
if (!user) {
|
||||
annotate({ action: { name: "auth.oidc.native.token.user_missing" } });
|
||||
return invalidCode();
|
||||
}
|
||||
|
||||
// (10) Mint the native bundle. Step (2) guarantees the cookie-less-native
|
||||
// branch of `finishLogin`, i.e. the 24h access + 60d rotating refresh with
|
||||
// the `auth.token.autoissue.native` audit row. `mfaVerified` is deliberately
|
||||
// unset — the SSO login can never satisfy step-up.
|
||||
const ua = request.headers.get("user-agent");
|
||||
const response = await finishLogin({
|
||||
user,
|
||||
request,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
source: "login.oidc.native",
|
||||
});
|
||||
|
||||
// (11) Stamp the issued refresh token's hash for replay-containment
|
||||
// reach-back, then audit the completed SSO login. Reading the bundle off a
|
||||
// clone leaves the client's response stream intact. Best-effort: the code is
|
||||
// already single-use-consumed, so a stamping hiccup only forgoes the
|
||||
// replay-revoke for this one row.
|
||||
try {
|
||||
const bundle = (await response.clone().json()) as {
|
||||
data?: { refreshToken?: unknown };
|
||||
};
|
||||
const refreshToken = bundle.data?.refreshToken;
|
||||
if (typeof refreshToken === "string") {
|
||||
await stampIssuedRefreshToken(result.handoffId, refreshToken);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — see comment above.
|
||||
}
|
||||
|
||||
await auditLog("auth.oidc.login", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { transport: "native" },
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on
|
||||
* `POST /api/measurements/batch`.
|
||||
* v1.30.7/v1.30.8 (iOS #34) — go-forward aggregated 10-min heart-rate wire
|
||||
* contract on `POST /api/measurements/batch`.
|
||||
*
|
||||
* Asserts:
|
||||
* - a fresh 10-min HR bucket inserts;
|
||||
* - a fresh 10-min HR bucket inserts, with its per-bucket min/max spread;
|
||||
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
|
||||
* duplicate row);
|
||||
* - a malformed aggregated HR bucket externalId is `skipped`;
|
||||
* - a malformed / off-grid aggregated HR bucket externalId is `skipped`;
|
||||
* - v1.30.8: an out-of-range or mis-ordered spread is dropped to null while
|
||||
* the trustworthy average survives;
|
||||
* - the per-sample uuid HR path is unaffected (immutable duplicate);
|
||||
* - the existing per-day step `stats:` overwrite path is unaffected.
|
||||
*/
|
||||
@@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
|
||||
expect(rows[0].valueMax).toBe(96);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// out-of-plausible-range spread (a sensor glitch / spurious discreteMax)
|
||||
{ valueMin: 58, valueMax: 99999 },
|
||||
{ valueMin: -9999, valueMax: 96 },
|
||||
// mis-ordered: valueMin above the average, or valueMax below it
|
||||
{ valueMin: 80, valueMax: 96 },
|
||||
{ valueMin: 58, valueMax: 70 },
|
||||
])(
|
||||
"v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o",
|
||||
async (spread) => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock
|
||||
.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: {
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
}[];
|
||||
}
|
||||
).data;
|
||||
// The trustworthy average survives; the invalid spread is dropped.
|
||||
expect(rows[0].value).toBe(72);
|
||||
expect(rows[0].valueMin).toBeNull();
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("overwrites min/max alongside the average on a re-post", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
|
||||
|
||||
@@ -318,15 +318,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// per-day cumulative `stats:` total, or a manual entry never carries a
|
||||
// spread, so we pin them to null there even if a client mis-sends the
|
||||
// fields — `value` stays the single source of truth for those rows.
|
||||
//
|
||||
// v1.30.8 — validate the spread the same way `value` is guarded (above):
|
||||
// it now drives the DAY min/max band + the intraday envelope, so an
|
||||
// out-of-range or mis-ordered spread (a spurious `discreteMin` of 0, a
|
||||
// sensor glitch) would poison the aggregate exactly like a bogus `value`
|
||||
// would. Persist the spread ONLY when both extremes are present, in range,
|
||||
// and correctly ordered around `value`; otherwise drop to null and keep the
|
||||
// trustworthy average. Partial or invalid spread → avg-only, still valid.
|
||||
const isHrBucket = isAggregatedBucketExternalId(entry.externalId);
|
||||
const valueMin =
|
||||
isHrBucket && typeof mapped.valueMin === "number"
|
||||
? mapped.valueMin
|
||||
: null;
|
||||
const valueMax =
|
||||
isHrBucket && typeof mapped.valueMax === "number"
|
||||
? mapped.valueMax
|
||||
: null;
|
||||
const spreadValid =
|
||||
isHrBucket &&
|
||||
typeof mapped.valueMin === "number" &&
|
||||
typeof mapped.valueMax === "number" &&
|
||||
validateMeasurementRange(mapped.type, mapped.valueMin) === null &&
|
||||
validateMeasurementRange(mapped.type, mapped.valueMax) === null &&
|
||||
mapped.valueMin <= mapped.value &&
|
||||
mapped.value <= mapped.valueMax;
|
||||
const valueMin = spreadValid ? mapped.valueMin : null;
|
||||
const valueMax = spreadValid ? mapped.valueMax : null;
|
||||
|
||||
prepared.push({
|
||||
index,
|
||||
|
||||
@@ -22,28 +22,15 @@
|
||||
* client keeps sleep on the dedicated route.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import pLimit from "p-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { returnAllZodIssues } from "@/lib/api-response";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
readDailySeries,
|
||||
type DailySeriesRow,
|
||||
} from "@/lib/measurements/daily-series-read";
|
||||
import { readSeriesBatch } from "@/lib/measurements/series-batch-read";
|
||||
import {
|
||||
seriesBatchQuerySchema,
|
||||
SERIES_BATCH_MAX_TYPES,
|
||||
} from "@/lib/validations/series-batch";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Concurrency cap for the per-type rollup reads. Mirrors the W-POOL
|
||||
* `p-limit(4)` discipline applied across the analytics fan-out so a
|
||||
* wide dashboard never packs the shared Prisma pool.
|
||||
*/
|
||||
const SERIES_BATCH_CONCURRENCY = 4;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -55,49 +42,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const { types, from, to } = parsed.data;
|
||||
// De-dupe while preserving order so a caller repeating a type doesn't
|
||||
// double-read it.
|
||||
const uniqueTypes = Array.from(new Set(types)) as MeasurementType[];
|
||||
|
||||
// Load the source-priority ladder ONCE and thread it across every type
|
||||
// so the batched reader doesn't re-query it per type.
|
||||
const priorityJson = await loadUserSourcePriority(user.id);
|
||||
|
||||
// Per-type isolation: a single type's transient DB reject degrades to an
|
||||
// empty slice for THAT type only, never a 500 that blanks every chart in
|
||||
// the row. The failing chart self-fetches through its existing fallback.
|
||||
const limit = pLimit(SERIES_BATCH_CONCURRENCY);
|
||||
let degradedTypes = 0;
|
||||
const entries = await Promise.all(
|
||||
uniqueTypes.map((type) =>
|
||||
limit(async (): Promise<readonly [MeasurementType, DailySeriesRow[]]> => {
|
||||
try {
|
||||
const rows = await readDailySeries({
|
||||
userId: user.id,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
priorityJson,
|
||||
});
|
||||
return [type, rows] as const;
|
||||
} catch {
|
||||
degradedTypes += 1;
|
||||
return [type, []] as const;
|
||||
}
|
||||
}),
|
||||
),
|
||||
// Shared core: priority ladder once, `p-limit(4)` per-type fan-out, per-type
|
||||
// fault isolation to `[]`. The RSC dashboard prefetch calls the same
|
||||
// function so the two paths never drift.
|
||||
const { series, degradedTypes } = await readSeriesBatch(
|
||||
user.id,
|
||||
types,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
|
||||
const series: Record<string, DailySeriesRow[]> = {};
|
||||
for (const [type, rows] of entries) {
|
||||
series[type] = rows;
|
||||
}
|
||||
|
||||
const seriesLists = Object.values(series);
|
||||
annotate({
|
||||
action: { name: "measurement.series.batch" },
|
||||
meta: {
|
||||
type_count: uniqueTypes.length,
|
||||
total: entries.reduce((sum, [, rows]) => sum + rows.length, 0),
|
||||
type_count: seriesLists.length,
|
||||
total: seriesLists.reduce((sum, rows) => sum + rows.length, 0),
|
||||
degraded_types: degradedTypes,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
+76
-30
@@ -87,10 +87,12 @@ const DASHBOARD_QUERY_OPTS = {
|
||||
// v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches.
|
||||
// Threaded to every chart as `preloadedCoverageDays` so a chart reads the
|
||||
// batched slice ONLY for a range tab whose window fits within it (7 / 30);
|
||||
// the 90 / All tabs exceed it and self-fetch the wider window. Pre-fix the
|
||||
// chart trusted the batch for any window > 7 days, so 90 / All silently
|
||||
// re-rendered this ~30-day slice. Keep this in lockstep with `batchWindow`.
|
||||
const BATCH_COVERAGE_DAYS = 31;
|
||||
// the 90 / All tabs exceed it and self-fetch the wider window.
|
||||
//
|
||||
// v1.30.9 — `BATCH_COVERAGE_DAYS` + the type-set + window derivations moved to
|
||||
// the client-safe `@/lib/dashboard/batch-chart-types` module so the RSC
|
||||
// prefetch (page.tsx) and this client read the SAME code — the batched-series
|
||||
// cache key is byte-identical on both sides or the prefetch silently no-ops.
|
||||
|
||||
// v1.16.8 — the loaders retry a rejected chunk import once (a lazy
|
||||
// import caches its rejection permanently, so a transient 404 from a
|
||||
@@ -123,6 +125,17 @@ const MedicationComplianceChart = (
|
||||
<MedicationComplianceChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
|
||||
// v1.30.9 — warm the shared `chart-runtime` chunk during hydration. The
|
||||
// dashboard charts mount through `ssr:false` dynamics, which begin their
|
||||
// import only at mount (post-hydration); firing the import at module
|
||||
// evaluation lets the chunk download overlap hydration instead of queueing
|
||||
// behind the first chart mount. No render-path change — the dynamics still
|
||||
// resolve through the same `importWithRetry` promise (a warm module cache),
|
||||
// and the `<ChartSkeleton>` fallback is untouched.
|
||||
if (typeof window !== "undefined") {
|
||||
void importWithRetry(() => import("@/components/charts/chart-runtime"));
|
||||
}
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
@@ -130,6 +143,12 @@ import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
import { useDailyDigest } from "@/lib/queries/use-daily-digest";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import {
|
||||
BATCH_COVERAGE_DAYS,
|
||||
deriveBatchChartTypes,
|
||||
computeLocalBatchWindow,
|
||||
type BatchWindow,
|
||||
} from "@/lib/dashboard/batch-chart-types";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick";
|
||||
import { isWindowSufficient } from "@/lib/analytics/window-confidence";
|
||||
@@ -187,7 +206,20 @@ function tileInsightHref(id: string): string | null {
|
||||
return TILE_INSIGHT_HREF[id] ?? null;
|
||||
}
|
||||
|
||||
export default function DashboardPageClient() {
|
||||
export default function DashboardPageClient({
|
||||
batchWindow: batchWindowProp,
|
||||
}: {
|
||||
/**
|
||||
* v1.30.9 — the batched-series ISO window computed server-side from the
|
||||
* PROFILE timezone and threaded down as an RSC prop. Present only when the
|
||||
* RSC prefetch ran (snapshot mode, `DASHBOARD_SSR_PREFETCH !== "false"`);
|
||||
* absent on the legacy / prefetch-off / fail-soft paths, where the client
|
||||
* falls back to its browser-local window. Adopting the prop verbatim keeps
|
||||
* the `chartSeriesBatch` key byte-identical to the key the RSC dehydrated
|
||||
* under, so the prefetched slice lands instead of triggering a refetch.
|
||||
*/
|
||||
batchWindow?: BatchWindow;
|
||||
} = {}) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const { t } = useTranslations();
|
||||
@@ -723,27 +755,28 @@ export default function DashboardPageClient() {
|
||||
// mounts them with no range-tab interaction); a chart whose window
|
||||
// widens past this slice (range tab / comparison) drops batched
|
||||
// coverage and self-fetches for the new window — see `usePreloaded`.
|
||||
const batchTypeSet = new Set<string>();
|
||||
if (showWeightChart) batchTypeSet.add("WEIGHT");
|
||||
if (showBpCharts) {
|
||||
batchTypeSet.add("BLOOD_PRESSURE_SYS");
|
||||
batchTypeSet.add("BLOOD_PRESSURE_DIA");
|
||||
}
|
||||
if (showPulseChart)
|
||||
batchTypeSet.add(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE");
|
||||
if (showBodyFatChart) batchTypeSet.add("BODY_FAT");
|
||||
if (showStepsChart) batchTypeSet.add("ACTIVITY_STEPS");
|
||||
const batchChartTypes = Array.from(batchTypeSet);
|
||||
//
|
||||
// v1.30.9 — the type-set derivation is the shared `deriveBatchChartTypes`,
|
||||
// the SAME function the RSC prefetch runs over the SAME snapshot payload
|
||||
// (`layout` + `data.summaries` in snapshot mode ARE the snapshot fields the
|
||||
// server dehydrated). One implementation → the CSV, and therefore the
|
||||
// batched-series cache key, is byte-identical on both sides by construction.
|
||||
const batchChartTypes = deriveBatchChartTypes(layout, data?.summaries);
|
||||
|
||||
// Stable day-bucketed window so the cache key is stable across the day
|
||||
// (mirrors the chart's own ISO-window stability contract). Computed
|
||||
// once per mount via lazy state so a re-render never re-keys the batch.
|
||||
const [batchWindow] = useState(() => {
|
||||
const to = new Date();
|
||||
to.setHours(23, 59, 59, 999);
|
||||
const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
});
|
||||
//
|
||||
// v1.30.9 — the server-computed PROFILE-tz window wins when present (it
|
||||
// rides the serialized RSC payload, so SSR and the first hydration render
|
||||
// see the identical object → the `chartSeriesBatch` key matches the key the
|
||||
// RSC dehydrated under, zero hydration seam). The browser-local fallback
|
||||
// stands for the prefetch-off / legacy / fail-soft paths where no prop
|
||||
// arrives. Lazy `useState` with a prop-derived initial value is
|
||||
// deterministic across the SSR/CSR pair.
|
||||
const [batchWindow] = useState(
|
||||
() => batchWindowProp ?? computeLocalBatchWindow(),
|
||||
);
|
||||
|
||||
const { data: batchedSeries } = useQuery({
|
||||
queryKey: queryKeys.chartSeriesBatch(
|
||||
@@ -815,17 +848,30 @@ export default function DashboardPageClient() {
|
||||
falling through to nothing. The dense tile grid + charts below
|
||||
are untouched. */}
|
||||
{insightsEnabled &&
|
||||
// `!mounted` pins the SSR pass AND the first hydration render to the
|
||||
// skeleton (v1.16.4 pattern): the digest query rehydrates from the
|
||||
// persisted cache on the client, so without this gate the client's
|
||||
// first render would paint the hero while the server rendered the
|
||||
// skeleton — a text-content hydration mismatch (React #418).
|
||||
(!mounted || digestQuery.isLoading ? (
|
||||
// v1.30.9 — DATA-FIRST gate so the SSR pass paints the POPULATED hero
|
||||
// (the LCP element) instead of a skeleton. This is hydration-safe ONLY
|
||||
// because of a bound invariant: the digest is server-DEHYDRATED
|
||||
// (`page.tsx` prefetch → `setQueryData(queryKeys.dailyDigest(), …)`),
|
||||
// so `digestQuery.data` is present on BOTH the SSR pass AND the first
|
||||
// hydration render (HydrationBoundary hydrates synchronously before
|
||||
// first render; `enabled:false` only gates FETCHING, not cached-data
|
||||
// reads) — both sides render identical HTML, no React #418.
|
||||
//
|
||||
// The one divergence class the old `!mounted` gate guarded against —
|
||||
// a client-only IndexedDB-restored cache painting the hero while the
|
||||
// server rendered a skeleton — CANNOT bite here: `["daily", …]` is NOT
|
||||
// in the persist allowlist (`PERSIST_ALLOWLIST_HEADS` in
|
||||
// `@/lib/pwa/query-persister` — `dashboard` / `chart-data` + the exact
|
||||
// `["user","dashboardWidgets"]` tuple only), so the digest never
|
||||
// rehydrates from disk. The error / empty branches stay reachable only
|
||||
// post-mount. DO NOT add `["daily", …]` to that allowlist, or render
|
||||
// the hero from any client-only source, without revisiting this gate.
|
||||
(digestQuery.data ? (
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : !mounted || digestQuery.isLoading ? (
|
||||
<TodayHeroSkeleton />
|
||||
) : digestQuery.isError ? (
|
||||
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
|
||||
) : digestQuery.data ? (
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : null)}
|
||||
|
||||
{/* v1.18.6 — the spotlight tour launcher moved to the app-shell
|
||||
|
||||
+126
-25
@@ -6,52 +6,83 @@ import {
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
import {
|
||||
computeBatchWindow,
|
||||
deriveBatchChartTypes,
|
||||
type BatchWindow,
|
||||
} from "@/lib/dashboard/batch-chart-types";
|
||||
import { resolveDashboardLayout } from "@/lib/dashboard-layout";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { loadDailyDigest } from "@/lib/daily/load-digest";
|
||||
import { readSeriesBatch } from "@/lib/measurements/series-batch-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import DashboardPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* Soft budget on the cold-day digest prefetch. `loadDailyDigest`'s only
|
||||
* heavy input is the `daily-digest-extras` SWR cell: on the FIRST digest read
|
||||
* of a user's local day it rebuilds the streak scan + intraday-pulse read cold
|
||||
* (low hundreds of ms). Racing it against this budget bounds the worst-case
|
||||
* TTFB regression — the abandoned work still runs to completion and warms the
|
||||
* SWR cell, so the client fetch that follows a skipped dehydrate lands warm.
|
||||
*/
|
||||
const DIGEST_SOFT_BUDGET_MS = 400;
|
||||
|
||||
/** Resolve `undefined` after `ms` — the soft-budget loser in the digest race. */
|
||||
function softTimeout<T>(ms: number): Promise<T | undefined> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin RSC wrapper around the (client) dashboard page.
|
||||
*
|
||||
* The measured first-load gap on `/` was never the server: FCP painted the
|
||||
* SSR skeleton shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled)
|
||||
* because the real tile content waited for the full JS download + hydrate +
|
||||
* a CLIENT-side snapshot fetch — while `/api/dashboard/snapshot` itself
|
||||
* answers in ~8 ms server-side. This wrapper runs that same cached read
|
||||
* during SSR and hands it to TanStack through `HydrationBoundary`, so the
|
||||
* first HTML already carries real tile data and the mounted snapshot cell
|
||||
* (`staleTime` 60 s, `refetchOnMount: false`) starts warm instead of
|
||||
* skeleton-first.
|
||||
* because the real above-the-fold content waited for the full JS download +
|
||||
* hydrate + CLIENT-side fetches. This wrapper runs the same cached reads the
|
||||
* routes use during SSR and hands them to TanStack through `HydrationBoundary`,
|
||||
* so the first HTML already carries real content and the mounted cells start
|
||||
* warm instead of skeleton-first:
|
||||
*
|
||||
* - the dashboard snapshot (`/api/dashboard/snapshot`) — every tile;
|
||||
* - the Today digest (`/api/daily/digest` → `loadDailyDigest`) — the hero;
|
||||
* - the batched chart series (`/api/measurements/series-batch` →
|
||||
* `readSeriesBatch`) — the chart row.
|
||||
*
|
||||
* Contract notes:
|
||||
* - Query keys come ONLY from the central factory. The live cell is
|
||||
* locale-keyed (`dashboardSnapshot(locale)`), and the locale here is the
|
||||
* same resolution the API route uses (`resolveServerLocale`), which the
|
||||
* client provider mirrors via the layout's `resolveInitialLocale`.
|
||||
* - The payload is JSON-round-tripped so the hydrated shape is exactly
|
||||
* what the client `queryFn` would produce from the wire ((await
|
||||
* res.json()).data — Dates as ISO strings), never a Date-carrying
|
||||
* sibling that would poison the cell for later readers.
|
||||
* - `dashboardWidgets` is seeded alongside, mirroring the client cell's
|
||||
* own warm-up in `use-dashboard-snapshot.ts` (the overlay-prefs hook
|
||||
* reads it before the chart cells fire).
|
||||
* - Fail-soft: no session (proxy is mid-redirect), a builder hiccup, or a
|
||||
* DB blip renders the page exactly as before this wrapper existed — the
|
||||
* client cell owns the fetch then.
|
||||
* - Query keys come ONLY from the central factory. Every dehydrated VALUE is
|
||||
* JSON-round-tripped so the hydrated shape is exactly what the client
|
||||
* `queryFn` would produce from the wire ((await res.json()).data — Dates as
|
||||
* ISO strings), never a Date-carrying sibling that poisons the cell.
|
||||
* - The batched-series key is the crux: the CSV type list comes from the
|
||||
* SHARED `deriveBatchChartTypes` over the SAME snapshot payload, and the ISO
|
||||
* window from `computeBatchWindow(now, user.timezone)` threaded to the
|
||||
* client as the `batchWindow` prop. Server and client build the identical
|
||||
* key by construction, so the prefetched slice lands instead of refetching.
|
||||
* - Module-gate parity: the digest route gates on `insights`; skip its
|
||||
* prefetch when the module is off (the client hook is disabled then too).
|
||||
* - Fail-soft: no session, a builder hiccup, or a DB blip renders the page
|
||||
* exactly as before this wrapper existed — the client cells own the fetch.
|
||||
* Each prefetch is independent; one failing never blocks the others.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
// Operator/test escape hatch: `DASHBOARD_SSR_PREFETCH=false` renders the
|
||||
// pure client-fetch dashboard (pre-prefetch behaviour). The e2e server
|
||||
// sets it so Playwright route mocks — which only see CLIENT fetches —
|
||||
// keep governing what the dashboard paints.
|
||||
// keep governing what the dashboard paints. It gates EVERY prefetch below.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <DashboardPageClient />;
|
||||
}
|
||||
let dehydratedState = null;
|
||||
let batchWindow: BatchWindow | undefined;
|
||||
try {
|
||||
const session = await getSession();
|
||||
if (session) {
|
||||
const { body, locale } = await readDashboardSnapshotCached(session.user);
|
||||
const { user } = session;
|
||||
const { body, locale } = await readDashboardSnapshotCached(user);
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO
|
||||
// date strings) — same-key-different-shape is silent cache poison.
|
||||
const wireBody = JSON.parse(JSON.stringify(body)) as unknown as Record<
|
||||
@@ -61,6 +92,76 @@ export default async function DashboardPage() {
|
||||
const queryClient = new QueryClient();
|
||||
queryClient.setQueryData(queryKeys.dashboardSnapshot(locale), wireBody);
|
||||
queryClient.setQueryData(queryKeys.dashboardWidgets(), wireBody.layout);
|
||||
|
||||
// Above-the-fold prefetch runs ONLY in snapshot mode: legacy mode derives
|
||||
// its chart-type list from analytics queries the RSC did not run, so there
|
||||
// is no byte-identical key to dehydrate the series under. In legacy mode
|
||||
// `batchWindow` stays undefined and the client falls back to its own
|
||||
// browser-local window exactly as before — no behaviour change off-flag.
|
||||
const snapshotMode = isDashboardSnapshotEnabled();
|
||||
|
||||
// The profile-tz window is computed ONCE (snapshot mode only) and threaded
|
||||
// to the client so the batched-series key matches byte-for-byte.
|
||||
if (snapshotMode) {
|
||||
batchWindow = computeBatchWindow(new Date(), user.timezone);
|
||||
}
|
||||
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const insightsEnabled = modules.insights !== false;
|
||||
|
||||
const seriesTypes = snapshotMode
|
||||
? deriveBatchChartTypes(
|
||||
resolveDashboardLayout(wireBody.layout),
|
||||
(
|
||||
wireBody.tiles as {
|
||||
summaries?: Record<string, { count?: number }>;
|
||||
}
|
||||
)?.summaries,
|
||||
)
|
||||
: [];
|
||||
|
||||
// Digest carries a soft budget (cold-day extras rebuild); series is a
|
||||
// warm rollup read (< 100 ms) with none. Both fail soft to `undefined`.
|
||||
const digestWork = insightsEnabled
|
||||
? loadDailyDigest(user).catch(() => undefined)
|
||||
: Promise.resolve(undefined);
|
||||
const seriesWork =
|
||||
batchWindow && seriesTypes.length > 0
|
||||
? readSeriesBatch(
|
||||
user.id,
|
||||
seriesTypes as MeasurementType[],
|
||||
new Date(batchWindow.from),
|
||||
new Date(batchWindow.to),
|
||||
)
|
||||
.then((result) => result.series)
|
||||
.catch(() => undefined)
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
const [digest, series] = await Promise.all([
|
||||
Promise.race([digestWork, softTimeout(DIGEST_SOFT_BUDGET_MS)]),
|
||||
seriesWork,
|
||||
]);
|
||||
|
||||
// JSON-round-trip both to the client wire shape before seeding.
|
||||
if (digest !== undefined) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.dailyDigest(),
|
||||
JSON.parse(JSON.stringify(digest)),
|
||||
);
|
||||
}
|
||||
// `series` is only ever set when `batchWindow` was (snapshot mode) — the
|
||||
// explicit `batchWindow` guard just narrows the type for the key build.
|
||||
if (series !== undefined && batchWindow) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.chartSeriesBatch(
|
||||
seriesTypes.join(","),
|
||||
batchWindow.from,
|
||||
batchWindow.to,
|
||||
),
|
||||
JSON.parse(JSON.stringify(series)),
|
||||
);
|
||||
}
|
||||
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
} catch {
|
||||
@@ -68,11 +169,11 @@ export default async function DashboardPage() {
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <DashboardPageClient />;
|
||||
return <DashboardPageClient batchWindow={batchWindow} />;
|
||||
}
|
||||
return (
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<DashboardPageClient />
|
||||
<DashboardPageClient batchWindow={batchWindow} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
oidcNativeHandoff: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Deterministic stand-in for the real HMAC — length-keyed so it never contains
|
||||
// the raw code, making the "raw code is never persisted" assertion meaningful.
|
||||
vi.mock("@/lib/auth/hmac", () => ({
|
||||
hashToken: vi.fn((t: string) => `H(${t.length})`),
|
||||
}));
|
||||
|
||||
const revokeRefreshTokenByHash = vi.fn();
|
||||
vi.mock("@/lib/auth/refresh-token", () => ({
|
||||
revokeRefreshTokenByHash: (h: string) => revokeRefreshTokenByHash(h),
|
||||
}));
|
||||
|
||||
const auditLog = vi.fn();
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: (...a: unknown[]) => auditLog(...a),
|
||||
}));
|
||||
|
||||
import {
|
||||
mintNativeHandoff,
|
||||
consumeNativeHandoff,
|
||||
buildNativeCallbackUrl,
|
||||
stampIssuedRefreshToken,
|
||||
NATIVE_OIDC_REDIRECT_URI,
|
||||
OIDC_NATIVE_HANDOFF_TTL_MS,
|
||||
} from "../oidc-native-handoff";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// A real RFC 7636 verifier + its real S256 challenge, so the constant-time
|
||||
// verify inside `consumeNativeHandoff` runs for real.
|
||||
const VERIFIER = "a".repeat(43);
|
||||
const CHALLENGE = s256Challenge(VERIFIER);
|
||||
|
||||
function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "ho-1",
|
||||
userId: "user-1",
|
||||
codeHash: "H(47)",
|
||||
codeChallenge: CHALLENGE,
|
||||
expiresAt: new Date(Date.now() + OIDC_NATIVE_HANDOFF_TTL_MS),
|
||||
consumedAt: null,
|
||||
issuedRefreshTokenHash: null,
|
||||
createdAt: new Date(),
|
||||
ipAddress: "1.2.3.4",
|
||||
userAgent: "HealthLog-iOS/1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("mintNativeHandoff", () => {
|
||||
it("mints an hlh_ code, stores only its hash, and binds the challenge + TTL", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.create).mockResolvedValue({
|
||||
id: "ho-1",
|
||||
} as never);
|
||||
|
||||
const before = Date.now();
|
||||
const { code, handoffId } = await mintNativeHandoff({
|
||||
userId: "user-1",
|
||||
appCodeChallenge: CHALLENGE,
|
||||
ipAddress: "1.2.3.4",
|
||||
userAgent: "HealthLog-iOS/1",
|
||||
});
|
||||
|
||||
expect(code.startsWith("hlh_")).toBe(true);
|
||||
expect(handoffId).toBe("ho-1");
|
||||
|
||||
const arg = vi.mocked(prisma.oidcNativeHandoff.create).mock.calls[0][0];
|
||||
// userId comes from the resolved identity; challenge bound at mint.
|
||||
expect(arg.data.userId).toBe("user-1");
|
||||
expect(arg.data.codeChallenge).toBe(CHALLENGE);
|
||||
// Only the hash is persisted — never the raw code.
|
||||
expect(arg.data.codeHash).toBe(`H(${code.length})`);
|
||||
expect(JSON.stringify(arg)).not.toContain(code);
|
||||
// 90-second TTL.
|
||||
const ttl = (arg.data.expiresAt as Date).getTime() - before;
|
||||
expect(ttl).toBeGreaterThan(OIDC_NATIVE_HANDOFF_TTL_MS - 2000);
|
||||
expect(ttl).toBeLessThanOrEqual(OIDC_NATIVE_HANDOFF_TTL_MS + 100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNativeCallbackUrl", () => {
|
||||
it("targets the fixed scheme and escapes params", () => {
|
||||
const url = buildNativeCallbackUrl({
|
||||
code: "hlh_abc",
|
||||
methods: "totp,recovery",
|
||||
});
|
||||
expect(url.startsWith(`${NATIVE_OIDC_REDIRECT_URI}?`)).toBe(true);
|
||||
expect(url).toContain("code=hlh_abc");
|
||||
// Comma is percent-escaped — no unescaped client value in the Location.
|
||||
expect(url).toContain("methods=totp%2Crecovery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consumeNativeHandoff", () => {
|
||||
it("returns not_found for an unknown code", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("not_found");
|
||||
expect(
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mock.calls[0][0],
|
||||
).toMatchObject({ where: { codeHash: "H(47)" } });
|
||||
});
|
||||
|
||||
it("replay: a consumed row revokes exactly the issued pair and audits", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow({
|
||||
consumedAt: new Date(),
|
||||
issuedRefreshTokenHash: "issued-refresh-hash",
|
||||
}) as never,
|
||||
);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("replayed");
|
||||
expect(revokeRefreshTokenByHash).toHaveBeenCalledWith(
|
||||
"issued-refresh-hash",
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.oidc.native.handoff_replay",
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
// A consumed row is never re-consumed.
|
||||
expect(prisma.oidcNativeHandoff.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replay without an issued hash still audits but revokes nothing", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow({
|
||||
consumedAt: new Date(),
|
||||
issuedRefreshTokenHash: null,
|
||||
}) as never,
|
||||
);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("replayed");
|
||||
expect(revokeRefreshTokenByHash).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("expired: burns the row (guarded) and never issues", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow({ expiresAt: new Date(Date.now() - 1000) }) as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("expired");
|
||||
expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1", consumedAt: null },
|
||||
data: { consumedAt: expect.any(Date) },
|
||||
});
|
||||
expect(revokeRefreshTokenByHash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pkce_mismatch: a wrong verifier burns the code", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff(
|
||||
"hlh_" + "x".repeat(43),
|
||||
"b".repeat(43),
|
||||
);
|
||||
expect(res.status).toBe("pkce_mismatch");
|
||||
// Burned via the guarded update — no issuance.
|
||||
expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1", consumedAt: null },
|
||||
data: { consumedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("ok: a live row + correct verifier consumes atomically and returns the identity", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res).toEqual({ status: "ok", userId: "user-1", handoffId: "ho-1" });
|
||||
expect(prisma.oidcNativeHandoff.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1", consumedAt: null },
|
||||
data: { consumedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("race_lost: a lost consume returns race_lost and revokes nothing", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.findUnique).mockResolvedValue(
|
||||
storedRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.oidcNativeHandoff.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await consumeNativeHandoff("hlh_" + "x".repeat(43), VERIFIER);
|
||||
expect(res.status).toBe("race_lost");
|
||||
expect(revokeRefreshTokenByHash).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stampIssuedRefreshToken", () => {
|
||||
it("stores the hash of the issued refresh token on the handoff row", async () => {
|
||||
vi.mocked(prisma.oidcNativeHandoff.update).mockResolvedValue({} as never);
|
||||
await stampIssuedRefreshToken("ho-1", "hlr_secret");
|
||||
expect(prisma.oidcNativeHandoff.update).toHaveBeenCalledWith({
|
||||
where: { id: "ho-1" },
|
||||
data: { issuedRefreshTokenHash: `H(${"hlr_secret".length})` },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,7 @@ import {
|
||||
issueAccessAndRefresh,
|
||||
rotateRefreshToken,
|
||||
revokeRefreshToken,
|
||||
revokeRefreshTokenByHash,
|
||||
revokeBearerAccessToken,
|
||||
} from "../refresh-token";
|
||||
import type { TokenPolicyDecision } from "../native-client";
|
||||
@@ -461,3 +462,42 @@ describe("revokeBearerAccessToken (M-2 logout bearer revoke)", () => {
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeRefreshTokenByHash (native OIDC handoff replay containment)", () => {
|
||||
it("revokes the refresh row + its paired access token by stored hash", async () => {
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: "u1",
|
||||
policy: NATIVE_POLICY,
|
||||
source: "login.oidc.native",
|
||||
deviceId: "dev-1",
|
||||
});
|
||||
const refreshHash = `hash:${bundle.refreshToken}`;
|
||||
const accessHash = `hash:${bundle.accessToken}`;
|
||||
|
||||
// The handoff row only ever stored the HASH of the refresh token, never
|
||||
// the raw secret — so replay containment must revoke by hash.
|
||||
const ok = await revokeRefreshTokenByHash(refreshHash);
|
||||
expect(ok).toBe(true);
|
||||
|
||||
expect(
|
||||
dbState.refreshTokens.find((r) => r.tokenHash === refreshHash)?.revokedAt,
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
dbState.apiTokens.find((a) => a.tokenHash === accessHash)?.revoked,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an unknown or already-revoked hash", async () => {
|
||||
expect(await revokeRefreshTokenByHash("hash:hlr_unknown")).toBe(false);
|
||||
|
||||
const bundle = await issueAccessAndRefresh({
|
||||
userId: "u1",
|
||||
policy: NATIVE_POLICY,
|
||||
source: "login.oidc.native",
|
||||
});
|
||||
const refreshHash = `hash:${bundle.refreshToken}`;
|
||||
await revokeRefreshTokenByHash(refreshHash);
|
||||
// Second call is a no-op (idempotent containment).
|
||||
expect(await revokeRefreshTokenByHash(refreshHash)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Native OIDC SSO session handoff (iOS#49).
|
||||
*
|
||||
* The web OIDC flow (`/api/auth/oidc/login` → IdP → `/api/auth/oidc/callback`)
|
||||
* ends in a session cookie. The native app is a Bearer-transport client and can
|
||||
* never consume a cookie. This module bridges the two: the callback mints a
|
||||
* one-time, PKCE-locked handoff code that rides ONLY the custom-scheme redirect
|
||||
* back to the app; the app exchanges it at `POST /api/auth/oidc/native/token`
|
||||
* for the standard native token bundle.
|
||||
*
|
||||
* Security posture (design spec §2–§4, §10):
|
||||
* - The return address is a compiled-in constant — there is no
|
||||
* `redirect_uri` parameter, so the open-redirect / scheme-hijack class is
|
||||
* removed by construction, not validated away.
|
||||
* - The raw code (`hlh_<…>`) is 256-bit CSPRNG, hashed at rest with the same
|
||||
* `hashToken` (HMAC-SHA256, `API_TOKEN_HMAC_KEY`) used for Bearer/refresh
|
||||
* tokens — a DB dump reconstructs nothing.
|
||||
* - Single-use via a guarded update (`WHERE consumedAt IS NULL`); a failed
|
||||
* PKCE check also burns the code (no verifier oracle, no retry surface).
|
||||
* - A presentation of an already-consumed code is treated as an interception
|
||||
* signal and revokes exactly the token pair the first exchange issued
|
||||
* (refresh-token reuse-detection reach-back).
|
||||
* - The token pair NEVER rides the redirect URL — only the opaque code (or an
|
||||
* MFA ticket) does.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { verifyPkceS256 } from "@/lib/mcp/oauth/pkce";
|
||||
import { revokeRefreshTokenByHash } from "@/lib/auth/refresh-token";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
/**
|
||||
* The ONLY callback target for the native flow. Compiled-in, not operator- or
|
||||
* client-supplied — see design spec §2. The `healthlog` scheme is an iOS-app
|
||||
* dependency the app must register (iOS-coord §9.2). If the shipped app owns a
|
||||
* different scheme, this constant is corrected once before release; it remains
|
||||
* a compile-time constant either way.
|
||||
*/
|
||||
export const NATIVE_OIDC_REDIRECT_URI = "healthlog://oidc-callback";
|
||||
|
||||
/**
|
||||
* 90-second handoff TTL. The gap it must cover is OS-level (scheme redirect →
|
||||
* app foregrounds → one POST) — sub-second in practice; 90s absorbs a slow
|
||||
* radio without holding the interception window open. Expiry is enforced at
|
||||
* read; a daily sweep prunes stale rows.
|
||||
*/
|
||||
export const OIDC_NATIVE_HANDOFF_TTL_MS = 90_000;
|
||||
|
||||
/** Distinct prefix — `hlk_` (access) / `hlr_` (refresh) / `hlac_` (MCP) are taken. */
|
||||
export const HANDOFF_CODE_PREFIX = "hlh_";
|
||||
|
||||
/**
|
||||
* The 43-char base64url body of a 32-byte (256-bit) CSPRNG code. The token
|
||||
* route's Zod schema pins `^hlh_[A-Za-z0-9_-]{43}$` so only well-formed codes
|
||||
* ever reach the hash lookup.
|
||||
*/
|
||||
function generateHandoffCode(): string {
|
||||
return `${HANDOFF_CODE_PREFIX}${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the native callback redirect from the compiled-in constant plus
|
||||
* query-escaped params only — nothing the client sends is ever echoed into a
|
||||
* `Location` header on the native branch. Assembled by string (not via `new
|
||||
* URL`) so the target is exactly `healthlog://oidc-callback?…` with no
|
||||
* scheme-normalisation surprises.
|
||||
*/
|
||||
export function buildNativeCallbackUrl(params: Record<string, string>): string {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return `${NATIVE_OIDC_REDIRECT_URI}?${query}`;
|
||||
}
|
||||
|
||||
export interface MintedHandoff {
|
||||
/** The raw `hlh_<…>` code — placed ONLY on the custom-scheme redirect. */
|
||||
code: string;
|
||||
handoffId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh single-use handoff code for a resolved identity. `userId` comes
|
||||
* from the server-side identity resolution, never from client input; the app's
|
||||
* S256 challenge is bound here so the exchange can prove the caller is the app
|
||||
* instance that started the flow. IP/UA are audit-only (a mobile radio changes
|
||||
* address between the browser context and the app's socket, so they are NOT
|
||||
* validated at exchange).
|
||||
*/
|
||||
export async function mintNativeHandoff(input: {
|
||||
userId: string;
|
||||
appCodeChallenge: string;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): Promise<MintedHandoff> {
|
||||
const code = generateHandoffCode();
|
||||
const row = await prisma.oidcNativeHandoff.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
codeHash: hashToken(code),
|
||||
codeChallenge: input.appCodeChallenge,
|
||||
expiresAt: new Date(Date.now() + OIDC_NATIVE_HANDOFF_TTL_MS),
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return { code, handoffId: row.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed result union for `consumeNativeHandoff`. Every non-`ok` variant maps
|
||||
* to the same generic 401 at the route so a code-guesser learns nothing;
|
||||
* distinct variants keep the wide events diagnosable.
|
||||
*/
|
||||
export type ConsumeHandoffResult =
|
||||
| { status: "ok"; userId: string; handoffId: string }
|
||||
| { status: "not_found" }
|
||||
| { status: "replayed"; userId: string; revokedIssuedPair: boolean }
|
||||
| { status: "expired" }
|
||||
| { status: "pkce_mismatch" }
|
||||
| { status: "race_lost" };
|
||||
|
||||
/**
|
||||
* Validate + atomically consume a handoff code (design spec §4 steps 4–8).
|
||||
*
|
||||
* Order: hash-lookup → replay containment → expiry burn → constant-time PKCE
|
||||
* verify (burn on mismatch) → guarded single-use consume. The replay branch
|
||||
* revokes exactly the pair the first exchange issued (via
|
||||
* `issuedRefreshTokenHash`) and audits — the accepted DoS-for-containment trade
|
||||
* (spec §10 T7). A failed PKCE check burns the code so an attacker holding the
|
||||
* code but not the verifier also denies themselves retries (spec §10 T1).
|
||||
*/
|
||||
export async function consumeNativeHandoff(
|
||||
rawCode: string,
|
||||
codeVerifier: string,
|
||||
): Promise<ConsumeHandoffResult> {
|
||||
const codeHash = hashToken(rawCode);
|
||||
const row = await prisma.oidcNativeHandoff.findUnique({
|
||||
where: { codeHash },
|
||||
});
|
||||
|
||||
if (!row) return { status: "not_found" };
|
||||
|
||||
// Replay: any presentation of an already-consumed row — regardless of
|
||||
// whether the presented verifier matches — is an interception signal.
|
||||
// Revoke exactly the pair the first exchange issued and audit; the code
|
||||
// itself is already spent, so no further consume is needed.
|
||||
if (row.consumedAt !== null) {
|
||||
// `issuedRefreshTokenHash` is null only in the sub-millisecond window
|
||||
// between the consume and the post-`finishLogin` stamp; a replay there
|
||||
// finds no pair to revoke (the legitimate pair stays live — a later replay
|
||||
// once stamped triggers containment). Report what actually happened so the
|
||||
// wide event never claims a revoke that did not occur.
|
||||
let revokedIssuedPair = false;
|
||||
if (row.issuedRefreshTokenHash) {
|
||||
await revokeRefreshTokenByHash(row.issuedRefreshTokenHash);
|
||||
revokedIssuedPair = true;
|
||||
}
|
||||
await auditLog("auth.oidc.native.handoff_replay", {
|
||||
userId: row.userId,
|
||||
ipAddress: row.ipAddress,
|
||||
});
|
||||
return { status: "replayed", userId: row.userId, revokedIssuedPair };
|
||||
}
|
||||
|
||||
// Expiry: enforced at read. Burn the row so a lingering expired code has no
|
||||
// second life even before the sweep job reaps it.
|
||||
if (row.expiresAt.getTime() <= Date.now()) {
|
||||
await burnHandoff(row.id);
|
||||
return { status: "expired" };
|
||||
}
|
||||
|
||||
// PKCE: constant-time S256. A mismatch burns the code (single-presentation
|
||||
// posture) — no verifier oracle.
|
||||
if (!verifyPkceS256(codeVerifier, row.codeChallenge)) {
|
||||
await burnHandoff(row.id);
|
||||
return { status: "pkce_mismatch" };
|
||||
}
|
||||
|
||||
// Single-use consume — the same claim-once shape as `claimChallenge` /
|
||||
// refresh rotation. Two concurrent exchanges mint at most one bundle.
|
||||
const consumed = await prisma.oidcNativeHandoff.updateMany({
|
||||
where: { id: row.id, consumedAt: null },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
if (consumed.count === 0) {
|
||||
// Lost the race — the winner's pair is legitimately in flight; do NOT
|
||||
// revoke. A genuine later replay is caught by the consumed-at branch above.
|
||||
return { status: "race_lost" };
|
||||
}
|
||||
|
||||
return { status: "ok", userId: row.userId, handoffId: row.id };
|
||||
}
|
||||
|
||||
/** Guarded burn: consume a live row without issuing anything. */
|
||||
async function burnHandoff(id: string): Promise<void> {
|
||||
await prisma.oidcNativeHandoff.updateMany({
|
||||
where: { id, consumedAt: null },
|
||||
data: { consumedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the hash of the refresh token minted at exchange onto the consumed
|
||||
* handoff row, so a later replay can revoke exactly this family member. Called
|
||||
* after `finishLogin` succeeds. Best-effort: a failure here never fails the
|
||||
* login (the code is already single-use-consumed), it only forgoes the
|
||||
* replay-reach-back for this one row.
|
||||
*/
|
||||
export async function stampIssuedRefreshToken(
|
||||
handoffId: string,
|
||||
refreshToken: string,
|
||||
): Promise<void> {
|
||||
await prisma.oidcNativeHandoff.update({
|
||||
where: { id: handoffId },
|
||||
data: { issuedRefreshTokenHash: hashToken(refreshToken) },
|
||||
});
|
||||
}
|
||||
@@ -47,12 +47,15 @@ export function isOidcConfigured(): boolean {
|
||||
* Enforced server-side on every password/passkey auth route (`/api/auth/
|
||||
* login`, `/api/auth/register`, `/api/auth/passkey/{login-options,
|
||||
* login-verify}`) — the login page hiding those buttons is not the
|
||||
* boundary. There is currently no OIDC-compatible login flow for the
|
||||
* native iOS client (it authenticates via password/passkey only, see
|
||||
* `src/lib/auth/native-client.ts`), so turning this on locks the iOS app
|
||||
* out of authentication entirely until a native SSO flow exists. Documented
|
||||
* in `.env.production.example`; check before recommending this to an
|
||||
* operator running the iOS app.
|
||||
* boundary. The native iOS client signs in via the native OIDC SSO handoff
|
||||
* (`GET /api/auth/oidc/login?client=native` → `healthlog://oidc-callback` →
|
||||
* `POST /api/auth/oidc/native/token`, see
|
||||
* `src/lib/auth/oidc-native-handoff.ts`); the MFA-verify and refresh routes
|
||||
* that complete / rotate that login are deliberately NOT OIDC-only-blocked.
|
||||
* `OIDC_ONLY` therefore requires the iOS build that ships this flow — an older
|
||||
* password/passkey-only client is locked out. Documented in
|
||||
* `.env.production.example`; check the app version before recommending this to
|
||||
* an operator running the iOS app.
|
||||
*/
|
||||
export function isOidcOnly(): boolean {
|
||||
return isOidcConfigured() && envOrEmpty("OIDC_ONLY").toLowerCase() === "true";
|
||||
|
||||
@@ -245,6 +245,41 @@ export async function revokeBearerAccessToken(
|
||||
return apiResult.count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a refresh token identified by its stored hash (not the raw secret).
|
||||
*
|
||||
* Replay-containment reach-back for the native OIDC handoff: the handoff row
|
||||
* records only `hashToken(refreshToken)` of the pair it issued, never the raw
|
||||
* token, so a replay must revoke by hash. Same effect as `revokeRefreshToken`
|
||||
* — flip the refresh row and its paired access token — keyed by the hash the
|
||||
* caller already holds.
|
||||
*
|
||||
* Returns true when a live refresh row was revoked.
|
||||
*/
|
||||
export async function revokeRefreshTokenByHash(
|
||||
refreshTokenHash: string,
|
||||
): Promise<boolean> {
|
||||
const row = await prisma.refreshToken.findUnique({
|
||||
where: { tokenHash: refreshTokenHash },
|
||||
select: { accessTokenHash: true, revokedAt: true },
|
||||
});
|
||||
if (!row || row.revokedAt) return false;
|
||||
|
||||
const result = await prisma.refreshToken.updateMany({
|
||||
where: { tokenHash: refreshTokenHash, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (result.count === 0) return false;
|
||||
|
||||
if (row.accessTokenHash) {
|
||||
await prisma.apiToken.updateMany({
|
||||
where: { tokenHash: row.accessTokenHash, revoked: false },
|
||||
data: { revoked: true },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Revoke a specific refresh token (logout-on-device).
|
||||
* Also revokes the paired access token so a leaked access token cannot
|
||||
* outlive the refresh-token sibling that the user just killed. */
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
BATCH_COVERAGE_DAYS,
|
||||
computeBatchWindow,
|
||||
computeLocalBatchWindow,
|
||||
deriveBatchChartTypes,
|
||||
} from "@/lib/dashboard/batch-chart-types";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetConfig,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.30.9 — the dashboard batched-series prefetch key crux.
|
||||
*
|
||||
* `deriveBatchChartTypes` + `computeBatchWindow` are the SINGLE source of the
|
||||
* two moving parts of the `chartSeriesBatch(csv, from, to)` cache key. The RSC
|
||||
* prefetch (server) and the dashboard client both call them over the SAME
|
||||
* snapshot payload / profile window; if they ever disagree the dehydrated
|
||||
* slice is dead weight and the client refetches. These tests pin the ordered
|
||||
* type derivation, the resting-HR-vs-pulse pick, the profile-tz window, and
|
||||
* the byte-identical key both sides build.
|
||||
*/
|
||||
|
||||
/** Build a resolved-shaped layout with exactly the given widget visibility. */
|
||||
function layoutOf(visible: Record<string, boolean>): DashboardLayout {
|
||||
const widgets: DashboardWidgetConfig[] = Object.entries(visible).map(
|
||||
([id, v], i) =>
|
||||
({
|
||||
id,
|
||||
visible: v,
|
||||
order: i,
|
||||
}) as DashboardWidgetConfig,
|
||||
);
|
||||
return { version: 1, widgets };
|
||||
}
|
||||
|
||||
const ALL_VISIBLE = {
|
||||
weight: true,
|
||||
bp: true,
|
||||
pulse: true,
|
||||
bodyFat: true,
|
||||
steps: true,
|
||||
};
|
||||
|
||||
describe("deriveBatchChartTypes", () => {
|
||||
it("emits every visible type with data in insertion order", () => {
|
||||
const types = deriveBatchChartTypes(layoutOf(ALL_VISIBLE), {
|
||||
WEIGHT: { count: 5 },
|
||||
BLOOD_PRESSURE_SYS: { count: 3 },
|
||||
BLOOD_PRESSURE_DIA: { count: 3 },
|
||||
PULSE: { count: 9 },
|
||||
BODY_FAT: { count: 2 },
|
||||
ACTIVITY_STEPS: { count: 30 },
|
||||
});
|
||||
expect(types).toEqual([
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"ACTIVITY_STEPS",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers RESTING_HEART_RATE over PULSE when resting rows exist", () => {
|
||||
const types = deriveBatchChartTypes(layoutOf({ pulse: true }), {
|
||||
PULSE: { count: 40 },
|
||||
RESTING_HEART_RATE: { count: 12 },
|
||||
});
|
||||
expect(types).toEqual(["RESTING_HEART_RATE"]);
|
||||
});
|
||||
|
||||
it("falls back to PULSE when only raw pulse rows exist", () => {
|
||||
const types = deriveBatchChartTypes(layoutOf({ pulse: true }), {
|
||||
PULSE: { count: 40 },
|
||||
RESTING_HEART_RATE: { count: 0 },
|
||||
});
|
||||
expect(types).toEqual(["PULSE"]);
|
||||
});
|
||||
|
||||
it("shows the pulse chart on resting rows even with zero raw pulse", () => {
|
||||
const types = deriveBatchChartTypes(layoutOf({ pulse: true }), {
|
||||
RESTING_HEART_RATE: { count: 7 },
|
||||
});
|
||||
expect(types).toEqual(["RESTING_HEART_RATE"]);
|
||||
});
|
||||
|
||||
it("emits both BP legs when either sys or dia has data", () => {
|
||||
const types = deriveBatchChartTypes(layoutOf({ bp: true }), {
|
||||
BLOOD_PRESSURE_SYS: { count: 4 },
|
||||
BLOOD_PRESSURE_DIA: { count: 0 },
|
||||
});
|
||||
expect(types).toEqual(["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]);
|
||||
});
|
||||
|
||||
it("excludes a hidden chart even with data (visibility floor)", () => {
|
||||
const types = deriveBatchChartTypes(
|
||||
layoutOf({ weight: false, steps: true }),
|
||||
{ WEIGHT: { count: 5 }, ACTIVITY_STEPS: { count: 5 } },
|
||||
);
|
||||
expect(types).toEqual(["ACTIVITY_STEPS"]);
|
||||
});
|
||||
|
||||
it("excludes a visible chart with no data (count floor)", () => {
|
||||
const types = deriveBatchChartTypes(layoutOf(ALL_VISIBLE), {
|
||||
WEIGHT: { count: 0 },
|
||||
BODY_FAT: { count: 1 },
|
||||
});
|
||||
expect(types).toEqual(["BODY_FAT"]);
|
||||
});
|
||||
|
||||
it("returns an empty list when nothing is visible-with-data", () => {
|
||||
expect(deriveBatchChartTypes(layoutOf(ALL_VISIBLE), {})).toEqual([]);
|
||||
expect(deriveBatchChartTypes(layoutOf(ALL_VISIBLE), undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("is insensitive to summaries key order (stable insertion order out)", () => {
|
||||
const a = deriveBatchChartTypes(layoutOf(ALL_VISIBLE), {
|
||||
ACTIVITY_STEPS: { count: 1 },
|
||||
WEIGHT: { count: 1 },
|
||||
BODY_FAT: { count: 1 },
|
||||
});
|
||||
expect(a).toEqual(["WEIGHT", "BODY_FAT", "ACTIVITY_STEPS"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBatchWindow", () => {
|
||||
it("ends the window at profile-tz end-of-day and spans BATCH_COVERAGE_DAYS", () => {
|
||||
const now = new Date("2026-07-15T12:00:00.000Z"); // 08:00 EDT
|
||||
const win = computeBatchWindow(now, "America/New_York");
|
||||
// 2026-07-15 23:59:59.999 -04:00 === 2026-07-16T03:59:59.999Z
|
||||
expect(win.to).toBe("2026-07-16T03:59:59.999Z");
|
||||
expect(new Date(win.to).getTime() - new Date(win.from).getTime()).toBe(
|
||||
BATCH_COVERAGE_DAYS * 86_400_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a half-hour-offset zone east of UTC", () => {
|
||||
const now = new Date("2026-07-15T20:00:00.000Z"); // 01:30 next day IST
|
||||
const win = computeBatchWindow(now, "Asia/Kolkata");
|
||||
// local day 2026-07-16, end 23:59:59.999 +05:30 === 2026-07-16T18:29:59.999Z
|
||||
expect(win.to).toBe("2026-07-16T18:29:59.999Z");
|
||||
});
|
||||
|
||||
it("computes a plain UTC end-of-day", () => {
|
||||
const now = new Date("2026-07-15T12:00:00.000Z");
|
||||
const win = computeBatchWindow(now, "UTC");
|
||||
expect(win.to).toBe("2026-07-15T23:59:59.999Z");
|
||||
});
|
||||
|
||||
it("always includes today (to >= now) regardless of zone", () => {
|
||||
const now = new Date("2026-07-15T23:30:00.000Z");
|
||||
for (const tz of ["UTC", "America/New_York", "Pacific/Auckland"]) {
|
||||
const win = computeBatchWindow(now, tz);
|
||||
expect(new Date(win.to).getTime()).toBeGreaterThanOrEqual(now.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it("is deterministic for the same now + tz (server prop === client adoption)", () => {
|
||||
const now = new Date("2026-07-15T12:00:00.000Z");
|
||||
expect(computeBatchWindow(now, "Europe/Berlin")).toEqual(
|
||||
computeBatchWindow(now, "Europe/Berlin"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back safely on an unusable timezone id", () => {
|
||||
const now = new Date("2026-07-15T12:00:00.000Z");
|
||||
const win = computeBatchWindow(now, "Not/AZone");
|
||||
expect(typeof win.to).toBe("string");
|
||||
expect(new Date(win.to).getTime() - new Date(win.from).getTime()).toBe(
|
||||
BATCH_COVERAGE_DAYS * 86_400_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLocalBatchWindow", () => {
|
||||
it("matches the legacy browser-local end-of-day computation", () => {
|
||||
const now = new Date("2026-07-15T09:00:00.000Z");
|
||||
const win = computeLocalBatchWindow(now);
|
||||
const to = new Date(now);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000);
|
||||
expect(win).toEqual({ from: from.toISOString(), to: to.toISOString() });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the batched-series key crux — server key === client key", () => {
|
||||
// A fixture snapshot payload: the SAME object the RSC dehydrates and the
|
||||
// client hydrates. Server and client each run the shared helpers over it.
|
||||
const snapshot = {
|
||||
layout: {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, order: 0 },
|
||||
{ id: "bp", visible: true, order: 1 },
|
||||
{ id: "pulse", visible: true, order: 2 },
|
||||
{ id: "bodyFat", visible: false, order: 3 },
|
||||
{ id: "steps", visible: true, order: 4 },
|
||||
],
|
||||
},
|
||||
tiles: {
|
||||
summaries: {
|
||||
WEIGHT: { count: 12 },
|
||||
BLOOD_PRESSURE_SYS: { count: 6 },
|
||||
BLOOD_PRESSURE_DIA: { count: 6 },
|
||||
RESTING_HEART_RATE: { count: 4 },
|
||||
PULSE: { count: 20 },
|
||||
BODY_FAT: { count: 3 },
|
||||
ACTIVITY_STEPS: { count: 40 },
|
||||
},
|
||||
},
|
||||
};
|
||||
const now = new Date("2026-07-15T12:00:00.000Z");
|
||||
const tz = "America/New_York";
|
||||
|
||||
it("derives the identical chartSeriesBatch key on both sides", () => {
|
||||
// Server side: resolve the round-tripped snapshot layout + summaries.
|
||||
const wire = JSON.parse(JSON.stringify(snapshot));
|
||||
const serverTypes = deriveBatchChartTypes(
|
||||
resolveDashboardLayout(wire.layout),
|
||||
wire.tiles.summaries,
|
||||
);
|
||||
const serverWindow = computeBatchWindow(now, tz);
|
||||
const serverKey = queryKeys.chartSeriesBatch(
|
||||
serverTypes.join(","),
|
||||
serverWindow.from,
|
||||
serverWindow.to,
|
||||
);
|
||||
|
||||
// Client side: same helpers over the same (live) snapshot, adopting the
|
||||
// server window verbatim as the `batchWindow` prop.
|
||||
const clientTypes = deriveBatchChartTypes(
|
||||
resolveDashboardLayout(snapshot.layout),
|
||||
snapshot.tiles.summaries,
|
||||
);
|
||||
const clientWindow = serverWindow; // threaded as the RSC prop
|
||||
const clientKey = queryKeys.chartSeriesBatch(
|
||||
clientTypes.join(","),
|
||||
clientWindow.from,
|
||||
clientWindow.to,
|
||||
);
|
||||
|
||||
expect(serverKey).toEqual(clientKey);
|
||||
// Pin the concrete shape too (bodyFat hidden → dropped; resting HR wins).
|
||||
expect(serverKey).toEqual([
|
||||
"chart-data",
|
||||
"series-batch",
|
||||
"WEIGHT,BLOOD_PRESSURE_SYS,BLOOD_PRESSURE_DIA,RESTING_HEART_RATE,ACTIVITY_STEPS",
|
||||
"2026-06-15T03:59:59.999Z",
|
||||
"2026-07-16T03:59:59.999Z",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* v1.30.9 — client-safe pure helpers shared by the dashboard's batched
|
||||
* chart-series read on BOTH sides of the render boundary.
|
||||
*
|
||||
* The dashboard fetches every visible non-sleep chart series in ONE batched
|
||||
* request keyed by `queryKeys.chartSeriesBatch(typesCsv, fromIso, toIso)`.
|
||||
* The RSC (`src/app/page.tsx`) prefetches that same batch into the dehydrated
|
||||
* TanStack cache; the client (`src/app/page-client.tsx`) reads it back on
|
||||
* mount. For the prefetch to land (not silently no-op into a client refetch)
|
||||
* the server-built key must be byte-identical to the client-built key.
|
||||
*
|
||||
* The key has two moving parts — the CSV type list and the ISO window — and
|
||||
* this module is the SINGLE source of truth for both, so server and client
|
||||
* cannot drift:
|
||||
*
|
||||
* - `deriveBatchChartTypes(layout, summaries)` — both sides call this over the
|
||||
* SAME snapshot payload (the server dehydrates the very snapshot the client
|
||||
* reads), so the ordered type list, and therefore the CSV, is identical by
|
||||
* construction.
|
||||
* - `computeBatchWindow(now, timezone)` — the server computes the window ONCE
|
||||
* from the profile timezone and threads it to the client as an RSC prop
|
||||
* (`batchWindow`). The client adopts the prop verbatim, so no independent
|
||||
* browser-vs-container computation can disagree by a millisecond or a zone.
|
||||
* `computeLocalBatchWindow()` is the browser-local fallback for the
|
||||
* prefetch-off / legacy / fail-soft paths where no prop arrives.
|
||||
*
|
||||
* Import-safe from the server RSC and the client component alike: pure, no
|
||||
* Prisma / db / `node:*` imports (only the client-safe `@/lib/tz/format`
|
||||
* Intl helpers and the layout type).
|
||||
*/
|
||||
import type { DashboardLayout } from "@/lib/dashboard-layout";
|
||||
import {
|
||||
DEFAULT_TIMEZONE,
|
||||
isValidTimezone,
|
||||
tzOffsetMinutes,
|
||||
userDayKey,
|
||||
} from "@/lib/tz/format";
|
||||
|
||||
/**
|
||||
* Day-span the batched dashboard series (`series-batch`) fetches. Threaded to
|
||||
* every chart as `preloadedCoverageDays` so a chart reads the batched slice
|
||||
* ONLY for a range tab whose window fits within it (7 / 30); the 90 / All tabs
|
||||
* exceed it and self-fetch the wider window. Hoisted here so page.tsx and
|
||||
* page-client share ONE constant and the window they build stays in lockstep.
|
||||
*/
|
||||
export const BATCH_COVERAGE_DAYS = 31;
|
||||
|
||||
/** Minimal structural view of a tile summary — only the data-floor count. */
|
||||
type SummaryCount = { count?: number | null } | null | undefined;
|
||||
|
||||
/**
|
||||
* Derive the ordered set of measurement types the dashboard batches into one
|
||||
* series request, from widget CHART visibility (`layout`) AND the per-type
|
||||
* data floors (`summaries[TYPE].count > 0`).
|
||||
*
|
||||
* This is the single implementation of the block that used to live inline in
|
||||
* `page-client.tsx` — preserving insertion order (weight → BP sys → BP dia →
|
||||
* pulse-or-resting → bodyFat → steps), the `RESTING_HEART_RATE`-vs-`PULSE`
|
||||
* pick, and the visibility + count-floor gates. Note the chart gate does NOT
|
||||
* carry the workouts-module flag the steps *tile* has: this mirrors the
|
||||
* chart-row logic exactly, not the strip-tile logic.
|
||||
*/
|
||||
export function deriveBatchChartTypes(
|
||||
layout: DashboardLayout,
|
||||
summaries: Record<string, SummaryCount> | undefined,
|
||||
): string[] {
|
||||
const isChartVisible = (id: string): boolean =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
|
||||
const count = (type: string): number => summaries?.[type]?.count ?? 0;
|
||||
|
||||
const hasWeight = count("WEIGHT") > 0;
|
||||
const hasBp =
|
||||
count("BLOOD_PRESSURE_SYS") > 0 || count("BLOOD_PRESSURE_DIA") > 0;
|
||||
const hasRestingHr = count("RESTING_HEART_RATE") > 0;
|
||||
const hasPulse = count("PULSE") > 0 || hasRestingHr;
|
||||
const hasBodyFat = count("BODY_FAT") > 0;
|
||||
const hasSteps = count("ACTIVITY_STEPS") > 0;
|
||||
|
||||
const types: string[] = [];
|
||||
if (isChartVisible("weight") && hasWeight) types.push("WEIGHT");
|
||||
if (isChartVisible("bp") && hasBp) {
|
||||
types.push("BLOOD_PRESSURE_SYS");
|
||||
types.push("BLOOD_PRESSURE_DIA");
|
||||
}
|
||||
if (isChartVisible("pulse") && hasPulse) {
|
||||
types.push(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE");
|
||||
}
|
||||
if (isChartVisible("bodyFat") && hasBodyFat) types.push("BODY_FAT");
|
||||
if (isChartVisible("steps") && hasSteps) types.push("ACTIVITY_STEPS");
|
||||
return types;
|
||||
}
|
||||
|
||||
/** ISO window (`from`/`to` instants) bounding the batched series slice. */
|
||||
export interface BatchWindow {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* End-of-day of `now` in `timezone`, as a UTC instant. Falls back to the
|
||||
* project default zone for an unusable IANA id. Two-step offset refine so the
|
||||
* rare DST edge (a transition between the as-if-UTC guess and the true local
|
||||
* instant) still resolves to the right offset.
|
||||
*/
|
||||
function endOfLocalDayUtc(now: Date, timezone: string): Date {
|
||||
const safeTz = isValidTimezone(timezone) ? timezone : DEFAULT_TIMEZONE;
|
||||
const dayKey = userDayKey(now, safeTz); // YYYY-MM-DD in the zone
|
||||
const [year, month, day] = dayKey.split("-").map(Number);
|
||||
// Wall-clock 23:59:59.999 treated as-if-UTC, then shifted by the zone offset.
|
||||
const asIfUtc = Date.UTC(year, month - 1, day, 23, 59, 59, 999);
|
||||
const offset1 = tzOffsetMinutes(new Date(asIfUtc), safeTz);
|
||||
const corrected = new Date(asIfUtc - offset1 * 60_000);
|
||||
const offset2 = tzOffsetMinutes(corrected, safeTz);
|
||||
return new Date(asIfUtc - offset2 * 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The batched-series window computed from the PROFILE timezone. End-of-current-
|
||||
* day in any zone (derived from `now`) is always ≥ `now`, so today's rows are
|
||||
* always included regardless of the container's UTC clock. Used server-side and
|
||||
* threaded to the client as the `batchWindow` prop so the key matches exactly.
|
||||
*/
|
||||
export function computeBatchWindow(now: Date, timezone: string): BatchWindow {
|
||||
const to = endOfLocalDayUtc(now, timezone);
|
||||
const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-local fallback window — the pre-prefetch client computation, kept
|
||||
* verbatim for the prefetch-off / legacy / fail-soft paths where no server
|
||||
* `batchWindow` prop arrives. Local end-of-day, minus `BATCH_COVERAGE_DAYS`.
|
||||
*/
|
||||
export function computeLocalBatchWindow(now: Date = new Date()): BatchWindow {
|
||||
const to = new Date(now);
|
||||
to.setHours(23, 59, 59, 999);
|
||||
const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000);
|
||||
return { from: from.toISOString(), to: to.toISOString() };
|
||||
}
|
||||
@@ -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 ─────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.30.x — daily cleanup for the `oidc_native_handoffs` table.
|
||||
*
|
||||
* Mirrors the OAuth-state sweeps (`whoop-oauth-state-cleanup`). Native OIDC
|
||||
* handoff codes are single-use + 90-second-lived: every exchange consumes its
|
||||
* row (`consumedAt`), and expiry is enforced at read, so a row only lingers
|
||||
* when the app never came back to exchange the code. Once `expiresAt` passes
|
||||
* the row is dead weight — deleting expired rows (consumed or not) keeps the
|
||||
* table and its `expires_at` index tight.
|
||||
*
|
||||
* Idempotent: a re-run within the same window matches zero rows the second time
|
||||
* because the first pass deleted everything older than `now()`.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export async function cleanupExpiredOidcNativeHandoffs(
|
||||
prisma: PrismaClient,
|
||||
now: Date = new Date(),
|
||||
): Promise<number> {
|
||||
const { count } = await prisma.oidcNativeHandoff.deleteMany({
|
||||
where: { expiresAt: { lt: now } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { cleanupExpiredMcpTokens } from "@/lib/jobs/mcp-token-cleanup";
|
||||
import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { cleanupOldCoachMessages } from "@/lib/jobs/coach-message-cleanup";
|
||||
import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup";
|
||||
import { cleanupExpiredOidcNativeHandoffs } from "@/lib/jobs/oidc-handoff-cleanup";
|
||||
import {
|
||||
cleanupExpiredMeasurementTombstones,
|
||||
cleanupExpiredMoodTombstones,
|
||||
@@ -265,6 +266,27 @@ export async function handleWithingsOAuthStateCleanup(
|
||||
});
|
||||
}
|
||||
|
||||
export interface OidcNativeHandoffCleanupPayload {
|
||||
triggeredAt?: string;
|
||||
}
|
||||
|
||||
export async function handleOidcNativeHandoffCleanup(
|
||||
jobs: Job<OidcNativeHandoffCleanupPayload>[],
|
||||
) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.oidc_native_handoff_cleanup", async (evt) => {
|
||||
const p = getWorkerPrisma();
|
||||
try {
|
||||
const deleted = await cleanupExpiredOidcNativeHandoffs(p);
|
||||
evt.addMeta("oidc_native_handoff_cleanup_deleted", deleted);
|
||||
} catch (err) {
|
||||
// The handoff flow tolerates a stale row for an extra day (expiry is
|
||||
// enforced at read) — log + carry on so the boss queue doesn't retry-loop.
|
||||
evt.addWarning(`oidc-native-handoff-cleanup failed: ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface WhoopOAuthStateCleanupPayload {
|
||||
triggeredAt?: string;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ import {
|
||||
handleWithingsOAuthStateCleanup,
|
||||
WhoopOAuthStateCleanupPayload,
|
||||
handleWhoopOAuthStateCleanup,
|
||||
OidcNativeHandoffCleanupPayload,
|
||||
handleOidcNativeHandoffCleanup,
|
||||
} from "./cleanup-handlers";
|
||||
import { NightscoutSyncPayload, handleNightscoutSync } from "./nightscout-sync";
|
||||
import { PolarSyncPayload, handlePolarSync } from "./polar-sync";
|
||||
@@ -219,6 +221,14 @@ const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15
|
||||
// schedule silently no-ops (the v1.4.37 dead-queue class).
|
||||
const STRAVA_SYNC_QUEUE = "strava-sync";
|
||||
const STRAVA_SYNC_CRON = "17 * * * *"; // every hour at :17
|
||||
// v1.30.x — daily sweep for the native OIDC handoff ledger. Handoff codes are
|
||||
// single-use + 90s-lived and expiry is enforced at read, so this is pure
|
||||
// hygiene: it reaps rows the app never came back to exchange. Slots at 03:28,
|
||||
// next to the Google Health OAuth-state sweep (03:26), inside the maintenance
|
||||
// window. The queue MUST be registered in `allQueues` below or pg-boss never
|
||||
// provisions it and the schedule silently no-ops (the v1.4.37 dead-queue class).
|
||||
const OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE = "oidc-native-handoff-cleanup";
|
||||
const OIDC_NATIVE_HANDOFF_CLEANUP_CRON = "28 3 * * *";
|
||||
|
||||
// pg-boss v12 requires explicit queue creation before scheduling. Every queue
|
||||
// MUST be registered here or pg-boss never provisions it and both the webhook
|
||||
@@ -273,6 +283,8 @@ const allQueues = [
|
||||
// v1.28.x — Strava OAuth poll sync + self-converging boot backfill.
|
||||
STRAVA_SYNC_QUEUE,
|
||||
STRAVA_BACKFILL_QUEUE,
|
||||
// v1.30.x — daily sweep for the native OIDC handoff ledger.
|
||||
OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
@@ -310,6 +322,8 @@ const schedules: ScheduleEntry[] = [
|
||||
[OURA_SYNC_QUEUE, OURA_SYNC_CRON],
|
||||
// v1.28.x — hourly Strava (:17) OAuth poll.
|
||||
[STRAVA_SYNC_QUEUE, STRAVA_SYNC_CRON],
|
||||
// v1.30.x — daily 03:28 Europe/Berlin prune for expired native OIDC handoffs.
|
||||
[OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE, OIDC_NATIVE_HANDOFF_CLEANUP_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -386,6 +400,12 @@ export async function registerIntegrationSyncQueues(
|
||||
{ localConcurrency: 1 },
|
||||
handleWhoopOAuthStateCleanup,
|
||||
);
|
||||
// v1.30.x — daily sweep for the native OIDC handoff ledger.
|
||||
await boss.work<OidcNativeHandoffCleanupPayload>(
|
||||
OIDC_NATIVE_HANDOFF_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleOidcNativeHandoffCleanup,
|
||||
);
|
||||
// v1.12.0 — Fitbit poll-sync (cron full-iteration; no webhook). Serial
|
||||
// concurrency so a backfill-heavy tick never crowds the request pool.
|
||||
await boss.work<FitbitSyncPayload>(
|
||||
|
||||
@@ -90,6 +90,19 @@ describe("isAggregatedBucketExternalId", () => {
|
||||
])("returns false for a non-bucket externalId %s", (id) => {
|
||||
expect(isAggregatedBucketExternalId(id as string | null)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// v1.30.8 — these PARSE via V8's silent rollover but are NOT canonical, so
|
||||
// they'd key a row the canonical (toISOString) client never emits → an
|
||||
// un-mergeable duplicate + wrong-day placement. Must be rejected.
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T24:00:00.000Z", // → 07-19T00
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-04-31T14:30:00.000Z", // → 05-01
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-02-29T14:30:00.000Z", // non-leap → 03-01
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-12-31T24:00:00.000Z", // → 2027-01-01
|
||||
])("rejects a non-canonical rollover instant %s", (id) => {
|
||||
expect(isAggregatedBucketExternalId(id)).toBe(false);
|
||||
expect(parseAggregatedBucketStart(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetsAggregatedBucket", () => {
|
||||
|
||||
@@ -897,7 +897,14 @@ export function isAggregatedBucketExternalId(
|
||||
const suffix = externalId.slice(prefix.length);
|
||||
if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return false;
|
||||
const parsed = new Date(suffix);
|
||||
return !Number.isNaN(parsed.getTime());
|
||||
if (Number.isNaN(parsed.getTime())) return false;
|
||||
// Reject a non-canonical instant that PARSES via V8's silent rollover
|
||||
// (`T24:00`, `2026-04-31`, a non-leap `02-29`) — it would key a row under
|
||||
// a literal string the canonical client (`heartRateBucketExternalId`, which
|
||||
// mints via `toISOString()`) never emits, so the two would never collapse
|
||||
// on the overwrite key. Round-trip equality pins the suffix to the one
|
||||
// canonical form.
|
||||
return parsed.toISOString() === suffix;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -937,7 +944,10 @@ export function parseAggregatedBucketStart(
|
||||
const suffix = externalId.slice(prefix.length);
|
||||
if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return null;
|
||||
const parsed = new Date(suffix);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
// Canonical-form gate (mirrors `isAggregatedBucketExternalId`): a rolled-over
|
||||
// instant would place the bucket on the wrong local day.
|
||||
return parsed.toISOString() === suffix ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* v1.30.9 — shared core of the batched dashboard daily-series read.
|
||||
*
|
||||
* Extracted from `GET /api/measurements/series-batch` so the route AND the
|
||||
* RSC dashboard prefetch (`src/app/page.tsx`) call ONE code path instead of
|
||||
* one HTTP-to-self hop. Behaviour is byte-identical to the pre-extraction
|
||||
* route body: load the source-priority ladder ONCE, fan out per-type through
|
||||
* the shared `readDailySeries` reader under a `p-limit(4)` cap, and isolate a
|
||||
* single type's transient DB reject to an empty slice for THAT type only
|
||||
* (never a 500 that blanks every chart in the row).
|
||||
*/
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
readDailySeries,
|
||||
type DailySeriesRow,
|
||||
} from "@/lib/measurements/daily-series-read";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Concurrency cap for the per-type rollup reads. Mirrors the W-POOL
|
||||
* `p-limit(4)` discipline applied across the analytics fan-out so a wide
|
||||
* dashboard never packs the shared Prisma pool.
|
||||
*/
|
||||
export const SERIES_BATCH_CONCURRENCY = 4;
|
||||
|
||||
export interface SeriesBatchResult {
|
||||
/** Per-type daily series, keyed by `MeasurementType` — the wire shape. */
|
||||
series: Record<string, DailySeriesRow[]>;
|
||||
/** How many types degraded to an empty slice on a transient reject. */
|
||||
degradedTypes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every requested type's rollup-backed daily series for `[from, to]`.
|
||||
* De-dupes the type list (a caller repeating a type doesn't double-read it)
|
||||
* while preserving order.
|
||||
*/
|
||||
export async function readSeriesBatch(
|
||||
userId: string,
|
||||
types: readonly MeasurementType[],
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<SeriesBatchResult> {
|
||||
const uniqueTypes = Array.from(new Set(types)) as MeasurementType[];
|
||||
|
||||
// Load the source-priority ladder ONCE and thread it across every type so
|
||||
// the batched reader doesn't re-query it per type.
|
||||
const priorityJson = await loadUserSourcePriority(userId);
|
||||
|
||||
const limit = pLimit(SERIES_BATCH_CONCURRENCY);
|
||||
let degradedTypes = 0;
|
||||
const entries = await Promise.all(
|
||||
uniqueTypes.map((type) =>
|
||||
limit(async (): Promise<readonly [MeasurementType, DailySeriesRow[]]> => {
|
||||
try {
|
||||
const rows = await readDailySeries({
|
||||
userId,
|
||||
type,
|
||||
from,
|
||||
to,
|
||||
priorityJson,
|
||||
});
|
||||
return [type, rows] as const;
|
||||
} catch {
|
||||
degradedTypes += 1;
|
||||
return [type, []] as const;
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const series: Record<string, DailySeriesRow[]> = {};
|
||||
for (const [type, rows] of entries) {
|
||||
series[type] = rows;
|
||||
}
|
||||
|
||||
return { series, degradedTypes };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mfaWebauthnLoginOptionsSchema,
|
||||
mfaWebauthnLoginVerifySchema,
|
||||
} from "@/lib/validations/mfa";
|
||||
import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import { dataEnvelope, stdResponses, errorEnvelope } from "./shared";
|
||||
|
||||
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
|
||||
@@ -354,6 +355,33 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/oidc/native/token": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Exchange a native OIDC handoff code for the token bundle",
|
||||
description:
|
||||
"The cookie-less native leg of the OIDC SSO flow. The iOS app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` inside an `ASWebAuthenticationSession`; the callback returns a one-time handoff code on `healthlog://oidc-callback?code=hlh_…` (or an `mfa_ticket` when the account has a second factor, completed at /api/auth/mfa/verify). This endpoint exchanges the code + its PKCE `codeVerifier` for the SAME native bundle password login issues.\n\n" +
|
||||
"Requires the native transport (no cookie, non-browser UA) — a browser is rejected. The code is single-use and expires in ~90 seconds; a replay of a consumed code revokes the pair the first exchange issued. A single generic 401 covers every invalid/expired/used/PKCE-mismatch case.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: oidcNativeTokenSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Handoff accepted — native access + refresh bundle.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
accessRefreshBundle,
|
||||
"OidcNativeTokenResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/mfa/totp/setup": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -65,6 +65,14 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB
|
||||
* - `["user","dashboardWidgets"]` — the resolved widget layout the snapshot
|
||||
* seeds, matched as an exact tuple so the rest of the overloaded `["user", …]`
|
||||
* head (profile, AI provider, insights layout, thresholds) stays off disk.
|
||||
*
|
||||
* INVARIANT (bound to the dashboard hero): `["daily", …]` — the Today digest —
|
||||
* is deliberately NOT here. The hero's data-first render gate in
|
||||
* `src/app/page-client.tsx` is hydration-safe only while the digest's first-
|
||||
* render data source is the server dehydration boundary alone, never a client-
|
||||
* only IndexedDB restore. Adding a `daily` head here would let a restored
|
||||
* digest paint the hero on the client while the server rendered a skeleton — a
|
||||
* React #418 text mismatch. Do not add it without revisiting that gate.
|
||||
*/
|
||||
const PERSIST_ALLOWLIST_HEADS = ["dashboard", "chart-data"] as const;
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ function formatIsoWithOffset(date: Date, tz: string): string {
|
||||
* `Intl.DateTimeFormat` does — we reconstruct the offset from the
|
||||
* difference between the wall-clock parts and the UTC parts.
|
||||
*/
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
export function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const fmt = getDateTimeFormat("en-CA", tz, WALL_CLOCK_PARTS_OPTIONS);
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): number =>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Request schema for the native OIDC token exchange
|
||||
* (`POST /api/auth/oidc/native/token`). Single-sourced here so the runtime
|
||||
* `safeParse` and the OpenAPI registry (`src/lib/openapi/routes/auth.ts`)
|
||||
* describe the identical wire contract.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* `{ code, codeVerifier }`.
|
||||
*
|
||||
* - `code` is the opaque handoff code from the `healthlog://oidc-callback?code=`
|
||||
* redirect: the `hlh_` prefix + the 43-char base64url body of a 256-bit
|
||||
* CSPRNG value. Pinned exactly so only well-formed codes reach the hash
|
||||
* lookup.
|
||||
* - `codeVerifier` is the app's PKCE verifier (RFC 7636 §4.1 — 43–128 chars of
|
||||
* the unreserved set); the server checks `S256(codeVerifier)` against the
|
||||
* challenge bound at mint (constant-time) before consuming the code.
|
||||
*/
|
||||
export const oidcNativeTokenSchema = z
|
||||
.object({
|
||||
code: z
|
||||
.string()
|
||||
.regex(/^hlh_[A-Za-z0-9_-]{43}$/, "Malformed handoff code.")
|
||||
.describe(
|
||||
"One-time handoff code from the healthlog://oidc-callback?code= redirect.",
|
||||
),
|
||||
codeVerifier: z
|
||||
.string()
|
||||
.regex(
|
||||
/^[A-Za-z0-9\-._~]{43,128}$/,
|
||||
"The PKCE code_verifier must be 43–128 chars of the RFC 7636 unreserved set.",
|
||||
)
|
||||
.describe("The app's PKCE code_verifier (RFC 7636)."),
|
||||
})
|
||||
.meta({
|
||||
id: "OidcNativeTokenRequest",
|
||||
description:
|
||||
"Exchange a one-time native OIDC handoff code (+ its PKCE verifier) for the standard native token bundle.",
|
||||
});
|
||||
|
||||
export type OidcNativeTokenRequest = z.infer<typeof oidcNativeTokenSchema>;
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise the route geometry to a GPX 1.1 document, entirely
|
||||
* client-side over data already loaded — zero server work, zero egress.
|
||||
* Timestamps (when present, parallel to the coordinates) are emitted so
|
||||
* the exported track round-trips into any GPX consumer.
|
||||
*/
|
||||
export function buildGpxDocument(params: {
|
||||
coordinates: readonly RouteCoordinate[];
|
||||
timestamps?: readonly string[] | null;
|
||||
sportType: string;
|
||||
startedAt: string;
|
||||
}): string {
|
||||
const { coordinates, timestamps, sportType, startedAt } = params;
|
||||
const segments: string[] = [];
|
||||
coordinates.forEach((c, i) => {
|
||||
if (!Array.isArray(c) || c.length < 2) return;
|
||||
const lon = c[0];
|
||||
const lat = c[1];
|
||||
if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) return;
|
||||
const parts = [`<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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user