mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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.
This commit is contained in:
1 parent
04320c415d
commit
e61fe42bd1
38 files changed
+3466
-511
No files matched your search
@@ -30,13 +30,31 @@ import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { buildWorkoutHrSeries } from "@/lib/workouts/hr-series";
|
||||
import {
|
||||
computeZones,
|
||||
hrMaxFromAge,
|
||||
parseWhoopZoneDurations,
|
||||
} from "@/lib/workouts/zones";
|
||||
import { computeSplits } from "@/lib/workouts/splits";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Own-history lookback for the sport-average comparison line. */
|
||||
const SPORT_CONTEXT_LOOKBACK_DAYS = 180;
|
||||
/** Slim-row cap for the sport-average read. */
|
||||
const SPORT_CONTEXT_MAX_ROWS = 500;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
// Web always sends `compact=1` to drop the raw 30k-sample / route
|
||||
// timestamp blobs. Absent the param the response is byte-identical to
|
||||
// the v1.4.32 contract → iOS is untouched, no coordination ticket.
|
||||
const compact = request.nextUrl.searchParams.get("compact") === "1";
|
||||
|
||||
annotate({ action: { name: "workouts.detail" }, meta: { workoutId: id } });
|
||||
|
||||
@@ -98,7 +116,7 @@ export const GET = apiHandler(
|
||||
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
select: { sourcePriorityJson: true, dateOfBirth: true },
|
||||
});
|
||||
const canonicalCluster = pickCanonicalWorkoutRows(
|
||||
clusterRows,
|
||||
@@ -112,20 +130,82 @@ export const GET = apiHandler(
|
||||
canonicalCluster[0] ??
|
||||
null;
|
||||
|
||||
// ── Enrichment reads (all over existing tables — no migration) ────
|
||||
|
||||
// Heart-rate curve: stored series first, pulse-window fallback
|
||||
// second, hide third. One server path, one DTO with provenance.
|
||||
const hrSeries = await buildWorkoutHrSeries({
|
||||
userId: user.id,
|
||||
startedAt: row.startedAt,
|
||||
endedAt: row.endedAt,
|
||||
durationSec: row.durationSec,
|
||||
storedSamples: row.samples?.samples ?? null,
|
||||
});
|
||||
annotate({
|
||||
action: { name: "workouts.detail.hr_series" },
|
||||
meta: {
|
||||
source: hrSeries?.source ?? "none",
|
||||
points: hrSeries?.points.length ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Effort zones: WHOOP device durations win; else %HRmax fold from
|
||||
// the series when profile age exists; else null.
|
||||
const ageYears = getAgeFromDateOfBirth(userRow?.dateOfBirth ?? null);
|
||||
const zones = computeZones({
|
||||
hrMax: hrMaxFromAge(ageYears),
|
||||
series: hrSeries?.points ?? [],
|
||||
bucketSec: hrSeries?.bucketSec ?? 0,
|
||||
whoopZoneDurations: parseWhoopZoneDurations(row.metadata),
|
||||
});
|
||||
|
||||
// Per-km splits computed server-side from the geometry + timestamps
|
||||
// so the web client keeps dropping the raw timestamp blob under
|
||||
// `compact=1` while still rendering splits (server-authoritative
|
||||
// parity — iOS gets the same resolved figures).
|
||||
const geometryCoords =
|
||||
row.route &&
|
||||
row.route.geometry &&
|
||||
typeof row.route.geometry === "object" &&
|
||||
Array.isArray(
|
||||
(row.route.geometry as { coordinates?: unknown }).coordinates,
|
||||
)
|
||||
? (row.route.geometry as { coordinates: RouteCoordinate[] }).coordinates
|
||||
: null;
|
||||
const splits =
|
||||
geometryCoords && Array.isArray(row.route?.sampleTimestamps)
|
||||
? computeSplits(geometryCoords, row.route.sampleTimestamps as string[])
|
||||
: null;
|
||||
|
||||
// Sport context: the user's own last-180-days average for this
|
||||
// sport, cross-source-collapsed so twins don't double-count.
|
||||
const sportContext = await buildSportContext(
|
||||
user.id,
|
||||
row.sportType,
|
||||
userRow?.sourcePriorityJson ?? null,
|
||||
);
|
||||
|
||||
const route = row.route
|
||||
? {
|
||||
geometry: row.route.geometry,
|
||||
sampleTimestamps: row.route.sampleTimestamps ?? null,
|
||||
// `compact=1` drops the (up to 20k-entry) timestamp array; the
|
||||
// SVG needs geometry, not the per-sample timestamps, and the
|
||||
// splits above are already derived from them server-side.
|
||||
sampleTimestamps: compact
|
||||
? null
|
||||
: (row.route.sampleTimestamps ?? null),
|
||||
}
|
||||
: null;
|
||||
|
||||
// v1.10.0 — route-independent per-workout HR series. Present for
|
||||
// both indoor (no route) and outdoor workouts that shipped a
|
||||
// `samples` array on ingest; null otherwise.
|
||||
// `samples` array on ingest; null otherwise. `compact=1` keeps the
|
||||
// denormalised count but drops the raw sample blob (the web curve
|
||||
// reads `hrSeries` instead).
|
||||
const samples = row.samples
|
||||
? {
|
||||
sampleCount: row.samples.sampleCount,
|
||||
samples: row.samples.samples,
|
||||
samples: compact ? null : row.samples.samples,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -148,6 +228,17 @@ export const GET = apiHandler(
|
||||
metadata: row.metadata,
|
||||
route,
|
||||
samples,
|
||||
// #67 enrichment — all additive, all over existing tables.
|
||||
hrSeries,
|
||||
zones,
|
||||
splits,
|
||||
sportContext,
|
||||
// Reserved Activity-Insight seam (strategic-concept Wave C, bet 5):
|
||||
// always null today. The Phase-2 pg-boss job writes a cached
|
||||
// paragraph onto the workout row and maps it here — the detail page
|
||||
// already composes `{aiInsight ? <card/> : null}`, so the card
|
||||
// mounts with zero layout rework.
|
||||
aiInsight: null,
|
||||
// v1.4.32 — when the requested id is a non-canonical twin the
|
||||
// caller can redirect to `canonicalId` to land on the cluster
|
||||
// winner. `canonicalId === id` when the requested row already
|
||||
@@ -156,3 +247,64 @@ export const GET = apiHandler(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The user's own recent average for a sport — rendered as one muted
|
||||
* comparison line. Cross-source twins are collapsed through
|
||||
* `pickCanonicalWorkoutRows()` first so a run recorded by two paired
|
||||
* watches counts once. Comparisons are to the user's own history only
|
||||
* (non-diagnostic standard).
|
||||
*/
|
||||
async function buildSportContext(
|
||||
userId: string,
|
||||
sportType: string,
|
||||
sourcePriorityJson: unknown,
|
||||
): Promise<{
|
||||
count: number;
|
||||
avgDurationSec: number;
|
||||
avgDistanceM: number | null;
|
||||
avgAvgHr: number | null;
|
||||
} | null> {
|
||||
const since = new Date(
|
||||
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const rows = await prisma.workout.findMany({
|
||||
where: { userId, sportType, startedAt: { gte: since } },
|
||||
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
|
||||
take: SPORT_CONTEXT_MAX_ROWS,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
avgHeartRate: true,
|
||||
},
|
||||
});
|
||||
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
|
||||
if (canonical.length === 0) return null;
|
||||
|
||||
let durationSum = 0;
|
||||
let distanceSum = 0;
|
||||
let distanceCount = 0;
|
||||
let hrSum = 0;
|
||||
let hrCount = 0;
|
||||
for (const r of canonical) {
|
||||
durationSum += r.durationSec;
|
||||
if (r.totalDistanceM != null) {
|
||||
distanceSum += r.totalDistanceM;
|
||||
distanceCount += 1;
|
||||
}
|
||||
if (r.avgHeartRate != null) {
|
||||
hrSum += r.avgHeartRate;
|
||||
hrCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
count: canonical.length,
|
||||
avgDurationSec: Math.round(durationSum / canonical.length),
|
||||
avgDistanceM: distanceCount > 0 ? distanceSum / distanceCount : null,
|
||||
avgAvgHr: hrCount > 0 ? Math.round(hrSum / hrCount) : null,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,9 @@ vi.mock("@/lib/db", () => ({
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -103,7 +106,14 @@ describe("GET /api/workouts/{id}", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
// #67 — the enrichment reads: the HR-curve pulse-window fallback
|
||||
// (`measurement.findMany`) and the sport-context average (a SECOND
|
||||
// `workout.findMany`). Default both to empty; each test's
|
||||
// `mockResolvedValueOnce` still wins for the cluster call.
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
|
||||
// clearAllMocks wipes the factory default; re-seed the gate to pass.
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({
|
||||
enabled: true,
|
||||
@@ -208,6 +218,72 @@ describe("GET /api/workouts/{id}", () => {
|
||||
expect(body.data.canonicalId).toBe("w-apple");
|
||||
});
|
||||
|
||||
it("adds the #67 enrichment fields and strips raw blobs under compact=1", async () => {
|
||||
const startMs = BASE_ROW.startedAt.getTime();
|
||||
const storedSamples = Array.from({ length: 40 }, (_, i) => ({
|
||||
t: new Date(startMs + i * 45_000).toISOString(),
|
||||
hr: 140 + (i % 6),
|
||||
}));
|
||||
vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({
|
||||
...BASE_ROW,
|
||||
// WHOOP device zones win → zones render even without profile age.
|
||||
metadata: {
|
||||
zoneDurations: {
|
||||
zone_one_milli: 60_000,
|
||||
zone_two_milli: 300_000,
|
||||
zone_three_milli: 600_000,
|
||||
zone_four_milli: 200_000,
|
||||
zone_five_milli: 40_000,
|
||||
},
|
||||
},
|
||||
route: {
|
||||
id: "wr-1",
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11, 49],
|
||||
[11.01, 49.01],
|
||||
],
|
||||
},
|
||||
sampleTimestamps: ["2026-05-15T07:00:00Z", "2026-05-15T07:01:00Z"],
|
||||
createdAt: BASE_ROW.createdAt,
|
||||
},
|
||||
samples: { sampleCount: storedSamples.length, samples: storedSamples },
|
||||
} as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
id: "w-1",
|
||||
source: "APPLE_HEALTH",
|
||||
startedAt: BASE_ROW.startedAt,
|
||||
sportType: "RUNNING",
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/workouts/w-1?compact=1", {
|
||||
method: "GET",
|
||||
}),
|
||||
makeParams("w-1"),
|
||||
);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Stored series wins → provenance is the native workout series.
|
||||
expect(body.data.hrSeries.source).toBe("workout_series");
|
||||
expect(body.data.hrSeries.points.length).toBeGreaterThanOrEqual(2);
|
||||
// WHOOP device zones.
|
||||
expect(body.data.zones.model).toBe("whoop");
|
||||
// Additive keys present; reserved seam is null.
|
||||
expect(body.data).toHaveProperty("splits");
|
||||
expect(body.data).toHaveProperty("sportContext");
|
||||
expect(body.data.aiInsight).toBeNull();
|
||||
// compact=1 drops the raw blobs but keeps count + geometry.
|
||||
expect(body.data.samples.sampleCount).toBe(40);
|
||||
expect(body.data.samples.samples).toBeNull();
|
||||
expect(body.data.route.geometry).not.toBeNull();
|
||||
expect(body.data.route.sampleTimestamps).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes the WorkoutRoute geometry when present", async () => {
|
||||
const routeGeometry = {
|
||||
type: "LineString",
|
||||
|
||||
@@ -94,6 +94,10 @@ interface CanonicalWorkoutRow {
|
||||
avgHeartRate: number | null;
|
||||
maxHeartRate: number | null;
|
||||
createdAt: Date;
|
||||
// #67 list glyphs — cheap relation-id selects so the list can flag
|
||||
// which sessions open into a rich detail (map / HR curve).
|
||||
route: { id: string } | null;
|
||||
samples: { sampleCount: number } | null;
|
||||
}
|
||||
|
||||
/** The cached, deduped, most-recent-first row set for one filter combination. */
|
||||
@@ -115,6 +119,8 @@ interface WorkoutsResult {
|
||||
maxHr: number | null;
|
||||
source: string;
|
||||
externalId: string | null;
|
||||
hasRoute: boolean;
|
||||
hasHrSeries: boolean;
|
||||
}>;
|
||||
meta: {
|
||||
total: number;
|
||||
@@ -169,6 +175,8 @@ async function buildWorkoutsProjection(
|
||||
avgHeartRate: true,
|
||||
maxHeartRate: true,
|
||||
createdAt: true,
|
||||
route: { select: { id: true } },
|
||||
samples: { select: { sampleCount: true } },
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
@@ -215,6 +223,8 @@ function sliceWorkoutsProjection(
|
||||
maxHr: row.maxHeartRate,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
hasRoute: row.route != null,
|
||||
hasHrSeries: row.samples != null && row.samples.sampleCount > 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user