Compare commits

..
21 Commits
Author SHA1 Message Date
Marc-André Bombeck 3743a46a40 Merge pull request #536 from MBombeck/release/v1.30.12
release/v1.30.12 — index the dashboard medication reads
2026-07-18 18:27:30 +02:00
Marc-André Bombeck 9e2c445ad7 chore(release): v1.30.12 — index the dashboard medication reads 2026-07-18 18:04:41 +02:00
Marc-André Bombeck b75771a445 perf(medications): index the dashboard meds-today read shapes
Two additive indexes on medication_intake_events: (user_id, scheduled_for)
for the sibling reads that filter without medication_id, and
(user_id, medication_id, taken_at) for the per-medication last-taken
group-by that scanned the whole intake history on every dashboard load.
Additive migration 0253, no data change, no behaviour change.
2026-07-18 18:04:26 +02:00
Marc-André Bombeck 64e7c57734 Merge pull request #535 from MBombeck/release/v1.30.11
release/v1.30.11 — native OIDC SSO sign-in
2026-07-18 17:31:32 +02:00
Marc-André Bombeck c2420d7f02 chore(release): v1.30.11 — native OIDC SSO sign-in 2026-07-18 17:03:35 +02:00
Marc-André Bombeck 16fd4966d0 fix(auth): force the native-bundle branch on the OIDC token exchange
Adversarial review found the transport gate (isCookielessNativeCaller) was
broader than finishLogin's bearer predicate: a blank-UA caller with no
X-Client-Type could pass it and fall into the cookie branch, receiving a
Set-Cookie session instead of the token bundle (inert, not exploitable, but
the endpoint's own invariant was false). Also require isNativeClientRequest
so the cookie path is structurally unreachable here, and report the actual
revoke outcome on a replay so the wide event never overclaims.
2026-07-18 17:02:45 +02:00
Marc-André Bombeck ada147522f test(auth): cover the native OIDC SSO handoff
Unit-test the handoff library (mint stores only the hash + binds the
challenge + 90s TTL; consume covers not-found / replay-with-revoke /
expiry-burn / PKCE-burn / atomic consume / lost-race), the
token-exchange route (rate limit, transport gate, one identical generic
401 across every invalid-code class, success with issued-hash stamp +
audit, and OIDC_ONLY does not block it), the callback native branches
(handoff mint + scheme redirect with no session, MFA ticket on the
scheme with no session/cookie, post-identity + IdP errors to the
scheme, CSRF web-fallback), the login native-param handling, and the
by-hash refresh-token revoke used for replay containment.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck e6d9cc229a feat(auth): native OIDC SSO session handoff for the iOS client
The web OIDC flow ends in a session cookie the Bearer-transport native
client cannot consume, so on an OIDC_ONLY instance the iOS app had no
sign-in. Add a native leg: the app opens the login URL with
client=native and its own PKCE S256 challenge inside an in-app browser;
the callback resolves identity exactly as before, then mints a one-time
handoff code and 302s to the fixed compiled-in scheme
healthlog://oidc-callback (or hands back the existing MFA ticket when
the account has a second factor). The app exchanges the code plus its
PKCE verifier at POST /api/auth/oidc/native/token for the standard
native token bundle.

The return address is a compile-time constant — no redirect_uri
parameter and no env allowlist, so the open-redirect / scheme-hijack
class is removed by construction. The native flag and app challenge
travel only inside the AES-256-GCM state blob, so a network attacker or
the IdP cannot flip a web login into a native one. The handoff code is
256-bit CSPRNG, HMAC-hashed at rest, 90-second TTL, single-use via a
guarded consume; a failed PKCE check burns it. A replay of a consumed
code revokes exactly the pair the first exchange issued. The token pair
never rides a URL; only the opaque code or the MFA ticket do. The SSO
login can never satisfy requireFreshMfa step-up — the transport is
Bearer and step-up is cookie-only.

Adds the OidcNativeHandoff model (migration 0252), the token-exchange
route, a daily cleanup sweep, and the OpenAPI entry.
2026-07-18 16:50:56 +02:00
Marc-André Bombeck 74891e95f4 Merge pull request #534 from MBombeck/release/v1.30.10
release/v1.30.10 — localized correlation insights + richer workout detail
2026-07-18 16:25:51 +02:00
Marc-André Bombeck 57e855eeb3 chore(release): v1.30.10 — localized correlation insights + richer workout detail 2026-07-18 16:13:53 +02:00
Marc-André Bombeck dfa8675a89 chore(insights): drop the now-unused ENVIRONMENT_CHANNEL_KEYS const 2026-07-18 16:09:28 +02:00
Marc-André Bombeck 906acee978 chore(insights): drop the now-unused environment-field label helpers
The correlation-narrative localisation replaced the env-channel narration
labels (which read through getEnvironmentField().narrationLabel) with the
localised insights.correlation.channel.ENV_* keys, leaving these two helpers
dead.
2026-07-18 16:08:29 +02:00
Marc-André Bombeck e61fe42bd1 feat(workouts): enrich the per-workout detail view (#67)
The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.

- HR curve: one server path with strict provenance — stored
  WorkoutSamples series first, a raw-PULSE window reconstruction second
  (quality-gated so sparse data hides rather than lies), nothing third.
  Adaptive bucketing keeps on-screen density constant across a 20-minute
  HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
  own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
  cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
  aspect-clamped viewBox, RDP downsampling, start/end markers, and an
  optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
  the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
  a reserved insight seam) behind an opt-in compact=1 param that drops
  the raw sample/timestamp blobs for the web client; without the param
  the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
  the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
2026-07-18 16:05:17 +02:00
Marc-André BombeckandClaude Opus 4.8 04320c415d fix(insights): localise correlation narratives across all six locales
The generated-insight narrative layer built its user-facing sentences by
string interpolation and never routed through the i18n bundle, so a
non-English UI rendered English text spliced into localised copy — e.g.
"Higher activity steps tends to go with lower next-day weight" surfacing
mid-German on the Weight insight. Because those strings bypassed t(), the
locale-integrity guard could not catch them.

Route every correlation narrative through the server translator:

- Correlation discovery (daily lagged pairs + labs-vs-outcome) now takes
  the reader's locale, resolved from the correlations route the same way
  other insight generators resolve it. Sentence templates, the direction
  word, and every channel label live under a new insights.correlation.*
  namespace, translated in de/en/es/fr/it/pl. The three fixed hypotheses
  (BP-compliance, mood-pulse, weight-weekday) and their weekday labels are
  localised the same way; the analytics cache key now includes the locale
  so a language switch cannot serve another locale's cached text.
- Metric labels resolve to stable localised channel labels; the never-
  causal framing ("not a cause" / "an association, never a cause") and the
  faint/moderate/high hedging tiers are preserved in every language.

English output is byte-for-byte unchanged. Adds a regression test pinning
German narration and asserting it is not the English string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018V8V9qapgHi7fhgtMJ8SkM
2026-07-18 16:05:17 +02:00
Marc-André Bombeck 36f73233b0 Merge pull request #533 from MBombeck/release/v1.30.9
release/v1.30.9 — faster dashboard first load
2026-07-18 15:04:43 +02:00
Marc-André Bombeck f3d92520d1 chore(release): v1.30.9 — faster dashboard first load 2026-07-18 14:43:57 +02:00
Marc-André Bombeck 5c60558d4f perf(dashboard): gate the profile-tz batch window on snapshot mode
Legacy mode (snapshot flag off) keeps its browser-local batch window
unchanged — the profile-tz window + series prefetch run only in snapshot
mode where a byte-identical dehydration key exists.
2026-07-18 14:42:18 +02:00
Marc-André Bombeck 271abd7b64 perf(dashboard): server-prefetch the Today hero + chart series into the dehydrated cache
Kill the above-the-fold first-load flash on `/`: the hero digest and the
batched chart series still fired client-side after JS download + hydrate,
so the hero and charts painted empty and filled in seconds later (LCP
~5.5 s mobile-throttled while the snapshot already dehydrated).

Extend the existing `page.tsx` snapshot prefetch to also seed the daily
digest and the chart series batch into the same dehydrated TanStack cache,
under keys byte-identical to the client's:

- Extract the series-batch route core into `readSeriesBatch(...)` so the
  RSC and the route share one code path (no HTTP-to-self); the route thins
  to auth + Zod + the shared reader, behaviour unchanged.
- New client-safe pure module `batch-chart-types.ts`: `deriveBatchChartTypes`
  (the single implementation of the visible-with-data type-set derivation,
  order + resting-HR-vs-pulse pick preserved) and `computeBatchWindow`
  (profile-tz end-of-day minus the coverage window). Server and client run
  the same function over the same snapshot payload, and the window is
  computed once server-side and threaded to the client as a prop, so the
  `chartSeriesBatch` key matches on both sides by construction.
- Render the hero data-first so the SSR HTML carries the populated hero
  (the LCP element) — hydration-safe because the digest is dehydrated and
  never restored from the IndexedDB persist allowlist; bound that invariant
  with comments on both the hero gate and the allowlist.
- Warm the shared chart-runtime chunk during hydration.

Both prefetches honour the `DASHBOARD_SSR_PREFETCH=false` escape and fail
soft to the client-fetch path on any error; a ~400 ms soft budget bounds
the cold-day digest cost. No contract change, no visual change.
2026-07-18 14:39:56 +02:00
Marc-André Bombeck 427b5f0b7c Merge pull request #532 from MBombeck/release/v1.30.8
release/v1.30.8 — harden aggregated heart-rate bucket validation
2026-07-18 12:16:26 +02:00
Marc-André Bombeck d4f78811d9 chore(release): v1.30.8 — harden aggregated heart-rate bucket validation 2026-07-18 11:59:38 +02:00
Marc-André Bombeck 6af0e36c88 fix(measurements): harden the aggregated HR bucket validation
Two data-integrity gaps found in review of the v1.30.7 contract:

- The bucket externalId guard accepted a non-canonical timestamp that V8
  silently rolls over (T24:00, 04-31, non-leap 02-29). Such a row would key
  under a literal string the canonical client never emits, so the two would
  never collapse on the overwrite key, and it would plot on the wrong local
  day. Require canonical round-trip (toISOString() === suffix) in both the
  guard and the bucket-start parser.
- The per-bucket min/max bypassed the plausibility-range guard that value
  passes; now that they drive the DAY min/max band and the intraday envelope,
  an out-of-range or mis-ordered spread poisoned the aggregate. Persist the
  spread only when both extremes are in range and ordered around value;
  otherwise drop to null and keep the average.
2026-07-18 11:58:51 +02:00
81 changed files with 6489 additions and 784 deletions

No files matched your search

