mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #721 from MBombeck/feat/workout-hr-profile
Show the whole workout heart-rate profile
This commit is contained in:
14 files changed
+775
-86
No files matched your search
@@ -3110,6 +3110,11 @@
|
||||
"notFound": "Workout nicht gefunden.",
|
||||
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
|
||||
"hrAxisMinutes": "Min.",
|
||||
"hrAvgMarker": "Ø {bpm}",
|
||||
"hrMaxMarker": "Max. {bpm}",
|
||||
"hrRangeValue": "{low} bis {high} bpm",
|
||||
"hrRangeLabel": "Spanne",
|
||||
"hrNoProfile": "Für diese Einheit liegen keine einzelnen Pulswerte vor.",
|
||||
"sportAverageLabel": "Dein aktueller Durchschnitt:",
|
||||
"zonesTitle": "Belastungszonen",
|
||||
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
|
||||
|
||||
@@ -3110,6 +3110,11 @@
|
||||
"notFound": "Workout not found.",
|
||||
"hrFromPulse": "From your heart-rate data around this session",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Avg {bpm}",
|
||||
"hrMaxMarker": "Peak {bpm}",
|
||||
"hrRangeValue": "{low} to {high} bpm",
|
||||
"hrRangeLabel": "Range",
|
||||
"hrNoProfile": "No reading-by-reading heart-rate data covers this session.",
|
||||
"sportAverageLabel": "Your recent average:",
|
||||
"zonesTitle": "Effort zones",
|
||||
"zonesCaption": "How your effort was distributed across heart-rate zones.",
|
||||
|
||||
@@ -3110,6 +3110,11 @@
|
||||
"notFound": "Entrenamiento no encontrado.",
|
||||
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Media {bpm}",
|
||||
"hrMaxMarker": "Máx. {bpm}",
|
||||
"hrRangeValue": "{low} a {high} bpm",
|
||||
"hrRangeLabel": "Rango",
|
||||
"hrNoProfile": "No hay valores de pulso individuales que cubran esta sesión.",
|
||||
"sportAverageLabel": "Tu promedio reciente:",
|
||||
"zonesTitle": "Zonas de esfuerzo",
|
||||
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
|
||||
|
||||
@@ -3110,6 +3110,11 @@
|
||||
"notFound": "Entraînement introuvable.",
|
||||
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Moy. {bpm}",
|
||||
"hrMaxMarker": "Max. {bpm}",
|
||||
"hrRangeValue": "{low} à {high} bpm",
|
||||
"hrRangeLabel": "Plage",
|
||||
"hrNoProfile": "Aucune valeur de pouls détaillée ne couvre cette séance.",
|
||||
"sportAverageLabel": "Votre moyenne récente :",
|
||||
"zonesTitle": "Zones d'effort",
|
||||
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
|
||||
|
||||
@@ -3110,6 +3110,11 @@
|
||||
"notFound": "Allenamento non trovato.",
|
||||
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Media {bpm}",
|
||||
"hrMaxMarker": "Max {bpm}",
|
||||
"hrRangeValue": "da {low} a {high} bpm",
|
||||
"hrRangeLabel": "Intervallo",
|
||||
"hrNoProfile": "Nessun valore di battito dettagliato copre questa sessione.",
|
||||
"sportAverageLabel": "La tua media recente:",
|
||||
"zonesTitle": "Zone di sforzo",
|
||||
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
|
||||
|
||||
@@ -3110,6 +3110,11 @@
|
||||
"notFound": "Nie znaleziono treningu.",
|
||||
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Śr. {bpm}",
|
||||
"hrMaxMarker": "Maks. {bpm}",
|
||||
"hrRangeValue": "{low} do {high} bpm",
|
||||
"hrRangeLabel": "Zakres",
|
||||
"hrNoProfile": "Brak pojedynczych pomiarów tętna dla tej sesji.",
|
||||
"sportAverageLabel": "Twoja ostatnia średnia:",
|
||||
"zonesTitle": "Strefy wysiłku",
|
||||
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
buildWorkoutHrChartData,
|
||||
workoutHrAxisDomain,
|
||||
type WorkoutHrCurvePoint,
|
||||
} from "@/components/charts/workout-hr-chart-data";
|
||||
|
||||
/**
|
||||
* The workout heart-rate curve's data mapping. The assertions that matter
|
||||
* are the two that decide whether the rendered curve tells the truth:
|
||||
* every value the axis sees has to be a bpm reading (so the domain lands
|
||||
* on the band the session was actually in), and a hole in the recording
|
||||
* has to stay a hole.
|
||||
*/
|
||||
|
||||
const BUCKET = 10;
|
||||
|
||||
/** A dense stretch with a two-bucket hole at 20 s and 30 s. */
|
||||
const POINTS: WorkoutHrCurvePoint[] = [
|
||||
{ tSec: 0, mean: 128, min: 122, max: 134 },
|
||||
{ tSec: 10, mean: 141, min: 133, max: 149 },
|
||||
{ tSec: 40, mean: 166, min: 158, max: 174 },
|
||||
];
|
||||
|
||||
describe("buildWorkoutHrChartData", () => {
|
||||
it("lays the buckets on a complete elapsed grid", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
expect(data.map((d) => d.tSec)).toEqual([0, 10, 20, 30, 40]);
|
||||
});
|
||||
|
||||
it("keeps a hole in the recording as a hole", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
expect(data[2]).toEqual({ tSec: 20, mean: null, band: null });
|
||||
expect(data[3]).toEqual({ tSec: 30, mean: null, band: null });
|
||||
});
|
||||
|
||||
it("carries the envelope as the bucket's own [min, max] pair", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
expect(data[0].band).toEqual([122, 134]);
|
||||
expect(data[4].band).toEqual([158, 174]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The regression this mapping exists to prevent. The band used to ship
|
||||
* as a stacked pair — a base at `min` plus a `max - min` delta — and a
|
||||
* stack is measured from zero, so the axis domain collapsed to include
|
||||
* 0 and the curve was drawn squashed against the top of the plot. Every
|
||||
* number handed to the chart must be a bpm reading in the session's own
|
||||
* range; a spread posing as a value is what broke it.
|
||||
*/
|
||||
it("hands the axis nothing but bpm readings — no spread, no zero baseline", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
const lowestReading = Math.min(...POINTS.map((p) => p.min));
|
||||
const highestReading = Math.max(...POINTS.map((p) => p.max));
|
||||
|
||||
const values = data.flatMap((d) => [
|
||||
...(d.mean == null ? [] : [d.mean]),
|
||||
...(d.band ?? []),
|
||||
]);
|
||||
expect(values.length).toBeGreaterThan(0);
|
||||
for (const value of values) {
|
||||
expect(value).toBeGreaterThanOrEqual(lowestReading);
|
||||
expect(value).toBeLessThanOrEqual(highestReading);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the band when the server judged the buckets too sparse for one", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, false);
|
||||
expect(data.every((d) => d.band === null)).toBe(true);
|
||||
expect(data[0].mean).toBe(128);
|
||||
});
|
||||
|
||||
it("maps an empty or unusable series to no data at all", () => {
|
||||
expect(buildWorkoutHrChartData([], BUCKET, true)).toEqual([]);
|
||||
expect(buildWorkoutHrChartData(POINTS, 0, true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workoutHrAxisDomain", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
|
||||
it("covers the plotted readings with breathing room", () => {
|
||||
expect(workoutHrAxisDomain(data)).toEqual([112, 184]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A reference line outside the axis domain is discarded by recharts,
|
||||
* not clamped — so a peak above the curve's own highest bucket mean
|
||||
* would simply never be drawn. The reported peak is a single reading
|
||||
* and the curve plots bucket means, so that is the common case, not
|
||||
* the edge one.
|
||||
*/
|
||||
it("stretches to hold a peak the bucketed curve never reaches", () => {
|
||||
const domain = workoutHrAxisDomain(data, [145, 191]);
|
||||
expect(domain).not.toBeNull();
|
||||
expect(domain![1]).toBeGreaterThanOrEqual(191);
|
||||
});
|
||||
|
||||
it("stretches downwards for an average below the curve too", () => {
|
||||
const domain = workoutHrAxisDomain(data, [96, null]);
|
||||
expect(domain![0]).toBeLessThanOrEqual(96);
|
||||
});
|
||||
|
||||
it("ignores markers that are absent or not numbers", () => {
|
||||
expect(workoutHrAxisDomain(data, [null, undefined, Number.NaN])).toEqual(
|
||||
workoutHrAxisDomain(data),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the axis alone when there is nothing plotted", () => {
|
||||
expect(workoutHrAxisDomain([], [140])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Data mapping for the per-workout heart-rate curve.
|
||||
*
|
||||
* Pure and separate from the chart component so the shape the axis reads
|
||||
* can be asserted without mounting recharts — the y-axis domain is
|
||||
* computed from these values, and getting them wrong flattens the curve
|
||||
* rather than erroring.
|
||||
*/
|
||||
|
||||
export interface WorkoutHrCurvePoint {
|
||||
/** Elapsed seconds from the workout start (bucket left edge). */
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface WorkoutHrChartDatum {
|
||||
tSec: number;
|
||||
/** Bucket mean, or null where the recording has a hole. */
|
||||
mean: number | null;
|
||||
/**
|
||||
* The min→max ribbon as an explicit `[low, high]` pair — recharts reads
|
||||
* an array `dataKey` as a range area and takes its baseline from the
|
||||
* first element.
|
||||
*
|
||||
* This deliberately is NOT a stacked pair of `min` and `max - min`.
|
||||
* Stacked series are measured from zero: recharts derives the axis
|
||||
* domain for a stack from the stacked offsets, whose baseline is 0
|
||||
* (`getDomainOfStackGroups` over `stackOffsetNone`). A chart asking for
|
||||
* `dataMin - 10` then resolves to −10 instead of to the bottom of the
|
||||
* band, and a session spent between 120 and 170 bpm gets drawn on a
|
||||
* −10…180 axis — the shape of the effort, which is the whole point of
|
||||
* the curve, squashes into the top quarter. A range area keeps the
|
||||
* domain on the bpm values that are actually in the data.
|
||||
*/
|
||||
band: [number, number] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the bucket points onto a complete elapsed-time grid.
|
||||
*
|
||||
* A missing bucket becomes an explicit null rather than being skipped, so
|
||||
* a hole in the recording breaks the line (`connectNulls` false) instead
|
||||
* of being drawn across as if the reading had continued. Absence reads as
|
||||
* absence.
|
||||
*
|
||||
* `envelope` is the server's density verdict: below its threshold the
|
||||
* per-bucket min→max spread is noise rather than signal, and the chart
|
||||
* draws the mean line alone.
|
||||
*/
|
||||
export function buildWorkoutHrChartData(
|
||||
points: readonly WorkoutHrCurvePoint[],
|
||||
bucketSec: number,
|
||||
envelope: boolean,
|
||||
): WorkoutHrChartDatum[] {
|
||||
if (points.length === 0 || bucketSec <= 0) return [];
|
||||
const byT = new Map(points.map((p) => [p.tSec, p]));
|
||||
const maxT = points[points.length - 1].tSec;
|
||||
const grid: WorkoutHrChartDatum[] = [];
|
||||
for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
|
||||
const p = byT.get(tSec);
|
||||
grid.push({
|
||||
tSec,
|
||||
mean: p ? p.mean : null,
|
||||
band: p && envelope ? [p.min, p.max] : null,
|
||||
});
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
/** Breathing room above and below the plotted readings, in bpm. */
|
||||
const AXIS_PAD_BPM = 10;
|
||||
|
||||
/**
|
||||
* The bpm range the y-axis has to cover: every plotted reading plus every
|
||||
* reference line drawn over them.
|
||||
*
|
||||
* The markers have to be in the domain or they are not drawn at all —
|
||||
* recharts discards an out-of-range `ReferenceLine` rather than clamping
|
||||
* it. The session's reported peak is routinely above the curve's own
|
||||
* highest point, because the curve plots bucket means and a peak is a
|
||||
* single reading, so a domain derived from the data alone drops the peak
|
||||
* marker on exactly the sessions that have one. Silently: no warning, no
|
||||
* gap, just a line that never appears.
|
||||
*
|
||||
* Returns null for an empty series, leaving the axis to recharts.
|
||||
*/
|
||||
export function workoutHrAxisDomain(
|
||||
data: readonly WorkoutHrChartDatum[],
|
||||
markers: readonly (number | null | undefined)[] = [],
|
||||
): [number, number] | null {
|
||||
const values: number[] = [];
|
||||
for (const datum of data) {
|
||||
if (datum.mean != null) values.push(datum.mean);
|
||||
if (datum.band) values.push(datum.band[0], datum.band[1]);
|
||||
}
|
||||
if (values.length === 0) return null;
|
||||
for (const marker of markers) {
|
||||
if (marker != null && Number.isFinite(marker)) values.push(marker);
|
||||
}
|
||||
return [
|
||||
Math.floor(Math.min(...values) - AXIS_PAD_BPM),
|
||||
Math.ceil(Math.max(...values) + AXIS_PAD_BPM),
|
||||
];
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
buildWorkoutHrChartData,
|
||||
workoutHrAxisDomain,
|
||||
type WorkoutHrCurvePoint,
|
||||
} from "./workout-hr-chart-data";
|
||||
|
||||
/**
|
||||
* Per-workout heart-rate curve. Rendered ONLY through the shared
|
||||
@@ -22,20 +27,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* library stays a single shared chunk — never import this file directly
|
||||
* at a call site.
|
||||
*
|
||||
* `ComposedChart`: an `Area` for the min→max envelope (drawn only when
|
||||
* the series is dense enough to be honest), a `Line` for the bucket
|
||||
* mean, optional %HRmax zone bands behind them, and a dashed reference
|
||||
* line at the workout's average HR. Gaps stay gaps (`connectNulls`
|
||||
* `ComposedChart`: a range `Area` for the min→max envelope (drawn only
|
||||
* when the series is dense enough to be honest), a `Line` for the bucket
|
||||
* mean, optional %HRmax zone bands behind them, and dashed reference
|
||||
* lines at the session's average and peak. Gaps stay gaps (`connectNulls`
|
||||
* false) — a hole in the recording never reads as an interpolated
|
||||
* curve.
|
||||
*/
|
||||
|
||||
export interface WorkoutHrChartPoint {
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
export type WorkoutHrChartPoint = WorkoutHrCurvePoint;
|
||||
|
||||
export interface WorkoutHrZoneBand {
|
||||
zone: number;
|
||||
@@ -50,6 +50,8 @@ export interface WorkoutHrChartProps {
|
||||
envelope: boolean;
|
||||
/** Workout average HR for the reference line. */
|
||||
avgHr: number | null;
|
||||
/** Workout peak HR for the reference line. */
|
||||
maxHr?: number | null;
|
||||
/** Optional %HRmax zone bands for the shaded background. */
|
||||
zones?: WorkoutHrZoneBand[] | null;
|
||||
}
|
||||
@@ -71,33 +73,19 @@ export function WorkoutHrChart({
|
||||
bucketSec,
|
||||
envelope,
|
||||
avgHr,
|
||||
maxHr,
|
||||
zones,
|
||||
}: WorkoutHrChartProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Build a full elapsed-time grid so a gap of missing buckets breaks
|
||||
// the line rather than interpolating across it.
|
||||
const data = useMemo(() => {
|
||||
if (points.length === 0) return [];
|
||||
const byT = new Map(points.map((p) => [p.tSec, p]));
|
||||
const maxT = points[points.length - 1].tSec;
|
||||
const grid: Array<{
|
||||
tSec: number;
|
||||
mean: number | null;
|
||||
lo: number | null;
|
||||
hi: number | null;
|
||||
}> = [];
|
||||
for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
|
||||
const p = byT.get(tSec);
|
||||
grid.push({
|
||||
tSec,
|
||||
mean: p ? p.mean : null,
|
||||
lo: p ? p.min : null,
|
||||
hi: p && envelope ? p.max - p.min : null,
|
||||
});
|
||||
}
|
||||
return grid;
|
||||
}, [points, bucketSec, envelope]);
|
||||
const data = useMemo(
|
||||
() => buildWorkoutHrChartData(points, bucketSec, envelope),
|
||||
[points, bucketSec, envelope],
|
||||
);
|
||||
const yDomain = useMemo(
|
||||
() => workoutHrAxisDomain(data, [avgHr, maxHr]),
|
||||
[data, avgHr, maxHr],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -137,7 +125,7 @@ export function WorkoutHrChart({
|
||||
unit=""
|
||||
/>
|
||||
<YAxis
|
||||
domain={["dataMin - 10", "dataMax + 10"]}
|
||||
domain={yDomain ?? ["dataMin - 10", "dataMax + 10"]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -154,37 +142,41 @@ export function WorkoutHrChart({
|
||||
labelFormatter={(tSec) =>
|
||||
`${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}`
|
||||
}
|
||||
formatter={(value, name) =>
|
||||
name === "mean"
|
||||
? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")]
|
||||
: [null, null]
|
||||
}
|
||||
formatter={(value, name) => {
|
||||
if (name === "mean") {
|
||||
return [
|
||||
`${value} bpm`,
|
||||
t("insights.workouts.detail.hrChartTitle"),
|
||||
];
|
||||
}
|
||||
// The envelope's own `[min, max]` pair, read back as the
|
||||
// spread inside that bucket rather than as a second series.
|
||||
if (name === "band" && Array.isArray(value)) {
|
||||
return [
|
||||
t("insights.workouts.detail.hrRangeValue", {
|
||||
low: String(value[0]),
|
||||
high: String(value[1]),
|
||||
}),
|
||||
t("insights.workouts.detail.hrRangeLabel"),
|
||||
];
|
||||
}
|
||||
return [null, null];
|
||||
}}
|
||||
/>
|
||||
{/* Envelope band: a transparent base at `lo` plus a stacked
|
||||
`hi` (= max − min) renders the min→max ribbon. Only present
|
||||
when `envelope` gated it in. */}
|
||||
{/* Envelope band: one range `Area` over the bucket's own
|
||||
`[min, max]` pair. Deliberately not a stack — a stacked pair
|
||||
is measured from zero and drags the y-axis domain down with
|
||||
it, squashing the curve (see `workout-hr-chart-data.ts`). */}
|
||||
{envelope ? (
|
||||
<>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="lo"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill="transparent"
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="hi"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill="var(--chart-1)"
|
||||
fillOpacity={0.15}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="band"
|
||||
stroke="none"
|
||||
fill="var(--chart-1)"
|
||||
fillOpacity={0.15}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
) : null}
|
||||
{avgHr != null ? (
|
||||
<ReferenceLine
|
||||
@@ -192,6 +184,33 @@ export function WorkoutHrChart({
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.7}
|
||||
label={{
|
||||
value: t("insights.workouts.detail.hrAvgMarker", {
|
||||
bpm: String(Math.round(avgHr)),
|
||||
}),
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* The session's peak, the second number the stats grid already
|
||||
names. Drawn so the curve can be read against it rather than
|
||||
leaving the reader to eyeball where the top was. */}
|
||||
{maxHr != null ? (
|
||||
<ReferenceLine
|
||||
y={maxHr}
|
||||
stroke="var(--chart-4)"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.6}
|
||||
label={{
|
||||
value: t("insights.workouts.detail.hrMaxMarker", {
|
||||
bpm: String(Math.round(maxHr)),
|
||||
}),
|
||||
position: "insideBottomLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Line
|
||||
|
||||
@@ -207,8 +207,27 @@ describe("<WorkoutDetailRoute>", () => {
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailHrSection>", () => {
|
||||
it("returns null without an HR series", () => {
|
||||
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).toBe("");
|
||||
it("names the absence when the session has heart-rate figures but no profile", () => {
|
||||
// The stats grid is showing an average and a peak for this session,
|
||||
// so the reader can see heart rate WAS recorded. Rendering nothing
|
||||
// here would read as "this page has no heart-rate section" rather
|
||||
// than as "the shape behind those numbers is not on record".
|
||||
const html = render(<WorkoutDetailHrSection workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr-empty"');
|
||||
expect(html).toContain("No reading-by-reading heart-rate data");
|
||||
expect(html).not.toContain('data-slot="workout-hr-chart"');
|
||||
});
|
||||
|
||||
it("returns null when the session carries no heart rate at all", () => {
|
||||
// Nothing was measured, so there is no absence to report and an
|
||||
// empty card would be a shell.
|
||||
const noHr: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
avgHr: null,
|
||||
maxHr: null,
|
||||
minHr: null,
|
||||
};
|
||||
expect(render(<WorkoutDetailHrSection workout={noHr} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders the card and a provenance chip for a pulse-window series", () => {
|
||||
|
||||
@@ -35,17 +35,35 @@ export interface WorkoutDetailHrSectionProps {
|
||||
|
||||
/**
|
||||
* Heart-rate curve card — mean line + optional min→max envelope + %HRmax
|
||||
* zone bands + an average-HR reference line. A muted provenance chip
|
||||
* zone bands + average and peak reference lines. A muted provenance chip
|
||||
* discloses when the curve was reconstructed from pulse data around the
|
||||
* session rather than the workout's own sensor stream. Returns `null`
|
||||
* when no series is available (hide, don't render empty).
|
||||
* session rather than the workout's own sensor stream.
|
||||
*
|
||||
* Three states, and the middle one is the reason this is not a plain
|
||||
* hide-when-empty section:
|
||||
*
|
||||
* - a curve, when the server resolved one;
|
||||
* - a named absence, when the session HAS heart-rate figures (the
|
||||
* stats grid is showing an average, a peak) but no reading-by-reading
|
||||
* profile behind them. Silence there reads as "this page has no
|
||||
* heart-rate section", which is wrong — the data exists, the shape of
|
||||
* it does not, and saying so is the difference between an honest gap
|
||||
* and an invisible one;
|
||||
* - nothing at all, when the session carries no heart rate of any kind.
|
||||
* There is no absence to report where nothing was ever measured, and
|
||||
* an empty card would be a shell (page contract: hide, don't render
|
||||
* empty).
|
||||
*/
|
||||
export function WorkoutDetailHrSection({
|
||||
workout,
|
||||
}: WorkoutDetailHrSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const series = workout.hrSeries;
|
||||
if (!series || series.points.length < 2) return null;
|
||||
const hasCurve = Boolean(series && series.points.length >= 2);
|
||||
const hasHeartRateAtAll =
|
||||
workout.avgHr != null || workout.maxHr != null || workout.minHr != null;
|
||||
|
||||
if (!hasCurve && !hasHeartRateAtAll) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-hr">
|
||||
@@ -55,7 +73,7 @@ export function WorkoutDetailHrSection({
|
||||
title={t("insights.workouts.detail.hrChartTitle")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
{series.source === "pulse_window" ? (
|
||||
{hasCurve && series!.source === "pulse_window" ? (
|
||||
<p
|
||||
data-slot="workout-detail-hr-provenance"
|
||||
className="text-muted-foreground text-xs"
|
||||
@@ -65,13 +83,23 @@ export function WorkoutDetailHrSection({
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WorkoutHrChart
|
||||
points={series.points}
|
||||
bucketSec={series.bucketSec}
|
||||
envelope={series.envelope}
|
||||
avgHr={workout.avgHr}
|
||||
zones={workout.zones?.zones ?? null}
|
||||
/>
|
||||
{hasCurve ? (
|
||||
<WorkoutHrChart
|
||||
points={series!.points}
|
||||
bucketSec={series!.bucketSec}
|
||||
envelope={series!.envelope}
|
||||
avgHr={workout.avgHr}
|
||||
maxHr={workout.maxHr}
|
||||
zones={workout.zones?.zones ?? null}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
data-slot="workout-detail-hr-empty"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("insights.workouts.detail.hrNoProfile")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -180,3 +180,74 @@ describe("buildWorkoutHrSeries", () => {
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `durationSec` is a denormalised DURATION, and not every source reports
|
||||
* the elapsed one: Strava sends `moving_time` while the row's `endedAt`
|
||||
* is `startedAt + elapsed_time`. A ride with 20 minutes of stops
|
||||
* therefore carries a `durationSec` 20 minutes shorter than its own
|
||||
* wall-clock span, and folding the curve against it drops every reading
|
||||
* after the moving-time mark — the tail of the session vanishes with no
|
||||
* gap, no marker, nothing that reads as missing.
|
||||
*/
|
||||
describe("buildWorkoutHrSeries — sessions whose duration is not their span", () => {
|
||||
const SPAN_SEC = 3600;
|
||||
const MOVING_SEC = 2400;
|
||||
const strava = {
|
||||
userId: "u1",
|
||||
startedAt: START,
|
||||
endedAt: new Date(startMs + SPAN_SEC * 1000),
|
||||
durationSec: MOVING_SEC,
|
||||
now: new Date(startMs + SPAN_SEC * 1000 + 1000),
|
||||
};
|
||||
|
||||
it("carries a stored series to the end of the session, not to the moving-time mark", async () => {
|
||||
const stored = Array.from({ length: SPAN_SEC / 15 }, (_, i) =>
|
||||
storedSample(i * 15, 120 + (i % 20)),
|
||||
);
|
||||
const result = await buildWorkoutHrSeries({
|
||||
...strava,
|
||||
storedSamples: stored,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
const lastTSec = result!.points[result!.points.length - 1].tSec;
|
||||
// The final bucket sits within one bucket width of the session end.
|
||||
expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - result!.bucketSec);
|
||||
expect(lastTSec).toBeLessThan(SPAN_SEC);
|
||||
});
|
||||
|
||||
it("carries a pulse-window series past the moving-time mark too", async () => {
|
||||
const rows = Array.from({ length: SPAN_SEC / 20 }, (_, i) => ({
|
||||
value: 125 + (i % 9),
|
||||
measuredAt: new Date(startMs + i * 20_000),
|
||||
externalId: null,
|
||||
}));
|
||||
findMany.mockResolvedValue(rows);
|
||||
const result = await buildWorkoutHrSeries({
|
||||
...strava,
|
||||
storedSamples: null,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source).toBe("pulse_window");
|
||||
const lastTSec = result!.points[result!.points.length - 1].tSec;
|
||||
expect(lastTSec).toBeGreaterThan(MOVING_SEC);
|
||||
// The rows sit 20 s apart, so the final one can miss the very last
|
||||
// bucket; landing within a sample interval of the end is the claim.
|
||||
expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - 20 - result!.bucketSec);
|
||||
});
|
||||
|
||||
it("keeps the duration as the floor when the row carries no usable span", async () => {
|
||||
const stored = Array.from({ length: 40 }, (_, i) =>
|
||||
storedSample(i * 15, 130),
|
||||
);
|
||||
const result = await buildWorkoutHrSeries({
|
||||
...strava,
|
||||
// A row whose end is not after its start: the span is unusable, so
|
||||
// the denormalised duration has to stay the fold window.
|
||||
endedAt: START,
|
||||
storedSamples: stored,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.bucketSec).toBe(adaptiveBucketSec(MOVING_SEC));
|
||||
});
|
||||
});
|
||||
@@ -82,27 +82,55 @@ function median(values: number[]): number {
|
||||
: sorted[mid];
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's own length on the wall clock.
|
||||
*
|
||||
* `durationSec` is a denormalised DURATION, and not every source reports
|
||||
* the elapsed one — Strava sends `moving_time` while the row's `endedAt`
|
||||
* is `startedAt + elapsed_time` (`src/lib/strava/client.ts`). A ride with
|
||||
* twenty minutes of stops therefore carries a `durationSec` twenty
|
||||
* minutes shorter than its own span, and a curve folded against it ends
|
||||
* at the moving-time mark with no gap and no marker: the tail of the
|
||||
* session simply is not there. The curve covers the session, so the span
|
||||
* is what it folds over.
|
||||
*
|
||||
* The duration stays the floor. A row whose `endedAt` is not after its
|
||||
* `startedAt` (a manual entry saved with one timestamp, a provider that
|
||||
* shipped an end it could not resolve) has no usable span, and every
|
||||
* source that computes `endedAt` from the duration lands on the same
|
||||
* number either way.
|
||||
*/
|
||||
export function sessionSpanSec(
|
||||
startedAt: Date,
|
||||
endedAt: Date,
|
||||
durationSec: number,
|
||||
): number {
|
||||
const floor = Math.max(0, durationSec);
|
||||
const span = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
return Number.isFinite(span) && span > floor ? span : floor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold raw HR samples into session-grain buckets. Samples outside the
|
||||
* session window `[0, durationSec)` are dropped from the curve (they
|
||||
* only widen the DB query so a boundary reading still lands in bucket
|
||||
* 0 / the last bucket). Empty buckets stay as gaps — no interpolation.
|
||||
* session window `[0, spanSec)` are dropped from the curve (they only
|
||||
* widen the DB query so a boundary reading still lands in bucket 0 / the
|
||||
* last bucket). Empty buckets stay as gaps — no interpolation.
|
||||
* Pure; exported for unit testing.
|
||||
*/
|
||||
export function foldHrBuckets(
|
||||
samples: readonly RawHrSample[],
|
||||
startMs: number,
|
||||
durationSec: number,
|
||||
spanSec: number,
|
||||
bucketSec: number,
|
||||
): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } {
|
||||
const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec));
|
||||
const bucketCount = Math.max(1, Math.ceil(spanSec / bucketSec));
|
||||
const acc = new Map<
|
||||
number,
|
||||
{ sum: number; min: number; max: number; n: number }
|
||||
>();
|
||||
for (const s of samples) {
|
||||
const rel = (s.tMs - startMs) / 1000;
|
||||
if (rel < 0 || rel >= durationSec) continue;
|
||||
if (rel < 0 || rel >= spanSec) continue;
|
||||
const idx = Math.floor(rel / bucketSec);
|
||||
const bucket = acc.get(idx);
|
||||
if (bucket) {
|
||||
@@ -171,7 +199,11 @@ export async function buildWorkoutHrSeries(
|
||||
const { userId, startedAt, endedAt, durationSec, storedSamples } = input;
|
||||
const now = input.now ?? new Date();
|
||||
const startMs = startedAt.getTime();
|
||||
const bucketSec = adaptiveBucketSec(durationSec);
|
||||
// Fold over the session's span, not its duration — see `sessionSpanSec`.
|
||||
// The bucket width tracks the span too, so a stop-heavy ride keeps the
|
||||
// same on-screen point density as a continuous one of the same length.
|
||||
const spanSec = sessionSpanSec(startedAt, endedAt, durationSec);
|
||||
const bucketSec = adaptiveBucketSec(spanSec);
|
||||
|
||||
// 1. Stored series — the native, highest-fidelity path.
|
||||
const stored = parseStoredSamples(storedSamples);
|
||||
@@ -179,7 +211,7 @@ export async function buildWorkoutHrSeries(
|
||||
const { points, medianDensity } = foldHrBuckets(
|
||||
stored,
|
||||
startMs,
|
||||
durationSec,
|
||||
spanSec,
|
||||
bucketSec,
|
||||
);
|
||||
if (points.length >= 2) {
|
||||
@@ -222,7 +254,7 @@ export async function buildWorkoutHrSeries(
|
||||
const { points, bucketCount, medianDensity } = foldHrBuckets(
|
||||
raw,
|
||||
startMs,
|
||||
durationSec,
|
||||
spanSec,
|
||||
bucketSec,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Integration suite for the heart-rate curve on `GET /api/workouts/{id}`.
|
||||
*
|
||||
* The curve is the one part of the workout-detail envelope that is
|
||||
* assembled from rows the workout does not own: when a session carries no
|
||||
* stored sensor stream, the server reconstructs it from the account's raw
|
||||
* `PULSE` measurements around the session. Two boundaries decide whether
|
||||
* that reconstruction is honest, and neither is provable from a unit test
|
||||
* with a mocked Prisma:
|
||||
*
|
||||
* - the WINDOW. A reading taken after the session ended is not part of
|
||||
* the session. The query pads by five minutes so a boundary reading
|
||||
* still lands in the first / last bucket, and the fold then clamps to
|
||||
* the session itself — a pad reading must not extend the curve past
|
||||
* the finish.
|
||||
* - the OWNER. The measurement read is scoped to the calling account.
|
||||
* Another account's pulse at the same wall-clock minute must not
|
||||
* enter this curve.
|
||||
*
|
||||
* Both run through the real route against a real Postgres, and the
|
||||
* fixture places a reading on the far side of each boundary with a bpm
|
||||
* value no in-window reading has, so a leak is visible in the numbers
|
||||
* rather than only in a count.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const TEST_USER_ID = "user-workout-hr-series";
|
||||
const OTHER_USER_ID = "user-workout-hr-series-other";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { GET } = await import("@/app/api/workouts/[id]/route");
|
||||
|
||||
/** Session window: 07:00 → 07:30 on a fixed day. */
|
||||
const STARTED_AT = new Date("2026-05-15T07:00:00.000Z");
|
||||
const ENDED_AT = new Date("2026-05-15T07:30:00.000Z");
|
||||
const SPAN_SEC = 1800;
|
||||
|
||||
/**
|
||||
* In-window readings sit in this band; every out-of-window reading is
|
||||
* placed outside it, so an assertion on the curve's bpm bounds names any
|
||||
* leak directly.
|
||||
*/
|
||||
const IN_WINDOW_LOW = 120;
|
||||
const IN_WINDOW_HIGH = 168;
|
||||
/** After the finish but inside the query's five-minute pad. */
|
||||
const PAD_TAIL_BPM = 44;
|
||||
/** Well outside the padded window on either side. */
|
||||
const FAR_BEFORE_BPM = 201;
|
||||
const FAR_AFTER_BPM = 51;
|
||||
/** Another account's reading in the middle of this session. */
|
||||
const FOREIGN_BPM = 199;
|
||||
|
||||
interface HrSeriesPoint {
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface WorkoutDetailBody {
|
||||
data: {
|
||||
id: string;
|
||||
hrSeries: {
|
||||
source: string;
|
||||
bucketSec: number;
|
||||
envelope: boolean;
|
||||
points: HrSeriesPoint[];
|
||||
} | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function createUser(id: string, username: string) {
|
||||
await getPrismaClient().user.create({
|
||||
data: { id, username, email: `${username}@example.test` },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await createUser(TEST_USER_ID, "workout-hr-series");
|
||||
await createUser(OTHER_USER_ID, "workout-hr-series-other");
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
/**
|
||||
* The session under test, with no stored sample stream — so the route has
|
||||
* to reconstruct the curve from `PULSE` rows and the boundaries above are
|
||||
* the ones that decide what it sees.
|
||||
*/
|
||||
async function createWorkout(): Promise<string> {
|
||||
const row = await getPrismaClient().workout.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
sportType: "running",
|
||||
startedAt: STARTED_AT,
|
||||
endedAt: ENDED_AT,
|
||||
durationSec: SPAN_SEC,
|
||||
avgHeartRate: 144,
|
||||
maxHeartRate: IN_WINDOW_HIGH,
|
||||
source: "MANUAL",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function pulseRow(userId: string, offsetSec: number, value: number) {
|
||||
return {
|
||||
userId,
|
||||
type: "PULSE" as const,
|
||||
value,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date(STARTED_AT.getTime() + offsetSec * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A dense in-window sweep plus one reading on the far side of each
|
||||
* boundary. The sweep alternates across the in-window band so the fold
|
||||
* has a real shape to carry.
|
||||
*/
|
||||
async function seedPulse() {
|
||||
const rows = [] as ReturnType<typeof pulseRow>[];
|
||||
for (let offset = 0; offset < SPAN_SEC; offset += 5) {
|
||||
const swing = Math.round(
|
||||
((IN_WINDOW_HIGH - IN_WINDOW_LOW) / 2) *
|
||||
(1 + Math.sin((offset / SPAN_SEC) * Math.PI * 4)),
|
||||
);
|
||||
rows.push(pulseRow(TEST_USER_ID, offset, IN_WINDOW_LOW + swing));
|
||||
}
|
||||
// Two minutes after the finish: inside the query's pad, outside the
|
||||
// session. The query returns it; the curve must not.
|
||||
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 120, PAD_TAIL_BPM));
|
||||
// Far outside the padded window on both sides.
|
||||
rows.push(pulseRow(TEST_USER_ID, -20 * 60, FAR_BEFORE_BPM));
|
||||
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 15 * 60, FAR_AFTER_BPM));
|
||||
// Another account, mid-session.
|
||||
rows.push(pulseRow(OTHER_USER_ID, Math.floor(SPAN_SEC / 2), FOREIGN_BPM));
|
||||
await getPrismaClient().measurement.createMany({ data: rows });
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string): Promise<WorkoutDetailBody> {
|
||||
const request = new NextRequest(
|
||||
`http://localhost/api/workouts/${id}?compact=1`,
|
||||
);
|
||||
const response = await GET(request, { params: Promise.resolve({ id }) });
|
||||
return (await response.json()) as WorkoutDetailBody;
|
||||
}
|
||||
|
||||
describe("GET /api/workouts/{id} — reconstructed heart-rate curve", () => {
|
||||
it("reconstructs the curve from the account's pulse readings", async () => {
|
||||
const id = await createWorkout();
|
||||
await seedPulse();
|
||||
|
||||
const body = await fetchDetail(id);
|
||||
expect(body.error).toBeNull();
|
||||
const series = body.data?.hrSeries;
|
||||
expect(series).not.toBeNull();
|
||||
expect(series!.source).toBe("pulse_window");
|
||||
expect(series!.points.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("stops the curve at the finish — a reading inside the query pad is not part of the session", async () => {
|
||||
const id = await createWorkout();
|
||||
await seedPulse();
|
||||
|
||||
const series = (await fetchDetail(id)).data!.hrSeries!;
|
||||
const lastTSec = series.points[series.points.length - 1].tSec;
|
||||
expect(lastTSec).toBeLessThan(SPAN_SEC);
|
||||
// The pad reading is the only source of a sub-100 bpm value in the
|
||||
// fixture, so naming the value proves it stayed out rather than
|
||||
// merely proving a count.
|
||||
expect(series.points.map((p) => p.min)).not.toContain(PAD_TAIL_BPM);
|
||||
expect(Math.min(...series.points.map((p) => p.min))).toBeGreaterThanOrEqual(
|
||||
IN_WINDOW_LOW,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps readings from outside the window and from other accounts out of the curve", async () => {
|
||||
const id = await createWorkout();
|
||||
await seedPulse();
|
||||
|
||||
const series = (await fetchDetail(id)).data!.hrSeries!;
|
||||
const maxima = series.points.map((p) => p.max);
|
||||
expect(maxima).not.toContain(FAR_BEFORE_BPM);
|
||||
expect(maxima).not.toContain(FOREIGN_BPM);
|
||||
expect(series.points.map((p) => p.min)).not.toContain(FAR_AFTER_BPM);
|
||||
expect(Math.max(...maxima)).toBeLessThanOrEqual(IN_WINDOW_HIGH);
|
||||
});
|
||||
|
||||
it("serves no curve when the account's readings do not cover the session", async () => {
|
||||
const id = await createWorkout();
|
||||
// A handful of opportunistic readings in the first minute. Enough
|
||||
// rows to clear the sample floor, nowhere near enough to describe a
|
||||
// half-hour session — the route says nothing rather than drawing a
|
||||
// curve out of a corner of it.
|
||||
await getPrismaClient().measurement.createMany({
|
||||
data: Array.from({ length: 12 }, (_, i) =>
|
||||
pulseRow(TEST_USER_ID, i * 4, 130 + i),
|
||||
),
|
||||
});
|
||||
|
||||
const body = await fetchDetail(id);
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.hrSeries).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reach the reconstruction at all for another account's workout", async () => {
|
||||
const foreign = await getPrismaClient().workout.create({
|
||||
data: {
|
||||
userId: OTHER_USER_ID,
|
||||
sportType: "running",
|
||||
startedAt: STARTED_AT,
|
||||
endedAt: ENDED_AT,
|
||||
durationSec: SPAN_SEC,
|
||||
source: "MANUAL",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await seedPulse();
|
||||
|
||||
const request = new NextRequest(
|
||||
`http://localhost/api/workouts/${foreign.id}?compact=1`,
|
||||
);
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ id: foreign.id }),
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user