mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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
@@ -44,3 +44,5 @@ export { EfficacyChart } from "@/components/medications/detail/efficacy/efficacy
|
||||
export { AssessmentHistoryChart } from "@/components/mental-health/assessment-history-chart";
|
||||
export { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart";
|
||||
export { NutrientDailyBarChart } from "./nutrient-daily-bar-chart";
|
||||
export { WorkoutHrChart } from "./workout-hr-chart";
|
||||
export { WorkoutElevationChart } from "./workout-elevation-chart";
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Workout elevation profile — a small cumulative-distance / altitude
|
||||
* area under the route map. Rendered ONLY through `chart-runtime.ts`.
|
||||
* Present only when ≥ 60 % of route coordinates carry altitude (the
|
||||
* caller gates it); partial altimeter data would draw a lie.
|
||||
*/
|
||||
|
||||
export interface WorkoutElevationPoint {
|
||||
distanceM: number;
|
||||
altitude: number;
|
||||
}
|
||||
|
||||
export function WorkoutElevationChart({
|
||||
points,
|
||||
}: {
|
||||
points: WorkoutElevationPoint[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude }));
|
||||
|
||||
return (
|
||||
<div className="h-24 w-full" data-slot="workout-elevation-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 4, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="workoutElevationFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="km"
|
||||
type="number"
|
||||
domain={[0, "dataMax"]}
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
tickFormatter={(v) => Number(v).toFixed(1)}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={["dataMin - 5", "dataMax + 5"]}
|
||||
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
labelFormatter={(km) => `${Number(km).toFixed(2)} km`}
|
||||
formatter={(value) => [
|
||||
`${Math.round(Number(value))} m`,
|
||||
t("insights.workouts.detail.elevationTitle"),
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="alt"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth={2}
|
||||
fill="url(#workoutElevationFill)"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Per-workout heart-rate curve. Rendered ONLY through the shared
|
||||
* `chart-runtime.ts` barrel (the one recharts async boundary) so the
|
||||
* 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`
|
||||
* false) — a hole in the recording never reads as an interpolated
|
||||
* curve.
|
||||
*/
|
||||
|
||||
export interface WorkoutHrChartPoint {
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface WorkoutHrZoneBand {
|
||||
zone: number;
|
||||
lowBpm: number | null;
|
||||
highBpm: number | null;
|
||||
}
|
||||
|
||||
export interface WorkoutHrChartProps {
|
||||
points: WorkoutHrChartPoint[];
|
||||
bucketSec: number;
|
||||
/** Draw the min→max envelope band behind the mean line. */
|
||||
envelope: boolean;
|
||||
/** Workout average HR for the reference line. */
|
||||
avgHr: number | null;
|
||||
/** Optional %HRmax zone bands for the shaded background. */
|
||||
zones?: WorkoutHrZoneBand[] | null;
|
||||
}
|
||||
|
||||
const ZONE_FILLS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
];
|
||||
|
||||
function minuteTick(seconds: number): string {
|
||||
return String(Math.round(seconds / 60));
|
||||
}
|
||||
|
||||
export function WorkoutHrChart({
|
||||
points,
|
||||
bucketSec,
|
||||
envelope,
|
||||
avgHr,
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-48 w-full touch-pan-y sm:h-64"
|
||||
data-slot="workout-hr-chart"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
{zones?.map((z) =>
|
||||
z.lowBpm != null ? (
|
||||
<ReferenceArea
|
||||
key={z.zone}
|
||||
y1={z.lowBpm}
|
||||
y2={z.highBpm ?? undefined}
|
||||
fill={ZONE_FILLS[z.zone - 1]}
|
||||
fillOpacity={0.06}
|
||||
ifOverflow="hidden"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="tSec"
|
||||
type="number"
|
||||
domain={[0, "dataMax"]}
|
||||
tickFormatter={minuteTick}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit=""
|
||||
/>
|
||||
<YAxis
|
||||
domain={["dataMin - 10", "dataMax + 10"]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={44}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
labelFormatter={(tSec) =>
|
||||
`${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}`
|
||||
}
|
||||
formatter={(value, name) =>
|
||||
name === "mean"
|
||||
? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")]
|
||||
: [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 ? (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{avgHr != null ? (
|
||||
<ReferenceLine
|
||||
y={avgHr}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.7}
|
||||
/>
|
||||
) : null}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="mean"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,21 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
WorkoutDetailHeader,
|
||||
WorkoutDetailHRChart,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailStats,
|
||||
WorkoutDetailRoute,
|
||||
WorkoutDetailHrSection,
|
||||
WorkoutDetailZones,
|
||||
WorkoutDetailSplits,
|
||||
WorkoutDetailDayLinks,
|
||||
} from "../workout-detail";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import type { RouteCoordinate } from "@/lib/workouts/route-svg";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<WorkoutDetail*>` unit tests.
|
||||
*
|
||||
* Each primitive runs through SSR with a single canonical fixture.
|
||||
* The tests pin the visible labels + the graceful-fallback shapes so
|
||||
* the page renders something useful even when the optional fields
|
||||
* (route, HR samples, energy) are absent.
|
||||
* #67 — `<WorkoutDetail*>` unit tests over the split `workout-detail/`
|
||||
* directory. Each primitive runs through SSR with a canonical fixture;
|
||||
* the tests pin the visible labels and the hide-don't-render contract
|
||||
* (every data-less section returns null).
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
@@ -43,6 +45,12 @@ const FIXTURE: WorkoutDetailPayload = {
|
||||
externalId: "ext-w-1",
|
||||
metadata: null,
|
||||
route: null,
|
||||
samples: null,
|
||||
hrSeries: null,
|
||||
zones: null,
|
||||
splits: null,
|
||||
sportContext: null,
|
||||
aiInsight: null,
|
||||
canonicalId: "w-1",
|
||||
};
|
||||
|
||||
@@ -62,7 +70,6 @@ describe("<WorkoutDetailStats>", () => {
|
||||
expect(html).toContain("Duration");
|
||||
expect(html).toContain("Distance");
|
||||
expect(html).toContain("Active energy");
|
||||
expect(html).toContain("Average HR");
|
||||
expect(html).toContain("145 bpm");
|
||||
expect(html).toContain("Steps");
|
||||
expect(html).toContain("Pace");
|
||||
@@ -82,68 +89,176 @@ describe("<WorkoutDetailStats>", () => {
|
||||
const html = render(<WorkoutDetailStats workout={minimal} />);
|
||||
expect(html).toContain("Duration");
|
||||
expect(html).not.toContain("Distance");
|
||||
expect(html).not.toContain("Active energy");
|
||||
expect(html).not.toContain("Steps");
|
||||
expect(html).not.toContain("Average HR");
|
||||
});
|
||||
|
||||
it("renders the own-history average line when ≥ 2 sessions exist", () => {
|
||||
const withCtx: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
sportContext: {
|
||||
count: 8,
|
||||
avgDurationSec: 2040,
|
||||
avgDistanceM: 5800,
|
||||
avgAvgHr: 148,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailStats workout={withCtx} />);
|
||||
expect(html).toContain('data-slot="workout-detail-sport-average"');
|
||||
expect(html).toContain("148 bpm");
|
||||
});
|
||||
|
||||
it("hides the average line for a lone session", () => {
|
||||
const lone: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
sportContext: {
|
||||
count: 1,
|
||||
avgDurationSec: 1800,
|
||||
avgDistanceM: 5200,
|
||||
avgAvgHr: 145,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailStats workout={lone} />);
|
||||
expect(html).not.toContain('data-slot="workout-detail-sport-average"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailRoute>", () => {
|
||||
it("renders the empty-state when the workout has no route", () => {
|
||||
it("returns null when the workout has no route", () => {
|
||||
const html = render(<WorkoutDetailRoute workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route-empty"');
|
||||
expect(html).toContain("No GPS route");
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders an SVG polyline when GeoJSON LineString geometry is present", () => {
|
||||
it("renders an SVG path for a real route", () => {
|
||||
const coords: RouteCoordinate[] = [];
|
||||
for (let i = 0; i < 20; i++)
|
||||
coords.push([11.0 + i * 0.0004, 50.0 + i * 0.0003]);
|
||||
const withRoute: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
route: {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11.0, 49.0],
|
||||
[11.01, 49.005],
|
||||
[11.02, 49.01],
|
||||
],
|
||||
},
|
||||
geometry: { type: "LineString", coordinates: coords },
|
||||
sampleTimestamps: null,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailRoute workout={withRoute} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route"');
|
||||
expect(html).toContain("<polyline");
|
||||
expect(html).toContain("<path");
|
||||
expect(html).toContain("Export GPX");
|
||||
});
|
||||
|
||||
it("falls back to the empty state for a LineString with fewer than 2 points", () => {
|
||||
it("returns null for a degenerate point-shaped route", () => {
|
||||
const stub: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
route: {
|
||||
geometry: { type: "LineString", coordinates: [[11, 49]] },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11, 49],
|
||||
[11.0001, 49.0001],
|
||||
],
|
||||
},
|
||||
sampleTimestamps: null,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailRoute workout={stub} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route-empty"');
|
||||
expect(render(<WorkoutDetailRoute workout={stub} />)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailHRChart>", () => {
|
||||
it("renders the unavailable notice with aggregate HR values when present", () => {
|
||||
const html = render(<WorkoutDetailHRChart workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr-chart"');
|
||||
expect(html).toContain("145 bpm");
|
||||
expect(html).toContain("170 bpm");
|
||||
describe("<WorkoutDetailHrSection>", () => {
|
||||
it("returns null without an HR series", () => {
|
||||
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders just the unavailable notice when no HR aggregates are present", () => {
|
||||
const noHr: WorkoutDetailPayload = {
|
||||
it("renders the card and a provenance chip for a pulse-window series", () => {
|
||||
const withHr: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
avgHr: null,
|
||||
maxHr: null,
|
||||
hrSeries: {
|
||||
source: "pulse_window",
|
||||
bucketSec: 8,
|
||||
points: [
|
||||
{ tSec: 0, mean: 130, min: 125, max: 135 },
|
||||
{ tSec: 8, mean: 140, min: 135, max: 145 },
|
||||
],
|
||||
envelope: false,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailHRChart workout={noHr} />);
|
||||
expect(html).toContain("Per-second heart-rate");
|
||||
expect(html).not.toContain("145 bpm");
|
||||
const html = render(<WorkoutDetailHrSection workout={withHr} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr"');
|
||||
expect(html).toContain('data-slot="workout-detail-hr-provenance"');
|
||||
expect(html).toContain("From your heart-rate data");
|
||||
});
|
||||
|
||||
it("omits the provenance chip for a stored series", () => {
|
||||
const withHr: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
hrSeries: {
|
||||
source: "workout_series",
|
||||
bucketSec: 8,
|
||||
points: [
|
||||
{ tSec: 0, mean: 130, min: 125, max: 135 },
|
||||
{ tSec: 8, mean: 140, min: 135, max: 145 },
|
||||
],
|
||||
envelope: true,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailHrSection workout={withHr} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr"');
|
||||
expect(html).not.toContain('data-slot="workout-detail-hr-provenance"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailZones>", () => {
|
||||
it("returns null without zone data", () => {
|
||||
expect(render(<WorkoutDetailZones workout={FIXTURE} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders the stacked bar and per-zone minutes", () => {
|
||||
const withZones: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
zones: {
|
||||
model: "tanaka",
|
||||
hrMax: 180,
|
||||
zones: [
|
||||
{ zone: 1, lowBpm: 90, highBpm: 108, seconds: 300 },
|
||||
{ zone: 2, lowBpm: 108, highBpm: 126, seconds: 600 },
|
||||
{ zone: 3, lowBpm: 126, highBpm: 144, seconds: 500 },
|
||||
{ zone: 4, lowBpm: 144, highBpm: 162, seconds: 200 },
|
||||
{ zone: 5, lowBpm: 162, highBpm: null, seconds: 60 },
|
||||
],
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailZones workout={withZones} />);
|
||||
expect(html).toContain('data-slot="workout-detail-zones"');
|
||||
expect(html).toContain("Effort zones");
|
||||
expect(html).toContain("Z3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailSplits>", () => {
|
||||
it("returns null without splits", () => {
|
||||
expect(render(<WorkoutDetailSplits workout={FIXTURE} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders a row per kilometre", () => {
|
||||
const withSplits: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
splits: [
|
||||
{ km: 1, durationSec: 300, paceSecPerKm: 300 },
|
||||
{ km: 2, durationSec: 288, paceSecPerKm: 288 },
|
||||
],
|
||||
};
|
||||
const html = render(<WorkoutDetailSplits workout={withSplits} />);
|
||||
expect(html).toContain('data-slot="workout-detail-splits"');
|
||||
expect(html).toContain("5:00");
|
||||
expect(html).toContain("4:48 /km");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailDayLinks>", () => {
|
||||
it("renders the that-day navigation links", () => {
|
||||
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-day-links"');
|
||||
expect(html).toContain('href="/insights/pulse"');
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
expect(html).toContain('href="/insights/mood"');
|
||||
});
|
||||
});
|
||||
@@ -1,438 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Flame,
|
||||
Footprints,
|
||||
HeartPulse,
|
||||
Map,
|
||||
Mountain,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
/**
|
||||
* v1.4.32 — workout-detail components: header, stats, optional route,
|
||||
* optional HR chart.
|
||||
*
|
||||
* Mounted by `/insights/workouts/[id]/page.tsx`. The page itself owns
|
||||
* the data fetch + the empty / loading / error branches; each
|
||||
* component below is a pure render primitive over a non-null
|
||||
* `WorkoutDetailPayload`.
|
||||
*/
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) {
|
||||
return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
return `${m}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
function formatNumber(
|
||||
value: number,
|
||||
locale: string,
|
||||
fractionDigits = 0,
|
||||
): string {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPaceMinPerKm(
|
||||
durationSec: number,
|
||||
meters: number,
|
||||
locale: string,
|
||||
): string {
|
||||
// Pace is the seconds-per-kilometre value. Only meaningful for
|
||||
// run / walk / hike. The caller already gated on `meters > 0`.
|
||||
const secPerKm = (durationSec / meters) * 1000;
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")} ${locale === "en" ? "/km" : "/km"}`;
|
||||
}
|
||||
|
||||
function formatDateRange(
|
||||
startedAt: string,
|
||||
endedAt: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
});
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
|
||||
}
|
||||
|
||||
// ── Header ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkoutDetailHeaderProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
function renderSportIconBadge(sportType: string) {
|
||||
const Icon = iconForSport(sportType);
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
const sportName =
|
||||
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot="workout-detail-header"
|
||||
// v1.30 mobile audit (W-1) — the three columns (icon + title block +
|
||||
// duration) held at every width, so at 360px the middle column got
|
||||
// ~120px and the long-format date line wrapped 4-5 lines beside a
|
||||
// text-2xl duration. Stack below sm: duration drops under the title
|
||||
// block as its own row instead of squeezing beside it.
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
{renderSportIconBadge(workout.sportType)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{workout.source}
|
||||
</p>
|
||||
<h2 className="truncate text-lg font-semibold sm:text-xl">
|
||||
{sportName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDateRange(
|
||||
workout.startedAt,
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{formatDuration(workout.durationSec)}
|
||||
</span>
|
||||
{workout.distanceM != null ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────
|
||||
|
||||
interface StatTileProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
function StatTile({ icon, label, value, hint }: StatTileProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="workout-detail-stat"
|
||||
className="flex flex-col gap-1 rounded-lg border p-3"
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<span aria-hidden="true" className="size-4">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-base font-semibold tabular-nums">{value}</span>
|
||||
{hint ? (
|
||||
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailStatsProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const tiles: StatTileProps[] = [];
|
||||
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDuration"),
|
||||
value: formatDuration(workout.durationSec),
|
||||
});
|
||||
|
||||
if (workout.distanceM != null && workout.distanceM > 0) {
|
||||
tiles.push({
|
||||
icon: <Map className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDistance"),
|
||||
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.activeEnergyKcal != null) {
|
||||
tiles.push({
|
||||
icon: <Flame className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsActiveEnergy"),
|
||||
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.avgHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsAvgHr"),
|
||||
value: `${workout.avgHr} bpm`,
|
||||
hint:
|
||||
workout.maxHr != null
|
||||
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.minHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsMinHr"),
|
||||
value: `${workout.minHr} bpm`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.stepCount != null && workout.stepCount > 0) {
|
||||
tiles.push({
|
||||
icon: <Footprints className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsStepCount"),
|
||||
value: formatNumber(workout.stepCount, locale),
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.elevationM != null && workout.elevationM !== 0) {
|
||||
tiles.push({
|
||||
icon: <Mountain className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsElevation"),
|
||||
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
|
||||
});
|
||||
}
|
||||
|
||||
// Pace is only meaningful for foot- and ride-paced sports. Surface
|
||||
// it when the workout has both a distance and a sport with a
|
||||
// canonical pace unit; the fallback covers the long-tail HK enum
|
||||
// values without lighting up a misleading number.
|
||||
const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
|
||||
if (
|
||||
workout.distanceM != null &&
|
||||
workout.distanceM > 100 &&
|
||||
PACE_SPORTS.has(workout.sportType)
|
||||
) {
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsPace"),
|
||||
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-stats">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.statsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{tiles.map((tile) => (
|
||||
<StatTile key={tile.label} {...tile} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Route ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkoutDetailRouteProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
interface LineStringGeometry {
|
||||
type: "LineString";
|
||||
coordinates: Array<[number, number] | [number, number, number]>;
|
||||
}
|
||||
|
||||
function isLineString(value: unknown): value is LineStringGeometry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return v.type === "LineString" && Array.isArray(v.coordinates);
|
||||
}
|
||||
|
||||
export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
|
||||
const { t } = useTranslations();
|
||||
const geometry = workout.route?.geometry;
|
||||
|
||||
if (!isLineString(geometry) || geometry.coordinates.length < 2) {
|
||||
return (
|
||||
<Card data-slot="workout-detail-route-empty">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.routeTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
{t("insights.workouts.detail.routeUnavailable")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// The map widget itself defers to v1.5.x (MapLibre / Leaflet not yet
|
||||
// pulled in). For v1.4.32 we paint a lightweight SVG polyline of the
|
||||
// route geometry — projection-free, normalised against the bounding
|
||||
// box, no third-party dependency. Resolution is fine on screen
|
||||
// because routes are bounded to 20 000 points by the ingest cap.
|
||||
const coordinates = geometry.coordinates;
|
||||
let minLon = Infinity;
|
||||
let maxLon = -Infinity;
|
||||
let minLat = Infinity;
|
||||
let maxLat = -Infinity;
|
||||
for (const [lon, lat] of coordinates) {
|
||||
if (lon < minLon) minLon = lon;
|
||||
if (lon > maxLon) maxLon = lon;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
const padLon = (maxLon - minLon) * 0.05 || 0.0001;
|
||||
const padLat = (maxLat - minLat) * 0.05 || 0.0001;
|
||||
minLon -= padLon;
|
||||
maxLon += padLon;
|
||||
minLat -= padLat;
|
||||
maxLat += padLat;
|
||||
const VIEW_W = 400;
|
||||
const VIEW_H = 240;
|
||||
const points = coordinates
|
||||
.map(([lon, lat]) => {
|
||||
const x = ((lon - minLon) / (maxLon - minLon)) * VIEW_W;
|
||||
// SVG y-axis grows downward; flip lat → y.
|
||||
const y = VIEW_H - ((lat - minLat) / (maxLat - minLat)) * VIEW_H;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-route">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.routeTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className="bg-muted/40 h-60 w-full rounded-md"
|
||||
role="img"
|
||||
aria-label={t("insights.workouts.detail.routeTitle")}
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary"
|
||||
/>
|
||||
</svg>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Heart-rate chart ─────────────────────────────────────────────────
|
||||
|
||||
export function WorkoutDetailHRChart({
|
||||
workout,
|
||||
}: {
|
||||
workout: WorkoutDetailPayload;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Per-second HR samples are not persisted on the Workout row today —
|
||||
// the W8d ingest schema stores aggregates only (avg / max / min).
|
||||
// The per-second series lives on the corresponding `Measurement`
|
||||
// rows posted alongside the workout via `POST /api/measurements/batch`.
|
||||
// Wiring that into the detail page consumes a future v1.5.x sprint
|
||||
// (see R-F T2 + W8d outline); for v1.4.32 the slot renders a
|
||||
// graceful empty state with the aggregates already surfaced on
|
||||
// `<WorkoutDetailStats>`.
|
||||
return (
|
||||
<Card data-slot="workout-detail-hr-chart">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.hrChartTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground space-y-2 text-sm">
|
||||
<p>{t("insights.workouts.detail.hrChartUnavailable")}</p>
|
||||
{workout.avgHr != null || workout.maxHr != null ? (
|
||||
<p>
|
||||
{workout.avgHr != null
|
||||
? `${t("insights.workouts.detail.statsAvgHr")}: ${workout.avgHr} bpm`
|
||||
: null}
|
||||
{workout.avgHr != null && workout.maxHr != null ? " · " : ""}
|
||||
{workout.maxHr != null
|
||||
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
|
||||
: null}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Activity, Moon, Smile } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
interface DayLink {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const LINKS: DayLink[] = [
|
||||
{
|
||||
href: "/insights/pulse",
|
||||
labelKey: "insights.workouts.detail.dayPulse",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
href: "/insights/sleep",
|
||||
labelKey: "insights.workouts.detail.daySleep",
|
||||
icon: Moon,
|
||||
},
|
||||
{
|
||||
href: "/insights/mood",
|
||||
labelKey: "insights.workouts.detail.dayMood",
|
||||
icon: Smile,
|
||||
},
|
||||
];
|
||||
|
||||
export interface WorkoutDetailDayLinksProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That day" — a plain inline link row to the day's other signals
|
||||
* (intraday pulse, that night's sleep, mood). No reads; just navigation
|
||||
* so the workout sits in the context of the rest of the day.
|
||||
*/
|
||||
export function WorkoutDetailDayLinks({ workout }: WorkoutDetailDayLinksProps) {
|
||||
const { t } = useTranslations();
|
||||
void workout;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-day-links">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={CalendarDays}
|
||||
title={t("insights.workouts.detail.thatDayTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LINKS.map(({ href, labelKey, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="border-border hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import {
|
||||
hourCycleOptions,
|
||||
type TimeFormatPreference,
|
||||
} from "@/lib/format-locale";
|
||||
|
||||
/**
|
||||
* Shared formatters for the workout-detail surface. Pure helpers pulled
|
||||
* out of the former single `workout-detail.tsx` when it was split into a
|
||||
* `workout-detail/` directory (#67), so header / stats / splits format
|
||||
* durations and distances identically.
|
||||
*/
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) {
|
||||
return `${h}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
return `${m}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
/** Compact "34 min" style duration for the sport-average comparison line. */
|
||||
export function formatDurationMinutes(seconds: number, locale: string): string {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return formatNumber(minutes, locale);
|
||||
}
|
||||
|
||||
export function formatDistanceKm(meters: number, locale: string): string {
|
||||
const km = meters / 1000;
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: km < 10 ? 2 : 1,
|
||||
}).format(km);
|
||||
}
|
||||
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
locale: string,
|
||||
fractionDigits = 0,
|
||||
): string {
|
||||
return getNumberFormat(locale, {
|
||||
maximumFractionDigits: fractionDigits,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatPaceMinPerKm(
|
||||
durationSec: number,
|
||||
meters: number,
|
||||
): string {
|
||||
// Seconds-per-kilometre. Only meaningful for run / walk / hike / ride;
|
||||
// the caller gates on distance + sport.
|
||||
const secPerKm = (durationSec / meters) * 1000;
|
||||
return formatPaceSeconds(secPerKm);
|
||||
}
|
||||
|
||||
/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */
|
||||
export function formatPaceSeconds(secPerKm: number): string {
|
||||
const m = Math.floor(secPerKm / 60);
|
||||
const s = Math.round(secPerKm % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")} /km`;
|
||||
}
|
||||
|
||||
export function formatDateRange(
|
||||
startedAt: string,
|
||||
endedAt: string,
|
||||
locale: string,
|
||||
timeFormat: TimeFormatPreference,
|
||||
): string {
|
||||
const start = new Date(startedAt);
|
||||
const end = new Date(endedAt);
|
||||
const dateFmt = getDateTimeFormat(locale, {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeFmt = getDateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...hourCycleOptions(timeFormat),
|
||||
});
|
||||
return `${dateFmt.format(start)} · ${timeFmt.format(start)} – ${timeFmt.format(end)}`;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
|
||||
import { iconForSport } from "@/lib/workouts/sport-icons";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
import { formatDuration, formatDistanceKm, formatDateRange } from "./format";
|
||||
|
||||
/**
|
||||
* Hero header — the at-a-glance verdict row. Sport icon badge, source
|
||||
* provenance line, sport name, date/time range, big duration + distance.
|
||||
*/
|
||||
|
||||
export interface WorkoutDetailHeaderProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
function renderSportIconBadge(sportType: string) {
|
||||
const Icon = iconForSport(sportType);
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-12 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<Icon className="size-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeFormat = useTimeFormatPreference();
|
||||
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
const sportName =
|
||||
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot="workout-detail-header"
|
||||
// v1.30 mobile audit (W-1) — stack below sm so the long-format date
|
||||
// line never squeezes beside a text-2xl duration at 360px.
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
{renderSportIconBadge(workout.sportType)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{workout.source}
|
||||
</p>
|
||||
<h2 className="truncate text-lg font-semibold sm:text-xl">
|
||||
{sportName}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{formatDateRange(
|
||||
workout.startedAt,
|
||||
workout.endedAt,
|
||||
locale,
|
||||
timeFormat,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
|
||||
<span className="text-2xl font-semibold tabular-nums">
|
||||
{formatDuration(workout.durationSec)}
|
||||
</span>
|
||||
{workout.distanceM != null ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatDistanceKm(workout.distanceM, locale)} km
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { HeartPulse } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
// Route the HR curve through the ONE recharts async boundary so the
|
||||
// library stays a single shared chunk (chart-runtime.ts header rule).
|
||||
const WorkoutHrChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.WorkoutHrChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
function WorkoutHrChart(props: ComponentProps<typeof WorkoutHrChartLazy>) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<WorkoutHrChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailHrSectionProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heart-rate curve card — mean line + optional min→max envelope + %HRmax
|
||||
* zone bands + an average-HR reference line. 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).
|
||||
*/
|
||||
export function WorkoutDetailHrSection({
|
||||
workout,
|
||||
}: WorkoutDetailHrSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const series = workout.hrSeries;
|
||||
if (!series || series.points.length < 2) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-hr">
|
||||
<CardHeader className="gap-1">
|
||||
<TileHeader
|
||||
icon={HeartPulse}
|
||||
title={t("insights.workouts.detail.hrChartTitle")}
|
||||
/>
|
||||
{series.source === "pulse_window" ? (
|
||||
<p
|
||||
data-slot="workout-detail-hr-provenance"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.workouts.detail.hrFromPulse")}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WorkoutHrChart
|
||||
points={series.points}
|
||||
bucketSec={series.bucketSec}
|
||||
envelope={series.envelope}
|
||||
avgHr={workout.avgHr}
|
||||
zones={workout.zones?.zones ?? null}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Barrel for the workout-detail surface (#67). The former single
|
||||
* `workout-detail.tsx` grew past a comfortable size once the HR curve,
|
||||
* zones, route SVG, splits and day-links landed, so it was split into
|
||||
* this directory. Import path stays `@/components/insights/workout-detail`.
|
||||
*/
|
||||
export { WorkoutDetailHeader } from "./header";
|
||||
export { WorkoutDetailStats } from "./stats";
|
||||
export { WorkoutDetailHrSection } from "./hr-section";
|
||||
export { WorkoutDetailZones } from "./zones";
|
||||
export { WorkoutDetailRoute } from "./route-map";
|
||||
export { WorkoutDetailSplits } from "./splits";
|
||||
export { WorkoutDetailDayLinks } from "./day-links";
|
||||
export {
|
||||
WorkoutInsightCard,
|
||||
type WorkoutActivityInsightData,
|
||||
} from "./insight-slot";
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Reserved Activity-Insight seam (strategic-concept Wave C, bet 5).
|
||||
*
|
||||
* NOTHING renders here today: the workout payload's `aiInsight` field is
|
||||
* always `null`, so the page composes `{aiInsight ? <WorkoutInsightCard
|
||||
* insight={aiInsight}/> : null}` and this component never mounts. The
|
||||
* shape below is what the Phase-2 pg-boss job will write onto the
|
||||
* workout row — when it lands, the server maps the row to `aiInsight`,
|
||||
* the union in `use-workouts.ts` widens from `null` to
|
||||
* `WorkoutActivityInsightData | null`, and the card mounts right under
|
||||
* the hero header with zero layout rework. Non-diagnostic by
|
||||
* construction: a descriptive paragraph, device-attributed, never a
|
||||
* verdict.
|
||||
*/
|
||||
export interface WorkoutActivityInsightData {
|
||||
/** The cached, plain-text insight paragraph. Rendered as text children. */
|
||||
paragraph: string;
|
||||
/** ISO timestamp the paragraph was generated. */
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export function WorkoutInsightCard({
|
||||
insight,
|
||||
}: {
|
||||
insight: WorkoutActivityInsightData;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Card data-slot="workout-detail-insight">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.workouts.detail.insightTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed">{insight.paragraph}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ComponentProps } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Map as MapIcon, Download } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
projectRoute,
|
||||
buildElevationProfile,
|
||||
buildGpxDocument,
|
||||
type RouteCoordinate,
|
||||
} from "@/lib/workouts/route-svg";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
const WorkoutElevationChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.WorkoutElevationChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <div className="h-24 w-full" /> },
|
||||
);
|
||||
function WorkoutElevationChart(
|
||||
props: ComponentProps<typeof WorkoutElevationChartLazy>,
|
||||
) {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<WorkoutElevationChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
interface LineStringGeometry {
|
||||
type: "LineString";
|
||||
coordinates: RouteCoordinate[];
|
||||
}
|
||||
|
||||
function isLineString(value: unknown): value is LineStringGeometry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return v.type === "LineString" && Array.isArray(v.coordinates);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailRouteProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* GPS route card — a self-rendered, aspect-true SVG polyline over the
|
||||
* user's own stored geometry. Zero external requests: no tile server, no
|
||||
* geocoder, nothing leaves the origin (binding privacy/CSP property).
|
||||
* Start = filled `--success` dot, end = `--foreground` ring. Optional
|
||||
* elevation profile below when the track carries altitude. A GPX export
|
||||
* lets the user take their track with them — built client-side, zero
|
||||
* egress. Returns `null` when there is no usable geometry (hide, don't
|
||||
* render empty).
|
||||
*/
|
||||
export function WorkoutDetailRoute({ workout }: WorkoutDetailRouteProps) {
|
||||
const { t } = useTranslations();
|
||||
const geometry = workout.route?.geometry;
|
||||
const coordinates = isLineString(geometry) ? geometry.coordinates : null;
|
||||
|
||||
const projected = useMemo(
|
||||
() => (coordinates ? projectRoute(coordinates) : null),
|
||||
[coordinates],
|
||||
);
|
||||
const elevation = useMemo(
|
||||
() => (coordinates ? buildElevationProfile(coordinates) : null),
|
||||
[coordinates],
|
||||
);
|
||||
|
||||
if (!coordinates || !projected) return null;
|
||||
|
||||
const exportGpx = () => {
|
||||
const gpx = buildGpxDocument({
|
||||
coordinates,
|
||||
timestamps: workout.route?.sampleTimestamps ?? null,
|
||||
sportType: workout.sportType,
|
||||
startedAt: workout.startedAt,
|
||||
});
|
||||
const blob = new Blob([gpx], { type: "application/gpx+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `workout-${workout.id}.gpx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const markerR = 1.6;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-route">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={MapIcon}
|
||||
title={t("insights.workouts.detail.routeTitle")}
|
||||
right={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={exportGpx}
|
||||
data-slot="workout-detail-gpx-export"
|
||||
>
|
||||
<Download className="size-4" aria-hidden="true" />
|
||||
{t("insights.workouts.detail.exportGpx")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<svg
|
||||
viewBox={projected.viewBox}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="bg-muted/40 max-h-72 w-full rounded-md"
|
||||
role="img"
|
||||
aria-label={t("insights.workouts.detail.routeTitle")}
|
||||
>
|
||||
<path
|
||||
d={projected.path}
|
||||
fill="none"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{/* End ring first so the start dot always sits on top on a loop. */}
|
||||
<circle
|
||||
cx={projected.end.x}
|
||||
cy={projected.end.y}
|
||||
r={markerR}
|
||||
fill="none"
|
||||
stroke="var(--foreground)"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={projected.start.x}
|
||||
cy={projected.start.y}
|
||||
r={markerR}
|
||||
fill="var(--success)"
|
||||
/>
|
||||
</svg>
|
||||
{elevation ? (
|
||||
<div data-slot="workout-detail-elevation">
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{t("insights.workouts.detail.elevationTitle")}
|
||||
</p>
|
||||
<WorkoutElevationChart points={elevation} />
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { Flag } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
import { formatPaceSeconds } from "./format";
|
||||
|
||||
function formatSplitTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface WorkoutDetailSplitsProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-kilometre splits table, derived server-side from the route
|
||||
* geometry + timestamps. Returns `null` when the workout has no
|
||||
* split data (hide, don't render empty). The fastest km is marked
|
||||
* with a discreet muted accent — descriptive, not a verdict.
|
||||
*/
|
||||
export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) {
|
||||
const { t } = useTranslations();
|
||||
const splits = workout.splits;
|
||||
if (!splits || splits.length === 0) return null;
|
||||
|
||||
const fastest = Math.min(...splits.map((s) => s.paceSecPerKm));
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-splits">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Flag}
|
||||
title={t("insights.workouts.detail.splitsTitle")}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground text-left text-xs">
|
||||
<th className="pb-2 font-medium">
|
||||
{t("insights.workouts.detail.splitsKm")}
|
||||
</th>
|
||||
<th className="pb-2 text-right font-medium">
|
||||
{t("insights.workouts.detail.splitsTime")}
|
||||
</th>
|
||||
<th className="pb-2 text-right font-medium">
|
||||
{t("insights.workouts.detail.splitsPace")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{splits.map((s) => (
|
||||
<tr key={s.km} className="border-t">
|
||||
<td className="py-1.5 tabular-nums">{s.km}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{formatSplitTime(s.durationSec)}
|
||||
</td>
|
||||
<td
|
||||
className={
|
||||
s.paceSecPerKm === fastest
|
||||
? "py-1.5 text-right font-semibold tabular-nums"
|
||||
: "py-1.5 text-right tabular-nums"
|
||||
}
|
||||
>
|
||||
{formatPaceSeconds(s.paceSecPerKm)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Flame,
|
||||
Footprints,
|
||||
HeartPulse,
|
||||
Map,
|
||||
Mountain,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
import {
|
||||
formatDuration,
|
||||
formatDistanceKm,
|
||||
formatNumber,
|
||||
formatPaceMinPerKm,
|
||||
formatDurationMinutes,
|
||||
} from "./format";
|
||||
|
||||
interface StatTileProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
function StatTile({ icon, label, value, hint }: StatTileProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="workout-detail-stat"
|
||||
className="flex flex-col gap-1 rounded-lg border p-3"
|
||||
>
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<span aria-hidden="true" className="size-4">
|
||||
{icon}
|
||||
</span>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-base font-semibold tabular-nums">{value}</span>
|
||||
{hint ? (
|
||||
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkoutDetailStatsProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]);
|
||||
|
||||
/**
|
||||
* Build the muted own-history comparison line ("Your recent average:
|
||||
* 5.8 km · 34 min · 148 bpm"). Own-history only — never a population
|
||||
* comparison (non-diagnostic standard). Rendered only when at least two
|
||||
* sessions of the sport exist, so it never restates the single workout
|
||||
* you are already looking at.
|
||||
*/
|
||||
function sportAverageLine(
|
||||
workout: WorkoutDetailPayload,
|
||||
locale: string,
|
||||
): string | null {
|
||||
const ctx = workout.sportContext;
|
||||
if (!ctx || ctx.count < 2) return null;
|
||||
const parts: string[] = [];
|
||||
if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) {
|
||||
parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`);
|
||||
}
|
||||
parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`);
|
||||
if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const tiles: StatTileProps[] = [];
|
||||
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDuration"),
|
||||
value: formatDuration(workout.durationSec),
|
||||
});
|
||||
|
||||
if (workout.distanceM != null && workout.distanceM > 0) {
|
||||
tiles.push({
|
||||
icon: <Map className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsDistance"),
|
||||
value: `${formatDistanceKm(workout.distanceM, locale)} km`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.activeEnergyKcal != null) {
|
||||
tiles.push({
|
||||
icon: <Flame className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsActiveEnergy"),
|
||||
value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.avgHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsAvgHr"),
|
||||
value: `${workout.avgHr} bpm`,
|
||||
hint:
|
||||
workout.maxHr != null
|
||||
? `${t("insights.workouts.detail.statsMaxHr")}: ${workout.maxHr} bpm`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.minHr != null) {
|
||||
tiles.push({
|
||||
icon: <HeartPulse className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsMinHr"),
|
||||
value: `${workout.minHr} bpm`,
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.stepCount != null && workout.stepCount > 0) {
|
||||
tiles.push({
|
||||
icon: <Footprints className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsStepCount"),
|
||||
value: formatNumber(workout.stepCount, locale),
|
||||
});
|
||||
}
|
||||
|
||||
if (workout.elevationM != null && workout.elevationM !== 0) {
|
||||
tiles.push({
|
||||
icon: <Mountain className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsElevation"),
|
||||
value: `${formatNumber(workout.elevationM, locale, 1)} m`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
workout.distanceM != null &&
|
||||
workout.distanceM > 100 &&
|
||||
PACE_SPORTS.has(workout.sportType)
|
||||
) {
|
||||
tiles.push({
|
||||
icon: <Timer className="size-4" />,
|
||||
label: t("insights.workouts.detail.statsPace"),
|
||||
value: formatPaceMinPerKm(workout.durationSec, workout.distanceM),
|
||||
});
|
||||
}
|
||||
|
||||
const averageLine = sportAverageLine(workout, locale);
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-stats">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{t("insights.workouts.detail.statsTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{tiles.map((tile) => (
|
||||
<StatTile key={tile.label} {...tile} />
|
||||
))}
|
||||
</div>
|
||||
{averageLine ? (
|
||||
<p
|
||||
data-slot="workout-detail-sport-average"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.workouts.detail.sportAverageLabel")}{" "}
|
||||
<span className="tabular-nums">{averageLine}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Gauge } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
const ZONE_COLORS = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
];
|
||||
|
||||
function formatMinutes(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.round(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export interface WorkoutDetailZonesProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effort-zone distribution — a single horizontal stacked bar (%HRmax
|
||||
* zones) with minutes per zone underneath. Descriptive only: "how the
|
||||
* effort was distributed", never a training prescription or cardiac
|
||||
* read (non-diagnostic standard). Returns `null` when zones could not
|
||||
* be derived (no profile age and no device zones).
|
||||
*/
|
||||
export function WorkoutDetailZones({ workout }: WorkoutDetailZonesProps) {
|
||||
const { t } = useTranslations();
|
||||
const zones = workout.zones;
|
||||
if (!zones) return null;
|
||||
|
||||
const total = zones.zones.reduce((sum, z) => sum + z.seconds, 0);
|
||||
if (total <= 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-zones">
|
||||
<CardHeader className="gap-1">
|
||||
<TileHeader
|
||||
icon={Gauge}
|
||||
title={t("insights.workouts.detail.zonesTitle")}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{zones.model === "whoop"
|
||||
? t("insights.workouts.detail.zonesCaptionDevice")
|
||||
: t("insights.workouts.detail.zonesCaption")}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div
|
||||
className="flex h-3 w-full overflow-hidden rounded-full"
|
||||
role="img"
|
||||
aria-label={t("insights.workouts.detail.zonesTitle")}
|
||||
>
|
||||
{zones.zones.map((z, i) =>
|
||||
z.seconds > 0 ? (
|
||||
<div
|
||||
key={z.zone}
|
||||
style={{
|
||||
width: `${(z.seconds / total) * 100}%`,
|
||||
backgroundColor: ZONE_COLORS[i],
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-1 text-center">
|
||||
{zones.zones.map((z, i) => (
|
||||
<div key={z.zone} className="flex flex-col items-center gap-1">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: ZONE_COLORS[i] }}
|
||||
/>
|
||||
<span className="text-xs font-medium">Z{z.zone}</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatMinutes(z.seconds)}
|
||||
</span>
|
||||
{z.lowBpm != null ? (
|
||||
<span className="text-muted-foreground text-[10px] tabular-nums">
|
||||
{z.lowBpm}
|
||||
{z.highBpm != null ? `–${z.highBpm}` : "+"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Map as MapIcon, HeartPulse } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
@@ -106,7 +107,26 @@ export function WorkoutList({ workouts, className }: WorkoutListProps) {
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="truncate font-medium">{sportName}</span>
|
||||
<span className="flex items-center gap-1.5 truncate font-medium">
|
||||
{sportName}
|
||||
{/* #67 — discreet glyphs flag which sessions open rich
|
||||
(map / HR curve). Muted so they never compete with
|
||||
the sport name. */}
|
||||
{workout.hasRoute ? (
|
||||
<MapIcon
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
data-slot="workout-list-glyph-route"
|
||||
/>
|
||||
) : null}
|
||||
{workout.hasHrSeries ? (
|
||||
<HeartPulse
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
data-slot="workout-list-glyph-hr"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{formatDate(workout.startedAt, locale, timeFormat)}
|
||||
</span>
|
||||
|
||||
Reference in new issue
Block a user