+6 -4
View File
@@ -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
+42
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -109,6 +109,8 @@ const eslintConfig = defineConfig([
"src/components/medications/dose-strength-curve.tsx",
"src/components/medications/drug-level-chart.tsx",
"src/components/mental-health/assessment-history-chart.tsx",
"src/components/charts/workout-hr-chart.tsx",
"src/components/charts/workout-elevation-chart.tsx",
],
rules: {
"no-restricted-imports": [
+73 -1
View File
@@ -1979,6 +1979,61 @@
"notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
},
"insights": {
"correlation": {
"direction": {
"higher": "höher",
"lower": "niedriger"
},
"weekdayDirection": {
"above": "über",
"below": "unter"
},
"channelFactor": "die Bewertung von {factor}",
"channel": {
"WEIGHT": "das Gewicht",
"BLOOD_GLUCOSE": "der Blutzucker",
"BLOOD_PRESSURE_SYS": "der systolische Blutdruck",
"BLOOD_PRESSURE_DIA": "der diastolische Blutdruck",
"ACTIVITY_STEPS": "die tägliche Schrittzahl",
"SLEEP_DURATION": "die Schlafdauer",
"HEART_RATE_VARIABILITY": "die Herzratenvariabilität",
"RESTING_HEART_RATE": "die Ruheherzfrequenz",
"TIME_IN_DAYLIGHT": "die Zeit im Tageslicht",
"MOOD": "die Stimmung",
"PULSE": "der Ruhepuls",
"MEDICATION_COMPLIANCE": "die Medikamententreue",
"SYMPTOM_SEVERITY": "die Symptomstärke",
"ENV_TEMP_MEAN": "die Tagestemperatur",
"ENV_TEMP_MIN": "die nächtliche Tiefsttemperatur",
"ENV_SUNSHINE": "der Sonnenschein",
"ENV_DAYLIGHT": "das Tageslicht",
"ENV_PRECIP": "der Niederschlag",
"ENV_PRESSURE_MEAN": "der Luftdruck",
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag"
},
"daily": {
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.",
"moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.",
"faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache."
},
"lab": {
"default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.",
"faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache."
},
"hypothesis": {
"bpCompliance": {
"support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.",
"surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.",
"none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
},
"moodPulse": {
"support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.",
"surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.",
"none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
},
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient."
}
},
"emptyTitle": "Noch nicht genug Daten",
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
"emptyAddMeasurement": "Messwert erfassen",
@@ -2934,7 +2989,24 @@
"hrChartTitle": "Herzfrequenz",
"hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.",
"notFound": "Workout nicht gefunden.",
"loading": "Workout wird geladen…"
"loading": "Workout wird geladen…",
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
"hrAxisMinutes": "Min.",
"sportAverageLabel": "Dein aktueller Durchschnitt:",
"zonesTitle": "Belastungszonen",
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
"zonesCaptionDevice": "Zeit pro Zone laut deinem Gerät.",
"exportGpx": "GPX exportieren",
"elevationTitle": "Höhe",
"splitsTitle": "Kilometerzeiten",
"splitsKm": "km",
"splitsTime": "Zeit",
"splitsPace": "Tempo",
"thatDayTitle": "An diesem Tag",
"dayPulse": "Puls",
"daySleep": "Schlaf",
"dayMood": "Stimmung",
"insightTitle": "Aktivitäts-Einblick"
},
"sport": {
"walking": "Gehen",
+73 -1
View File
@@ -1979,6 +1979,61 @@
"notAvailableForCompliance": "Comparison N/A for adherence"
},
"insights": {
"correlation": {
"direction": {
"higher": "higher",
"lower": "lower"
},
"weekdayDirection": {
"above": "above",
"below": "below"
},
"channelFactor": "rated {factor}",
"channel": {
"WEIGHT": "weight",
"BLOOD_GLUCOSE": "blood glucose",
"BLOOD_PRESSURE_SYS": "systolic blood pressure",
"BLOOD_PRESSURE_DIA": "diastolic blood pressure",
"ACTIVITY_STEPS": "daily steps",
"SLEEP_DURATION": "sleep duration",
"HEART_RATE_VARIABILITY": "heart-rate variability",
"RESTING_HEART_RATE": "resting heart rate",
"TIME_IN_DAYLIGHT": "time in daylight",
"MOOD": "mood",
"PULSE": "resting pulse",
"MEDICATION_COMPLIANCE": "medication compliance",
"SYMPTOM_SEVERITY": "symptom severity",
"ENV_TEMP_MEAN": "daily temperature",
"ENV_TEMP_MIN": "overnight low temperature",
"ENV_SUNSHINE": "sunshine",
"ENV_DAYLIGHT": "daylight",
"ENV_PRECIP": "precipitation",
"ENV_PRESSURE_MEAN": "barometric pressure",
"ENV_PRESSURE_DELTA": "intraday pressure swing"
},
"daily": {
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.",
"moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
"faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause."
},
"lab": {
"default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.",
"faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause."
},
"hypothesis": {
"bpCompliance": {
"support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
"surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.",
"none": "Compliance and systolic do not move together strongly in this window."
},
"moodPulse": {
"support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.",
"surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.",
"none": "Mood and resting pulse do not move together strongly in this window."
},
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching."
}
},
"noAnalysisYet": "No assessment yet.",
"assessmentPreparing": "Preparing your assessment…",
"emptyTitle": "Not enough data yet",
@@ -2934,7 +2989,24 @@
"hrChartTitle": "Heart rate",
"hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.",
"notFound": "Workout not found.",
"loading": "Loading workout…"
"loading": "Loading workout…",
"hrFromPulse": "From your heart-rate data around this session",
"hrAxisMinutes": "min",
"sportAverageLabel": "Your recent average:",
"zonesTitle": "Effort zones",
"zonesCaption": "How your effort was distributed across heart-rate zones.",
"zonesCaptionDevice": "Time in zone as reported by your device.",
"exportGpx": "Export GPX",
"elevationTitle": "Elevation",
"splitsTitle": "Splits",
"splitsKm": "km",
"splitsTime": "Time",
"splitsPace": "Pace",
"thatDayTitle": "That day",
"dayPulse": "Pulse",
"daySleep": "Sleep",
"dayMood": "Mood",
"insightTitle": "Activity insight"
},
"sport": {
"walking": "Walking",
+73 -1
View File
@@ -1979,6 +1979,61 @@
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
},
"insights": {
"correlation": {
"direction": {
"higher": "subir",
"lower": "bajar"
},
"weekdayDirection": {
"above": "por encima de",
"below": "por debajo de"
},
"channelFactor": "la valoración de {factor}",
"channel": {
"WEIGHT": "el peso",
"BLOOD_GLUCOSE": "la glucosa en sangre",
"BLOOD_PRESSURE_SYS": "la presión sistólica",
"BLOOD_PRESSURE_DIA": "la presión diastólica",
"ACTIVITY_STEPS": "los pasos diarios",
"SLEEP_DURATION": "la duración del sueño",
"HEART_RATE_VARIABILITY": "la variabilidad de la frecuencia cardíaca",
"RESTING_HEART_RATE": "la frecuencia cardíaca en reposo",
"TIME_IN_DAYLIGHT": "el tiempo con luz natural",
"MOOD": "el estado de ánimo",
"PULSE": "el pulso en reposo",
"MEDICATION_COMPLIANCE": "la adherencia a la medicación",
"SYMPTOM_SEVERITY": "la intensidad de los síntomas",
"ENV_TEMP_MEAN": "la temperatura diaria",
"ENV_TEMP_MIN": "la temperatura mínima nocturna",
"ENV_SUNSHINE": "las horas de sol",
"ENV_DAYLIGHT": "la luz natural",
"ENV_PRECIP": "las precipitaciones",
"ENV_PRESSURE_MEAN": "la presión atmosférica",
"ENV_PRESSURE_DELTA": "la variación de presión del día"
},
"daily": {
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.",
"moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.",
"faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa."
},
"lab": {
"default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.",
"faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa."
},
"hypothesis": {
"bpCompliance": {
"support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.",
"surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.",
"none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo."
},
"moodPulse": {
"support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.",
"surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.",
"none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo."
},
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar."
}
},
"noAnalysisYet": "Aún no hay valoración.",
"assessmentPreparing": "Preparando tu valoración…",
"emptyTitle": "Aún no hay datos suficientes",
@@ -2934,7 +2989,24 @@
"hrChartTitle": "Frecuencia cardíaca",
"hrChartUnavailable": "Las muestras de frecuencia cardíaca por segundo no se almacenan para este entrenamiento.",
"notFound": "Entrenamiento no encontrado.",
"loading": "Cargando entrenamiento…"
"loading": "Cargando entrenamiento…",
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
"hrAxisMinutes": "min",
"sportAverageLabel": "Tu promedio reciente:",
"zonesTitle": "Zonas de esfuerzo",
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
"zonesCaptionDevice": "Tiempo por zona según tu dispositivo.",
"exportGpx": "Exportar GPX",
"elevationTitle": "Altitud",
"splitsTitle": "Parciales",
"splitsKm": "km",
"splitsTime": "Tiempo",
"splitsPace": "Ritmo",
"thatDayTitle": "Ese día",
"dayPulse": "Pulso",
"daySleep": "Sueño",
"dayMood": "Ánimo",
"insightTitle": "Análisis de la actividad"
},
"sport": {
"walking": "Caminar",
+73 -1
View File
@@ -1979,6 +1979,61 @@
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
},
"insights": {
"correlation": {
"direction": {
"higher": "augmenter",
"lower": "diminuer"
},
"weekdayDirection": {
"above": "au-dessus de",
"below": "en dessous de"
},
"channelFactor": "l'évaluation de {factor}",
"channel": {
"WEIGHT": "le poids",
"BLOOD_GLUCOSE": "la glycémie",
"BLOOD_PRESSURE_SYS": "la pression systolique",
"BLOOD_PRESSURE_DIA": "la pression diastolique",
"ACTIVITY_STEPS": "le nombre de pas quotidien",
"SLEEP_DURATION": "la durée de sommeil",
"HEART_RATE_VARIABILITY": "la variabilité de la fréquence cardiaque",
"RESTING_HEART_RATE": "la fréquence cardiaque au repos",
"TIME_IN_DAYLIGHT": "le temps passé à la lumière du jour",
"MOOD": "l'humeur",
"PULSE": "le pouls au repos",
"MEDICATION_COMPLIANCE": "l'observance des médicaments",
"SYMPTOM_SEVERITY": "la sévérité des symptômes",
"ENV_TEMP_MEAN": "la température quotidienne",
"ENV_TEMP_MIN": "la température minimale nocturne",
"ENV_SUNSHINE": "l'ensoleillement",
"ENV_DAYLIGHT": "la lumière du jour",
"ENV_PRECIP": "les précipitations",
"ENV_PRESSURE_MEAN": "la pression atmosphérique",
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée"
},
"daily": {
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.",
"moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.",
"faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause."
},
"lab": {
"default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.",
"faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause."
},
"hypothesis": {
"bpCompliance": {
"support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.",
"surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.",
"none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période."
},
"moodPulse": {
"support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.",
"surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.",
"none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période."
},
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller."
}
},
"noAnalysisYet": "Pas encore d’évaluation.",
"assessmentPreparing": "Préparation de votre évaluation…",
"emptyTitle": "Pas encore assez de données",
@@ -2934,7 +2989,24 @@
"hrChartTitle": "Fréquence cardiaque",
"hrChartUnavailable": "Les échantillons de fréquence cardiaque à la seconde ne sont pas stockés pour cet entraînement.",
"notFound": "Entraînement introuvable.",
"loading": "Chargement de l'entraînement…"
"loading": "Chargement de l'entraînement…",
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
"hrAxisMinutes": "min",
"sportAverageLabel": "Votre moyenne récente :",
"zonesTitle": "Zones d'effort",
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
"zonesCaptionDevice": "Temps par zone indiqué par votre appareil.",
"exportGpx": "Exporter le GPX",
"elevationTitle": "Dénivelé",
"splitsTitle": "Temps par kilomètre",
"splitsKm": "km",
"splitsTime": "Temps",
"splitsPace": "Allure",
"thatDayTitle": "Ce jour-là",
"dayPulse": "Pouls",
"daySleep": "Sommeil",
"dayMood": "Humeur",
"insightTitle": "Analyse de l'activité"
},
"sport": {
"walking": "Marche",
+73 -1
View File
@@ -1979,6 +1979,61 @@
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
},
"insights": {
"correlation": {
"direction": {
"higher": "salire",
"lower": "scendere"
},
"weekdayDirection": {
"above": "sopra",
"below": "sotto"
},
"channelFactor": "la valutazione di {factor}",
"channel": {
"WEIGHT": "il peso",
"BLOOD_GLUCOSE": "la glicemia",
"BLOOD_PRESSURE_SYS": "la pressione sistolica",
"BLOOD_PRESSURE_DIA": "la pressione diastolica",
"ACTIVITY_STEPS": "i passi giornalieri",
"SLEEP_DURATION": "la durata del sonno",
"HEART_RATE_VARIABILITY": "la variabilità della frequenza cardiaca",
"RESTING_HEART_RATE": "la frequenza cardiaca a riposo",
"TIME_IN_DAYLIGHT": "il tempo alla luce del giorno",
"MOOD": "l'umore",
"PULSE": "il polso a riposo",
"MEDICATION_COMPLIANCE": "l'aderenza ai farmaci",
"SYMPTOM_SEVERITY": "la gravità dei sintomi",
"ENV_TEMP_MEAN": "la temperatura giornaliera",
"ENV_TEMP_MIN": "la temperatura minima notturna",
"ENV_SUNSHINE": "il soleggiamento",
"ENV_DAYLIGHT": "la luce del giorno",
"ENV_PRECIP": "le precipitazioni",
"ENV_PRESSURE_MEAN": "la pressione atmosferica",
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata"
},
"daily": {
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.",
"moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.",
"faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa."
},
"lab": {
"default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.",
"faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa."
},
"hypothesis": {
"bpCompliance": {
"support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.",
"surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.",
"none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo."
},
"moodPulse": {
"support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.",
"surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.",
"none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo."
},
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare."
}
},
"noAnalysisYet": "Ancora nessuna valutazione.",
"assessmentPreparing": "Preparazione della valutazione…",
"emptyTitle": "Dati ancora insufficienti",
@@ -2934,7 +2989,24 @@
"hrChartTitle": "Frequenza cardiaca",
"hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.",
"notFound": "Allenamento non trovato.",
"loading": "Caricamento allenamento…"
"loading": "Caricamento allenamento…",
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
"hrAxisMinutes": "min",
"sportAverageLabel": "La tua media recente:",
"zonesTitle": "Zone di sforzo",
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
"zonesCaptionDevice": "Tempo per zona riportato dal tuo dispositivo.",
"exportGpx": "Esporta GPX",
"elevationTitle": "Dislivello",
"splitsTitle": "Parziali",
"splitsKm": "km",
"splitsTime": "Tempo",
"splitsPace": "Ritmo",
"thatDayTitle": "Quel giorno",
"dayPulse": "Battito",
"daySleep": "Sonno",
"dayMood": "Umore",
"insightTitle": "Analisi dell'attività"
},
"sport": {
"walking": "Camminata",
+73 -1
View File
@@ -1979,6 +1979,61 @@
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
},
"insights": {
"correlation": {
"direction": {
"higher": "wzrostu",
"lower": "spadku"
},
"weekdayDirection": {
"above": "powyżej",
"below": "poniżej"
},
"channelFactor": "ocena czynnika {factor}",
"channel": {
"WEIGHT": "waga",
"BLOOD_GLUCOSE": "poziom cukru we krwi",
"BLOOD_PRESSURE_SYS": "ciśnienie skurczowe",
"BLOOD_PRESSURE_DIA": "ciśnienie rozkurczowe",
"ACTIVITY_STEPS": "dzienna liczba kroków",
"SLEEP_DURATION": "długość snu",
"HEART_RATE_VARIABILITY": "zmienność rytmu serca",
"RESTING_HEART_RATE": "spoczynkowe tętno",
"TIME_IN_DAYLIGHT": "czas w świetle dziennym",
"MOOD": "nastrój",
"PULSE": "tętno spoczynkowe",
"MEDICATION_COMPLIANCE": "przyjmowanie leków",
"SYMPTOM_SEVERITY": "nasilenie objawów",
"ENV_TEMP_MEAN": "temperatura dobowa",
"ENV_TEMP_MIN": "nocna temperatura minimalna",
"ENV_SUNSHINE": "nasłonecznienie",
"ENV_DAYLIGHT": "światło dzienne",
"ENV_PRECIP": "opady",
"ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne",
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia"
},
"daily": {
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.",
"moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.",
"faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna."
},
"lab": {
"default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.",
"faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna."
},
"hypothesis": {
"bpCompliance": {
"support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.",
"surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.",
"none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie."
},
"moodPulse": {
"support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.",
"surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.",
"none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie."
},
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji."
}
},
"noAnalysisYet": "Brak oceny.",
"assessmentPreparing": "Przygotowywanie oceny…",
"emptyTitle": "Jeszcze za mało danych",
@@ -2934,7 +2989,24 @@
"hrChartTitle": "Tętno",
"hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.",
"notFound": "Nie znaleziono treningu.",
"loading": "Wczytywanie treningu…"
"loading": "Wczytywanie treningu…",
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
"hrAxisMinutes": "min",
"sportAverageLabel": "Twoja ostatnia średnia:",
"zonesTitle": "Strefy wysiłku",
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
"zonesCaptionDevice": "Czas w strefach według Twojego urządzenia.",
"exportGpx": "Eksportuj GPX",
"elevationTitle": "Wznios",
"splitsTitle": "Międzyczasy",
"splitsKm": "km",
"splitsTime": "Czas",
"splitsPace": "Tempo",
"thatDayTitle": "Tego dnia",
"dayPulse": "Tętno",
"daySleep": "Sen",
"dayMood": "Nastrój",
"insightTitle": "Analiza aktywności"
},
"sport": {
"walking": "Marsz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.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");
+40
View File
@@ -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
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.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/*`
+12 -3
View File
@@ -22,6 +22,8 @@ import { resolveRestMode } from "@/lib/illness/rest-mode";
import { buildHealthScoreBpInputs } from "@/lib/analytics/health-score-inputs";
import { deriveBpWindow90 } from "@/lib/analytics/window-confidence";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { type Locale } from "@/lib/i18n/config";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
@@ -96,12 +98,18 @@ export const GET = apiHandler(async (request?: Request) => {
// thick body is the 30-query chain feeding the dashboard + insights
// hero score; after a measurement sync the next mount serves the
// prior body instantly while one background recompute refreshes it.
// v1.30.10 — the `correlations` block carries user-facing narration that is
// localised per reader, so the cache key MUST include the locale; otherwise a
// language switch would serve another locale's cached interpretation for up to
// the TTL. Every other block is locale-independent, so per-locale entries only
// cost a little duplication (most users have one locale).
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
const cachedBody = await cachedSwr(
caches.analytics as ServerCache<
Awaited<ReturnType<typeof buildAnalyticsResponse>>
>,
`${user.id}|default`,
() => buildAnalyticsResponse(user),
`${user.id}|default|${locale}`,
() => buildAnalyticsResponse(user, locale),
annotate,
);
@@ -173,7 +181,7 @@ function enrichLastSeenDaysAgo<
* matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` →
* `buildCoachSnapshotImpl` shape.
*/
async function buildAnalyticsResponse(user: AuthedUser) {
async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
// v1.4.37.1 hotfix — fire-and-forget the rollup-fresh check.
// Awaiting it on the read path can stall the Node event loop for
// 30–60 s on a power-user account whose iOS step samples keep the
@@ -405,6 +413,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
userTz,
now: new Date(),
coverage,
locale,
});
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
@@ -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",
);
});
});
+145 -48
View File
@@ -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");
});
});
});
+49 -11
View File
@@ -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;
}
});
});
+172
View File
@@ -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;
});
+11 -2
View File
@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -82,6 +83,11 @@ export const GET = apiHandler(async () => {
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
// Reader's locale for the narrated `interpretation` — the correlation cards
// render this string verbatim, so it MUST be localised (cookie / User.locale /
// Accept-Language). Without it the never-causal sentence leaked English into a
// non-English UI.
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
@@ -165,7 +171,7 @@ export const GET = apiHandler(async () => {
series.push(envSeries);
}
const result = discoverCorrelations(series);
const result = discoverCorrelations(series, { locale });
// v1.22 — rolling early-detection pass over the trailing window, re-using
// the already-built series (no extra DB read). Emerging pairs exclude anything
@@ -176,11 +182,14 @@ export const GET = apiHandler(async () => {
);
const emerging = discoverEmergingCorrelations(series, result, {
recentFromDayKey,
locale,
});
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). Degrades
// to absent when the user has too few draws to clear the per-pair floor.
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series);
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series, {
locale,
});
annotate({
action: { name: "insights.correlations.discover" },
@@ -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 },
+18 -8
View File
@@ -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,
+12 -51
View File
@@ -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,
},
});
+157 -5
View File
@@ -30,13 +30,31 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
import {
computeZones,
hrMaxFromAge,
parseWhoopZoneDurations,
} from "@/lib/workouts/zones";
import { computeSplits } from "@/lib/workouts/splits";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
type RouteParams = { params: Promise<{ id: string }> };
/** Own-history lookback for the sport-average comparison line. */
const SPORT_CONTEXT_LOOKBACK_DAYS = 180;
/** Slim-row cap for the sport-average read. */
const SPORT_CONTEXT_MAX_ROWS = 500;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// Web always sends `compact=1` to drop the raw 30k-sample / route
// timestamp blobs. Absent the param the response is byte-identical to
// the v1.4.32 contract → iOS is untouched, no coordination ticket.
const compact = request.nextUrl.searchParams.get("compact") === "1";
annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } });
@@ -98,7 +116,7 @@ export const GET = apiHandler(
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
select: { sourcePriorityJson: true, dateOfBirth: true },
});
const canonicalCluster = pickCanonicalWorkoutRows(
clusterRows,
@@ -112,20 +130,82 @@ export const GET = apiHandler(
canonicalCluster[0] ??
null;
// ── Enrichment reads (all over existing tables — no migration) ────
// Heart-rate curve: stored series first, pulse-window fallback
// second, hide third. One server path, one DTO with provenance.
const hrSeries = await buildWorkoutHrSeries({
userId: user.id,
startedAt: row.startedAt,
endedAt: row.endedAt,
durationSec: row.durationSec,
storedSamples: row.samples?.samples ?? null,
});
annotate({
action: { name: "workouts.detail.hr_series" },
meta: {
source: hrSeries?.source ?? "none",
points: hrSeries?.points.length ?? 0,
},
});
// Effort zones: WHOOP device durations win; else %HRmax fold from
// the series when profile age exists; else null.
const ageYears = getAgeFromDateOfBirth(userRow?.dateOfBirth ?? null);
const zones = computeZones({
hrMax: hrMaxFromAge(ageYears),
series: hrSeries?.points ?? [],
bucketSec: hrSeries?.bucketSec ?? 0,
whoopZoneDurations: parseWhoopZoneDurations(row.metadata),
});
// Per-km splits computed server-side from the geometry + timestamps
// so the web client keeps dropping the raw timestamp blob under
// `compact=1` while still rendering splits (server-authoritative
// parity — iOS gets the same resolved figures).
const geometryCoords =
row.route &&
row.route.geometry &&
typeof row.route.geometry === "object" &&
Array.isArray(
(row.route.geometry as { coordinates?: unknown }).coordinates,
)
? (row.route.geometry as { coordinates: RouteCoordinate[] }).coordinates
: null;
const splits =
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
? computeSplits(geometryCoords, row.route.sampleTimestamps as string[])
: null;
// Sport context: the user's own last-180-days average for this
// sport, cross-source-collapsed so twins don't double-count.
const sportContext = await buildSportContext(
user.id,
row.sportType,
userRow?.sourcePriorityJson ?? null,
);
const route = row.route
? {
geometry: row.route.geometry,
sampleTimestamps: row.route.sampleTimestamps ?? null,
// `compact=1` drops the (up to 20k-entry) timestamp array; the
// SVG needs geometry, not the per-sample timestamps, and the
// splits above are already derived from them server-side.
sampleTimestamps: compact
? null
: (row.route.sampleTimestamps ?? null),
}
: null;
// v1.10.0 — route-independent per-workout HR series. Present for
// both indoor (no route) and outdoor workouts that shipped a
// `samples` array on ingest; null otherwise.
// `samples` array on ingest; null otherwise. `compact=1` keeps the
// denormalised count but drops the raw sample blob (the web curve
// reads `hrSeries` instead).
const samples = row.samples
? {
sampleCount: row.samples.sampleCount,
samples: row.samples.samples,
samples: compact ? null : row.samples.samples,
}
: null;
@@ -148,6 +228,17 @@ export const GET = apiHandler(
metadata: row.metadata,
route,
samples,
// #67 enrichment — all additive, all over existing tables.
hrSeries,
zones,
splits,
sportContext,
// Reserved Activity-Insight seam (strategic-concept Wave C, bet 5):
// always null today. The Phase-2 pg-boss job writes a cached
// paragraph onto the workout row and maps it here — the detail page
// already composes `{aiInsight ? <card/> : null}`, so the card
// mounts with zero layout rework.
aiInsight: null,
// v1.4.32 — when the requested id is a non-canonical twin the
// caller can redirect to `canonicalId` to land on the cluster
// winner. `canonicalId === id` when the requested row already
@@ -156,3 +247,64 @@ export const GET = apiHandler(
});
},
);
/**
* The user's own recent average for a sport — rendered as one muted
* comparison line. Cross-source twins are collapsed through
* `pickCanonicalWorkoutRows()` first so a run recorded by two paired
* watches counts once. Comparisons are to the user's own history only
* (non-diagnostic standard).
*/
async function buildSportContext(
userId: string,
sportType: string,
sourcePriorityJson: unknown,
): Promise<{
count: number;
avgDurationSec: number;
avgDistanceM: number | null;
avgAvgHr: number | null;
} | null> {
const since = new Date(
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
);
const rows = await prisma.workout.findMany({
where: { userId, sportType, startedAt: { gte: since } },
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
take: SPORT_CONTEXT_MAX_ROWS,
select: {
id: true,
source: true,
startedAt: true,
sportType: true,
durationSec: true,
totalDistanceM: true,
avgHeartRate: true,
},
});
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
if (canonical.length === 0) return null;
let durationSum = 0;
let distanceSum = 0;
let distanceCount = 0;
let hrSum = 0;
let hrCount = 0;
for (const r of canonical) {
durationSum += r.durationSec;
if (r.totalDistanceM != null) {
distanceSum += r.totalDistanceM;
distanceCount += 1;
}
if (r.avgHeartRate != null) {
hrSum += r.avgHeartRate;
hrCount += 1;
}
}
return {
count: canonical.length,
avgDurationSec: Math.round(durationSum / canonical.length),
avgDistanceM: distanceCount > 0 ? distanceSum / distanceCount : null,
avgAvgHr: hrCount > 0 ? Math.round(hrSum / hrCount) : null,
};
}
@@ -27,6 +27,9 @@ vi.mock("@/lib/db", () => ({
user: {
findUnique: vi.fn(),
},
measurement: {
findMany: vi.fn(),
},
},
}));
@@ -103,7 +106,14 @@ describe("GET /api/workouts/{id}", () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
dateOfBirth: null,
} as never);
// #67 — the enrichment reads: the HR-curve pulse-window fallback
// (`measurement.findMany`) and the sport-context average (a SECOND
// `workout.findMany`). Default both to empty; each test's
// `mockResolvedValueOnce` still wins for the cluster call.
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
// clearAllMocks wipes the factory default; re-seed the gate to pass.
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
@@ -208,6 +218,72 @@ describe("GET /api/workouts/{id}", () => {
expect(body.data.canonicalId).toBe("w-apple");
});
it("adds the #67 enrichment fields and strips raw blobs under compact=1", async () => {
const startMs = BASE_ROW.startedAt.getTime();
const storedSamples = Array.from({ length: 40 }, (_, i) => ({
t: new Date(startMs + i * 45_000).toISOString(),
hr: 140 + (i % 6),
}));
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
...BASE_ROW,
// WHOOP device zones win → zones render even without profile age.
metadata: {
zoneDurations: {
zone_one_milli: 60_000,
zone_two_milli: 300_000,
zone_three_milli: 600_000,
zone_four_milli: 200_000,
zone_five_milli: 40_000,
},
},
route: {
id: "wr-1",
geometry: {
type: "LineString",
coordinates: [
[11, 49],
[11.01, 49.01],
],
},
sampleTimestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:01:00Z"],
createdAt: BASE_ROW.createdAt,
},
samples: { sampleCount: storedSamples.length, samples: storedSamples },
} as never);
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
{
id: "w-1",
source: "APPLE_HEALTH",
startedAt: BASE_ROW.startedAt,
sportType: "RUNNING",
},
] as never);
const res = await GET(
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
method: "GET",
}),
makeParams("w-1"),
);
const body = await res.json();
expect(res.status).toBe(200);
// Stored series wins → provenance is the native workout series.
expect(body.data.hrSeries.source).toBe("workout_series");
expect(body.data.hrSeries.points.length).toBeGreaterThanOrEqual(2);
// WHOOP device zones.
expect(body.data.zones.model).toBe("whoop");
// Additive keys present; reserved seam is null.
expect(body.data).toHaveProperty("splits");
expect(body.data).toHaveProperty("sportContext");
expect(body.data.aiInsight).toBeNull();
// compact=1 drops the raw blobs but keeps count + geometry.
expect(body.data.samples.sampleCount).toBe(40);
expect(body.data.samples.samples).toBeNull();
expect(body.data.route.geometry).not.toBeNull();
expect(body.data.route.sampleTimestamps).toBeNull();
});
it("exposes the WorkoutRoute geometry when present", async () => {
const routeGeometry = {
type: "LineString",
+10
View File
@@ -94,6 +94,10 @@ interface CanonicalWorkoutRow {
avgHeartRate: number | null;
maxHeartRate: number | null;
createdAt: Date;
// #67 list glyphs — cheap relation-id selects so the list can flag
// which sessions open into a rich detail (map / HR curve).
route: { id: string } | null;
samples: { sampleCount: number } | null;
}
/** The cached, deduped, most-recent-first row set for one filter combination. */
@@ -115,6 +119,8 @@ interface WorkoutsResult {
maxHr: number | null;
source: string;
externalId: string | null;
hasRoute: boolean;
hasHrSeries: boolean;
}>;
meta: {
total: number;
@@ -169,6 +175,8 @@ async function buildWorkoutsProjection(
avgHeartRate: true,
maxHeartRate: true,
createdAt: true,
route: { select: { id: true } },
samples: { select: { sampleCount: true } },
},
}),
prisma.user.findUnique({
@@ -215,6 +223,8 @@ function sliceWorkoutsProjection(
maxHr: row.maxHeartRate,
source: row.source,
externalId: row.externalId,
hasRoute: row.route != null,
hasHrSeries: row.samples != null && row.samples.sampleCount > 0,
}));
return {
+39 -16
View File
@@ -11,22 +11,34 @@ import { BackLink } from "@/components/ui/back-link";
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import {
WorkoutDetailHeader,
WorkoutDetailHRChart,
WorkoutDetailRoute,
WorkoutDetailStats,
WorkoutDetailHrSection,
WorkoutDetailZones,
WorkoutDetailRoute,
WorkoutDetailSplits,
WorkoutDetailDayLinks,
WorkoutInsightCard,
} from "@/components/insights/workout-detail";
/**
* v1.4.32 — `/insights/workouts/[id]`.
* `/insights/workouts/[id]` — workout detail surface.
*
* Workout detail surface. Mounts the header + stats + optional GPS
* route + heart-rate chart slot. The route data flows from
* `GET /api/workouts/{id}` (v1.4.32) and includes the cross-source
* canonical-id pointer so a deep-link into a non-canonical twin
* surfaces a graceful redirect cue (handled inline on the header for
* v1.4.32; iOS-side redirect arrives in v1.5).
* Layout, top to bottom (mobile-first single column):
* hero header → reserved Activity-Insight seam (renders nothing today)
* → stats grid + sport-average line → HR curve → effort zones → GPS
* route → per-km splits → "that day" cross-links → coach launch.
*
* Every data-less section returns `null` (hide, don't render empty), so
* an aggregates-only workout (a Strava ride with no wearable, a manual
* entry) reads as hero + stats + "that day" + coach — compact but
* honest, no empty shells.
*
* Data flows from `GET /api/workouts/{id}?compact=1` (v1.4.32 + the #67
* enrichment fields). The `canonicalId` pointer still resolves a
* deep-link into a non-canonical twin; the header carries the source.
*/
export default function InsightsWorkoutDetailPage({
params,
@@ -36,7 +48,7 @@ export default function InsightsWorkoutDetailPage({
const { t } = useTranslations();
const { id } = use(params);
const { ready } = useModulePageGuard("workouts");
const { data, isLoading, error } = useWorkoutDetail(id);
const { data, isLoading, error, refetch } = useWorkoutDetail(id);
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
if (!ready) {
@@ -63,9 +75,13 @@ export default function InsightsWorkoutDetailPage({
<div data-slot="workout-detail-loading" className="space-y-3">
<Skeleton className="h-20 w-full rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
<Skeleton className="h-56 w-full rounded-lg" />
<Skeleton className="h-60 w-full rounded-lg" />
</div>
) : error || !data ? (
) : error ? (
// A failed query must never read as "no data" (UI-STANDARDS §6).
<QueryErrorCard onRetry={() => refetch()} />
) : !data ? (
<p
data-slot="workout-detail-error"
className="text-muted-foreground text-sm"
@@ -75,13 +91,20 @@ export default function InsightsWorkoutDetailPage({
) : (
<>
<WorkoutDetailHeader workout={data} />
{/* Reserved Activity-Insight seam — `aiInsight` is always null
today, so this renders nothing. When the Phase-2 job
populates it, the card mounts here with zero layout rework. */}
{data.aiInsight ? (
<WorkoutInsightCard insight={data.aiInsight} />
) : null}
<WorkoutDetailStats workout={data} />
<WorkoutDetailHrSection workout={data} />
<WorkoutDetailZones workout={data} />
<WorkoutDetailRoute workout={data} />
<WorkoutDetailHRChart workout={data} />
{/* 2026-07-17 UX-flows audit F6-1 — the button used to open an
unscoped chat; the user had to restate which session they
meant. `workouts` narrows the snapshot the first turn reads
(pairs with the coach-scope param added for F4-1). */}
<WorkoutDetailSplits workout={data} />
<WorkoutDetailDayLinks workout={data} />
{/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the
snapshot the first coach turn reads. */}
<CoachLaunchButton scope={{ metric: "workouts" }} />
</>
)}
+76 -30
View File
@@ -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
View File
@@ -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>
);
}
+2
View File
@@ -44,3 +44,5 @@ export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart";
export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
export { WorkoutHrChart } from "./workout-hr-chart";
export { WorkoutElevationChart } from "./workout-elevation-chart";
@@ -0,0 +1,101 @@
"use client";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
/**
* Workout elevation profile — a small cumulative-distance / altitude
* area under the route map. Rendered ONLY through `chart-runtime.ts`.
* Present only when ≥ 60 % of route coordinates carry altitude (the
* caller gates it); partial altimeter data would draw a lie.
*/
export interface WorkoutElevationPoint {
distanceM: number;
altitude: number;
}
export function WorkoutElevationChart({
points,
}: {
points: WorkoutElevationPoint[];
}) {
const { t } = useTranslations();
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
return (
<div className="h-24 w-full" data-slot="workout-elevation-chart">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{ top: 4, right: 8, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="workoutElevationFill"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.35} />
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.4}
/>
<XAxis
dataKey="km"
type="number"
domain={[0, "dataMax"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickFormatter={(v) => Number(v).toFixed(1)}
tickLine={false}
axisLine={false}
/>
<YAxis
domain={["dataMin - 5", "dataMax + 5"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={36}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
formatter={(value) => [
`${Math.round(Number(value))} m`,
t("insights.workouts.detail.elevationTitle"),
]}
/>
<Area
type="monotone"
dataKey="alt"
stroke="var(--chart-2)"
strokeWidth={2}
fill="url(#workoutElevationFill)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useMemo } from "react";
import {
ComposedChart,
Area,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ReferenceArea,
ResponsiveContainer,
} from "recharts";
import { useTranslations } from "@/lib/i18n/context";
/**
* Per-workout heart-rate curve. Rendered ONLY through the shared
* `chart-runtime.ts` barrel (the one recharts async boundary) so the
* library stays a single shared chunk — never import this file directly
* at a call site.
*
* `ComposedChart`: an `Area` for the min→max envelope (drawn only when
* the series is dense enough to be honest), a `Line` for the bucket
* mean, optional %HRmax zone bands behind them, and a dashed reference
* line at the workout's average HR. Gaps stay gaps (`connectNulls`
* false) — a hole in the recording never reads as an interpolated
* curve.
*/
export interface WorkoutHrChartPoint {
tSec: number;
mean: number;
min: number;
max: number;
}
export interface WorkoutHrZoneBand {
zone: number;
lowBpm: number | null;
highBpm: number | null;
}
export interface WorkoutHrChartProps {
points: WorkoutHrChartPoint[];
bucketSec: number;
/** Draw the min→max envelope band behind the mean line. */
envelope: boolean;
/** Workout average HR for the reference line. */
avgHr: number | null;
/** Optional %HRmax zone bands for the shaded background. */
zones?: WorkoutHrZoneBand[] | null;
}
const ZONE_FILLS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
function minuteTick(seconds: number): string {
return String(Math.round(seconds / 60));
}
export function WorkoutHrChart({
points,
bucketSec,
envelope,
avgHr,
zones,
}: WorkoutHrChartProps) {
const { t } = useTranslations();
// Build a full elapsed-time grid so a gap of missing buckets breaks
// the line rather than interpolating across it.
const data = useMemo(() => {
if (points.length === 0) return [];
const byT = new Map(points.map((p) => [p.tSec, p]));
const maxT = points[points.length - 1].tSec;
const grid: Array<{
tSec: number;
mean: number | null;
lo: number | null;
hi: number | null;
}> = [];
for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
const p = byT.get(tSec);
grid.push({
tSec,
mean: p ? p.mean : null,
lo: p ? p.min : null,
hi: p && envelope ? p.max - p.min : null,
});
}
return grid;
}, [points, bucketSec, envelope]);
return (
<div
className="h-48 w-full touch-pan-y sm:h-64"
data-slot="workout-hr-chart"
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.5}
/>
{zones?.map((z) =>
z.lowBpm != null ? (
<ReferenceArea
key={z.zone}
y1={z.lowBpm}
y2={z.highBpm ?? undefined}
fill={ZONE_FILLS[z.zone - 1]}
fillOpacity={0.06}
ifOverflow="hidden"
/>
) : null,
)}
<XAxis
dataKey="tSec"
type="number"
domain={[0, "dataMax"]}
tickFormatter={minuteTick}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
unit=""
/>
<YAxis
domain={["dataMin - 10", "dataMax + 10"]}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
tickLine={false}
axisLine={false}
width={44}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
labelFormatter={(tSec) =>
`${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}`
}
formatter={(value, name) =>
name === "mean"
? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")]
: [null, null]
}
/>
{/* Envelope band: a transparent base at `lo` plus a stacked
`hi` (= max − min) renders the min→max ribbon. Only present
when `envelope` gated it in. */}
{envelope ? (
<>
<Area
type="monotone"
dataKey="lo"
stackId="band"
stroke="none"
fill="transparent"
connectNulls={false}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="hi"
stackId="band"
stroke="none"
fill="var(--chart-1)"
fillOpacity={0.15}
connectNulls={false}
isAnimationActive={false}
/>
</>
) : null}
{avgHr != null ? (
<ReferenceLine
y={avgHr}
stroke="var(--muted-foreground)"
strokeDasharray="5 5"
strokeOpacity={0.7}
/>
) : null}
<Line
type="monotone"
dataKey="mean"
stroke="var(--chart-1)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
@@ -4,19 +4,21 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
WorkoutDetailHeader,
WorkoutDetailHRChart,
WorkoutDetailRoute,
WorkoutDetailStats,
WorkoutDetailRoute,
WorkoutDetailHrSection,
WorkoutDetailZones,
WorkoutDetailSplits,
WorkoutDetailDayLinks,
} from "../workout-detail";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
/**
* v1.4.32 — `<WorkoutDetail*>` unit tests.
*
* Each primitive runs through SSR with a single canonical fixture.
* The tests pin the visible labels + the graceful-fallback shapes so
* the page renders something useful even when the optional fields
* (route, HR samples, energy) are absent.
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
* directory. Each primitive runs through SSR with a canonical fixture;
* the tests pin the visible labels and the hide-don't-render contract
* (every data-less section returns null).
*/
function render(node: React.ReactNode) {
@@ -43,6 +45,12 @@ const FIXTURE: WorkoutDetailPayload = {
externalId: "ext-w-1",
metadata: null,
route: null,
samples: null,
hrSeries: null,
zones: null,
splits: null,
sportContext: null,
aiInsight: null,
canonicalId: "w-1",
};
@@ -62,7 +70,6 @@ describe("<WorkoutDetailStats>", () => {
expect(html).toContain("Duration");
expect(html).toContain("Distance");
expect(html).toContain("Active energy");
expect(html).toContain("Average HR");
expect(html).toContain("145 bpm");
expect(html).toContain("Steps");
expect(html).toContain("Pace");
@@ -82,68 +89,176 @@ describe("<WorkoutDetailStats>", () => {
const html = render(<WorkoutDetailStats workout={minimal} />);
expect(html).toContain("Duration");
expect(html).not.toContain("Distance");
expect(html).not.toContain("Active energy");
expect(html).not.toContain("Steps");
expect(html).not.toContain("Average HR");
});
it("renders the own-history average line when ≥ 2 sessions exist", () => {
const withCtx: WorkoutDetailPayload = {
...FIXTURE,
sportContext: {
count: 8,
avgDurationSec: 2040,
avgDistanceM: 5800,
avgAvgHr: 148,
},
};
const html = render(<WorkoutDetailStats workout={withCtx} />);
expect(html).toContain('data-slot="workout-detail-sport-average"');
expect(html).toContain("148 bpm");
});
it("hides the average line for a lone session", () => {
const lone: WorkoutDetailPayload = {
...FIXTURE,
sportContext: {
count: 1,
avgDurationSec: 1800,
avgDistanceM: 5200,
avgAvgHr: 145,
},
};
const html = render(<WorkoutDetailStats workout={lone} />);
expect(html).not.toContain('data-slot="workout-detail-sport-average"');
});
});
describe("<WorkoutDetailRoute>", () => {
it("renders the empty-state when the workout has no route", () => {
it("returns null when the workout has no route", () => {
const html = render(<WorkoutDetailRoute workout={FIXTURE} />);
expect(html).toContain('data-slot="workout-detail-route-empty"');
expect(html).toContain("No GPS route");
expect(html).toBe("");
});
it("renders an SVG polyline when GeoJSON LineString geometry is present", () => {
it("renders an SVG path for a real route", () => {
const coords: RouteCoordinate[] = [];
for (let i = 0; i < 20; i++)
coords.push([11.0 + i * 0.0004, 50.0 + i * 0.0003]);
const withRoute: WorkoutDetailPayload = {
...FIXTURE,
route: {
geometry: {
type: "LineString",
coordinates: [
[11.0, 49.0],
[11.01, 49.005],
[11.02, 49.01],
],
},
geometry: { type: "LineString", coordinates: coords },
sampleTimestamps: null,
},
};
const html = render(<WorkoutDetailRoute workout={withRoute} />);
expect(html).toContain('data-slot="workout-detail-route"');
expect(html).toContain("<polyline");
expect(html).toContain("<path");
expect(html).toContain("Export GPX");
});
it("falls back to the empty state for a LineString with fewer than 2 points", () => {
it("returns null for a degenerate point-shaped route", () => {
const stub: WorkoutDetailPayload = {
...FIXTURE,
route: {
geometry: { type: "LineString", coordinates: [[11, 49]] },
geometry: {
type: "LineString",
coordinates: [
[11, 49],
[11.0001, 49.0001],
],
},
sampleTimestamps: null,
},
};
const html = render(<WorkoutDetailRoute workout={stub} />);
expect(html).toContain('data-slot="workout-detail-route-empty"');
expect(render(<WorkoutDetailRoute workout={stub} />)).toBe("");
});
});
describe("<WorkoutDetailHRChart>", () => {
it("renders the unavailable notice with aggregate HR values when present", () => {
const html = render(<WorkoutDetailHRChart workout={FIXTURE} />);
expect(html).toContain('data-slot="workout-detail-hr-chart"');
expect(html).toContain("145 bpm");
expect(html).toContain("170 bpm");
describe("<WorkoutDetailHrSection>", () => {
it("returns null without an HR series", () => {
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).toBe("");
});
it("renders just the unavailable notice when no HR aggregates are present", () => {
const noHr: WorkoutDetailPayload = {
it("renders the card and a provenance chip for a pulse-window series", () => {
const withHr: WorkoutDetailPayload = {
...FIXTURE,
avgHr: null,
maxHr: null,
hrSeries: {
source: "pulse_window",
bucketSec: 8,
points: [
{ tSec: 0, mean: 130, min: 125, max: 135 },
{ tSec: 8, mean: 140, min: 135, max: 145 },
],
envelope: false,
},
};
const html = render(<WorkoutDetailHRChart workout={noHr} />);
expect(html).toContain("Per-second heart-rate");
expect(html).not.toContain("145 bpm");
const html = render(<WorkoutDetailHrSection workout={withHr} />);
expect(html).toContain('data-slot="workout-detail-hr"');
expect(html).toContain('data-slot="workout-detail-hr-provenance"');
expect(html).toContain("From your heart-rate data");
});
it("omits the provenance chip for a stored series", () => {
const withHr: WorkoutDetailPayload = {
...FIXTURE,
hrSeries: {
source: "workout_series",
bucketSec: 8,
points: [
{ tSec: 0, mean: 130, min: 125, max: 135 },
{ tSec: 8, mean: 140, min: 135, max: 145 },
],
envelope: true,
},
};
const html = render(<WorkoutDetailHrSection workout={withHr} />);
expect(html).toContain('data-slot="workout-detail-hr"');
expect(html).not.toContain('data-slot="workout-detail-hr-provenance"');
});
});
describe("<WorkoutDetailZones>", () => {
it("returns null without zone data", () => {
expect(render(<WorkoutDetailZones workout={FIXTURE} />)).toBe("");
});
it("renders the stacked bar and per-zone minutes", () => {
const withZones: WorkoutDetailPayload = {
...FIXTURE,
zones: {
model: "tanaka",
hrMax: 180,
zones: [
{ zone: 1, lowBpm: 90, highBpm: 108, seconds: 300 },
{ zone: 2, lowBpm: 108, highBpm: 126, seconds: 600 },
{ zone: 3, lowBpm: 126, highBpm: 144, seconds: 500 },
{ zone: 4, lowBpm: 144, highBpm: 162, seconds: 200 },
{ zone: 5, lowBpm: 162, highBpm: null, seconds: 60 },
],
},
};
const html = render(<WorkoutDetailZones workout={withZones} />);
expect(html).toContain('data-slot="workout-detail-zones"');
expect(html).toContain("Effort zones");
expect(html).toContain("Z3");
});
});
describe("<WorkoutDetailSplits>", () => {
it("returns null without splits", () => {
expect(render(<WorkoutDetailSplits workout={FIXTURE} />)).toBe("");
});
it("renders a row per kilometre", () => {
const withSplits: WorkoutDetailPayload = {
...FIXTURE,
splits: [
{ km: 1, durationSec: 300, paceSecPerKm: 300 },
{ km: 2, durationSec: 288, paceSecPerKm: 288 },
],
};
const html = render(<WorkoutDetailSplits workout={withSplits} />);
expect(html).toContain('data-slot="workout-detail-splits"');
expect(html).toContain("5:00");
expect(html).toContain("4:48 /km");
});
});
describe("<WorkoutDetailDayLinks>", () => {
it("renders the that-day navigation links", () => {
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
expect(html).toContain('data-slot="workout-detail-day-links"');
expect(html).toContain('href="/insights/pulse"');
expect(html).toContain('href="/insights/sleep"');
expect(html).toContain('href="/insights/mood"');
});
});
-438
View File
@@ -1,438 +0,0 @@
"use client";
import type { ReactNode } from "react";
import {
Flame,
Footprints,
HeartPulse,
Map,
Mountain,
Timer,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
/**
* v1.4.32 — workout-detail components: header, stats, optional route,
* optional HR chart.
*
* Mounted by `/insights/workouts/[id]/page.tsx`. The page itself owns
* the data fetch + the empty / loading / error branches; each
* component below is a pure render primitive over a non-null
* `WorkoutDetailPayload`.
*/
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
}
return `${m}m ${s.toString().padStart(2, "0")}s`;
}
function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
}
function formatNumber(
value: number,
locale: string,
fractionDigits = 0,
): string {
return getNumberFormat(locale, {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
}).format(value);
}
function formatPaceMinPerKm(
durationSec: number,
meters: number,
locale: string,
): string {
// Pace is the seconds-per-kilometre value. Only meaningful for
// run / walk / hike. The caller already gated on `meters > 0`.
const secPerKm = (durationSec / meters) * 1000;
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")} ${locale === "en" ? "/km" : "/km"}`;
}
function formatDateRange(
startedAt: string,
endedAt: string,
locale: string,
timeFormat: TimeFormatPreference,
): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
const dateFmt = getDateTimeFormat(locale, {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
const timeFmt = getDateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
}
// ── Header ───────────────────────────────────────────────────────────
export interface WorkoutDetailHeaderProps {
workout: WorkoutDetailPayload;
}
function renderSportIconBadge(sportType: string) {
const Icon = iconForSport(sportType);
return (
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-6" />
</span>
);
}
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<header
data-slot="workout-detail-header"
// v1.30 mobile audit (W-1) — the three columns (icon + title block +
// duration) held at every width, so at 360px the middle column got
// ~120px and the long-format date line wrapped 4-5 lines beside a
// text-2xl duration. Stack below sm: duration drops under the title
// block as its own row instead of squeezing beside it.
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
>
<div className="flex min-w-0 items-start gap-4">
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
<span className="text-2xl font-semibold tabular-nums">
{formatDuration(workout.durationSec)}
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
</span>
) : null}
</div>
</header>
);
}
// ── Stats ────────────────────────────────────────────────────────────
interface StatTileProps {
icon: ReactNode;
label: string;
value: string;
hint?: string;
}
function StatTile({ icon, label, value, hint }: StatTileProps) {
return (
<div
data-slot="workout-detail-stat"
className="flex flex-col gap-1 rounded-lg border p-3"
>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<span aria-hidden="true" className="size-4">
{icon}
</span>
{label}
</span>
<span className="text-base font-semibold tabular-nums">{value}</span>
{hint ? (
<span className="text-muted-foreground text-xs">{hint}</span>
) : null}
</div>
);
}
export interface WorkoutDetailStatsProps {
workout: WorkoutDetailPayload;
}
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const tiles: StatTileProps[] = [];
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsDuration"),
value: formatDuration(workout.durationSec),
});
if (workout.distanceM != null && workout.distanceM > 0) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
});
}
if (workout.activeEnergyKcal != null) {
tiles.push({
icon: <Flame className="size-4" />,
label: t("insights.workouts.detail.statsActiveEnergy"),
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
});
}
if (workout.avgHr != null) {
tiles.push({
icon: <HeartPulse className="size-4" />,
label: t("insights.workouts.detail.statsAvgHr"),
value: `${workout.avgHr} bpm`,
hint:
workout.maxHr != null
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
: undefined,
});
}
if (workout.minHr != null) {
tiles.push({
icon: <HeartPulse className="size-4" />,
label: t("insights.workouts.detail.statsMinHr"),
value: `${workout.minHr} bpm`,
});
}
if (workout.stepCount != null && workout.stepCount > 0) {
tiles.push({
icon: <Footprints className="size-4" />,
label: t("insights.workouts.detail.statsStepCount"),
value: formatNumber(workout.stepCount, locale),
});
}
if (workout.elevationM != null && workout.elevationM !== 0) {
tiles.push({
icon: <Mountain className="size-4" />,
label: t("insights.workouts.detail.statsElevation"),
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
});
}
// Pace is only meaningful for foot- and ride-paced sports. Surface
// it when the workout has both a distance and a sport with a
// canonical pace unit; the fallback covers the long-tail HK enum
// values without lighting up a misleading number.
const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
if (
workout.distanceM != null &&
workout.distanceM > 100 &&
PACE_SPORTS.has(workout.sportType)
) {
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale),
});
}
return (
<Card data-slot="workout-detail-stats">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.statsTitle")}
</CardTitle>
</CardHeader>
<CardContent
className={cn(
"grid gap-2",
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{tiles.map((tile) => (
<StatTile key={tile.label} {...tile} />
))}
</CardContent>
</Card>
);
}
// ── Route ────────────────────────────────────────────────────────────
export interface WorkoutDetailRouteProps {
workout: WorkoutDetailPayload;
}
interface LineStringGeometry {
type: "LineString";
coordinates: Array<[number, number] | [number, number, number]>;
}
function isLineString(value: unknown): value is LineStringGeometry {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.type === "LineString" && Array.isArray(v.coordinates);
}
export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
const { t } = useTranslations();
const geometry = workout.route?.geometry;
if (!isLineString(geometry) || geometry.coordinates.length < 2) {
return (
<Card data-slot="workout-detail-route-empty">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.routeTitle")}
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground text-sm">
{t("insights.workouts.detail.routeUnavailable")}
</CardContent>
</Card>
);
}
// The map widget itself defers to v1.5.x (MapLibre / Leaflet not yet
// pulled in). For v1.4.32 we paint a lightweight SVG polyline of the
// route geometry — projection-free, normalised against the bounding
// box, no third-party dependency. Resolution is fine on screen
// because routes are bounded to 20 000 points by the ingest cap.
const coordinates = geometry.coordinates;
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const [lon, lat] of coordinates) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
const padLon = (maxLon - minLon) * 0.05 || 0.0001;
const padLat = (maxLat - minLat) * 0.05 || 0.0001;
minLon -= padLon;
maxLon += padLon;
minLat -= padLat;
maxLat += padLat;
const VIEW_W = 400;
const VIEW_H = 240;
const points = coordinates
.map(([lon, lat]) => {
const x = ((lon - minLon) / (maxLon - minLon)) * VIEW_W;
// SVG y-axis grows downward; flip lat → y.
const y = VIEW_H - ((lat - minLat) / (maxLat - minLat)) * VIEW_H;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<Card data-slot="workout-detail-route">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.routeTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="bg-muted/40 h-60 w-full rounded-md"
role="img"
aria-label={t("insights.workouts.detail.routeTitle")}
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
/>
</svg>
</CardContent>
</Card>
);
}
// ── Heart-rate chart ─────────────────────────────────────────────────
export function WorkoutDetailHRChart({
workout,
}: {
workout: WorkoutDetailPayload;
}) {
const { t } = useTranslations();
// Per-second HR samples are not persisted on the Workout row today —
// the W8d ingest schema stores aggregates only (avg / max / min).
// The per-second series lives on the corresponding `Measurement`
// rows posted alongside the workout via `POST /api/measurements/batch`.
// Wiring that into the detail page consumes a future v1.5.x sprint
// (see R-F T2 + W8d outline); for v1.4.32 the slot renders a
// graceful empty state with the aggregates already surfaced on
// `<WorkoutDetailStats>`.
return (
<Card data-slot="workout-detail-hr-chart">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.hrChartTitle")}
</CardTitle>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<p>{t("insights.workouts.detail.hrChartUnavailable")}</p>
{workout.avgHr != null || workout.maxHr != null ? (
<p>
{workout.avgHr != null
? `${t("insights.workouts.detail.statsAvgHr")}: ${workout.avgHr} bpm`
: null}
{workout.avgHr != null && workout.maxHr != null ? " · " : ""}
{workout.maxHr != null
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
: null}
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { Activity, Moon, Smile } from "lucide-react";
import type { ComponentType } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { CalendarDays } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
interface DayLink {
href: string;
labelKey: string;
icon: ComponentType<{ className?: string }>;
}
const LINKS: DayLink[] = [
{
href: "/insights/pulse",
labelKey: "insights.workouts.detail.dayPulse",
icon: Activity,
},
{
href: "/insights/sleep",
labelKey: "insights.workouts.detail.daySleep",
icon: Moon,
},
{
href: "/insights/mood",
labelKey: "insights.workouts.detail.dayMood",
icon: Smile,
},
];
export interface WorkoutDetailDayLinksProps {
workout: WorkoutDetailPayload;
}
/**
* "That day" — a plain inline link row to the day's other signals
* (intraday pulse, that night's sleep, mood). No reads; just navigation
* so the workout sits in the context of the rest of the day.
*/
export function WorkoutDetailDayLinks({ workout }: WorkoutDetailDayLinksProps) {
const { t } = useTranslations();
void workout;
return (
<Card data-slot="workout-detail-day-links">
<CardHeader>
<TileHeader
icon={CalendarDays}
title={t("insights.workouts.detail.thatDayTitle")}
/>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{LINKS.map(({ href, labelKey, icon: Icon }) => (
<Link
key={href}
href={href}
className="border-border hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
>
<Icon
className="text-muted-foreground size-4"
aria-hidden="true"
/>
{t(labelKey)}
</Link>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,86 @@
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
/**
* Shared formatters for the workout-detail surface. Pure helpers pulled
* out of the former single `workout-detail.tsx` when it was split into a
* `workout-detail/` directory (#67), so header / stats / splits format
* durations and distances identically.
*/
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) {
return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
}
return `${m}m ${s.toString().padStart(2, "0")}s`;
}
/** Compact "34 min" style duration for the sport-average comparison line. */
export function formatDurationMinutes(seconds: number, locale: string): string {
const minutes = Math.round(seconds / 60);
return formatNumber(minutes, locale);
}
export function formatDistanceKm(meters: number, locale: string): string {
const km = meters / 1000;
return getNumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: km < 10 ? 2 : 1,
}).format(km);
}
export function formatNumber(
value: number,
locale: string,
fractionDigits = 0,
): string {
return getNumberFormat(locale, {
maximumFractionDigits: fractionDigits,
minimumFractionDigits: fractionDigits,
}).format(value);
}
export function formatPaceMinPerKm(
durationSec: number,
meters: number,
): string {
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
// the caller gates on distance + sport.
const secPerKm = (durationSec / meters) * 1000;
return formatPaceSeconds(secPerKm);
}
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
export function formatPaceSeconds(secPerKm: number): string {
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")} /km`;
}
export function formatDateRange(
startedAt: string,
endedAt: string,
locale: string,
timeFormat: TimeFormatPreference,
): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
const dateFmt = getDateTimeFormat(locale, {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
const timeFmt = getDateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
}
@@ -0,0 +1,76 @@
"use client";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
* provenance line, sport name, date/time range, big duration + distance.
*/
export interface WorkoutDetailHeaderProps {
workout: WorkoutDetailPayload;
}
function renderSportIconBadge(sportType: string) {
const Icon = iconForSport(sportType);
return (
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-6" />
</span>
);
}
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<header
data-slot="workout-detail-header"
// v1.30 mobile audit (W-1) — stack below sm so the long-format date
// line never squeezes beside a text-2xl duration at 360px.
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
>
<div className="flex min-w-0 items-start gap-4">
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
<span className="text-2xl font-semibold tabular-nums">
{formatDuration(workout.durationSec)}
</span>
{workout.distanceM != null ? (
<span className="text-muted-foreground text-xs tabular-nums">
{formatDistanceKm(workout.distanceM, locale)} km
</span>
) : null}
</div>
</header>
);
}
@@ -0,0 +1,77 @@
"use client";
import dynamic from "next/dynamic";
import { HeartPulse } from "lucide-react";
import type { ComponentProps } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
// Route the HR curve through the ONE recharts async boundary so the
// library stays a single shared chunk (chart-runtime.ts header rule).
const WorkoutHrChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.WorkoutHrChart }),
),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
function WorkoutHrChart(props: ComponentProps<typeof WorkoutHrChartLazy>) {
return (
<ChartErrorBoundary>
<WorkoutHrChartLazy {...props} />
</ChartErrorBoundary>
);
}
export interface WorkoutDetailHrSectionProps {
workout: WorkoutDetailPayload;
}
/**
* Heart-rate curve card — mean line + optional min→max envelope + %HRmax
* zone bands + an average-HR reference line. A muted provenance chip
* discloses when the curve was reconstructed from pulse data around the
* session rather than the workout's own sensor stream. Returns `null`
* when no series is available (hide, don't render empty).
*/
export function WorkoutDetailHrSection({
workout,
}: WorkoutDetailHrSectionProps) {
const { t } = useTranslations();
const series = workout.hrSeries;
if (!series || series.points.length < 2) return null;
return (
<Card data-slot="workout-detail-hr">
<CardHeader className="gap-1">
<TileHeader
icon={HeartPulse}
title={t("insights.workouts.detail.hrChartTitle")}
/>
{series.source === "pulse_window" ? (
<p
data-slot="workout-detail-hr-provenance"
className="text-muted-foreground text-xs"
>
{t("insights.workouts.detail.hrFromPulse")}
</p>
) : null}
</CardHeader>
<CardContent>
<WorkoutHrChart
points={series.points}
bucketSec={series.bucketSec}
envelope={series.envelope}
avgHr={workout.avgHr}
zones={workout.zones?.zones ?? null}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,17 @@
/**
* Barrel for the workout-detail surface (#67). The former single
* `workout-detail.tsx` grew past a comfortable size once the HR curve,
* zones, route SVG, splits and day-links landed, so it was split into
* this directory. Import path stays `@/components/insights/workout-detail`.
*/
export { WorkoutDetailHeader } from "./header";
export { WorkoutDetailStats } from "./stats";
export { WorkoutDetailHrSection } from "./hr-section";
export { WorkoutDetailZones } from "./zones";
export { WorkoutDetailRoute } from "./route-map";
export { WorkoutDetailSplits } from "./splits";
export { WorkoutDetailDayLinks } from "./day-links";
export {
WorkoutInsightCard,
type WorkoutActivityInsightData,
} from "./insight-slot";
@@ -0,0 +1,49 @@
"use client";
import { Sparkles } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
/**
* Reserved Activity-Insight seam (strategic-concept Wave C, bet 5).
*
* NOTHING renders here today: the workout payload's `aiInsight` field is
* always `null`, so the page composes `{aiInsight ? <WorkoutInsightCard
* insight={aiInsight}/> : null}` and this component never mounts. The
* shape below is what the Phase-2 pg-boss job will write onto the
* workout row — when it lands, the server maps the row to `aiInsight`,
* the union in `use-workouts.ts` widens from `null` to
* `WorkoutActivityInsightData | null`, and the card mounts right under
* the hero header with zero layout rework. Non-diagnostic by
* construction: a descriptive paragraph, device-attributed, never a
* verdict.
*/
export interface WorkoutActivityInsightData {
/** The cached, plain-text insight paragraph. Rendered as text children. */
paragraph: string;
/** ISO timestamp the paragraph was generated. */
generatedAt: string;
}
export function WorkoutInsightCard({
insight,
}: {
insight: WorkoutActivityInsightData;
}) {
const { t } = useTranslations();
return (
<Card data-slot="workout-detail-insight">
<CardHeader>
<TileHeader
icon={Sparkles}
title={t("insights.workouts.detail.insightTitle")}
/>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed">{insight.paragraph}</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,164 @@
"use client";
import { useMemo, type ComponentProps } from "react";
import dynamic from "next/dynamic";
import { Map as MapIcon, Download } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { TileHeader } from "@/components/insights/tile-header";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { importWithRetry } from "@/lib/retry-import";
import { useTranslations } from "@/lib/i18n/context";
import {
projectRoute,
buildElevationProfile,
buildGpxDocument,
type RouteCoordinate,
} from "@/lib/workouts/route-svg";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
const WorkoutElevationChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
(mod) => ({ default: mod.WorkoutElevationChart }),
),
{ ssr: false, loading: () => <div className="h-24 w-full" /> },
);
function WorkoutElevationChart(
props: ComponentProps<typeof WorkoutElevationChartLazy>,
) {
return (
<ChartErrorBoundary>
<WorkoutElevationChartLazy {...props} />
</ChartErrorBoundary>
);
}
interface LineStringGeometry {
type: "LineString";
coordinates: RouteCoordinate[];
}
function isLineString(value: unknown): value is LineStringGeometry {
if (!value || typeof value !== "object") return false;
const v = value as Record<string, unknown>;
return v.type === "LineString" && Array.isArray(v.coordinates);
}
export interface WorkoutDetailRouteProps {
workout: WorkoutDetailPayload;
}
/**
* GPS route card — a self-rendered, aspect-true SVG polyline over the
* user's own stored geometry. Zero external requests: no tile server, no
* geocoder, nothing leaves the origin (binding privacy/CSP property).
* Start = filled `--success` dot, end = `--foreground` ring. Optional
* elevation profile below when the track carries altitude. A GPX export
* lets the user take their track with them — built client-side, zero
* egress. Returns `null` when there is no usable geometry (hide, don't
* render empty).
*/
export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
const { t } = useTranslations();
const geometry = workout.route?.geometry;
const coordinates = isLineString(geometry) ? geometry.coordinates : null;
const projected = useMemo(
() => (coordinates ? projectRoute(coordinates) : null),
[coordinates],
);
const elevation = useMemo(
() => (coordinates ? buildElevationProfile(coordinates) : null),
[coordinates],
);
if (!coordinates || !projected) return null;
const exportGpx = () => {
const gpx = buildGpxDocument({
coordinates,
timestamps: workout.route?.sampleTimestamps ?? null,
sportType: workout.sportType,
startedAt: workout.startedAt,
});
const blob = new Blob([gpx], { type: "application/gpx+xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `workout-${workout.id}.gpx`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const markerR = 1.6;
return (
<Card data-slot="workout-detail-route">
<CardHeader>
<TileHeader
icon={MapIcon}
title={t("insights.workouts.detail.routeTitle")}
right={
<Button
type="button"
variant="ghost"
size="sm"
onClick={exportGpx}
data-slot="workout-detail-gpx-export"
>
<Download className="size-4" aria-hidden="true" />
{t("insights.workouts.detail.exportGpx")}
</Button>
}
/>
</CardHeader>
<CardContent className="space-y-3">
<svg
viewBox={projected.viewBox}
preserveAspectRatio="xMidYMid meet"
className="bg-muted/40 max-h-72 w-full rounded-md"
role="img"
aria-label={t("insights.workouts.detail.routeTitle")}
>
<path
d={projected.path}
fill="none"
stroke="var(--chart-1)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{/* End ring first so the start dot always sits on top on a loop. */}
<circle
cx={projected.end.x}
cy={projected.end.y}
r={markerR}
fill="none"
stroke="var(--foreground)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={projected.start.x}
cy={projected.start.y}
r={markerR}
fill="var(--success)"
/>
</svg>
{elevation ? (
<div data-slot="workout-detail-elevation">
<p className="text-muted-foreground mb-1 text-xs">
{t("insights.workouts.detail.elevationTitle")}
</p>
<WorkoutElevationChart points={elevation} />
</div>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,81 @@
"use client";
import { Flag } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { formatPaceSeconds } from "./format";
function formatSplitTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export interface WorkoutDetailSplitsProps {
workout: WorkoutDetailPayload;
}
/**
* Per-kilometre splits table, derived server-side from the route
* geometry + timestamps. Returns `null` when the workout has no
* split data (hide, don't render empty). The fastest km is marked
* with a discreet muted accent — descriptive, not a verdict.
*/
export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
const { t } = useTranslations();
const splits = workout.splits;
if (!splits || splits.length === 0) return null;
const fastest = Math.min(...splits.map((s) => s.paceSecPerKm));
return (
<Card data-slot="workout-detail-splits">
<CardHeader>
<TileHeader
icon={Flag}
title={t("insights.workouts.detail.splitsTitle")}
/>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground text-left text-xs">
<th className="pb-2 font-medium">
{t("insights.workouts.detail.splitsKm")}
</th>
<th className="pb-2 text-right font-medium">
{t("insights.workouts.detail.splitsTime")}
</th>
<th className="pb-2 text-right font-medium">
{t("insights.workouts.detail.splitsPace")}
</th>
</tr>
</thead>
<tbody>
{splits.map((s) => (
<tr key={s.km} className="border-t">
<td className="py-1.5 tabular-nums">{s.km}</td>
<td className="py-1.5 text-right tabular-nums">
{formatSplitTime(s.durationSec)}
</td>
<td
className={
s.paceSecPerKm === fastest
? "py-1.5 text-right font-semibold tabular-nums"
: "py-1.5 text-right tabular-nums"
}
>
{formatPaceSeconds(s.paceSecPerKm)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
);
}
@@ -0,0 +1,187 @@
"use client";
import type { ReactNode } from "react";
import {
Flame,
Footprints,
HeartPulse,
Map,
Mountain,
Timer,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import {
formatDuration,
formatDistanceKm,
formatNumber,
formatPaceMinPerKm,
formatDurationMinutes,
} from "./format";
interface StatTileProps {
icon: ReactNode;
label: string;
value: string;
hint?: string;
}
function StatTile({ icon, label, value, hint }: StatTileProps) {
return (
<div
data-slot="workout-detail-stat"
className="flex flex-col gap-1 rounded-lg border p-3"
>
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<span aria-hidden="true" className="size-4">
{icon}
</span>
{label}
</span>
<span className="text-base font-semibold tabular-nums">{value}</span>
{hint ? (
<span className="text-muted-foreground text-xs">{hint}</span>
) : null}
</div>
);
}
export interface WorkoutDetailStatsProps {
workout: WorkoutDetailPayload;
}
const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
/**
* Build the muted own-history comparison line ("Your recent average:
* 5.8 km · 34 min · 148 bpm"). Own-history only — never a population
* comparison (non-diagnostic standard). Rendered only when at least two
* sessions of the sport exist, so it never restates the single workout
* you are already looking at.
*/
function sportAverageLine(
workout: WorkoutDetailPayload,
locale: string,
): string | null {
const ctx = workout.sportContext;
if (!ctx || ctx.count < 2) return null;
const parts: string[] = [];
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
}
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
return parts.join(" · ");
}
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
const { t, locale } = useTranslations();
const tiles: StatTileProps[] = [];
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsDuration"),
value: formatDuration(workout.durationSec),
});
if (workout.distanceM != null && workout.distanceM > 0) {
tiles.push({
icon: <Map className="size-4" />,
label: t("insights.workouts.detail.statsDistance"),
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
});
}
if (workout.activeEnergyKcal != null) {
tiles.push({
icon: <Flame className="size-4" />,
label: t("insights.workouts.detail.statsActiveEnergy"),
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
});
}
if (workout.avgHr != null) {
tiles.push({
icon: <HeartPulse className="size-4" />,
label: t("insights.workouts.detail.statsAvgHr"),
value: `${workout.avgHr} bpm`,
hint:
workout.maxHr != null
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
: undefined,
});
}
if (workout.minHr != null) {
tiles.push({
icon: <HeartPulse className="size-4" />,
label: t("insights.workouts.detail.statsMinHr"),
value: `${workout.minHr} bpm`,
});
}
if (workout.stepCount != null && workout.stepCount > 0) {
tiles.push({
icon: <Footprints className="size-4" />,
label: t("insights.workouts.detail.statsStepCount"),
value: formatNumber(workout.stepCount, locale),
});
}
if (workout.elevationM != null && workout.elevationM !== 0) {
tiles.push({
icon: <Mountain className="size-4" />,
label: t("insights.workouts.detail.statsElevation"),
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
});
}
if (
workout.distanceM != null &&
workout.distanceM > 100 &&
PACE_SPORTS.has(workout.sportType)
) {
tiles.push({
icon: <Timer className="size-4" />,
label: t("insights.workouts.detail.statsPace"),
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
});
}
const averageLine = sportAverageLine(workout, locale);
return (
<Card data-slot="workout-detail-stats">
<CardHeader>
<CardTitle className="text-base">
{t("insights.workouts.detail.statsTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div
className={cn(
"grid gap-2",
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
)}
>
{tiles.map((tile) => (
<StatTile key={tile.label} {...tile} />
))}
</div>
{averageLine ? (
<p
data-slot="workout-detail-sport-average"
className="text-muted-foreground text-xs"
>
{t("insights.workouts.detail.sportAverageLabel")}{" "}
<span className="tabular-nums">{averageLine}</span>
</p>
) : null}
</CardContent>
</Card>
);
}
@@ -0,0 +1,98 @@
"use client";
import { Gauge } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useTranslations } from "@/lib/i18n/context";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
const ZONE_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
function formatMinutes(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export interface WorkoutDetailZonesProps {
workout: WorkoutDetailPayload;
}
/**
* Effort-zone distribution — a single horizontal stacked bar (%HRmax
* zones) with minutes per zone underneath. Descriptive only: "how the
* effort was distributed", never a training prescription or cardiac
* read (non-diagnostic standard). Returns `null` when zones could not
* be derived (no profile age and no device zones).
*/
export function WorkoutDetailZones({ workout }: WorkoutDetailZonesProps) {
const { t } = useTranslations();
const zones = workout.zones;
if (!zones) return null;
const total = zones.zones.reduce((sum, z) => sum + z.seconds, 0);
if (total <= 0) return null;
return (
<Card data-slot="workout-detail-zones">
<CardHeader className="gap-1">
<TileHeader
icon={Gauge}
title={t("insights.workouts.detail.zonesTitle")}
/>
<p className="text-muted-foreground text-xs">
{zones.model === "whoop"
? t("insights.workouts.detail.zonesCaptionDevice")
: t("insights.workouts.detail.zonesCaption")}
</p>
</CardHeader>
<CardContent className="space-y-3">
<div
className="flex h-3 w-full overflow-hidden rounded-full"
role="img"
aria-label={t("insights.workouts.detail.zonesTitle")}
>
{zones.zones.map((z, i) =>
z.seconds > 0 ? (
<div
key={z.zone}
style={{
width: `${(z.seconds / total) * 100}%`,
backgroundColor: ZONE_COLORS[i],
}}
/>
) : null,
)}
</div>
<div className="grid grid-cols-5 gap-1 text-center">
{zones.zones.map((z, i) => (
<div key={z.zone} className="flex flex-col items-center gap-1">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ backgroundColor: ZONE_COLORS[i] }}
/>
<span className="text-xs font-medium">Z{z.zone}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{formatMinutes(z.seconds)}
</span>
{z.lowBpm != null ? (
<span className="text-muted-foreground text-[10px] tabular-nums">
{z.lowBpm}
{z.highBpm != null ? `–${z.highBpm}` : "+"}
</span>
) : null}
</div>
))}
</div>
</CardContent>
</Card>
);
}
+21 -1
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { Map as MapIcon, HeartPulse } from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
@@ -106,7 +107,26 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
<Icon className="size-4" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate font-medium">{sportName}</span>
<span className="flex items-center gap-1.5 truncate font-medium">
{sportName}
{/* #67 — discreet glyphs flag which sessions open rich
(map / HR curve). Muted so they never compete with
the sport name. */}
{workout.hasRoute ? (
<MapIcon
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
data-slot="workout-list-glyph-route"
/>
) : null}
{workout.hasHrSeries ? (
<HeartPulse
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
data-slot="workout-list-glyph-hr"
/>
) : null}
</span>
<span className="text-muted-foreground truncate text-xs">
{formatDate(workout.startedAt, locale, timeFormat)}
</span>
+74 -1
View File
@@ -34,6 +34,14 @@ export interface WorkoutListEntry {
maxHr: number | null;
source: string;
externalId: string | null;
/**
* #67 list glyphs — whether the workout opens into a rich detail with
* a map / HR curve. Optional so pre-#67 fixtures (and the dashboard
* recent-tile) still satisfy the type; the list endpoint always sends
* them.
*/
hasRoute?: boolean;
hasHrSeries?: boolean;
}
export interface WorkoutListMeta {
@@ -118,6 +126,55 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
};
}
export interface WorkoutHrSeriesPoint {
tSec: number;
mean: number;
min: number;
max: number;
}
export interface WorkoutHrSeriesDto {
source: "workout_series" | "pulse_window";
bucketSec: number;
points: WorkoutHrSeriesPoint[];
envelope: boolean;
}
export interface WorkoutZoneBandDto {
zone: number;
lowBpm: number | null;
highBpm: number | null;
seconds: number;
}
export interface WorkoutZonesDto {
model: "whoop" | "tanaka";
hrMax: number | null;
zones: WorkoutZoneBandDto[];
}
export interface WorkoutSplitDto {
km: number;
durationSec: number;
paceSecPerKm: number;
}
export interface WorkoutSportContextDto {
count: number;
avgDurationSec: number;
avgDistanceM: number | null;
avgAvgHr: number | null;
}
/**
* Reserved Activity-Insight payload (strategic-concept Wave C, bet 5).
* The wire field is `null` today; the Phase-2 job will populate it and
* the detail page's seam mounts a card with zero rework. Declared here
* so the type is stable in advance — widen the union (never change the
* seam shape) when the job ships.
*/
export type WorkoutActivityInsight = null;
export interface WorkoutDetailPayload extends WorkoutListEntry {
minHr: number | null;
stepCount: number | null;
@@ -128,12 +185,25 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
geometry: unknown;
sampleTimestamps: string[] | null;
} | null;
/**
* Raw per-workout HR sample envelope. `samples.samples` is dropped
* (null) under `compact=1` — the web curve reads `hrSeries` instead —
* but `sampleCount` is retained. Finally declared as of #67.
*/
samples: { sampleCount: number; samples: unknown } | null;
hrSeries: WorkoutHrSeriesDto | null;
zones: WorkoutZonesDto | null;
splits: WorkoutSplitDto[] | null;
sportContext: WorkoutSportContextDto | null;
aiInsight: WorkoutActivityInsight;
canonicalId: string;
}
async function fetchWorkoutDetail(id: string): Promise<WorkoutDetailPayload> {
// Web always sends `compact=1`: the SVG needs geometry, not the raw
// 30k-sample / route-timestamp blobs (those ride the iOS path only).
return apiGet<WorkoutDetailPayload>(
`/api/workouts/${encodeURIComponent(id)}`,
`/api/workouts/${encodeURIComponent(id)}?compact=1`,
);
}
@@ -141,6 +211,8 @@ export interface UseWorkoutDetailResult {
data: WorkoutDetailPayload | undefined;
isLoading: boolean;
error: Error | null;
/** Re-runs the query — the retry action for the detail error card. */
refetch: () => void;
}
export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
@@ -155,5 +227,6 @@ export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
data: query.data,
isLoading: query.isLoading,
error: query.error as Error | null,
refetch: () => void query.refetch(),
};
}
+15 -3
View File
@@ -63,6 +63,8 @@ import {
import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse";
import { isNearUtc, userDayKey } from "@/lib/tz/resolver";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* v1.4.37 W2 — cold-path correlation window. Trim from 30 to 28 days
@@ -120,6 +122,12 @@ export interface CorrelationsFastPathInput {
userTz: string;
now: Date;
coverage?: RollupCoverageMap;
/**
* Reader's locale for the three hypotheses' narrated `interpretation`. The
* correlation cards render these verbatim, so they must be localised. Defaults
* to English.
*/
locale?: Locale;
}
export async function computeCorrelationHypothesesFastPath(
@@ -127,6 +135,7 @@ export async function computeCorrelationHypothesesFastPath(
): Promise<CorrelationHypothesesResult> {
const DAY_MS = 24 * 60 * 60 * 1000;
const { userId, userTz, now } = input;
const { t } = getServerTranslator(input.locale ?? defaultLocale);
const since = new Date(now.getTime() - CORRELATION_WINDOW_DAYS * DAY_MS);
const coverage = input.coverage ?? (await probeRollupCoverage(userId));
@@ -303,7 +312,7 @@ export async function computeCorrelationHypothesesFastPath(
compliancePct,
});
}
const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs });
const bpCompliance = correlateBpCompliance({ daily: bpCompliancePairs }, t);
// ── Hypothesis 2: Mood × resting pulse ──────────────────────
const dailyMood = new Map<string, number[]>();
@@ -328,7 +337,7 @@ export async function computeCorrelationHypothesesFastPath(
restingPulse,
});
}
const moodPulse = correlateMoodPulse({ daily: moodPulsePairs });
const moodPulse = correlateMoodPulse({ daily: moodPulsePairs }, t);
// ── Hypothesis 3: Weight × weekday ──────────────────────────
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
@@ -339,7 +348,10 @@ export async function computeCorrelationHypothesesFastPath(
weight: row.value,
});
}
const weightWeekday = correlateWeightWeekday({ daily: weightWeekdayPairs });
const weightWeekday = correlateWeightWeekday(
{ daily: weightWeekdayPairs },
t,
);
annotate({
meta: {
@@ -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);
});
});
+217
View File
@@ -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) },
});
}
+9 -6
View File
@@ -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";
+35
View File
@@ -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",
]);
});
});
+139
View File
@@ -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() };
}
-12
View File
@@ -117,15 +117,3 @@ export const ENVIRONMENT_FIELDS: readonly EnvironmentField[] = [
] as const;
/** The correlation channel keys, in declaration order. */
export const ENVIRONMENT_CHANNEL_KEYS: readonly string[] =
ENVIRONMENT_FIELDS.map((f) => f.key);
/** True for a registered environmental channel key. */
export function isEnvironmentChannelKey(key: string): boolean {
return ENVIRONMENT_CHANNEL_KEYS.includes(key);
}
/** Resolve a field by its key, or null when unregistered. */
export function getEnvironmentField(key: string): EnvironmentField | null {
return ENVIRONMENT_FIELDS.find((f) => f.key === key) ?? null;
}
@@ -365,6 +365,53 @@ describe("D2-2 — effect-size floor on discovered drivers", () => {
expect(pair!.interpretation).not.toMatch(/tends to go with/);
expect(pair!.interpretation).toMatch(/never a cause/);
});
// ── i18n regression guard (v1.30.10) ─────────────────────────────────
//
// The generated-insight narrative layer built its user-facing sentences by
// TypeScript string interpolation and never routed through the i18n bundle,
// so a German UI rendered an English sentence spliced into German text. This
// pins the fix: with `locale: "de"` the interpretation is the German template
// (localised metric label + correctly-negated "keine Ursache"), NOT English.
it("narrates in the reader's locale — German, never the English string", () => {
const n = 90;
const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4));
const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)];
const seriesInput: NamedSeries[] = [
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
];
const find = (r: ReturnType<typeof discoverCorrelations>) =>
r.discovered.find(
(p) =>
p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION",
);
const de = find(discoverCorrelations(seriesInput, { locale: "de" }));
const en = find(discoverCorrelations(seriesInput, { locale: "en" }));
expect(de).toBeDefined();
expect(en).toBeDefined();
// German narration: localised metric labels + the German "high" template.
expect(de!.interpretation).toContain("die Zeit im Tageslicht");
expect(de!.interpretation).toContain("die Schlafdauer");
// The load-bearing, correctly-negated "not a cause" survives in German.
expect(de!.interpretation).toMatch(/keine Ursache/);
// And it is NOT the English leak the maintainer saw.
expect(de!.interpretation).not.toMatch(/tends to go with/);
expect(de!.interpretation).not.toMatch(
/a pattern worth watching, not a cause/,
);
expect(de!.interpretation).not.toEqual(en!.interpretation);
// English default stays exactly the established wording.
expect(en!.interpretation).toMatch(/tends to go with/);
expect(en!.interpretation).toMatch(/not a cause/);
});
});
// ── FDREXTEND — medication compliance + symptom severity channels ─────
+91 -38
View File
@@ -39,10 +39,19 @@
* link. The series builders live in `correlation-series-builders.ts`.
*/
import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
import {
getEnvironmentField,
isEnvironmentChannelKey,
} from "@/lib/environment/fields";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* Locale-aware string builder — the same signature the server translator
* exposes. Every user-facing narration in this module routes through one of
* these so the sentence is emitted in the reader's language, not hardcoded
* English. Falls back to English (then the raw key) inside the translator.
*/
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/** Default Benjamini-Hochberg target false-discovery rate. */
export const FDR_Q = 0.1;
@@ -288,46 +297,64 @@ export function benjaminiHochberg(
* Sign of `r` flips the direction; the `tier` (RECON1 D2-2 / D2-6) flips the
* CONFIDENCE of the phrasing so a faint effect reads as "a faint hint, if
* anything" rather than a confident "tends to go with". Never claims causation.
*
* The sentence is assembled from locale-keyed templates under
* `insights.correlation.daily.*` (+ a per-locale direction word) so the calm,
* never-causal framing survives verbatim in every language rather than leaking
* English into a non-English UI.
*/
function interpret(
behaviour: string,
outcome: string,
r: number,
tier: ConfidenceTier,
t: Translate,
): string {
const b = humanise(behaviour);
const o = humanise(outcome);
const lower = r < 0 ? "lower" : "higher";
if (tier === "faint") {
// Below the confident effect-size threshold: a real-but-small signal,
// hedged so the narrated confidence never outruns the effect.
return `Higher ${b} shows a faint hint, if anything, of ${lower} next-day ${o} in your data — too small to lean on, never a cause.`;
}
const lead =
tier === "high"
? `Higher ${b} tends to go with ${lower} next-day ${o} in your data`
: `Higher ${b} looks like it goes with ${lower} next-day ${o} in your data, on the evidence so far`;
return `${lead} — a pattern worth watching, not a cause.`;
const params = {
behaviour: humanise(behaviour, t),
outcome: humanise(outcome, t),
direction: t(
`insights.correlation.direction.${r < 0 ? "lower" : "higher"}`,
),
};
// faint = below the confident effect-size threshold (a real-but-small signal,
// hedged); high = confident "tends to go with"; moderate = the in-between
// "on the evidence so far" phrasing. Each tier keeps its own template.
const tierKey =
tier === "faint" ? "faint" : tier === "high" ? "high" : "moderate";
return t(`insights.correlation.daily.${tierKey}`, params);
}
/**
* Lower-case, space-separated label from a channel key.
* Localised, human-readable label for a channel key.
*
* v1.14.0 — a `FACTOR:<key>` channel (a RATED mood factor folded into the
* matrix) strips its namespace prefix so the phrasing reads "rated <factor>"
* rather than leaking the raw key. The prefix exists only to keep a factor
* key from colliding with a `MeasurementType` in the channel set.
* key from colliding with a `MeasurementType` in the channel set. The factor
* name itself is user data, kept verbatim (lower-cased) inside the localised
* `channelFactor` frame.
*
* Every other channel (measurement types, MOOD, medication compliance, symptom
* severity, and the ENV_* exposure channels) resolves through a stable
* `insights.correlation.channel.<KEY>` label, translated in all six locales.
* An unknown key falls back to its prettified form so a newly-added channel
* never blanks.
*/
function humanise(key: string): string {
if (key.startsWith("FACTOR:")) {
return `rated ${key.slice("FACTOR:".length).replace(/[_-]/g, " ").toLowerCase()}`;
function humanise(key: string, t: Translate): string {
if (key.startsWith(FACTOR_CHANNEL_PREFIX)) {
const factor = key
.slice(FACTOR_CHANNEL_PREFIX.length)
.replace(/[_-]/g, " ")
.toLowerCase();
return t("insights.correlation.channelFactor", { factor });
}
// v1.25 (W-ENV) — env channels read with their descriptive label ("daily
// temperature", "daylight") rather than the raw "env temp mean" key.
if (isEnvironmentChannelKey(key)) {
return getEnvironmentField(key)?.narrationLabel ?? key.toLowerCase();
}
return key.replace(/_/g, " ").toLowerCase();
const labelKey = `insights.correlation.channel.${key}`;
const label = t(labelKey);
// The server translator echoes the key on a miss; fall back to a readable
// prettified form so an unmapped channel degrades gracefully, never English-
// leaks a raw key.
return label === labelKey ? key.replace(/_/g, " ").toLowerCase() : label;
}
/** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */
@@ -402,11 +429,26 @@ export function discoveryMeasurementTypes(keys: readonly string[]): string[] {
*/
export function discoverCorrelations(
series: NamedSeries[],
opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {},
opts: {
lagDays?: number;
minPairs?: number;
fdrQ?: number;
/**
* Reader's locale for the narrated `interpretation`. Defaults to English
* so the many server callers that feed the string into an LLM prompt (which
* re-localises) keep their existing behaviour; the user-facing correlations
* route passes the resolved locale so the card renders in the reader's
* language.
*/
locale?: Locale;
} = {},
): CorrelationDiscoveryResult {
const lagDays = opts.lagDays ?? 1;
const minPairs = opts.minPairs ?? MIN_PAIRED_N;
const fdrQ = opts.fdrQ ?? FDR_Q;
// Named `translate` (not `t`) — the pair-mapping callbacks below bind `t` to
// the RawPair element, so the translator must not shadow-collide with them.
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
const behaviours = series.filter((s) => s.role === "behaviour");
const outcomes = series.filter((s) => s.role === "outcome");
@@ -469,7 +511,7 @@ export function discoverCorrelations(
qValue: Math.round(t.qValue * 1000) / 1000,
shrunkR: Math.round(t.shrunkR * 1000) / 1000,
tier: t.tier,
interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier),
interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier, translate),
lagDays,
}))
// RECON1 (D2-2 / D4) — rank by the SHRUNK effect magnitude (a deep, strong
@@ -650,6 +692,8 @@ export function discoverEmergingCorrelations(
lagDays?: number;
minPairs?: number;
fdrQ?: number;
/** Reader's locale, threaded to the inner scan's narration. */
locale?: Locale;
},
): EmergingCorrelationResult {
const windowDays = opts.windowDays ?? EARLY_WINDOW_DAYS;
@@ -661,6 +705,7 @@ export function discoverEmergingCorrelations(
lagDays: opts.lagDays,
minPairs,
fdrQ,
locale: opts.locale,
});
const established = new Set(
@@ -810,12 +855,16 @@ export function discoverLabOutcomeCorrelations(
minDraws?: number;
minWindowPoints?: number;
fdrQ?: number;
/** Reader's locale for the narrated `interpretation`. Defaults to English. */
locale?: Locale;
} = {},
): LabCorrelationResult {
const windowDays = opts.windowDays ?? LAB_OUTCOME_WINDOW_DAYS;
const minDraws = opts.minDraws ?? LAB_MIN_DRAWS;
const minWindowPoints = opts.minWindowPoints ?? LAB_MIN_WINDOW_POINTS;
const fdrQ = opts.fdrQ ?? FDR_Q;
// Named `translate` — the pair-mapping callback below binds `t` to its element.
const { t: translate } = getServerTranslator(opts.locale ?? defaultLocale);
// Group draws by biomarker key (newest-irrelevant; Pearson is order-free).
const drawsByLab = new Map<string, LabDrawPoint[]>();
@@ -902,7 +951,7 @@ export function discoverLabOutcomeCorrelations(
pValue: t.pValue,
qValue: Math.round(t.qValue * 1000) / 1000,
windowDays,
interpretation: interpretLab(t.lab, t.outcome, t.r, t.tier),
interpretation: interpretLab(t.lab, t.outcome, t.r, t.tier, translate),
}))
.sort((a, b) => Math.abs(b.r) - Math.abs(a.r) || a.qValue - b.qValue);
@@ -928,12 +977,16 @@ function interpretLab(
outcome: string,
r: number,
tier: ConfidenceTier,
t: Translate,
): string {
const l = humaniseLab(lab);
const o = humanise(outcome);
const dir = r < 0 ? "lower" : "higher";
if (tier === "faint") {
return `Higher ${l} readings show a faint hint, if anything, of ${dir} ${o} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause.`;
}
return `Higher ${l} readings line up with ${dir} ${o} over the same periods in your data — an association worth watching with your clinician, never a cause.`;
const params = {
// The analyte name is user data — kept verbatim inside the localised frame.
lab: humaniseLab(lab),
outcome: humanise(outcome, t),
direction: t(
`insights.correlation.direction.${r < 0 ? "lower" : "higher"}`,
),
};
const tierKey = tier === "faint" ? "faint" : "default";
return t(`insights.correlation.lab.${tierKey}`, params);
}
+60 -21
View File
@@ -34,8 +34,44 @@
*
* Conservative phrasing — interpretations always read as "a pattern worth
* watching" or similar, never "X causes Y". Causation language is banned
* from this module by code-review convention.
* from this module by code-review convention. Every user-facing interpretation
* is assembled from locale-keyed templates under `insights.correlation.*` so
* the never-causal framing survives verbatim in all six locales rather than
* leaking English into a non-English UI.
*/
import { defaultLocale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/** Locale-aware string builder — mirrors the server translator's `t`. */
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* English by default so the pure callers + unit tests that pass no locale keep
* their existing (English) output; the analytics route threads the reader's
* locale through the fast-path so the cards render in the reader's language.
*/
function defaultTranslate(): Translate {
return getServerTranslator(defaultLocale).t;
}
/**
* Weekday index (0 = Monday … 6 = Sunday, the order the ANOVA outlier index
* uses) → the shared `charts.weekdaysFull.*` label key, reused so the weekday
* name is already localised in every bundle.
*/
const WEEKDAY_LABEL_KEYS = [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
] as const;
export type CorrelationKind = "bp-compliance" | "mood-pulse" | "weight-weekday";
export interface CorrelationConfidenceBand {
@@ -612,6 +648,7 @@ export interface BpComplianceInput {
*/
export function correlateBpCompliance(
input: BpComplianceInput,
t: Translate = defaultTranslate(),
): CorrelationResult {
const points = input.daily.map((d) => ({
x: d.compliancePct,
@@ -640,10 +677,10 @@ export function correlateBpCompliance(
}
const interpretation =
r.r < -0.1
? "Higher medication compliance lines up with lower systolic readings — a pattern worth watching."
? t("insights.correlation.hypothesis.bpCompliance.support")
: r.r > 0.1
? "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything."
: "Compliance and systolic do not move together strongly in this window.";
? t("insights.correlation.hypothesis.bpCompliance.surprising")
: t("insights.correlation.hypothesis.bpCompliance.none");
return {
kind: "bp-compliance",
status: "ok",
@@ -673,7 +710,10 @@ export interface MoodPulseInput {
* Runs Pearson on (mood, restingPulse). A negative r supports the
* hypothesis (mood down → pulse up).
*/
export function correlateMoodPulse(input: MoodPulseInput): CorrelationResult {
export function correlateMoodPulse(
input: MoodPulseInput,
t: Translate = defaultTranslate(),
): CorrelationResult {
const points = input.daily.map((d) => ({ x: d.mood, y: d.restingPulse }));
const xs = points.map((p) => p.x);
const ys = points.map((p) => p.y);
@@ -698,10 +738,10 @@ export function correlateMoodPulse(input: MoodPulseInput): CorrelationResult {
}
const interpretation =
r.r < -0.1
? "Lower-mood days line up with higher resting pulse — a pattern worth watching."
? t("insights.correlation.hypothesis.moodPulse.support")
: r.r > 0.1
? "Higher-mood days line up with higher resting pulse in your data — surprising."
: "Mood and resting pulse do not move together strongly in this window.";
? t("insights.correlation.hypothesis.moodPulse.surprising")
: t("insights.correlation.hypothesis.moodPulse.none");
return {
kind: "mood-pulse",
status: "ok",
@@ -726,16 +766,6 @@ export interface WeightWeekdayInput {
daily: ReadonlyArray<{ weekday: number; weight: number }>;
}
const WEEKDAY_LABELS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
] as const;
/**
* Hypothesis #3 — weight has a weekly pattern (e.g. Monday spikes
* after weekends).
@@ -745,6 +775,7 @@ const WEEKDAY_LABELS = [
*/
export function correlateWeightWeekday(
input: WeightWeekdayInput,
t: Translate = defaultTranslate(),
): CorrelationResult {
const points = input.daily.map((d) => ({ x: d.weekday, y: d.weight }));
const anova = weekdayAnova(
@@ -768,13 +799,21 @@ export function correlateWeightWeekday(
points,
};
}
const dayLabel = WEEKDAY_LABELS[anova.outlierIndex];
const dayLabel = t(
`charts.weekdaysFull.${WEEKDAY_LABEL_KEYS[anova.outlierIndex]}`,
);
const grandMean =
input.daily.reduce((s, p) => s + p.weight, 0) / input.daily.length;
const outlierMean = anova.means[anova.outlierIndex] ?? grandMean;
const direction = outlierMean > grandMean ? "above" : "below";
const direction = t(
`insights.correlation.weekdayDirection.${outlierMean > grandMean ? "above" : "below"}`,
);
const delta = Math.abs(outlierMean - grandMean).toFixed(1);
const interpretation = `${dayLabel} weights run ${delta} kg ${direction} your other-day average — a pattern worth watching.`;
const interpretation = t("insights.correlation.hypothesis.weightWeekday", {
weekday: dayLabel,
delta,
direction,
});
return {
kind: "weight-weekday",
+24
View File
@@ -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;
}
+22
View File
@@ -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", () => {
+12 -2
View File
@@ -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;
}
+80
View File
@@ -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 };
}
+28
View File
@@ -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"],
+71 -1
View File
@@ -56,6 +56,9 @@ const workoutListEntry = z
maxHr: z.number().int().nullable(),
source: measurementSourceEnum,
externalId: z.string().nullable(),
// #67 list glyphs — flags which sessions open into a rich detail.
hasRoute: z.boolean(),
hasHrSeries: z.boolean(),
})
.meta({ id: "WorkoutListEntry" });
@@ -89,6 +92,61 @@ const workoutHrSeries = z
})
.meta({ id: "WorkoutHrSeries" });
// #67 — per-workout HR curve, one DTO with explicit provenance. Stored
// `WorkoutSamples` series first, raw PULSE-window reconstruction second.
const workoutHrCurve = z
.object({
source: z.enum(["workout_series", "pulse_window"]),
bucketSec: z.number().int().positive(),
points: z.array(
z.object({
tSec: z.number().int().nonnegative(),
mean: z.number().int(),
min: z.number().int(),
max: z.number().int(),
}),
),
envelope: z.boolean(),
})
.meta({ id: "WorkoutHrCurve" });
// #67 — effort-zone distribution. WHOOP device durations win; else a
// %HRmax fold from the HR curve when profile age exists.
const workoutZones = z
.object({
model: z.enum(["whoop", "tanaka"]),
hrMax: z.number().int().nullable(),
zones: z.array(
z.object({
zone: z.number().int().min(1).max(5),
lowBpm: z.number().int().nullable(),
highBpm: z.number().int().nullable(),
seconds: z.number().int().nonnegative(),
}),
),
})
.meta({ id: "WorkoutZones" });
// #67 — per-kilometre splits, derived server-side from geometry +
// timestamps so the raw timestamp blob can be dropped under `compact=1`.
const workoutSplit = z
.object({
km: z.number().int().positive(),
durationSec: z.number().int().nonnegative(),
paceSecPerKm: z.number().int().nonnegative(),
})
.meta({ id: "WorkoutSplit" });
// #67 — own-history average for the sport (comparison line).
const workoutSportContext = z
.object({
count: z.number().int().nonnegative(),
avgDurationSec: z.number().int().nonnegative(),
avgDistanceM: z.number().nullable(),
avgAvgHr: z.number().int().nullable(),
})
.meta({ id: "WorkoutSportContext" });
const workoutDetailResponse = z
.object({
id: z.string(),
@@ -109,6 +167,15 @@ const workoutDetailResponse = z
metadata: z.unknown().nullable(),
route: workoutRouteGeometry.nullable(),
samples: workoutHrSeries.nullable(),
// #67 enrichment — additive; absent from a pre-enrichment client's
// expectations, opt-in `compact=1` only trims the raw blobs above.
hrSeries: workoutHrCurve.nullable(),
zones: workoutZones.nullable(),
splits: z.array(workoutSplit).nullable(),
sportContext: workoutSportContext.nullable(),
// Reserved Activity-Insight seam — always null until the Phase-2 job
// populates it. Typed here so the wire contract is stable in advance.
aiInsight: z.null(),
canonicalId: z.string(),
})
.meta({ id: "WorkoutDetailResponse" });
@@ -147,9 +214,12 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Measurements"],
summary: "Workout detail (v1.4.32)",
description:
"Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).",
"Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed).",
requestParams: {
path: z.object({ id: z.string() }),
query: z.object({
compact: z.enum(["1"]).optional(),
}),
},
responses: {
"200": {
+8
View File
@@ -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;
+1 -1
View File
@@ -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 =>
+42
View File
@@ -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>");
});
});
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { computeSplits } from "@/lib/workouts/splits";
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
/**
* Build a straight eastward track of `n` evenly spaced points spanning
* `totalKm`, with `secPerKm` pace. One degree of longitude at 50°N is
* ~71.7 km, but we anchor the math on the actual haversine distance the
* function computes, so we just space points and let the boundaries
* fall out.
*/
function track(
totalMeters: number,
n: number,
totalSeconds: number,
): { coords: RouteCoordinate[]; timestamps: string[] } {
const coords: RouteCoordinate[] = [];
const timestamps: string[] = [];
const lat = 0; // equator → a degree of lon ≈ 111.32 km, easy to reason about
const startMs = Date.parse("2026-05-15T07:00:00Z");
const totalDeg = totalMeters / 111_320; // metres → degrees lon at equator
for (let i = 0; i <= n; i++) {
const f = i / n;
coords.push([f * totalDeg, lat]);
timestamps.push(new Date(startMs + f * totalSeconds * 1000).toISOString());
}
return { coords, timestamps };
}
describe("computeSplits", () => {
it("returns one split per full kilometre with interpolated pace", () => {
// 3.2 km in 960 s → 300 s/km even pace; three full-km boundaries.
const { coords, timestamps } = track(3200, 320, 960);
const splits = computeSplits(coords, timestamps)!;
expect(splits).toHaveLength(3);
expect(splits[0].km).toBe(1);
for (const s of splits) {
expect(s.paceSecPerKm).toBeGreaterThan(285);
expect(s.paceSecPerKm).toBeLessThan(315);
expect(s.durationSec).toBe(s.paceSecPerKm);
}
});
it("returns null without aligned timestamps", () => {
const { coords } = track(3000, 300, 900);
expect(computeSplits(coords, null)).toBeNull();
expect(computeSplits(coords, ["2026-05-15T07:00:00Z"])).toBeNull();
});
it("returns null under one full kilometre", () => {
const { coords, timestamps } = track(400, 50, 120);
expect(computeSplits(coords, timestamps)).toBeNull();
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
computeZones,
parseWhoopZoneDurations,
hrMaxFromAge,
} from "@/lib/workouts/zones";
import type { HrSeriesPoint } from "@/lib/workouts/hr-series";
function pt(tSec: number, mean: number): HrSeriesPoint {
return { tSec, mean, min: mean, max: mean };
}
describe("hrMaxFromAge", () => {
it("applies Tanaka and rounds", () => {
expect(hrMaxFromAge(40)).toBe(180); // 208 − 0.7·40
expect(hrMaxFromAge(null)).toBeNull();
});
});
describe("computeZones — %HRmax (tanaka)", () => {
const hrMax = 180; // age 40 → Z1 90-108, Z3 126-144, Z5 162+
it("folds series buckets into %HRmax zones by mean HR", () => {
const series = [
pt(0, 100), // 55 % → Z1
pt(10, 135), // 75 % → Z3
pt(20, 140), // 78 % → Z3
pt(30, 170), // 94 % → Z5
];
const zones = computeZones({
hrMax,
series,
bucketSec: 10,
whoopZoneDurations: null,
})!;
expect(zones.model).toBe("tanaka");
expect(zones.hrMax).toBe(180);
const byZone = Object.fromEntries(
zones.zones.map((z) => [z.zone, z.seconds]),
);
expect(byZone[1]).toBe(10);
expect(byZone[3]).toBe(20);
expect(byZone[5]).toBe(10);
expect(byZone[2]).toBe(0);
// Bounds carry bpm edges; the top zone is open-ended.
const z5 = zones.zones.find((z) => z.zone === 5)!;
expect(z5.lowBpm).toBe(162);
expect(z5.highBpm).toBeNull();
});
it("excludes below-Z1 (rest) time and returns null when all rest", () => {
const series = [pt(0, 80), pt(10, 85)]; // < 50 % HRmax
expect(
computeZones({ hrMax, series, bucketSec: 10, whoopZoneDurations: null }),
).toBeNull();
});
it("returns null without profile age", () => {
expect(
computeZones({
hrMax: null,
series: [pt(0, 140)],
bucketSec: 10,
whoopZoneDurations: null,
}),
).toBeNull();
});
});
describe("computeZones — WHOOP wins", () => {
it("uses device-reported durations over the computed series", () => {
const zones = computeZones({
hrMax: 180,
series: [pt(0, 140)],
bucketSec: 10,
whoopZoneDurations: [60, 120, 180, 240, 300],
})!;
expect(zones.model).toBe("whoop");
const seconds = zones.zones.map((z) => z.seconds);
expect(seconds).toEqual([60, 120, 180, 240, 300]);
// Bounds still resolve from HRmax when age is known.
expect(zones.zones[0].lowBpm).toBe(90);
});
it("carries null bounds when HRmax is unknown but WHOOP zones exist", () => {
const zones = computeZones({
hrMax: null,
series: [],
bucketSec: 10,
whoopZoneDurations: [10, 20, 30, 40, 50],
})!;
expect(zones.model).toBe("whoop");
expect(zones.hrMax).toBeNull();
expect(zones.zones[0].lowBpm).toBeNull();
});
});
describe("parseWhoopZoneDurations", () => {
it("maps zone_one..zone_five milliseconds to Z1..Z5 seconds", () => {
const parsed = parseWhoopZoneDurations({
zoneDurations: {
zone_zero_milli: 900000,
zone_one_milli: 60000,
zone_two_milli: 120000,
zone_three_milli: 0,
zone_four_milli: 0,
zone_five_milli: 30000,
},
});
expect(parsed).toEqual([60, 120, 0, 0, 30]);
});
it("returns null for absent, malformed, or all-zero durations", () => {
expect(parseWhoopZoneDurations(null)).toBeNull();
expect(parseWhoopZoneDurations({})).toBeNull();
expect(parseWhoopZoneDurations({ zoneDurations: "nope" })).toBeNull();
expect(
parseWhoopZoneDurations({ zoneDurations: { zone_one_milli: 0 } }),
).toBeNull();
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* Per-workout heart-rate curve — one server code path, one DTO with
* explicit provenance.
*
* Source priority (strict):
* 1. `WorkoutSamples` (stored series) — the session's own sensor
* stream, highest fidelity. Used whenever the parsed series yields
* ≥ 2 usable HR points.
* 2. Pulse-window reconstruction — raw `PULSE` measurement rows in
* `[startedAt − 5 min, endedAt + 5 min]`, bucketed at session
* grain. Only attempted for workouts younger than the dense
* retention window (beyond it only hourly folds survive, which are
* useless for a session curve), and only when the fallback quality
* gate passes.
* 3. Nothing — the caller hides the HR-curve card. The avg/max/min
* aggregates already live in the stats grid; an empty chart lies.
*
* The fallback gate keeps the same posture as the tension detector:
* silence over speculation. A watchless ride with two opportunistic BPM
* readings must not paint a two-point "curve".
*/
import { prisma } from "@/lib/db";
/** Dense raw-PULSE retention. Older days keep only hourly folds. */
const DENSE_INTRADAY_RETENTION_DAYS = 90;
/** ± padding around the session for the fallback window. */
const WINDOW_PAD_MS = 5 * 60 * 1000;
/** Defensive read cap — dense accounts emit ~1 PULSE sample/second. */
const MAX_WINDOW_PULSE_ROWS = 6000;
/** Fallback gate: raw samples required in the padded window. */
const MIN_FALLBACK_SAMPLES = 8;
/** Fallback gate: fraction of session buckets that must be non-empty. */
const MIN_BUCKET_COVERAGE = 0.4;
/** Envelope band renders once buckets are dense enough to be meaningful. */
const ENVELOPE_MIN_MEDIAN_DENSITY = 3;
export type HrSeriesSource = "workout_series" | "pulse_window";
export interface HrSeriesPoint {
/** Elapsed seconds from the workout start (bucket left edge). */
tSec: number;
mean: number;
min: number;
max: number;
}
export interface WorkoutHrSeries {
source: HrSeriesSource;
bucketSec: number;
points: HrSeriesPoint[];
/**
* True when per-bucket density supports a min→max envelope band —
* this is what makes intervals read as spiky instead of averaged
* away. Below the threshold the chart draws the mean line only.
*/
envelope: boolean;
}
interface RawHrSample {
tMs: number;
hr: number;
}
function clamp(value: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, value));
}
/**
* Adaptive bucket width targeting a constant on-screen density: a
* 20-minute HIIT and a 6-hour ride both land at ≤ ~240–480 points.
*/
export function adaptiveBucketSec(durationSec: number): number {
return clamp(Math.ceil(durationSec / 240), 5, 60);
}
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
/**
* Fold raw HR samples into session-grain buckets. Samples outside the
* session window `[0, durationSec)` are dropped from the curve (they
* only widen the DB query so a boundary reading still lands in bucket
* 0 / the last bucket). Empty buckets stay as gaps — no interpolation.
* Pure; exported for unit testing.
*/
export function foldHrBuckets(
samples: readonly RawHrSample[],
startMs: number,
durationSec: number,
bucketSec: number,
): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } {
const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec));
const acc = new Map<
number,
{ sum: number; min: number; max: number; n: number }
>();
for (const s of samples) {
const rel = (s.tMs - startMs) / 1000;
if (rel < 0 || rel >= durationSec) continue;
const idx = Math.floor(rel / bucketSec);
const bucket = acc.get(idx);
if (bucket) {
bucket.sum += s.hr;
bucket.n += 1;
if (s.hr < bucket.min) bucket.min = s.hr;
if (s.hr > bucket.max) bucket.max = s.hr;
} else {
acc.set(idx, { sum: s.hr, min: s.hr, max: s.hr, n: 1 });
}
}
const points: HrSeriesPoint[] = [];
const densities: number[] = [];
for (const [idx, b] of [...acc.entries()].sort((a, c) => a[0] - c[0])) {
points.push({
tSec: idx * bucketSec,
mean: Math.round(b.sum / b.n),
min: b.min,
max: b.max,
});
densities.push(b.n);
}
return { points, bucketCount, medianDensity: median(densities) };
}
/**
* Parse the stored `WorkoutSamples.samples` JSONB into usable HR
* samples. The blob is validated at ingest against
* `workoutHrSamplesSchema`, so this stays a lean, defensive read rather
* than a full re-validation of up to 30 000 rows.
*/
function parseStoredSamples(raw: unknown): RawHrSample[] {
if (!Array.isArray(raw)) return [];
const out: RawHrSample[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const rec = entry as Record<string, unknown>;
const hr = rec.hr;
const t = rec.t;
if (typeof hr !== "number" || !Number.isFinite(hr)) continue;
if (typeof t !== "string") continue;
const tMs = Date.parse(t);
if (!Number.isFinite(tMs)) continue;
out.push({ tMs, hr });
}
return out;
}
export interface WorkoutHrSeriesInput {
userId: string;
startedAt: Date;
endedAt: Date;
durationSec: number;
storedSamples: unknown;
/** Reference clock for the retention cut — defaults to `now`. */
now?: Date;
}
/**
* Resolve the workout's HR curve. Returns `null` when neither a stored
* series nor an adequate pulse window exists.
*/
export async function buildWorkoutHrSeries(
input: WorkoutHrSeriesInput,
): Promise<WorkoutHrSeries | null> {
const { userId, startedAt, endedAt, durationSec, storedSamples } = input;
const now = input.now ?? new Date();
const startMs = startedAt.getTime();
const bucketSec = adaptiveBucketSec(durationSec);
// 1. Stored series — the native, highest-fidelity path.
const stored = parseStoredSamples(storedSamples);
if (stored.length >= 2) {
const { points, medianDensity } = foldHrBuckets(
stored,
startMs,
durationSec,
bucketSec,
);
if (points.length >= 2) {
return {
source: "workout_series",
bucketSec,
points,
envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY,
};
}
}
// 2. Pulse-window fallback — only within the dense-retention horizon.
const ageDays = (now.getTime() - startMs) / (24 * 60 * 60 * 1000);
if (ageDays > DENSE_INTRADAY_RETENTION_DAYS) return null;
const windowStart = new Date(startMs - WINDOW_PAD_MS);
const windowEnd = new Date(endedAt.getTime() + WINDOW_PAD_MS);
const rows = await prisma.measurement.findMany({
where: {
userId,
type: "PULSE",
deletedAt: null,
measuredAt: { gte: windowStart, lte: windowEnd },
},
orderBy: { measuredAt: "asc" },
take: MAX_WINDOW_PULSE_ROWS,
select: { value: true, measuredAt: true, externalId: true },
});
// Raw per-sample rows only — a consolidated `stats:` fold is a day
// shape, not a session reading.
const raw: RawHrSample[] = rows
.filter((r) => !r.externalId?.startsWith("stats:"))
.map((r) => ({ tMs: r.measuredAt.getTime(), hr: r.value }));
// Gate part 1: enough raw samples in the padded window.
if (raw.length < MIN_FALLBACK_SAMPLES) return null;
const { points, bucketCount, medianDensity } = foldHrBuckets(
raw,
startMs,
durationSec,
bucketSec,
);
// Gate part 2: enough of the session is actually covered.
if (points.length / bucketCount < MIN_BUCKET_COVERAGE) return null;
if (points.length < 2) return null;
return {
source: "pulse_window",
bucketSec,
points,
envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY,
};
}
+353
View File
@@ -0,0 +1,353 @@
/**
* Client-safe route geometry math for the workout-detail map.
*
* PURE — no Prisma, no server imports, no Node APIs. It runs in the
* browser bundle (the v1.29 client/server-boundary lesson) and turns a
* stored GeoJSON `LineString` into an inline SVG polyline plus optional
* elevation profile and a GPX export blob. Nothing here reaches the
* network: the whole point of self-rendering the route is that the
* user's track never leaves the origin (binding privacy/CSP decision —
* no tile server, ever).
*
* Projection is equirectangular with a single `cos(midLat)` longitude
* scale factor. At workout scale (≤ a few tens of km) that is visually
* exact; the previous uncorrected normalisation stretched a route ~40 %
* horizontally at 50° N (a circular circuit read as an ellipse). The
* correction squashes the longitude axis by `cos(midLat)` so one screen
* unit east equals one screen unit north in real metres.
*/
export type RouteCoordinate =
[number, number] | [number, number, number] | number[];
export interface RoutePoint {
x: number;
y: number;
}
export interface ProjectedRoute {
/** SVG path data (`M … L …`) in viewBox units. */
path: string;
/** `0 0 W H` — W is fixed at 100, H is the aspect-derived, clamped height. */
viewBox: string;
width: number;
height: number;
/** Start marker position (viewBox units). */
start: RoutePoint;
/** End marker position (viewBox units). */
end: RoutePoint;
/** Number of points after RDP downsampling. */
pointCount: number;
}
export interface ElevationPoint {
/** Cumulative distance from the route start, metres. */
distanceM: number;
/** Altitude, metres. */
altitude: number;
}
const DEG_TO_RAD = Math.PI / 180;
const METERS_PER_DEG_LAT = 111_320;
/** viewBox width — fixed; height rides the projected aspect ratio. */
const VIEW_W = 100;
/** Aspect clamp so a straight out-and-back can't render as a 4 px sliver. */
const MIN_H = 40;
const MAX_H = 140;
/** Guards against a degenerate "route" (an indoor session's 3 GPS pings). */
const MIN_POINTS = 10;
const MIN_SPAN_M = 30;
/** Shape-preserving downsample ceiling. */
const MAX_RENDER_POINTS = 1200;
function isFiniteNumber(v: unknown): v is number {
return typeof v === "number" && Number.isFinite(v);
}
/**
* Pull the `[lon, lat]` pairs out of a GeoJSON `LineString`'s
* coordinates, dropping any malformed entry. Altitude (3rd element) is
* ignored here — `buildElevationProfile` reads it separately.
*/
export function extractLonLat(
coordinates: readonly RouteCoordinate[],
): Array<[number, number]> {
const out: Array<[number, number]> = [];
for (const c of coordinates) {
if (!Array.isArray(c) || c.length < 2) continue;
const lon = c[0];
const lat = c[1];
if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) continue;
if (Math.abs(lon) > 180 || Math.abs(lat) > 90) continue;
out.push([lon, lat]);
}
return out;
}
/** Great-circle distance between two lon/lat points, metres. */
export function haversineMeters(
aLon: number,
aLat: number,
bLon: number,
bLat: number,
): number {
const R = 6_371_000;
const dLat = (bLat - aLat) * DEG_TO_RAD;
const dLon = (bLon - aLon) * DEG_TO_RAD;
const lat1 = aLat * DEG_TO_RAD;
const lat2 = bLat * DEG_TO_RAD;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** Perpendicular distance from `p` to the segment `a→b` (planar). */
function perpDistance(
p: [number, number],
a: [number, number],
b: [number, number],
): number {
const [px, py] = p;
const [ax, ay] = a;
const [bx, by] = b;
const dx = bx - ax;
const dy = by - ay;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
/**
* Ramer–Douglas–Peucker line simplification. Keeps switchbacks that a
* naive stride would erase. Iterative (an explicit stack) so a
* 20 000-point ultra can't blow the call stack.
*/
export function rdpSimplify(
points: ReadonlyArray<[number, number]>,
tolerance: number,
): Array<[number, number]> {
if (points.length <= 2 || tolerance <= 0) return points.slice();
const keep = new Uint8Array(points.length);
keep[0] = 1;
keep[points.length - 1] = 1;
const stack: Array<[number, number]> = [[0, points.length - 1]];
while (stack.length > 0) {
const [first, last] = stack.pop() as [number, number];
let maxDist = 0;
let index = -1;
for (let i = first + 1; i < last; i++) {
const d = perpDistance(points[i], points[first], points[last]);
if (d > maxDist) {
maxDist = d;
index = i;
}
}
if (maxDist > tolerance && index !== -1) {
keep[index] = 1;
stack.push([first, index]);
stack.push([index, last]);
}
}
const out: Array<[number, number]> = [];
for (let i = 0; i < points.length; i++) if (keep[i]) out.push(points[i]);
return out;
}
/** Uniform stride down to at most `max` points (post-RDP safety cap). */
function strideTo<T>(points: readonly T[], max: number): T[] {
if (points.length <= max) return points.slice();
const step = points.length / max;
const out: T[] = [];
for (let i = 0; i < max; i++) out.push(points[Math.floor(i * step)]);
const last = points[points.length - 1];
if (out[out.length - 1] !== last) out.push(last);
return out;
}
/**
* Project a GeoJSON LineString into an aspect-true, downsampled SVG
* path. Returns `null` for a degenerate route (too few points or a
* bounding box under ~30 m) — the caller then hides the route card
* rather than painting a meaningless dot.
*/
export function projectRoute(
coordinates: readonly RouteCoordinate[],
): ProjectedRoute | null {
const lonLat = extractLonLat(coordinates);
if (lonLat.length < MIN_POINTS) return null;
let minLon = Infinity;
let maxLon = -Infinity;
let minLat = Infinity;
let maxLat = -Infinity;
for (const [lon, lat] of lonLat) {
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
}
const midLat = (minLat + maxLat) / 2;
const cosMid = Math.cos(midLat * DEG_TO_RAD);
// Real-world span (metres) — the small-route guard rejects the indoor
// session that logged a couple of stationary GPS pings.
const spanXm = (maxLon - minLon) * cosMid * METERS_PER_DEG_LAT;
const spanYm = (maxLat - minLat) * METERS_PER_DEG_LAT;
if (Math.hypot(spanXm, spanYm) < MIN_SPAN_M) return null;
// Projected coordinates: longitude squashed by cos(midLat); latitude
// flipped so SVG y grows downward.
const projected: Array<[number, number]> = lonLat.map(([lon, lat]) => [
(lon - minLon) * cosMid,
maxLat - lat,
]);
const xSpanRaw = (maxLon - minLon) * cosMid || 1e-9;
// RDP tolerance derived from the projected bbox (spec: xSpan / 2000).
let simplified = rdpSimplify(projected, xSpanRaw / 2000);
if (simplified.length > MAX_RENDER_POINTS) {
simplified = strideTo(simplified, MAX_RENDER_POINTS);
}
// Projected bbox of the (simplified) track + 5 % padding per side.
let pMinX = Infinity;
let pMaxX = -Infinity;
let pMinY = Infinity;
let pMaxY = -Infinity;
for (const [x, y] of simplified) {
if (x < pMinX) pMinX = x;
if (x > pMaxX) pMaxX = x;
if (y < pMinY) pMinY = y;
if (y > pMaxY) pMaxY = y;
}
const rawXSpan = pMaxX - pMinX || 1e-9;
const rawYSpan = pMaxY - pMinY || 1e-9;
const padX = rawXSpan * 0.05;
const padY = rawYSpan * 0.05;
pMinX -= padX;
pMaxX += padX;
pMinY -= padY;
pMaxY += padY;
const xSpan = pMaxX - pMinX;
const ySpan = pMaxY - pMinY;
// Height rides the true aspect ratio (uniform scale when unclamped);
// the clamp only bites on extreme slivers, where the intentional
// distortion trades geographic fidelity for on-screen readability.
const height = Math.max(MIN_H, Math.min(MAX_H, (VIEW_W * ySpan) / xSpan));
const scaleX = VIEW_W / xSpan;
const scaleY = height / ySpan;
const toVB = ([x, y]: [number, number]): RoutePoint => ({
x: (x - pMinX) * scaleX,
y: (y - pMinY) * scaleY,
});
const vbPoints = simplified.map(toVB);
const path = vbPoints
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(" ");
return {
path,
viewBox: `0 0 ${VIEW_W} ${height.toFixed(2)}`,
width: VIEW_W,
height,
start: vbPoints[0],
end: vbPoints[vbPoints.length - 1],
pointCount: vbPoints.length,
};
}
/** Fraction of coordinates that carry a finite altitude (3rd element). */
function altitudeCoverage(coordinates: readonly RouteCoordinate[]): number {
if (coordinates.length === 0) return 0;
let withAlt = 0;
for (const c of coordinates) {
if (Array.isArray(c) && c.length >= 3 && isFiniteNumber(c[2])) withAlt++;
}
return withAlt / coordinates.length;
}
/** Minimum altitude coverage before an elevation profile is honest. */
export const ELEVATION_COVERAGE_THRESHOLD = 0.6;
/**
* Build a cumulative-distance elevation profile when at least 60 % of
* coordinates carry altitude. Below the threshold partial altimeter
* data would draw a lie, so this returns `null` and the caller omits
* the profile.
*/
export function buildElevationProfile(
coordinates: readonly RouteCoordinate[],
): ElevationPoint[] | null {
if (altitudeCoverage(coordinates) < ELEVATION_COVERAGE_THRESHOLD) return null;
const out: ElevationPoint[] = [];
let cumulative = 0;
let prev: [number, number] | null = null;
for (const c of coordinates) {
if (!Array.isArray(c) || c.length < 3) continue;
const lon = c[0];
const lat = c[1];
const alt = c[2];
if (!isFiniteNumber(lon) || !isFiniteNumber(lat) || !isFiniteNumber(alt)) {
continue;
}
if (prev) cumulative += haversineMeters(prev[0], prev[1], lon, lat);
out.push({ distanceM: cumulative, altitude: alt });
prev = [lon, lat];
}
return out.length >= 2 ? out : null;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Serialise the route geometry to a GPX 1.1 document, entirely
* client-side over data already loaded — zero server work, zero egress.
* Timestamps (when present, parallel to the coordinates) are emitted so
* the exported track round-trips into any GPX consumer.
*/
export function buildGpxDocument(params: {
coordinates: readonly RouteCoordinate[];
timestamps?: readonly string[] | null;
sportType: string;
startedAt: string;
}): string {
const { coordinates, timestamps, sportType, startedAt } = params;
const segments: string[] = [];
coordinates.forEach((c, i) => {
if (!Array.isArray(c) || c.length < 2) return;
const lon = c[0];
const lat = c[1];
if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) return;
const parts = [`<trkpt lat="${lat}" lon="${lon}">`];
if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`<ele>${c[2]}</ele>`);
const ts = timestamps?.[i];
if (typeof ts === "string" && ts.length > 0) {
parts.push(`<time>${escapeXml(ts)}</time>`);
}
parts.push(`</trkpt>`);
segments.push(parts.join(""));
});
return [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<gpx version="1.1" creator="HealthLog" xmlns="http://www.topografix.com/GPX/1/1">`,
`<metadata><time>${escapeXml(startedAt)}</time></metadata>`,
`<trk><name>${escapeXml(sportType)}</name><trkseg>`,
...segments,
`</trkseg></trk>`,
`</gpx>`,
].join("\n");
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Per-kilometre splits from a workout's route geometry + per-sample
* timestamps. Pure; computed server-side so the web client keeps
* dropping the raw `sampleTimestamps` blob under `compact=1` (the
* derived splits survive, the 20k-point timestamp array does not) and
* iOS gets the same resolved figures — server-authoritative parity.
*
* Only meaningful for distance/pace sports; the caller gates the sport.
* A workout without aligned timestamps (or under one full km) yields
* `null` and the caller hides the splits card.
*/
import {
extractLonLat,
haversineMeters,
type RouteCoordinate,
} from "@/lib/workouts/route-svg";
export interface WorkoutSplit {
/** 1-based kilometre index. */
km: number;
/** Time to cover this kilometre, seconds. */
durationSec: number;
/** Pace over the kilometre, seconds per km (== durationSec here). */
paceSecPerKm: number;
}
const KM = 1000;
/**
* Compute whole-kilometre splits. Walks the polyline accumulating
* haversine distance; at each km boundary the crossing time is linearly
* interpolated between the two bounding samples by distance fraction,
* so a split boundary that falls mid-segment is timed honestly.
*/
export function computeSplits(
coordinates: readonly RouteCoordinate[],
timestamps: readonly string[] | null | undefined,
): WorkoutSplit[] | null {
if (!timestamps || timestamps.length !== coordinates.length) return null;
// Keep coordinate/timestamp pairs that are both well-formed.
const pts: Array<{ lon: number; lat: number; tMs: number }> = [];
coordinates.forEach((c, i) => {
const [lonLat] = extractLonLat([c]);
if (!lonLat) return;
const tMs = Date.parse(timestamps[i] ?? "");
if (!Number.isFinite(tMs)) return;
pts.push({ lon: lonLat[0], lat: lonLat[1], tMs });
});
if (pts.length < 2) return null;
const splits: WorkoutSplit[] = [];
let cumulative = 0;
let nextBoundary = KM;
let prevBoundaryTimeMs = pts[0].tMs;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
const segDist = haversineMeters(a.lon, a.lat, b.lon, b.lat);
if (segDist <= 0) continue;
const segStart = cumulative;
cumulative += segDist;
// A single long segment can straddle several km boundaries.
while (cumulative >= nextBoundary) {
const frac = (nextBoundary - segStart) / segDist;
const boundaryTimeMs = a.tMs + (b.tMs - a.tMs) * frac;
const durationSec = Math.max(
0,
Math.round((boundaryTimeMs - prevBoundaryTimeMs) / 1000),
);
splits.push({
km: splits.length + 1,
durationSec,
paceSecPerKm: durationSec,
});
prevBoundaryTimeMs = boundaryTimeMs;
nextBoundary += KM;
}
}
return splits.length > 0 ? splits : null;
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Effort zones for the workout-detail page. Descriptive, never
* prescriptive — a distribution of time-in-zone, not a training
* verdict or a cardiac interpretation (non-diagnostic standard).
*
* Display model: five %HRmax zones (Z1 50–60 %, Z2 60–70 %, Z3 70–80 %,
* Z4 80–90 %, Z5 90 %+), HRmax from the grounded `tanakaHrMax()`
* (profile age). %HRmax over Karvonen because it needs only age and
* matches every consumer device's zone labelling — the strain engine
* owns the HRR/TRIMP math internally; the detail page merely describes.
*
* Resolution order:
* 1. WHOOP `metadata.zoneDurations` — device-authoritative time in
* zone, parsed through a narrow slice (JSON is never duck-typed).
* 2. Computed time-in-zone from the HR series, when profile age
* exists.
* 3. Neither → `null`, and the caller hides the zones card.
*/
import { z } from "zod/v4";
import { tanakaHrMax } from "@/lib/insights/strain-score";
import type { HrSeriesPoint } from "@/lib/workouts/hr-series";
export type ZonesModel = "whoop" | "tanaka";
export interface ZoneBand {
/** 1–5. */
zone: number;
/** Lower bpm bound (null when HRmax is unknown — WHOOP-only path). */
lowBpm: number | null;
/** Upper bpm bound; null for the open-ended top zone. */
highBpm: number | null;
/** Time spent in the zone, seconds. */
seconds: number;
}
export interface WorkoutZones {
model: ZonesModel;
hrMax: number | null;
zones: ZoneBand[];
}
/** %HRmax band edges. The top zone is open-ended (highPct = null). */
const ZONE_EDGES: Array<{
zone: number;
lowPct: number;
highPct: number | null;
}> = [
{ zone: 1, lowPct: 0.5, highPct: 0.6 },
{ zone: 2, lowPct: 0.6, highPct: 0.7 },
{ zone: 3, lowPct: 0.7, highPct: 0.8 },
{ zone: 4, lowPct: 0.8, highPct: 0.9 },
{ zone: 5, lowPct: 0.9, highPct: null },
];
function bandBounds(hrMax: number | null) {
return ZONE_EDGES.map((e) => ({
zone: e.zone,
lowBpm: hrMax != null ? Math.round(hrMax * e.lowPct) : null,
highBpm:
hrMax != null && e.highPct != null ? Math.round(hrMax * e.highPct) : null,
}));
}
/**
* Narrow slice of WHOOP's `zone_durations` (milliseconds per zone). The
* API ships `zone_zero_milli` (below Z1, recovery) through
* `zone_five_milli`; we map zones one–five onto our Z1–Z5 and drop
* zone-zero from the five-zone display, mirroring the device's own
* five-zone labelling.
*/
const whoopZoneDurationsSchema = z
.object({
zone_zero_milli: z.number().nonnegative().optional(),
zone_one_milli: z.number().nonnegative().optional(),
zone_two_milli: z.number().nonnegative().optional(),
zone_three_milli: z.number().nonnegative().optional(),
zone_four_milli: z.number().nonnegative().optional(),
zone_five_milli: z.number().nonnegative().optional(),
})
.transform((v) => [
v.zone_one_milli ?? 0,
v.zone_two_milli ?? 0,
v.zone_three_milli ?? 0,
v.zone_four_milli ?? 0,
v.zone_five_milli ?? 0,
]);
/**
* Pull WHOOP zone durations (seconds per Z1–Z5) out of a workout's
* metadata blob, or `null` when absent / malformed / all-zero.
*/
export function parseWhoopZoneDurations(metadata: unknown): number[] | null {
if (!metadata || typeof metadata !== "object") return null;
const raw = (metadata as Record<string, unknown>).zoneDurations;
if (!raw || typeof raw !== "object") return null;
const parsed = whoopZoneDurationsSchema.safeParse(raw);
if (!parsed.success) return null;
const seconds = parsed.data.map((ms) => ms / 1000);
return seconds.some((s) => s > 0) ? seconds : null;
}
/** %HRmax zone index (1–5) for a heart rate, or 0 for below-Z1 (rest). */
function zoneForHr(hr: number, hrMax: number): number {
const pct = hr / hrMax;
for (const e of ZONE_EDGES) {
if (e.highPct == null) return e.zone; // top zone catches everything ≥ Z5 low
if (pct < e.highPct) return pct >= e.lowPct ? e.zone : 0;
}
return 0;
}
export interface ComputeZonesInput {
hrMax: number | null;
series: readonly HrSeriesPoint[];
bucketSec: number;
whoopZoneDurations: number[] | null;
}
/**
* Resolve the effort-zone distribution. WHOOP's device-reported
* durations win when present; otherwise time-in-zone is folded from the
* HR series (each non-empty bucket contributes `bucketSec` seconds at
* its mean HR). Below-Z1 (rest) time is excluded from the five-zone
* display. Returns `null` when neither source is available.
*/
export function computeZones(input: ComputeZonesInput): WorkoutZones | null {
const { hrMax, series, bucketSec, whoopZoneDurations } = input;
if (whoopZoneDurations) {
const bounds = bandBounds(hrMax);
return {
model: "whoop",
hrMax,
zones: bounds.map((b, i) => ({
...b,
seconds: Math.round(whoopZoneDurations[i] ?? 0),
})),
};
}
if (hrMax == null || series.length === 0) return null;
const seconds = [0, 0, 0, 0, 0];
for (const p of series) {
const zone = zoneForHr(p.mean, hrMax);
if (zone >= 1 && zone <= 5) seconds[zone - 1] += bucketSec;
}
if (seconds.every((s) => s === 0)) return null;
const bounds = bandBounds(hrMax);
return {
model: "tanaka",
hrMax,
zones: bounds.map((b, i) => ({ ...b, seconds: seconds[i] })),
};
}
/** Convenience: HRmax from profile age (Tanaka), or null when no age. */
export function hrMaxFromAge(ageYears: number | null): number | null {
return ageYears != null ? Math.round(tanakaHrMax(ageYears)) : null;
}