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

56 lines
2.0 KiB
TypeScript

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();
});
});