From 61969fb025655d653352fe6e6bcc67f6436ea900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 15:43:17 +0200 Subject: [PATCH] feat(insights): workout detail page with HR + route + stats Adds `/insights/workouts/[id]` on top of the v1.4.32 `GET /api/workouts/{id}` endpoint. The page mounts a header strip (sport icon + source + date range + duration summary), a stats card that surfaces every aggregate field present on the row (duration, distance, active energy, average / max / min HR, steps, elevation, pace for foot- and ride-paced sports), an SVG-rendered route preview when the workout carries `WorkoutRoute` GeoJSON LineString geometry, and an HR-chart placeholder that exposes the aggregates inline until the per-second sample series wires up in v1.5.x. Loading state mounts skeletons; the not-found branch surfaces a neutral notice rather than a 404 page so deep-links from the list page degrade gracefully when the workout has been deleted upstream. --- src/app/insights/workouts/[id]/page.tsx | 80 ++++ .../__tests__/workout-detail.test.tsx | 149 ++++++ src/components/insights/workout-detail.tsx | 438 ++++++++++++++++++ 3 files changed, 667 insertions(+) create mode 100644 src/app/insights/workouts/[id]/page.tsx create mode 100644 src/components/insights/__tests__/workout-detail.test.tsx create mode 100644 src/components/insights/workout-detail.tsx diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx new file mode 100644 index 000000000..d648a8b06 --- /dev/null +++ b/src/app/insights/workouts/[id]/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { use } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useWorkoutDetail } from "@/hooks/use-workouts"; +import { Button } from "@/components/ui/button"; +import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + WorkoutDetailHeader, + WorkoutDetailHRChart, + WorkoutDetailRoute, + WorkoutDetailStats, +} from "@/components/insights/workout-detail"; + +/** + * v1.4.32 — `/insights/workouts/[id]`. + * + * Workout detail surface. Mounts the header + stats + optional GPS + * route + heart-rate chart slot. The route data flows from + * `GET /api/workouts/{id}` (v1.4.32) and includes the cross-source + * canonical-id pointer so a deep-link into a non-canonical twin + * surfaces a graceful redirect cue (handled inline on the header for + * v1.4.32; iOS-side redirect arrives in v1.5). + */ +export default function InsightsWorkoutDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { t } = useTranslations(); + const { id } = use(params); + const { data, isLoading, error } = useWorkoutDetail(id); + + return ( + + + + {isLoading ? ( +
+ + + +
+ ) : error || !data ? ( +

+ {t("insights.workouts.detail.notFound")} +

+ ) : ( + <> + + + + + + + )} +
+ ); +} diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx new file mode 100644 index 000000000..c850e5cb9 --- /dev/null +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -0,0 +1,149 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + WorkoutDetailHeader, + WorkoutDetailHRChart, + WorkoutDetailRoute, + WorkoutDetailStats, +} from "../workout-detail"; +import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; + +/** + * v1.4.32 — `` 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. + */ + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const FIXTURE: WorkoutDetailPayload = { + id: "w-1", + sportType: "running", + startedAt: "2026-05-15T07:00:00Z", + endedAt: "2026-05-15T07:30:00Z", + durationSec: 1800, + distanceM: 5200, + activeEnergyKcal: 320, + avgHr: 145, + maxHr: 170, + minHr: 110, + stepCount: 5800, + elevationM: 12.5, + pauseDurationSec: null, + source: "APPLE_HEALTH", + externalId: "ext-w-1", + metadata: null, + route: null, + canonicalId: "w-1", +}; + +describe("", () => { + it("renders the sport label, source, and duration summary", () => { + const html = render(); + expect(html).toContain("Running"); + expect(html).toContain("APPLE_HEALTH"); + expect(html).toContain("30m 00s"); + expect(html).toContain("5.20 km"); + }); +}); + +describe("", () => { + it("renders one tile per available field", () => { + const html = render(); + 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"); + }); + + it("omits tiles for null fields", () => { + const minimal: WorkoutDetailPayload = { + ...FIXTURE, + distanceM: null, + activeEnergyKcal: null, + avgHr: null, + maxHr: null, + minHr: null, + stepCount: null, + elevationM: null, + }; + const html = render(); + 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"); + }); +}); + +describe("", () => { + it("renders the empty-state when the workout has no route", () => { + const html = render(); + expect(html).toContain('data-slot="workout-detail-route-empty"'); + expect(html).toContain("No GPS route"); + }); + + it("renders an SVG polyline when GeoJSON LineString geometry is present", () => { + const withRoute: WorkoutDetailPayload = { + ...FIXTURE, + route: { + geometry: { + type: "LineString", + coordinates: [ + [11.0, 49.0], + [11.01, 49.005], + [11.02, 49.01], + ], + }, + sampleTimestamps: null, + }, + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-route"'); + expect(html).toContain(" { + const stub: WorkoutDetailPayload = { + ...FIXTURE, + route: { + geometry: { type: "LineString", coordinates: [[11, 49]] }, + sampleTimestamps: null, + }, + }; + const html = render(); + expect(html).toContain('data-slot="workout-detail-route-empty"'); + }); +}); + +describe("", () => { + it("renders the unavailable notice with aggregate HR values when present", () => { + const html = render(); + expect(html).toContain('data-slot="workout-detail-hr-chart"'); + expect(html).toContain("145 bpm"); + expect(html).toContain("170 bpm"); + }); + + it("renders just the unavailable notice when no HR aggregates are present", () => { + const noHr: WorkoutDetailPayload = { + ...FIXTURE, + avgHr: null, + maxHr: null, + }; + const html = render(); + expect(html).toContain("Per-second heart-rate"); + expect(html).not.toContain("145 bpm"); + }); +}); diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx new file mode 100644 index 000000000..496c9d0ec --- /dev/null +++ b/src/components/insights/workout-detail.tsx @@ -0,0 +1,438 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Activity, + Bike, + Dumbbell, + Flame, + Footprints, + HeartPulse, + Map, + Mountain, + PersonStanding, + Timer, + type LucideIcon, +} 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"; + +/** + * 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`. + */ + +const SPORT_TYPE_ICON: Record = { + walking: Footprints, + running: PersonStanding, + cycling: Bike, + hiking: Mountain, + swimming: Activity, + rowing: Activity, + elliptical: Activity, + stairClimber: Activity, + yoga: PersonStanding, + mindAndBody: PersonStanding, + strength: Dumbbell, + hiit: Activity, + dance: Activity, + golf: Activity, + tennis: Activity, + basketball: Activity, + soccer: Activity, + crossTraining: Activity, + mixedCardio: HeartPulse, + other: Activity, +}; + +function iconForSport(sportType: string): LucideIcon { + return SPORT_TYPE_ICON[sportType] ?? Activity; +} + +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 new Intl.NumberFormat(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: km < 10 ? 2 : 1, + }).format(km); +} + +function formatNumber(value: number, locale: string, fractionDigits = 0): string { + return new Intl.NumberFormat(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): string { + const start = new Date(startedAt); + const end = new Date(endedAt); + const dateFmt = new Intl.DateTimeFormat(locale, { + weekday: "long", + day: "2-digit", + month: "long", + year: "numeric", + }); + const timeFmt = new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + }); + 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 ( + + ); +} + +export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { + const { t, locale } = useTranslations(); + const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; + const sportLabel = t(sportLabelKey); + const sportName = + sportLabel === sportLabelKey ? workout.sportType : sportLabel; + + return ( +
+ {renderSportIconBadge(workout.sportType)} +
+

+ {workout.source} +

+

+ {sportName} +

+

+ {formatDateRange(workout.startedAt, workout.endedAt, locale)} +

+
+
+ + {formatDuration(workout.durationSec)} + + {workout.distanceM != null ? ( + + {formatDistanceKm(workout.distanceM, locale)} km + + ) : null} +
+
+ ); +} + +// ── Stats ──────────────────────────────────────────────────────────── + +interface StatTileProps { + icon: ReactNode; + label: string; + value: string; + hint?: string; +} + +function StatTile({ icon, label, value, hint }: StatTileProps) { + return ( +
+ + + {label} + + {value} + {hint ? ( + {hint} + ) : null} +
+ ); +} + +export interface WorkoutDetailStatsProps { + workout: WorkoutDetailPayload; +} + +export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { + const { t, locale } = useTranslations(); + const tiles: StatTileProps[] = []; + + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsDuration"), + value: formatDuration(workout.durationSec), + }); + + if (workout.distanceM != null && workout.distanceM > 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsDistance"), + value: `${formatDistanceKm(workout.distanceM, locale)} km`, + }); + } + + if (workout.activeEnergyKcal != null) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsActiveEnergy"), + value: `${formatNumber(workout.activeEnergyKcal, locale)} kcal`, + }); + } + + if (workout.avgHr != null) { + tiles.push({ + icon: , + 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: , + label: t("insights.workouts.detail.statsMinHr"), + value: `${workout.minHr} bpm`, + }); + } + + if (workout.stepCount != null && workout.stepCount > 0) { + tiles.push({ + icon: , + label: t("insights.workouts.detail.statsStepCount"), + value: formatNumber(workout.stepCount, locale), + }); + } + + if (workout.elevationM != null && workout.elevationM !== 0) { + tiles.push({ + icon: , + 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: , + label: t("insights.workouts.detail.statsPace"), + value: formatPaceMinPerKm(workout.durationSec, workout.distanceM, locale), + }); + } + + return ( + + + + {t("insights.workouts.detail.statsTitle")} + + + + {tiles.map((tile) => ( + + ))} + + + ); +} + +// ── 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; + 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 ( + + + + {t("insights.workouts.detail.routeTitle")} + + + + {t("insights.workouts.detail.routeUnavailable")} + + + ); + } + + // 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 ( + + + + {t("insights.workouts.detail.routeTitle")} + + + + + + + + + ); +} + +// ── 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 + // ``. + return ( + + + + {t("insights.workouts.detail.hrChartTitle")} + + + +

{t("insights.workouts.detail.hrChartUnavailable")}

+ {workout.avgHr != null || workout.maxHr != null ? ( +

+ {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} +

+ ) : null} +
+
+ ); +}