mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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.
56 lines
2.0 KiB
TypeScript
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();
|
|
});
|
|
});
|