;
+ 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 (
+
+
+
+
+ {t("insights.workouts.detail.exportGpx")}
+
+ }
+ />
+
+
+
+
+ {/* End ring first so the start dot always sits on top on a loop. */}
+
+
+
+ {elevation ? (
+
+
+ {t("insights.workouts.detail.elevationTitle")}
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/insights/workout-detail/splits.tsx b/src/components/insights/workout-detail/splits.tsx
new file mode 100644
index 000000000..267ac13ce
--- /dev/null
+++ b/src/components/insights/workout-detail/splits.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { Flag } from "lucide-react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { TileHeader } from "@/components/insights/tile-header";
+import { useTranslations } from "@/lib/i18n/context";
+import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
+
+import { formatPaceSeconds } from "./format";
+
+function formatSplitTime(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = Math.round(seconds % 60);
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+export interface WorkoutDetailSplitsProps {
+ workout: WorkoutDetailPayload;
+}
+
+/**
+ * Per-kilometre splits table, derived server-side from the route
+ * geometry + timestamps. Returns `null` when the workout has no
+ * split data (hide, don't render empty). The fastest km is marked
+ * with a discreet muted accent — descriptive, not a verdict.
+ */
+export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
+ const { t } = useTranslations();
+ const splits = workout.splits;
+ if (!splits || splits.length === 0) return null;
+
+ const fastest = Math.min(...splits.map((s) => s.paceSecPerKm));
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t("insights.workouts.detail.splitsKm")}
+
+
+ {t("insights.workouts.detail.splitsTime")}
+
+
+ {t("insights.workouts.detail.splitsPace")}
+
+
+
+
+ {splits.map((s) => (
+
+ {s.km}
+
+ {formatSplitTime(s.durationSec)}
+
+
+ {formatPaceSeconds(s.paceSecPerKm)}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx
new file mode 100644
index 000000000..408f61a34
--- /dev/null
+++ b/src/components/insights/workout-detail/stats.tsx
@@ -0,0 +1,187 @@
+"use client";
+
+import type { ReactNode } from "react";
+import {
+ Flame,
+ Footprints,
+ HeartPulse,
+ Map,
+ Mountain,
+ Timer,
+} from "lucide-react";
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
+
+import {
+ formatDuration,
+ formatDistanceKm,
+ formatNumber,
+ formatPaceMinPerKm,
+ formatDurationMinutes,
+} from "./format";
+
+interface StatTileProps {
+ icon: ReactNode;
+ label: string;
+ value: string;
+ hint?: string;
+}
+
+function StatTile({ icon, label, value, hint }: StatTileProps) {
+ return (
+
+
+
+ {icon}
+
+ {label}
+
+ {value}
+ {hint ? (
+ {hint}
+ ) : null}
+
+ );
+}
+
+export interface WorkoutDetailStatsProps {
+ workout: WorkoutDetailPayload;
+}
+
+const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
+
+/**
+ * Build the muted own-history comparison line ("Your recent average:
+ * 5.8 km · 34 min · 148 bpm"). Own-history only — never a population
+ * comparison (non-diagnostic standard). Rendered only when at least two
+ * sessions of the sport exist, so it never restates the single workout
+ * you are already looking at.
+ */
+function sportAverageLine(
+ workout: WorkoutDetailPayload,
+ locale: string,
+): string | null {
+ const ctx = workout.sportContext;
+ if (!ctx || ctx.count < 2) return null;
+ const parts: string[] = [];
+ if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
+ parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
+ }
+ parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
+ if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
+ return parts.join(" · ");
+}
+
+export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
+ const { t, locale } = useTranslations();
+ const tiles: StatTileProps[] = [];
+
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsDuration"),
+ value: formatDuration(workout.durationSec),
+ });
+
+ if (workout.distanceM != null && workout.distanceM > 0) {
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsDistance"),
+ value: `${formatDistanceKm(workout.distanceM, locale)} km`,
+ });
+ }
+
+ if (workout.activeEnergyKcal != null) {
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsActiveEnergy"),
+ value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
+ });
+ }
+
+ if (workout.avgHr != null) {
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsAvgHr"),
+ value: `${workout.avgHr} bpm`,
+ hint:
+ workout.maxHr != null
+ ? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
+ : undefined,
+ });
+ }
+
+ if (workout.minHr != null) {
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsMinHr"),
+ value: `${workout.minHr} bpm`,
+ });
+ }
+
+ if (workout.stepCount != null && workout.stepCount > 0) {
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsStepCount"),
+ value: formatNumber(workout.stepCount, locale),
+ });
+ }
+
+ if (workout.elevationM != null && workout.elevationM !== 0) {
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsElevation"),
+ value: `${formatNumber(workout.elevationM, locale, 1)} m`,
+ });
+ }
+
+ if (
+ workout.distanceM != null &&
+ workout.distanceM > 100 &&
+ PACE_SPORTS.has(workout.sportType)
+ ) {
+ tiles.push({
+ icon: ,
+ label: t("insights.workouts.detail.statsPace"),
+ value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
+ });
+ }
+
+ const averageLine = sportAverageLine(workout, locale);
+
+ return (
+
+
+
+ {t("insights.workouts.detail.statsTitle")}
+
+
+
+
+ {tiles.map((tile) => (
+
+ ))}
+
+ {averageLine ? (
+
+ {t("insights.workouts.detail.sportAverageLabel")}{" "}
+ {averageLine}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/insights/workout-detail/zones.tsx b/src/components/insights/workout-detail/zones.tsx
new file mode 100644
index 000000000..bc451d191
--- /dev/null
+++ b/src/components/insights/workout-detail/zones.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import { Gauge } from "lucide-react";
+
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { TileHeader } from "@/components/insights/tile-header";
+import { useTranslations } from "@/lib/i18n/context";
+import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
+
+const ZONE_COLORS = [
+ "var(--chart-1)",
+ "var(--chart-2)",
+ "var(--chart-3)",
+ "var(--chart-4)",
+ "var(--chart-5)",
+];
+
+function formatMinutes(seconds: number): string {
+ const m = Math.floor(seconds / 60);
+ const s = Math.round(seconds % 60);
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+export interface WorkoutDetailZonesProps {
+ workout: WorkoutDetailPayload;
+}
+
+/**
+ * Effort-zone distribution — a single horizontal stacked bar (%HRmax
+ * zones) with minutes per zone underneath. Descriptive only: "how the
+ * effort was distributed", never a training prescription or cardiac
+ * read (non-diagnostic standard). Returns `null` when zones could not
+ * be derived (no profile age and no device zones).
+ */
+export function WorkoutDetailZones({ workout }: WorkoutDetailZonesProps) {
+ const { t } = useTranslations();
+ const zones = workout.zones;
+ if (!zones) return null;
+
+ const total = zones.zones.reduce((sum, z) => sum + z.seconds, 0);
+ if (total <= 0) return null;
+
+ return (
+
+
+
+
+ {zones.model === "whoop"
+ ? t("insights.workouts.detail.zonesCaptionDevice")
+ : t("insights.workouts.detail.zonesCaption")}
+
+
+
+
+ {zones.zones.map((z, i) =>
+ z.seconds > 0 ? (
+
+ ) : null,
+ )}
+
+
+ {zones.zones.map((z, i) => (
+
+
+ Z{z.zone}
+
+ {formatMinutes(z.seconds)}
+
+ {z.lowBpm != null ? (
+
+ {z.lowBpm}
+ {z.highBpm != null ? `–${z.highBpm}` : "+"}
+
+ ) : null}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx
index 18cbaaec8..551e1a699 100644
--- a/src/components/insights/workout-list.tsx
+++ b/src/components/insights/workout-list.tsx
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
+import { Map as MapIcon, HeartPulse } from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
@@ -106,7 +107,26 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
- {sportName}
+
+ {sportName}
+ {/* #67 — discreet glyphs flag which sessions open rich
+ (map / HR curve). Muted so they never compete with
+ the sport name. */}
+ {workout.hasRoute ? (
+
+ ) : null}
+ {workout.hasHrSeries ? (
+
+ ) : null}
+
{formatDate(workout.startedAt, locale, timeFormat)}
diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts
index 82a490a42..f33cbece8 100644
--- a/src/hooks/use-workouts.ts
+++ b/src/hooks/use-workouts.ts
@@ -34,6 +34,14 @@ export interface WorkoutListEntry {
maxHr: number | null;
source: string;
externalId: string | null;
+ /**
+ * #67 list glyphs — whether the workout opens into a rich detail with
+ * a map / HR curve. Optional so pre-#67 fixtures (and the dashboard
+ * recent-tile) still satisfy the type; the list endpoint always sends
+ * them.
+ */
+ hasRoute?: boolean;
+ hasHrSeries?: boolean;
}
export interface WorkoutListMeta {
@@ -118,6 +126,55 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
};
}
+export interface WorkoutHrSeriesPoint {
+ tSec: number;
+ mean: number;
+ min: number;
+ max: number;
+}
+
+export interface WorkoutHrSeriesDto {
+ source: "workout_series" | "pulse_window";
+ bucketSec: number;
+ points: WorkoutHrSeriesPoint[];
+ envelope: boolean;
+}
+
+export interface WorkoutZoneBandDto {
+ zone: number;
+ lowBpm: number | null;
+ highBpm: number | null;
+ seconds: number;
+}
+
+export interface WorkoutZonesDto {
+ model: "whoop" | "tanaka";
+ hrMax: number | null;
+ zones: WorkoutZoneBandDto[];
+}
+
+export interface WorkoutSplitDto {
+ km: number;
+ durationSec: number;
+ paceSecPerKm: number;
+}
+
+export interface WorkoutSportContextDto {
+ count: number;
+ avgDurationSec: number;
+ avgDistanceM: number | null;
+ avgAvgHr: number | null;
+}
+
+/**
+ * Reserved Activity-Insight payload (strategic-concept Wave C, bet 5).
+ * The wire field is `null` today; the Phase-2 job will populate it and
+ * the detail page's seam mounts a card with zero rework. Declared here
+ * so the type is stable in advance — widen the union (never change the
+ * seam shape) when the job ships.
+ */
+export type WorkoutActivityInsight = null;
+
export interface WorkoutDetailPayload extends WorkoutListEntry {
minHr: number | null;
stepCount: number | null;
@@ -128,12 +185,25 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
geometry: unknown;
sampleTimestamps: string[] | null;
} | null;
+ /**
+ * Raw per-workout HR sample envelope. `samples.samples` is dropped
+ * (null) under `compact=1` — the web curve reads `hrSeries` instead —
+ * but `sampleCount` is retained. Finally declared as of #67.
+ */
+ samples: { sampleCount: number; samples: unknown } | null;
+ hrSeries: WorkoutHrSeriesDto | null;
+ zones: WorkoutZonesDto | null;
+ splits: WorkoutSplitDto[] | null;
+ sportContext: WorkoutSportContextDto | null;
+ aiInsight: WorkoutActivityInsight;
canonicalId: string;
}
async function fetchWorkoutDetail(id: string): Promise {
+ // Web always sends `compact=1`: the SVG needs geometry, not the raw
+ // 30k-sample / route-timestamp blobs (those ride the iOS path only).
return apiGet(
- `/api/workouts/${encodeURIComponent(id)}`,
+ `/api/workouts/${encodeURIComponent(id)}?compact=1`,
);
}
@@ -141,6 +211,8 @@ export interface UseWorkoutDetailResult {
data: WorkoutDetailPayload | undefined;
isLoading: boolean;
error: Error | null;
+ /** Re-runs the query — the retry action for the detail error card. */
+ refetch: () => void;
}
export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
@@ -155,5 +227,6 @@ export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
data: query.data,
isLoading: query.isLoading,
error: query.error as Error | null,
+ refetch: () => void query.refetch(),
};
}
diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts
index 0e38a58bb..abf96cde6 100644
--- a/src/lib/openapi/routes/workouts.ts
+++ b/src/lib/openapi/routes/workouts.ts
@@ -56,6 +56,9 @@ const workoutListEntry = z
maxHr: z.number().int().nullable(),
source: measurementSourceEnum,
externalId: z.string().nullable(),
+ // #67 list glyphs — flags which sessions open into a rich detail.
+ hasRoute: z.boolean(),
+ hasHrSeries: z.boolean(),
})
.meta({ id: "WorkoutListEntry" });
@@ -89,6 +92,61 @@ const workoutHrSeries = z
})
.meta({ id: "WorkoutHrSeries" });
+// #67 — per-workout HR curve, one DTO with explicit provenance. Stored
+// `WorkoutSamples` series first, raw PULSE-window reconstruction second.
+const workoutHrCurve = z
+ .object({
+ source: z.enum(["workout_series", "pulse_window"]),
+ bucketSec: z.number().int().positive(),
+ points: z.array(
+ z.object({
+ tSec: z.number().int().nonnegative(),
+ mean: z.number().int(),
+ min: z.number().int(),
+ max: z.number().int(),
+ }),
+ ),
+ envelope: z.boolean(),
+ })
+ .meta({ id: "WorkoutHrCurve" });
+
+// #67 — effort-zone distribution. WHOOP device durations win; else a
+// %HRmax fold from the HR curve when profile age exists.
+const workoutZones = z
+ .object({
+ model: z.enum(["whoop", "tanaka"]),
+ hrMax: z.number().int().nullable(),
+ zones: z.array(
+ z.object({
+ zone: z.number().int().min(1).max(5),
+ lowBpm: z.number().int().nullable(),
+ highBpm: z.number().int().nullable(),
+ seconds: z.number().int().nonnegative(),
+ }),
+ ),
+ })
+ .meta({ id: "WorkoutZones" });
+
+// #67 — per-kilometre splits, derived server-side from geometry +
+// timestamps so the raw timestamp blob can be dropped under `compact=1`.
+const workoutSplit = z
+ .object({
+ km: z.number().int().positive(),
+ durationSec: z.number().int().nonnegative(),
+ paceSecPerKm: z.number().int().nonnegative(),
+ })
+ .meta({ id: "WorkoutSplit" });
+
+// #67 — own-history average for the sport (comparison line).
+const workoutSportContext = z
+ .object({
+ count: z.number().int().nonnegative(),
+ avgDurationSec: z.number().int().nonnegative(),
+ avgDistanceM: z.number().nullable(),
+ avgAvgHr: z.number().int().nullable(),
+ })
+ .meta({ id: "WorkoutSportContext" });
+
const workoutDetailResponse = z
.object({
id: z.string(),
@@ -109,6 +167,15 @@ const workoutDetailResponse = z
metadata: z.unknown().nullable(),
route: workoutRouteGeometry.nullable(),
samples: workoutHrSeries.nullable(),
+ // #67 enrichment — additive; absent from a pre-enrichment client's
+ // expectations, opt-in `compact=1` only trims the raw blobs above.
+ hrSeries: workoutHrCurve.nullable(),
+ zones: workoutZones.nullable(),
+ splits: z.array(workoutSplit).nullable(),
+ sportContext: workoutSportContext.nullable(),
+ // Reserved Activity-Insight seam — always null until the Phase-2 job
+ // populates it. Typed here so the wire contract is stable in advance.
+ aiInsight: z.null(),
canonicalId: z.string(),
})
.meta({ id: "WorkoutDetailResponse" });
@@ -147,9 +214,12 @@ export const workoutPaths: NonNullable = {
tags: ["Measurements"],
summary: "Workout detail (v1.4.32)",
description:
- "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows surface as 404 (existence channel sealed).",
+ "Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404 (existence channel sealed).",
requestParams: {
path: z.object({ id: z.string() }),
+ query: z.object({
+ compact: z.enum(["1"]).optional(),
+ }),
},
responses: {
"200": {
diff --git a/src/lib/workouts/__tests__/hr-series.test.ts b/src/lib/workouts/__tests__/hr-series.test.ts
new file mode 100644
index 000000000..98e43e060
--- /dev/null
+++ b/src/lib/workouts/__tests__/hr-series.test.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const findMany = vi.fn();
+
+vi.mock("@/lib/db", () => ({
+ prisma: { measurement: { findMany: (...a: unknown[]) => findMany(...a) } },
+}));
+
+import {
+ adaptiveBucketSec,
+ foldHrBuckets,
+ buildWorkoutHrSeries,
+} from "@/lib/workouts/hr-series";
+
+const START = new Date("2026-05-15T07:00:00Z");
+const startMs = START.getTime();
+
+/** Stored-sample entries at `+seconds` offsets from START. */
+function storedSample(seconds: number, hr: number) {
+ return { t: new Date(startMs + seconds * 1000).toISOString(), hr };
+}
+
+beforeEach(() => {
+ findMany.mockReset();
+});
+
+describe("adaptiveBucketSec", () => {
+ it("clamps to [5, 60]", () => {
+ expect(adaptiveBucketSec(300)).toBe(5); // ceil(1.25)=2 → floored to 5
+ expect(adaptiveBucketSec(1800)).toBe(8);
+ expect(adaptiveBucketSec(6 * 3600)).toBe(60); // ceil(90)=90 → capped 60
+ });
+});
+
+describe("foldHrBuckets", () => {
+ it("buckets by elapsed time and drops samples outside the session", () => {
+ const samples = [
+ { tMs: startMs - 5000, hr: 200 }, // before start → dropped
+ { tMs: startMs + 1000, hr: 100 },
+ { tMs: startMs + 2000, hr: 120 },
+ { tMs: startMs + 11000, hr: 150 },
+ { tMs: startMs + 60000, hr: 130 }, // == durationSec → dropped (half-open)
+ ];
+ const { points, bucketCount } = foldHrBuckets(samples, startMs, 60, 10);
+ expect(bucketCount).toBe(6);
+ // bucket 0 holds the two early samples → mean 110, min 100, max 120.
+ expect(points[0]).toEqual({ tSec: 0, mean: 110, min: 100, max: 120 });
+ // bucket 1 holds the +11s sample.
+ expect(points[1]).toEqual({ tSec: 10, mean: 150, min: 150, max: 150 });
+ expect(points).toHaveLength(2); // gaps stay as gaps
+ });
+
+ it("reports median per-bucket density for the envelope decision", () => {
+ const samples = [
+ { tMs: startMs + 0, hr: 100 },
+ { tMs: startMs + 1000, hr: 110 },
+ { tMs: startMs + 2000, hr: 120 },
+ { tMs: startMs + 3000, hr: 130 },
+ ];
+ const { medianDensity } = foldHrBuckets(samples, startMs, 60, 10);
+ expect(medianDensity).toBe(4);
+ });
+});
+
+describe("buildWorkoutHrSeries", () => {
+ const base = {
+ userId: "u1",
+ startedAt: START,
+ endedAt: new Date(startMs + 600_000), // 10 min
+ durationSec: 600,
+ now: new Date(startMs + 600_000 + 1000),
+ };
+
+ it("prefers the stored series and never touches the DB", async () => {
+ const stored = Array.from({ length: 40 }, (_, i) =>
+ storedSample(i * 15, 120 + (i % 5)),
+ );
+ const result = await buildWorkoutHrSeries({
+ ...base,
+ storedSamples: stored,
+ });
+ expect(result).not.toBeNull();
+ expect(result!.source).toBe("workout_series");
+ expect(result!.bucketSec).toBe(adaptiveBucketSec(600));
+ expect(result!.points.length).toBeGreaterThanOrEqual(2);
+ expect(findMany).not.toHaveBeenCalled();
+ });
+
+ it("marks the envelope when buckets are dense", async () => {
+ // ~8 samples per bucket (1 Hz over an 8 s bucket) → well above the
+ // density floor, so the min→max envelope band renders.
+ const stored: ReturnType[] = [];
+ for (let s = 0; s < 600; s += 1) stored.push(storedSample(s, 140));
+ const result = await buildWorkoutHrSeries({
+ ...base,
+ storedSamples: stored,
+ });
+ expect(result!.envelope).toBe(true);
+ });
+
+ it("falls back to the pulse window when no stored series exists", async () => {
+ // Dense PULSE rows across the whole session → passes the gate.
+ const rows = Array.from({ length: 60 }, (_, i) => ({
+ value: 130 + (i % 7),
+ measuredAt: new Date(startMs + i * 10_000),
+ externalId: null,
+ }));
+ findMany.mockResolvedValue(rows);
+ const result = await buildWorkoutHrSeries({ ...base, storedSamples: null });
+ expect(findMany).toHaveBeenCalledOnce();
+ expect(result).not.toBeNull();
+ expect(result!.source).toBe("pulse_window");
+ });
+
+ it("hides (returns null) when the window has too few samples", async () => {
+ findMany.mockResolvedValue([
+ { value: 120, measuredAt: new Date(startMs + 1000), externalId: null },
+ { value: 122, measuredAt: new Date(startMs + 2000), externalId: null },
+ ]);
+ const result = await buildWorkoutHrSeries({ ...base, storedSamples: null });
+ expect(result).toBeNull();
+ });
+
+ it("hides when bucket coverage is below 40 %", async () => {
+ // 10 samples all clustered in the first minute of a 10-minute run →
+ // enough raw samples, but coverage is ~1 bucket of ~75.
+ const rows = Array.from({ length: 10 }, (_, i) => ({
+ value: 120 + i,
+ measuredAt: new Date(startMs + i * 500),
+ externalId: null,
+ }));
+ findMany.mockResolvedValue(rows);
+ const result = await buildWorkoutHrSeries({ ...base, storedSamples: null });
+ expect(result).toBeNull();
+ });
+
+ it("excludes consolidated stats: rows from the fallback", async () => {
+ const rows = Array.from({ length: 20 }, (_, i) => ({
+ value: 130,
+ measuredAt: new Date(startMs + i * 20_000),
+ externalId: "stats:HKQuantityTypeIdentifierHeartRate:2026-05-15T07",
+ }));
+ findMany.mockResolvedValue(rows);
+ const result = await buildWorkoutHrSeries({ ...base, storedSamples: null });
+ expect(result).toBeNull();
+ });
+
+ it("skips the fallback for workouts older than the retention window", async () => {
+ const old = {
+ ...base,
+ now: new Date(startMs + 100 * 24 * 60 * 60 * 1000),
+ };
+ const result = await buildWorkoutHrSeries({ ...old, storedSamples: null });
+ expect(result).toBeNull();
+ expect(findMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/workouts/__tests__/route-svg.test.ts b/src/lib/workouts/__tests__/route-svg.test.ts
new file mode 100644
index 000000000..0061b74c2
--- /dev/null
+++ b/src/lib/workouts/__tests__/route-svg.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ projectRoute,
+ rdpSimplify,
+ buildElevationProfile,
+ haversineMeters,
+ buildGpxDocument,
+ type RouteCoordinate,
+} from "@/lib/workouts/route-svg";
+
+/** A ring of `n` points around (lon0, lat0) with per-axis degree radii. */
+function ring(
+ lon0: number,
+ lat0: number,
+ dLon: number,
+ dLat: number,
+ n = 40,
+): RouteCoordinate[] {
+ const out: RouteCoordinate[] = [];
+ for (let i = 0; i <= n; i++) {
+ const a = (i / n) * 2 * Math.PI;
+ out.push([lon0 + dLon * Math.cos(a), lat0 + dLat * Math.sin(a)]);
+ }
+ return out;
+}
+
+describe("projectRoute cos(midLat) correction", () => {
+ it("squashes the longitude axis by cos(midLat) at 50°N", () => {
+ // A box twice as wide in DEGREES of longitude as in latitude. In
+ // real metres a degree of longitude at 50°N is only cos(50°) as long
+ // as a degree of latitude, so the projected aspect (ySpan/xSpan) must
+ // be 1 / (2·cos(50°)) ≈ 0.778 — NOT the uncorrected 0.5 the old
+ // normalisation produced. Both stay inside the [0.4, 1.4] clamp band.
+ const box = ring(11.0, 50.0, 0.02, 0.01, 30);
+ const projected = projectRoute(box);
+ expect(projected).not.toBeNull();
+ const aspect = projected!.height / projected!.width;
+ const corrected = 1 / (2 * Math.cos(50 * (Math.PI / 180))); // ≈ 0.778
+ expect(aspect).toBeGreaterThan(corrected * 0.95);
+ expect(aspect).toBeLessThan(corrected * 1.05);
+ // And distinctly above the uncorrected 0.5 the stretch bug gave.
+ expect(aspect).toBeGreaterThan(0.6);
+ });
+
+ it("clamps an extreme out-and-back sliver to the minimum height", () => {
+ // A near-straight east-west line — ySpan ≈ 0 → the aspect clamp
+ // floors the height at 40 rather than a 0-tall sliver.
+ const coords: RouteCoordinate[] = [];
+ for (let i = 0; i < 30; i++)
+ coords.push([11.0 + i * 0.001, 50.0 + i * 1e-6]);
+ const projected = projectRoute(coords);
+ expect(projected).not.toBeNull();
+ expect(projected!.height).toBe(40);
+ expect(projected!.viewBox).toBe("0 0 100 40.00");
+ });
+
+ it("rejects a degenerate point-shaped route (< 10 points)", () => {
+ expect(
+ projectRoute([
+ [11, 50],
+ [11.0001, 50.0001],
+ [11.0002, 50.0002],
+ ]),
+ ).toBeNull();
+ });
+
+ it("rejects a route whose real-world span is under ~30 m", () => {
+ // 15 points but jittering inside a ~5 m box.
+ const coords: RouteCoordinate[] = [];
+ for (let i = 0; i < 15; i++) {
+ coords.push([11.0 + i * 0.00001, 50.0 + (i % 2) * 0.00001]);
+ }
+ expect(projectRoute(coords)).toBeNull();
+ });
+
+ it("emits start and end markers and a valid path", () => {
+ const box = ring(11.0, 50.0, 0.02, 0.02, 30);
+ const projected = projectRoute(box)!;
+ expect(projected.path.startsWith("M")).toBe(true);
+ expect(projected.pointCount).toBeGreaterThanOrEqual(4);
+ expect(projected.start).toHaveProperty("x");
+ expect(projected.end).toHaveProperty("y");
+ });
+});
+
+describe("rdpSimplify", () => {
+ it("collapses collinear points but keeps corners", () => {
+ const line: Array<[number, number]> = [
+ [0, 0],
+ [1, 0],
+ [2, 0],
+ [3, 0],
+ [3, 3],
+ ];
+ const out = rdpSimplify(line, 0.01);
+ // The four collinear x-axis points collapse to the two endpoints of
+ // that run; the corner at [3,0] and the terminus [3,3] survive.
+ expect(out).toEqual([
+ [0, 0],
+ [3, 0],
+ [3, 3],
+ ]);
+ });
+
+ it("never drops the first or last point", () => {
+ const line: Array<[number, number]> = Array.from(
+ { length: 100 },
+ (_, i) => [i, Math.sin(i)],
+ );
+ const out = rdpSimplify(line, 0.5);
+ expect(out[0]).toEqual(line[0]);
+ expect(out[out.length - 1]).toEqual(line[line.length - 1]);
+ expect(out.length).toBeLessThan(line.length);
+ });
+});
+
+describe("buildElevationProfile", () => {
+ it("returns a cumulative-distance profile when ≥ 60 % carry altitude", () => {
+ const coords: RouteCoordinate[] = [
+ [11.0, 50.0, 300],
+ [11.001, 50.0, 305],
+ [11.002, 50.0, 310],
+ [11.003, 50.0, 315],
+ ];
+ const profile = buildElevationProfile(coords)!;
+ expect(profile).toHaveLength(4);
+ expect(profile[0].distanceM).toBe(0);
+ expect(profile[3].distanceM).toBeGreaterThan(profile[0].distanceM);
+ expect(profile[3].altitude).toBe(315);
+ });
+
+ it("returns null below the altitude-coverage threshold", () => {
+ const coords: RouteCoordinate[] = [
+ [11.0, 50.0, 300],
+ [11.001, 50.0],
+ [11.002, 50.0],
+ [11.003, 50.0],
+ ];
+ expect(buildElevationProfile(coords)).toBeNull();
+ });
+});
+
+describe("haversineMeters", () => {
+ it("measures ~111 km for one degree of latitude", () => {
+ const d = haversineMeters(11, 50, 11, 51);
+ expect(d).toBeGreaterThan(110_000);
+ expect(d).toBeLessThan(112_000);
+ });
+});
+
+describe("buildGpxDocument", () => {
+ it("emits trkpt elements with elevation and time when present", () => {
+ const gpx = buildGpxDocument({
+ coordinates: [
+ [11.0, 50.0, 300],
+ [11.001, 50.001, 305],
+ ],
+ timestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:00:05Z"],
+ sportType: "running",
+ startedAt: "2026-05-15T07:00:00Z",
+ });
+ expect(gpx).toContain('300");
+ expect(gpx).toContain("2026-05-15T07:00:05Z ");
+ });
+});
diff --git a/src/lib/workouts/__tests__/splits.test.ts b/src/lib/workouts/__tests__/splits.test.ts
new file mode 100644
index 000000000..6b335053a
--- /dev/null
+++ b/src/lib/workouts/__tests__/splits.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect } from "vitest";
+
+import { computeSplits } from "@/lib/workouts/splits";
+import type { RouteCoordinate } from "@/lib/workouts/route-svg";
+
+/**
+ * Build a straight eastward track of `n` evenly spaced points spanning
+ * `totalKm`, with `secPerKm` pace. One degree of longitude at 50°N is
+ * ~71.7 km, but we anchor the math on the actual haversine distance the
+ * function computes, so we just space points and let the boundaries
+ * fall out.
+ */
+function track(
+ totalMeters: number,
+ n: number,
+ totalSeconds: number,
+): { coords: RouteCoordinate[]; timestamps: string[] } {
+ const coords: RouteCoordinate[] = [];
+ const timestamps: string[] = [];
+ const lat = 0; // equator → a degree of lon ≈ 111.32 km, easy to reason about
+ const startMs = Date.parse("2026-05-15T07:00:00Z");
+ const totalDeg = totalMeters / 111_320; // metres → degrees lon at equator
+ for (let i = 0; i <= n; i++) {
+ const f = i / n;
+ coords.push([f * totalDeg, lat]);
+ timestamps.push(new Date(startMs + f * totalSeconds * 1000).toISOString());
+ }
+ return { coords, timestamps };
+}
+
+describe("computeSplits", () => {
+ it("returns one split per full kilometre with interpolated pace", () => {
+ // 3.2 km in 960 s → 300 s/km even pace; three full-km boundaries.
+ const { coords, timestamps } = track(3200, 320, 960);
+ const splits = computeSplits(coords, timestamps)!;
+ expect(splits).toHaveLength(3);
+ expect(splits[0].km).toBe(1);
+ for (const s of splits) {
+ expect(s.paceSecPerKm).toBeGreaterThan(285);
+ expect(s.paceSecPerKm).toBeLessThan(315);
+ expect(s.durationSec).toBe(s.paceSecPerKm);
+ }
+ });
+
+ it("returns null without aligned timestamps", () => {
+ const { coords } = track(3000, 300, 900);
+ expect(computeSplits(coords, null)).toBeNull();
+ expect(computeSplits(coords, ["2026-05-15T07:00:00Z"])).toBeNull();
+ });
+
+ it("returns null under one full kilometre", () => {
+ const { coords, timestamps } = track(400, 50, 120);
+ expect(computeSplits(coords, timestamps)).toBeNull();
+ });
+});
diff --git a/src/lib/workouts/__tests__/zones.test.ts b/src/lib/workouts/__tests__/zones.test.ts
new file mode 100644
index 000000000..f1ebaf9fe
--- /dev/null
+++ b/src/lib/workouts/__tests__/zones.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ computeZones,
+ parseWhoopZoneDurations,
+ hrMaxFromAge,
+} from "@/lib/workouts/zones";
+import type { HrSeriesPoint } from "@/lib/workouts/hr-series";
+
+function pt(tSec: number, mean: number): HrSeriesPoint {
+ return { tSec, mean, min: mean, max: mean };
+}
+
+describe("hrMaxFromAge", () => {
+ it("applies Tanaka and rounds", () => {
+ expect(hrMaxFromAge(40)).toBe(180); // 208 − 0.7·40
+ expect(hrMaxFromAge(null)).toBeNull();
+ });
+});
+
+describe("computeZones — %HRmax (tanaka)", () => {
+ const hrMax = 180; // age 40 → Z1 90-108, Z3 126-144, Z5 162+
+
+ it("folds series buckets into %HRmax zones by mean HR", () => {
+ const series = [
+ pt(0, 100), // 55 % → Z1
+ pt(10, 135), // 75 % → Z3
+ pt(20, 140), // 78 % → Z3
+ pt(30, 170), // 94 % → Z5
+ ];
+ const zones = computeZones({
+ hrMax,
+ series,
+ bucketSec: 10,
+ whoopZoneDurations: null,
+ })!;
+ expect(zones.model).toBe("tanaka");
+ expect(zones.hrMax).toBe(180);
+ const byZone = Object.fromEntries(
+ zones.zones.map((z) => [z.zone, z.seconds]),
+ );
+ expect(byZone[1]).toBe(10);
+ expect(byZone[3]).toBe(20);
+ expect(byZone[5]).toBe(10);
+ expect(byZone[2]).toBe(0);
+ // Bounds carry bpm edges; the top zone is open-ended.
+ const z5 = zones.zones.find((z) => z.zone === 5)!;
+ expect(z5.lowBpm).toBe(162);
+ expect(z5.highBpm).toBeNull();
+ });
+
+ it("excludes below-Z1 (rest) time and returns null when all rest", () => {
+ const series = [pt(0, 80), pt(10, 85)]; // < 50 % HRmax
+ expect(
+ computeZones({ hrMax, series, bucketSec: 10, whoopZoneDurations: null }),
+ ).toBeNull();
+ });
+
+ it("returns null without profile age", () => {
+ expect(
+ computeZones({
+ hrMax: null,
+ series: [pt(0, 140)],
+ bucketSec: 10,
+ whoopZoneDurations: null,
+ }),
+ ).toBeNull();
+ });
+});
+
+describe("computeZones — WHOOP wins", () => {
+ it("uses device-reported durations over the computed series", () => {
+ const zones = computeZones({
+ hrMax: 180,
+ series: [pt(0, 140)],
+ bucketSec: 10,
+ whoopZoneDurations: [60, 120, 180, 240, 300],
+ })!;
+ expect(zones.model).toBe("whoop");
+ const seconds = zones.zones.map((z) => z.seconds);
+ expect(seconds).toEqual([60, 120, 180, 240, 300]);
+ // Bounds still resolve from HRmax when age is known.
+ expect(zones.zones[0].lowBpm).toBe(90);
+ });
+
+ it("carries null bounds when HRmax is unknown but WHOOP zones exist", () => {
+ const zones = computeZones({
+ hrMax: null,
+ series: [],
+ bucketSec: 10,
+ whoopZoneDurations: [10, 20, 30, 40, 50],
+ })!;
+ expect(zones.model).toBe("whoop");
+ expect(zones.hrMax).toBeNull();
+ expect(zones.zones[0].lowBpm).toBeNull();
+ });
+});
+
+describe("parseWhoopZoneDurations", () => {
+ it("maps zone_one..zone_five milliseconds to Z1..Z5 seconds", () => {
+ const parsed = parseWhoopZoneDurations({
+ zoneDurations: {
+ zone_zero_milli: 900000,
+ zone_one_milli: 60000,
+ zone_two_milli: 120000,
+ zone_three_milli: 0,
+ zone_four_milli: 0,
+ zone_five_milli: 30000,
+ },
+ });
+ expect(parsed).toEqual([60, 120, 0, 0, 30]);
+ });
+
+ it("returns null for absent, malformed, or all-zero durations", () => {
+ expect(parseWhoopZoneDurations(null)).toBeNull();
+ expect(parseWhoopZoneDurations({})).toBeNull();
+ expect(parseWhoopZoneDurations({ zoneDurations: "nope" })).toBeNull();
+ expect(
+ parseWhoopZoneDurations({ zoneDurations: { zone_one_milli: 0 } }),
+ ).toBeNull();
+ });
+});
diff --git a/src/lib/workouts/hr-series.ts b/src/lib/workouts/hr-series.ts
new file mode 100644
index 000000000..e1db75335
--- /dev/null
+++ b/src/lib/workouts/hr-series.ts
@@ -0,0 +1,239 @@
+/**
+ * Per-workout heart-rate curve — one server code path, one DTO with
+ * explicit provenance.
+ *
+ * Source priority (strict):
+ * 1. `WorkoutSamples` (stored series) — the session's own sensor
+ * stream, highest fidelity. Used whenever the parsed series yields
+ * ≥ 2 usable HR points.
+ * 2. Pulse-window reconstruction — raw `PULSE` measurement rows in
+ * `[startedAt − 5 min, endedAt + 5 min]`, bucketed at session
+ * grain. Only attempted for workouts younger than the dense
+ * retention window (beyond it only hourly folds survive, which are
+ * useless for a session curve), and only when the fallback quality
+ * gate passes.
+ * 3. Nothing — the caller hides the HR-curve card. The avg/max/min
+ * aggregates already live in the stats grid; an empty chart lies.
+ *
+ * The fallback gate keeps the same posture as the tension detector:
+ * silence over speculation. A watchless ride with two opportunistic BPM
+ * readings must not paint a two-point "curve".
+ */
+import { prisma } from "@/lib/db";
+
+/** Dense raw-PULSE retention. Older days keep only hourly folds. */
+const DENSE_INTRADAY_RETENTION_DAYS = 90;
+/** ± padding around the session for the fallback window. */
+const WINDOW_PAD_MS = 5 * 60 * 1000;
+/** Defensive read cap — dense accounts emit ~1 PULSE sample/second. */
+const MAX_WINDOW_PULSE_ROWS = 6000;
+/** Fallback gate: raw samples required in the padded window. */
+const MIN_FALLBACK_SAMPLES = 8;
+/** Fallback gate: fraction of session buckets that must be non-empty. */
+const MIN_BUCKET_COVERAGE = 0.4;
+/** Envelope band renders once buckets are dense enough to be meaningful. */
+const ENVELOPE_MIN_MEDIAN_DENSITY = 3;
+
+export type HrSeriesSource = "workout_series" | "pulse_window";
+
+export interface HrSeriesPoint {
+ /** Elapsed seconds from the workout start (bucket left edge). */
+ tSec: number;
+ mean: number;
+ min: number;
+ max: number;
+}
+
+export interface WorkoutHrSeries {
+ source: HrSeriesSource;
+ bucketSec: number;
+ points: HrSeriesPoint[];
+ /**
+ * True when per-bucket density supports a min→max envelope band —
+ * this is what makes intervals read as spiky instead of averaged
+ * away. Below the threshold the chart draws the mean line only.
+ */
+ envelope: boolean;
+}
+
+interface RawHrSample {
+ tMs: number;
+ hr: number;
+}
+
+function clamp(value: number, lo: number, hi: number): number {
+ return Math.max(lo, Math.min(hi, value));
+}
+
+/**
+ * Adaptive bucket width targeting a constant on-screen density: a
+ * 20-minute HIIT and a 6-hour ride both land at ≤ ~240–480 points.
+ */
+export function adaptiveBucketSec(durationSec: number): number {
+ return clamp(Math.ceil(durationSec / 240), 5, 60);
+}
+
+function median(values: number[]): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0
+ ? (sorted[mid - 1] + sorted[mid]) / 2
+ : sorted[mid];
+}
+
+/**
+ * Fold raw HR samples into session-grain buckets. Samples outside the
+ * session window `[0, durationSec)` are dropped from the curve (they
+ * only widen the DB query so a boundary reading still lands in bucket
+ * 0 / the last bucket). Empty buckets stay as gaps — no interpolation.
+ * Pure; exported for unit testing.
+ */
+export function foldHrBuckets(
+ samples: readonly RawHrSample[],
+ startMs: number,
+ durationSec: number,
+ bucketSec: number,
+): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } {
+ const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec));
+ const acc = new Map<
+ number,
+ { sum: number; min: number; max: number; n: number }
+ >();
+ for (const s of samples) {
+ const rel = (s.tMs - startMs) / 1000;
+ if (rel < 0 || rel >= durationSec) continue;
+ const idx = Math.floor(rel / bucketSec);
+ const bucket = acc.get(idx);
+ if (bucket) {
+ bucket.sum += s.hr;
+ bucket.n += 1;
+ if (s.hr < bucket.min) bucket.min = s.hr;
+ if (s.hr > bucket.max) bucket.max = s.hr;
+ } else {
+ acc.set(idx, { sum: s.hr, min: s.hr, max: s.hr, n: 1 });
+ }
+ }
+ const points: HrSeriesPoint[] = [];
+ const densities: number[] = [];
+ for (const [idx, b] of [...acc.entries()].sort((a, c) => a[0] - c[0])) {
+ points.push({
+ tSec: idx * bucketSec,
+ mean: Math.round(b.sum / b.n),
+ min: b.min,
+ max: b.max,
+ });
+ densities.push(b.n);
+ }
+ return { points, bucketCount, medianDensity: median(densities) };
+}
+
+/**
+ * Parse the stored `WorkoutSamples.samples` JSONB into usable HR
+ * samples. The blob is validated at ingest against
+ * `workoutHrSamplesSchema`, so this stays a lean, defensive read rather
+ * than a full re-validation of up to 30 000 rows.
+ */
+function parseStoredSamples(raw: unknown): RawHrSample[] {
+ if (!Array.isArray(raw)) return [];
+ const out: RawHrSample[] = [];
+ for (const entry of raw) {
+ if (!entry || typeof entry !== "object") continue;
+ const rec = entry as Record;
+ const hr = rec.hr;
+ const t = rec.t;
+ if (typeof hr !== "number" || !Number.isFinite(hr)) continue;
+ if (typeof t !== "string") continue;
+ const tMs = Date.parse(t);
+ if (!Number.isFinite(tMs)) continue;
+ out.push({ tMs, hr });
+ }
+ return out;
+}
+
+export interface WorkoutHrSeriesInput {
+ userId: string;
+ startedAt: Date;
+ endedAt: Date;
+ durationSec: number;
+ storedSamples: unknown;
+ /** Reference clock for the retention cut — defaults to `now`. */
+ now?: Date;
+}
+
+/**
+ * Resolve the workout's HR curve. Returns `null` when neither a stored
+ * series nor an adequate pulse window exists.
+ */
+export async function buildWorkoutHrSeries(
+ input: WorkoutHrSeriesInput,
+): Promise {
+ const { userId, startedAt, endedAt, durationSec, storedSamples } = input;
+ const now = input.now ?? new Date();
+ const startMs = startedAt.getTime();
+ const bucketSec = adaptiveBucketSec(durationSec);
+
+ // 1. Stored series — the native, highest-fidelity path.
+ const stored = parseStoredSamples(storedSamples);
+ if (stored.length >= 2) {
+ const { points, medianDensity } = foldHrBuckets(
+ stored,
+ startMs,
+ durationSec,
+ bucketSec,
+ );
+ if (points.length >= 2) {
+ return {
+ source: "workout_series",
+ bucketSec,
+ points,
+ envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY,
+ };
+ }
+ }
+
+ // 2. Pulse-window fallback — only within the dense-retention horizon.
+ const ageDays = (now.getTime() - startMs) / (24 * 60 * 60 * 1000);
+ if (ageDays > DENSE_INTRADAY_RETENTION_DAYS) return null;
+
+ const windowStart = new Date(startMs - WINDOW_PAD_MS);
+ const windowEnd = new Date(endedAt.getTime() + WINDOW_PAD_MS);
+ const rows = await prisma.measurement.findMany({
+ where: {
+ userId,
+ type: "PULSE",
+ deletedAt: null,
+ measuredAt: { gte: windowStart, lte: windowEnd },
+ },
+ orderBy: { measuredAt: "asc" },
+ take: MAX_WINDOW_PULSE_ROWS,
+ select: { value: true, measuredAt: true, externalId: true },
+ });
+
+ // Raw per-sample rows only — a consolidated `stats:` fold is a day
+ // shape, not a session reading.
+ const raw: RawHrSample[] = rows
+ .filter((r) => !r.externalId?.startsWith("stats:"))
+ .map((r) => ({ tMs: r.measuredAt.getTime(), hr: r.value }));
+
+ // Gate part 1: enough raw samples in the padded window.
+ if (raw.length < MIN_FALLBACK_SAMPLES) return null;
+
+ const { points, bucketCount, medianDensity } = foldHrBuckets(
+ raw,
+ startMs,
+ durationSec,
+ bucketSec,
+ );
+
+ // Gate part 2: enough of the session is actually covered.
+ if (points.length / bucketCount < MIN_BUCKET_COVERAGE) return null;
+ if (points.length < 2) return null;
+
+ return {
+ source: "pulse_window",
+ bucketSec,
+ points,
+ envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY,
+ };
+}
diff --git a/src/lib/workouts/route-svg.ts b/src/lib/workouts/route-svg.ts
new file mode 100644
index 000000000..23d6d831c
--- /dev/null
+++ b/src/lib/workouts/route-svg.ts
@@ -0,0 +1,353 @@
+/**
+ * Client-safe route geometry math for the workout-detail map.
+ *
+ * PURE — no Prisma, no server imports, no Node APIs. It runs in the
+ * browser bundle (the v1.29 client/server-boundary lesson) and turns a
+ * stored GeoJSON `LineString` into an inline SVG polyline plus optional
+ * elevation profile and a GPX export blob. Nothing here reaches the
+ * network: the whole point of self-rendering the route is that the
+ * user's track never leaves the origin (binding privacy/CSP decision —
+ * no tile server, ever).
+ *
+ * Projection is equirectangular with a single `cos(midLat)` longitude
+ * scale factor. At workout scale (≤ a few tens of km) that is visually
+ * exact; the previous uncorrected normalisation stretched a route ~40 %
+ * horizontally at 50° N (a circular circuit read as an ellipse). The
+ * correction squashes the longitude axis by `cos(midLat)` so one screen
+ * unit east equals one screen unit north in real metres.
+ */
+
+export type RouteCoordinate =
+ [number, number] | [number, number, number] | number[];
+
+export interface RoutePoint {
+ x: number;
+ y: number;
+}
+
+export interface ProjectedRoute {
+ /** SVG path data (`M … L …`) in viewBox units. */
+ path: string;
+ /** `0 0 W H` — W is fixed at 100, H is the aspect-derived, clamped height. */
+ viewBox: string;
+ width: number;
+ height: number;
+ /** Start marker position (viewBox units). */
+ start: RoutePoint;
+ /** End marker position (viewBox units). */
+ end: RoutePoint;
+ /** Number of points after RDP downsampling. */
+ pointCount: number;
+}
+
+export interface ElevationPoint {
+ /** Cumulative distance from the route start, metres. */
+ distanceM: number;
+ /** Altitude, metres. */
+ altitude: number;
+}
+
+const DEG_TO_RAD = Math.PI / 180;
+const METERS_PER_DEG_LAT = 111_320;
+
+/** viewBox width — fixed; height rides the projected aspect ratio. */
+const VIEW_W = 100;
+/** Aspect clamp so a straight out-and-back can't render as a 4 px sliver. */
+const MIN_H = 40;
+const MAX_H = 140;
+/** Guards against a degenerate "route" (an indoor session's 3 GPS pings). */
+const MIN_POINTS = 10;
+const MIN_SPAN_M = 30;
+/** Shape-preserving downsample ceiling. */
+const MAX_RENDER_POINTS = 1200;
+
+function isFiniteNumber(v: unknown): v is number {
+ return typeof v === "number" && Number.isFinite(v);
+}
+
+/**
+ * Pull the `[lon, lat]` pairs out of a GeoJSON `LineString`'s
+ * coordinates, dropping any malformed entry. Altitude (3rd element) is
+ * ignored here — `buildElevationProfile` reads it separately.
+ */
+export function extractLonLat(
+ coordinates: readonly RouteCoordinate[],
+): Array<[number, number]> {
+ const out: Array<[number, number]> = [];
+ for (const c of coordinates) {
+ if (!Array.isArray(c) || c.length < 2) continue;
+ const lon = c[0];
+ const lat = c[1];
+ if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) continue;
+ if (Math.abs(lon) > 180 || Math.abs(lat) > 90) continue;
+ out.push([lon, lat]);
+ }
+ return out;
+}
+
+/** Great-circle distance between two lon/lat points, metres. */
+export function haversineMeters(
+ aLon: number,
+ aLat: number,
+ bLon: number,
+ bLat: number,
+): number {
+ const R = 6_371_000;
+ const dLat = (bLat - aLat) * DEG_TO_RAD;
+ const dLon = (bLon - aLon) * DEG_TO_RAD;
+ const lat1 = aLat * DEG_TO_RAD;
+ const lat2 = bLat * DEG_TO_RAD;
+ const h =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
+ return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
+}
+
+/** Perpendicular distance from `p` to the segment `a→b` (planar). */
+function perpDistance(
+ p: [number, number],
+ a: [number, number],
+ b: [number, number],
+): number {
+ const [px, py] = p;
+ const [ax, ay] = a;
+ const [bx, by] = b;
+ const dx = bx - ax;
+ const dy = by - ay;
+ const lenSq = dx * dx + dy * dy;
+ if (lenSq === 0) return Math.hypot(px - ax, py - ay);
+ let t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
+ t = Math.max(0, Math.min(1, t));
+ return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
+}
+
+/**
+ * Ramer–Douglas–Peucker line simplification. Keeps switchbacks that a
+ * naive stride would erase. Iterative (an explicit stack) so a
+ * 20 000-point ultra can't blow the call stack.
+ */
+export function rdpSimplify(
+ points: ReadonlyArray<[number, number]>,
+ tolerance: number,
+): Array<[number, number]> {
+ if (points.length <= 2 || tolerance <= 0) return points.slice();
+ const keep = new Uint8Array(points.length);
+ keep[0] = 1;
+ keep[points.length - 1] = 1;
+ const stack: Array<[number, number]> = [[0, points.length - 1]];
+ while (stack.length > 0) {
+ const [first, last] = stack.pop() as [number, number];
+ let maxDist = 0;
+ let index = -1;
+ for (let i = first + 1; i < last; i++) {
+ const d = perpDistance(points[i], points[first], points[last]);
+ if (d > maxDist) {
+ maxDist = d;
+ index = i;
+ }
+ }
+ if (maxDist > tolerance && index !== -1) {
+ keep[index] = 1;
+ stack.push([first, index]);
+ stack.push([index, last]);
+ }
+ }
+ const out: Array<[number, number]> = [];
+ for (let i = 0; i < points.length; i++) if (keep[i]) out.push(points[i]);
+ return out;
+}
+
+/** Uniform stride down to at most `max` points (post-RDP safety cap). */
+function strideTo(points: readonly T[], max: number): T[] {
+ if (points.length <= max) return points.slice();
+ const step = points.length / max;
+ const out: T[] = [];
+ for (let i = 0; i < max; i++) out.push(points[Math.floor(i * step)]);
+ const last = points[points.length - 1];
+ if (out[out.length - 1] !== last) out.push(last);
+ return out;
+}
+
+/**
+ * Project a GeoJSON LineString into an aspect-true, downsampled SVG
+ * path. Returns `null` for a degenerate route (too few points or a
+ * bounding box under ~30 m) — the caller then hides the route card
+ * rather than painting a meaningless dot.
+ */
+export function projectRoute(
+ coordinates: readonly RouteCoordinate[],
+): ProjectedRoute | null {
+ const lonLat = extractLonLat(coordinates);
+ if (lonLat.length < MIN_POINTS) return null;
+
+ let minLon = Infinity;
+ let maxLon = -Infinity;
+ let minLat = Infinity;
+ let maxLat = -Infinity;
+ for (const [lon, lat] of lonLat) {
+ if (lon < minLon) minLon = lon;
+ if (lon > maxLon) maxLon = lon;
+ if (lat < minLat) minLat = lat;
+ if (lat > maxLat) maxLat = lat;
+ }
+
+ const midLat = (minLat + maxLat) / 2;
+ const cosMid = Math.cos(midLat * DEG_TO_RAD);
+
+ // Real-world span (metres) — the small-route guard rejects the indoor
+ // session that logged a couple of stationary GPS pings.
+ const spanXm = (maxLon - minLon) * cosMid * METERS_PER_DEG_LAT;
+ const spanYm = (maxLat - minLat) * METERS_PER_DEG_LAT;
+ if (Math.hypot(spanXm, spanYm) < MIN_SPAN_M) return null;
+
+ // Projected coordinates: longitude squashed by cos(midLat); latitude
+ // flipped so SVG y grows downward.
+ const projected: Array<[number, number]> = lonLat.map(([lon, lat]) => [
+ (lon - minLon) * cosMid,
+ maxLat - lat,
+ ]);
+
+ const xSpanRaw = (maxLon - minLon) * cosMid || 1e-9;
+ // RDP tolerance derived from the projected bbox (spec: xSpan / 2000).
+ let simplified = rdpSimplify(projected, xSpanRaw / 2000);
+ if (simplified.length > MAX_RENDER_POINTS) {
+ simplified = strideTo(simplified, MAX_RENDER_POINTS);
+ }
+
+ // Projected bbox of the (simplified) track + 5 % padding per side.
+ let pMinX = Infinity;
+ let pMaxX = -Infinity;
+ let pMinY = Infinity;
+ let pMaxY = -Infinity;
+ for (const [x, y] of simplified) {
+ if (x < pMinX) pMinX = x;
+ if (x > pMaxX) pMaxX = x;
+ if (y < pMinY) pMinY = y;
+ if (y > pMaxY) pMaxY = y;
+ }
+ const rawXSpan = pMaxX - pMinX || 1e-9;
+ const rawYSpan = pMaxY - pMinY || 1e-9;
+ const padX = rawXSpan * 0.05;
+ const padY = rawYSpan * 0.05;
+ pMinX -= padX;
+ pMaxX += padX;
+ pMinY -= padY;
+ pMaxY += padY;
+ const xSpan = pMaxX - pMinX;
+ const ySpan = pMaxY - pMinY;
+
+ // Height rides the true aspect ratio (uniform scale when unclamped);
+ // the clamp only bites on extreme slivers, where the intentional
+ // distortion trades geographic fidelity for on-screen readability.
+ const height = Math.max(MIN_H, Math.min(MAX_H, (VIEW_W * ySpan) / xSpan));
+ const scaleX = VIEW_W / xSpan;
+ const scaleY = height / ySpan;
+
+ const toVB = ([x, y]: [number, number]): RoutePoint => ({
+ x: (x - pMinX) * scaleX,
+ y: (y - pMinY) * scaleY,
+ });
+
+ const vbPoints = simplified.map(toVB);
+ const path = vbPoints
+ .map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
+ .join(" ");
+
+ return {
+ path,
+ viewBox: `0 0 ${VIEW_W} ${height.toFixed(2)}`,
+ width: VIEW_W,
+ height,
+ start: vbPoints[0],
+ end: vbPoints[vbPoints.length - 1],
+ pointCount: vbPoints.length,
+ };
+}
+
+/** Fraction of coordinates that carry a finite altitude (3rd element). */
+function altitudeCoverage(coordinates: readonly RouteCoordinate[]): number {
+ if (coordinates.length === 0) return 0;
+ let withAlt = 0;
+ for (const c of coordinates) {
+ if (Array.isArray(c) && c.length >= 3 && isFiniteNumber(c[2])) withAlt++;
+ }
+ return withAlt / coordinates.length;
+}
+
+/** Minimum altitude coverage before an elevation profile is honest. */
+export const ELEVATION_COVERAGE_THRESHOLD = 0.6;
+
+/**
+ * Build a cumulative-distance elevation profile when at least 60 % of
+ * coordinates carry altitude. Below the threshold partial altimeter
+ * data would draw a lie, so this returns `null` and the caller omits
+ * the profile.
+ */
+export function buildElevationProfile(
+ coordinates: readonly RouteCoordinate[],
+): ElevationPoint[] | null {
+ if (altitudeCoverage(coordinates) < ELEVATION_COVERAGE_THRESHOLD) return null;
+ const out: ElevationPoint[] = [];
+ let cumulative = 0;
+ let prev: [number, number] | null = null;
+ for (const c of coordinates) {
+ if (!Array.isArray(c) || c.length < 3) continue;
+ const lon = c[0];
+ const lat = c[1];
+ const alt = c[2];
+ if (!isFiniteNumber(lon) || !isFiniteNumber(lat) || !isFiniteNumber(alt)) {
+ continue;
+ }
+ if (prev) cumulative += haversineMeters(prev[0], prev[1], lon, lat);
+ out.push({ distanceM: cumulative, altitude: alt });
+ prev = [lon, lat];
+ }
+ return out.length >= 2 ? out : null;
+}
+
+function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+/**
+ * Serialise the route geometry to a GPX 1.1 document, entirely
+ * client-side over data already loaded — zero server work, zero egress.
+ * Timestamps (when present, parallel to the coordinates) are emitted so
+ * the exported track round-trips into any GPX consumer.
+ */
+export function buildGpxDocument(params: {
+ coordinates: readonly RouteCoordinate[];
+ timestamps?: readonly string[] | null;
+ sportType: string;
+ startedAt: string;
+}): string {
+ const { coordinates, timestamps, sportType, startedAt } = params;
+ const segments: string[] = [];
+ coordinates.forEach((c, i) => {
+ if (!Array.isArray(c) || c.length < 2) return;
+ const lon = c[0];
+ const lat = c[1];
+ if (!isFiniteNumber(lon) || !isFiniteNumber(lat)) return;
+ const parts = [``];
+ if (c.length >= 3 && isFiniteNumber(c[2])) parts.push(`${c[2]} `);
+ const ts = timestamps?.[i];
+ if (typeof ts === "string" && ts.length > 0) {
+ parts.push(`${escapeXml(ts)} `);
+ }
+ parts.push(` `);
+ segments.push(parts.join(""));
+ });
+ return [
+ ``,
+ ``,
+ `${escapeXml(startedAt)} `,
+ `${escapeXml(sportType)} `,
+ ...segments,
+ ` `,
+ ` `,
+ ].join("\n");
+}
diff --git a/src/lib/workouts/splits.ts b/src/lib/workouts/splits.ts
new file mode 100644
index 000000000..3a8377c1b
--- /dev/null
+++ b/src/lib/workouts/splits.ts
@@ -0,0 +1,84 @@
+/**
+ * Per-kilometre splits from a workout's route geometry + per-sample
+ * timestamps. Pure; computed server-side so the web client keeps
+ * dropping the raw `sampleTimestamps` blob under `compact=1` (the
+ * derived splits survive, the 20k-point timestamp array does not) and
+ * iOS gets the same resolved figures — server-authoritative parity.
+ *
+ * Only meaningful for distance/pace sports; the caller gates the sport.
+ * A workout without aligned timestamps (or under one full km) yields
+ * `null` and the caller hides the splits card.
+ */
+import {
+ extractLonLat,
+ haversineMeters,
+ type RouteCoordinate,
+} from "@/lib/workouts/route-svg";
+
+export interface WorkoutSplit {
+ /** 1-based kilometre index. */
+ km: number;
+ /** Time to cover this kilometre, seconds. */
+ durationSec: number;
+ /** Pace over the kilometre, seconds per km (== durationSec here). */
+ paceSecPerKm: number;
+}
+
+const KM = 1000;
+
+/**
+ * Compute whole-kilometre splits. Walks the polyline accumulating
+ * haversine distance; at each km boundary the crossing time is linearly
+ * interpolated between the two bounding samples by distance fraction,
+ * so a split boundary that falls mid-segment is timed honestly.
+ */
+export function computeSplits(
+ coordinates: readonly RouteCoordinate[],
+ timestamps: readonly string[] | null | undefined,
+): WorkoutSplit[] | null {
+ if (!timestamps || timestamps.length !== coordinates.length) return null;
+
+ // Keep coordinate/timestamp pairs that are both well-formed.
+ const pts: Array<{ lon: number; lat: number; tMs: number }> = [];
+ coordinates.forEach((c, i) => {
+ const [lonLat] = extractLonLat([c]);
+ if (!lonLat) return;
+ const tMs = Date.parse(timestamps[i] ?? "");
+ if (!Number.isFinite(tMs)) return;
+ pts.push({ lon: lonLat[0], lat: lonLat[1], tMs });
+ });
+ if (pts.length < 2) return null;
+
+ const splits: WorkoutSplit[] = [];
+ let cumulative = 0;
+ let nextBoundary = KM;
+ let prevBoundaryTimeMs = pts[0].tMs;
+
+ for (let i = 1; i < pts.length; i++) {
+ const a = pts[i - 1];
+ const b = pts[i];
+ const segDist = haversineMeters(a.lon, a.lat, b.lon, b.lat);
+ if (segDist <= 0) continue;
+ const segStart = cumulative;
+ cumulative += segDist;
+
+ // A single long segment can straddle several km boundaries.
+ while (cumulative >= nextBoundary) {
+ const frac = (nextBoundary - segStart) / segDist;
+ const boundaryTimeMs = a.tMs + (b.tMs - a.tMs) * frac;
+ const durationSec = Math.max(
+ 0,
+ Math.round((boundaryTimeMs - prevBoundaryTimeMs) / 1000),
+ );
+ splits.push({
+ km: splits.length + 1,
+ durationSec,
+ paceSecPerKm: durationSec,
+ });
+ prevBoundaryTimeMs = boundaryTimeMs;
+ nextBoundary += KM;
+ }
+ }
+
+ return splits.length > 0 ? splits : null;
+}
diff --git a/src/lib/workouts/zones.ts b/src/lib/workouts/zones.ts
new file mode 100644
index 000000000..fb39e4f34
--- /dev/null
+++ b/src/lib/workouts/zones.ts
@@ -0,0 +1,162 @@
+/**
+ * Effort zones for the workout-detail page. Descriptive, never
+ * prescriptive — a distribution of time-in-zone, not a training
+ * verdict or a cardiac interpretation (non-diagnostic standard).
+ *
+ * Display model: five %HRmax zones (Z1 50–60 %, Z2 60–70 %, Z3 70–80 %,
+ * Z4 80–90 %, Z5 90 %+), HRmax from the grounded `tanakaHrMax()`
+ * (profile age). %HRmax over Karvonen because it needs only age and
+ * matches every consumer device's zone labelling — the strain engine
+ * owns the HRR/TRIMP math internally; the detail page merely describes.
+ *
+ * Resolution order:
+ * 1. WHOOP `metadata.zoneDurations` — device-authoritative time in
+ * zone, parsed through a narrow slice (JSON is never duck-typed).
+ * 2. Computed time-in-zone from the HR series, when profile age
+ * exists.
+ * 3. Neither → `null`, and the caller hides the zones card.
+ */
+import { z } from "zod/v4";
+
+import { tanakaHrMax } from "@/lib/insights/strain-score";
+import type { HrSeriesPoint } from "@/lib/workouts/hr-series";
+
+export type ZonesModel = "whoop" | "tanaka";
+
+export interface ZoneBand {
+ /** 1–5. */
+ zone: number;
+ /** Lower bpm bound (null when HRmax is unknown — WHOOP-only path). */
+ lowBpm: number | null;
+ /** Upper bpm bound; null for the open-ended top zone. */
+ highBpm: number | null;
+ /** Time spent in the zone, seconds. */
+ seconds: number;
+}
+
+export interface WorkoutZones {
+ model: ZonesModel;
+ hrMax: number | null;
+ zones: ZoneBand[];
+}
+
+/** %HRmax band edges. The top zone is open-ended (highPct = null). */
+const ZONE_EDGES: Array<{
+ zone: number;
+ lowPct: number;
+ highPct: number | null;
+}> = [
+ { zone: 1, lowPct: 0.5, highPct: 0.6 },
+ { zone: 2, lowPct: 0.6, highPct: 0.7 },
+ { zone: 3, lowPct: 0.7, highPct: 0.8 },
+ { zone: 4, lowPct: 0.8, highPct: 0.9 },
+ { zone: 5, lowPct: 0.9, highPct: null },
+];
+
+function bandBounds(hrMax: number | null) {
+ return ZONE_EDGES.map((e) => ({
+ zone: e.zone,
+ lowBpm: hrMax != null ? Math.round(hrMax * e.lowPct) : null,
+ highBpm:
+ hrMax != null && e.highPct != null ? Math.round(hrMax * e.highPct) : null,
+ }));
+}
+
+/**
+ * Narrow slice of WHOOP's `zone_durations` (milliseconds per zone). The
+ * API ships `zone_zero_milli` (below Z1, recovery) through
+ * `zone_five_milli`; we map zones one–five onto our Z1–Z5 and drop
+ * zone-zero from the five-zone display, mirroring the device's own
+ * five-zone labelling.
+ */
+const whoopZoneDurationsSchema = z
+ .object({
+ zone_zero_milli: z.number().nonnegative().optional(),
+ zone_one_milli: z.number().nonnegative().optional(),
+ zone_two_milli: z.number().nonnegative().optional(),
+ zone_three_milli: z.number().nonnegative().optional(),
+ zone_four_milli: z.number().nonnegative().optional(),
+ zone_five_milli: z.number().nonnegative().optional(),
+ })
+ .transform((v) => [
+ v.zone_one_milli ?? 0,
+ v.zone_two_milli ?? 0,
+ v.zone_three_milli ?? 0,
+ v.zone_four_milli ?? 0,
+ v.zone_five_milli ?? 0,
+ ]);
+
+/**
+ * Pull WHOOP zone durations (seconds per Z1–Z5) out of a workout's
+ * metadata blob, or `null` when absent / malformed / all-zero.
+ */
+export function parseWhoopZoneDurations(metadata: unknown): number[] | null {
+ if (!metadata || typeof metadata !== "object") return null;
+ const raw = (metadata as Record).zoneDurations;
+ if (!raw || typeof raw !== "object") return null;
+ const parsed = whoopZoneDurationsSchema.safeParse(raw);
+ if (!parsed.success) return null;
+ const seconds = parsed.data.map((ms) => ms / 1000);
+ return seconds.some((s) => s > 0) ? seconds : null;
+}
+
+/** %HRmax zone index (1–5) for a heart rate, or 0 for below-Z1 (rest). */
+function zoneForHr(hr: number, hrMax: number): number {
+ const pct = hr / hrMax;
+ for (const e of ZONE_EDGES) {
+ if (e.highPct == null) return e.zone; // top zone catches everything ≥ Z5 low
+ if (pct < e.highPct) return pct >= e.lowPct ? e.zone : 0;
+ }
+ return 0;
+}
+
+export interface ComputeZonesInput {
+ hrMax: number | null;
+ series: readonly HrSeriesPoint[];
+ bucketSec: number;
+ whoopZoneDurations: number[] | null;
+}
+
+/**
+ * Resolve the effort-zone distribution. WHOOP's device-reported
+ * durations win when present; otherwise time-in-zone is folded from the
+ * HR series (each non-empty bucket contributes `bucketSec` seconds at
+ * its mean HR). Below-Z1 (rest) time is excluded from the five-zone
+ * display. Returns `null` when neither source is available.
+ */
+export function computeZones(input: ComputeZonesInput): WorkoutZones | null {
+ const { hrMax, series, bucketSec, whoopZoneDurations } = input;
+
+ if (whoopZoneDurations) {
+ const bounds = bandBounds(hrMax);
+ return {
+ model: "whoop",
+ hrMax,
+ zones: bounds.map((b, i) => ({
+ ...b,
+ seconds: Math.round(whoopZoneDurations[i] ?? 0),
+ })),
+ };
+ }
+
+ if (hrMax == null || series.length === 0) return null;
+
+ const seconds = [0, 0, 0, 0, 0];
+ for (const p of series) {
+ const zone = zoneForHr(p.mean, hrMax);
+ if (zone >= 1 && zone <= 5) seconds[zone - 1] += bucketSec;
+ }
+ if (seconds.every((s) => s === 0)) return null;
+
+ const bounds = bandBounds(hrMax);
+ return {
+ model: "tanaka",
+ hrMax,
+ zones: bounds.map((b, i) => ({ ...b, seconds: seconds[i] })),
+ };
+}
+
+/** Convenience: HRmax from profile age (Tanaka), or null when no age. */
+export function hrMaxFromAge(ageYears: number | null): number | null {
+ return ageYears != null ? Math.round(tanakaHrMax(ageYears)) : null;
+}