mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
beaee5791a
commit
61969fb025
3 files changed
+667
No files matched your search
@@ -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 (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="workout-detail-back"
|
||||
>
|
||||
<Link href="/insights/workouts">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
{t("insights.workouts.detail.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{isLoading ? (
|
||||
<div data-slot="workout-detail-loading" className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
<Skeleton className="h-60 w-full rounded-lg" />
|
||||
</div>
|
||||
) : error || !data ? (
|
||||
<p
|
||||
data-slot="workout-detail-error"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("insights.workouts.detail.notFound")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<WorkoutDetailHeader workout={data} />
|
||||
<WorkoutDetailStats workout={data} />
|
||||
<WorkoutDetailRoute workout={data} />
|
||||
<WorkoutDetailHRChart workout={data} />
|
||||
<CoachLaunchButton />
|
||||
</>
|
||||
)}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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 — `<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.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<WorkoutDetailHeader>", () => {
|
||||
it("renders the sport label, source, and duration summary", () => {
|
||||
const html = render(<WorkoutDetailHeader workout={FIXTURE} />);
|
||||
expect(html).toContain("Running");
|
||||
expect(html).toContain("APPLE_HEALTH");
|
||||
expect(html).toContain("30m 00s");
|
||||
expect(html).toContain("5.20 km");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailStats>", () => {
|
||||
it("renders one tile per available field", () => {
|
||||
const html = render(<WorkoutDetailStats workout={FIXTURE} />);
|
||||
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(<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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailRoute>", () => {
|
||||
it("renders the empty-state 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");
|
||||
});
|
||||
|
||||
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(<WorkoutDetailRoute workout={withRoute} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route"');
|
||||
expect(html).toContain("<polyline");
|
||||
});
|
||||
|
||||
it("falls back to the empty state for a LineString with fewer than 2 points", () => {
|
||||
const stub: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
route: {
|
||||
geometry: { type: "LineString", coordinates: [[11, 49]] },
|
||||
sampleTimestamps: null,
|
||||
},
|
||||
};
|
||||
const html = render(<WorkoutDetailRoute workout={stub} />);
|
||||
expect(html).toContain('data-slot="workout-detail-route-empty"');
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("renders just the unavailable notice when no HR aggregates are present", () => {
|
||||
const noHr: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
avgHr: null,
|
||||
maxHr: null,
|
||||
};
|
||||
const html = render(<WorkoutDetailHRChart workout={noHr} />);
|
||||
expect(html).toContain("Per-second heart-rate");
|
||||
expect(html).not.toContain("145 bpm");
|
||||
});
|
||||
});
|
||||
@@ -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<string, LucideIcon> = {
|
||||
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 (
|
||||
<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 sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
|
||||
const sportLabel = t(sportLabelKey);
|
||||
const sportName =
|
||||
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
|
||||
|
||||
return (
|
||||
<header
|
||||
data-slot="workout-detail-header"
|
||||
className="flex items-start gap-4"
|
||||
>
|
||||
{renderSportIconBadge(workout.sportType)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{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)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end 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-[11px]">{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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user