Files
HealthLog/src/lib/workouts/splits.ts
T
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

85 lines
2.7 KiB
TypeScript

/**
* 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;
}