mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
85 lines
2.7 KiB
TypeScript
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;
|
|
}
|