diff --git a/messages/de.json b/messages/de.json
index d33d35a17..0a09b0432 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1103,6 +1103,11 @@
"navMedication": "Medikamente",
"navBmi": "BMI",
"navSleep": "Schlaf",
+ "navHrv": "HRV",
+ "navRestingHr": "Ruhepuls",
+ "navOxygenSaturation": "Sauerstoff",
+ "navBodyTemperature": "Temperatur",
+ "navActiveEnergy": "Aktive Energie",
"navAriaLabel": "Zu Abschnitt springen",
"subPage": {
"blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten KI-Einschätzung.",
@@ -1202,6 +1207,62 @@
"description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.",
"cta": "Withings verbinden"
}
+ },
+ "navWorkouts": "Workouts",
+ "workouts": {
+ "title": "Workouts",
+ "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
+ "emptyState": {
+ "title": "Noch keine Workouts",
+ "description": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier synchronisiert werden. Withings-Aktivitätsimport pflegt Geh- und Radtouren mit ein."
+ },
+ "row": {
+ "duration": "Dauer",
+ "distance": "Distanz",
+ "energy": "Aktive Energie"
+ },
+ "detail": {
+ "backToList": "Zurück zu Workouts",
+ "statsTitle": "Werte",
+ "statsDuration": "Dauer",
+ "statsDistance": "Distanz",
+ "statsActiveEnergy": "Aktive Energie",
+ "statsAvgHr": "Ø Herzfrequenz",
+ "statsMaxHr": "Max. Herzfrequenz",
+ "statsMinHr": "Min. Herzfrequenz",
+ "statsStepCount": "Schritte",
+ "statsElevation": "Höhenmeter",
+ "statsPace": "Pace",
+ "statsSource": "Quelle",
+ "routeTitle": "Strecke",
+ "routeUnavailable": "Für dieses Workout liegt keine GPS-Strecke vor.",
+ "hrChartTitle": "Herzfrequenz",
+ "hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.",
+ "notFound": "Workout nicht gefunden.",
+ "loading": "Workout wird geladen…"
+ },
+ "sport": {
+ "walking": "Gehen",
+ "running": "Laufen",
+ "cycling": "Radfahren",
+ "hiking": "Wandern",
+ "swimming": "Schwimmen",
+ "rowing": "Rudern",
+ "elliptical": "Crosstrainer",
+ "stairClimber": "Stepper",
+ "yoga": "Yoga",
+ "mindAndBody": "Mind and Body",
+ "strength": "Kraft",
+ "hiit": "HIIT",
+ "dance": "Tanz",
+ "golf": "Golf",
+ "tennis": "Tennis",
+ "basketball": "Basketball",
+ "soccer": "Fußball",
+ "crossTraining": "Cross-Training",
+ "mixedCardio": "Mixed Cardio",
+ "other": "Sonstiges"
+ }
}
},
"thresholds": {
diff --git a/messages/en.json b/messages/en.json
index 2d2e03d76..e795a87ad 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1103,6 +1103,11 @@
"navMedication": "Medication",
"navBmi": "BMI",
"navSleep": "Sleep",
+ "navHrv": "HRV",
+ "navRestingHr": "Resting HR",
+ "navOxygenSaturation": "Oxygen",
+ "navBodyTemperature": "Temperature",
+ "navActiveEnergy": "Active energy",
"navAriaLabel": "Skip to section",
"subPage": {
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.",
@@ -1202,6 +1207,62 @@
"description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.",
"cta": "Connect Withings"
}
+ },
+ "navWorkouts": "Workouts",
+ "workouts": {
+ "title": "Workouts",
+ "description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
+ "emptyState": {
+ "title": "No workouts yet",
+ "description": "Open the Apple Health app on your iPhone to sync workouts here. Withings activity import keeps walking and cycling sessions flowing in too."
+ },
+ "row": {
+ "duration": "Duration",
+ "distance": "Distance",
+ "energy": "Active energy"
+ },
+ "detail": {
+ "backToList": "Back to workouts",
+ "statsTitle": "Stats",
+ "statsDuration": "Duration",
+ "statsDistance": "Distance",
+ "statsActiveEnergy": "Active energy",
+ "statsAvgHr": "Average HR",
+ "statsMaxHr": "Max HR",
+ "statsMinHr": "Min HR",
+ "statsStepCount": "Steps",
+ "statsElevation": "Elevation",
+ "statsPace": "Pace",
+ "statsSource": "Source",
+ "routeTitle": "Route",
+ "routeUnavailable": "No GPS route recorded for this workout.",
+ "hrChartTitle": "Heart rate",
+ "hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.",
+ "notFound": "Workout not found.",
+ "loading": "Loading workout…"
+ },
+ "sport": {
+ "walking": "Walking",
+ "running": "Running",
+ "cycling": "Cycling",
+ "hiking": "Hiking",
+ "swimming": "Swimming",
+ "rowing": "Rowing",
+ "elliptical": "Elliptical",
+ "stairClimber": "Stair climber",
+ "yoga": "Yoga",
+ "mindAndBody": "Mind and body",
+ "strength": "Strength",
+ "hiit": "HIIT",
+ "dance": "Dance",
+ "golf": "Golf",
+ "tennis": "Tennis",
+ "basketball": "Basketball",
+ "soccer": "Soccer",
+ "crossTraining": "Cross training",
+ "mixedCardio": "Mixed cardio",
+ "other": "Other"
+ }
}
},
"thresholds": {
diff --git a/messages/es.json b/messages/es.json
index 53ceb97b6..d38f8c991 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1103,6 +1103,11 @@
"navMedication": "Medicamento",
"navBmi": "IMC",
"navSleep": "Sueño",
+ "navHrv": "VFC",
+ "navRestingHr": "FC reposo",
+ "navOxygenSaturation": "Oxígeno",
+ "navBodyTemperature": "Temperatura",
+ "navActiveEnergy": "Energía activa",
"navAriaLabel": "Skip to section",
"subPage": {
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.",
@@ -1202,6 +1207,62 @@
"description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.",
"cta": "Conectar Withings"
}
+ },
+ "navWorkouts": "Entrenamientos",
+ "workouts": {
+ "title": "Entrenamientos",
+ "description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
+ "emptyState": {
+ "title": "Aún no hay entrenamientos",
+ "description": "Abre la app Apple Salud en el iPhone para sincronizar tus entrenamientos. La importación de actividad de Withings también incorpora caminatas y rutas en bici."
+ },
+ "row": {
+ "duration": "Duración",
+ "distance": "Distancia",
+ "energy": "Energía activa"
+ },
+ "detail": {
+ "backToList": "Volver a entrenamientos",
+ "statsTitle": "Estadísticas",
+ "statsDuration": "Duración",
+ "statsDistance": "Distancia",
+ "statsActiveEnergy": "Energía activa",
+ "statsAvgHr": "FC media",
+ "statsMaxHr": "FC máx.",
+ "statsMinHr": "FC mín.",
+ "statsStepCount": "Pasos",
+ "statsElevation": "Desnivel",
+ "statsPace": "Ritmo",
+ "statsSource": "Fuente",
+ "routeTitle": "Ruta",
+ "routeUnavailable": "No se registró una ruta GPS para este entrenamiento.",
+ "hrChartTitle": "Frecuencia cardíaca",
+ "hrChartUnavailable": "Las muestras de frecuencia cardíaca por segundo no se almacenan para este entrenamiento.",
+ "notFound": "Entrenamiento no encontrado.",
+ "loading": "Cargando entrenamiento…"
+ },
+ "sport": {
+ "walking": "Caminar",
+ "running": "Correr",
+ "cycling": "Bici",
+ "hiking": "Senderismo",
+ "swimming": "Natación",
+ "rowing": "Remo",
+ "elliptical": "Elíptica",
+ "stairClimber": "Escalera",
+ "yoga": "Yoga",
+ "mindAndBody": "Mente y cuerpo",
+ "strength": "Fuerza",
+ "hiit": "HIIT",
+ "dance": "Baile",
+ "golf": "Golf",
+ "tennis": "Tenis",
+ "basketball": "Baloncesto",
+ "soccer": "Fútbol",
+ "crossTraining": "Entreno cruzado",
+ "mixedCardio": "Cardio mixto",
+ "other": "Otro"
+ }
}
},
"thresholds": {
diff --git a/messages/fr.json b/messages/fr.json
index 96bab27c1..b5e93317c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1103,6 +1103,11 @@
"navMedication": "Médicament",
"navBmi": "IMC",
"navSleep": "Sommeil",
+ "navHrv": "VFC",
+ "navRestingHr": "FC repos",
+ "navOxygenSaturation": "Oxygène",
+ "navBodyTemperature": "Température",
+ "navActiveEnergy": "Énergie active",
"navAriaLabel": "Skip to section",
"subPage": {
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.",
@@ -1202,6 +1207,62 @@
"description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.",
"cta": "Connecter Withings"
}
+ },
+ "navWorkouts": "Entraînements",
+ "workouts": {
+ "title": "Entraînements",
+ "description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.",
+ "emptyState": {
+ "title": "Aucun entraînement",
+ "description": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser tes entraînements. L'import d'activité Withings alimente également la marche et le vélo."
+ },
+ "row": {
+ "duration": "Durée",
+ "distance": "Distance",
+ "energy": "Énergie active"
+ },
+ "detail": {
+ "backToList": "Retour aux entraînements",
+ "statsTitle": "Statistiques",
+ "statsDuration": "Durée",
+ "statsDistance": "Distance",
+ "statsActiveEnergy": "Énergie active",
+ "statsAvgHr": "FC moyenne",
+ "statsMaxHr": "FC max",
+ "statsMinHr": "FC min",
+ "statsStepCount": "Pas",
+ "statsElevation": "Dénivelé",
+ "statsPace": "Allure",
+ "statsSource": "Source",
+ "routeTitle": "Parcours",
+ "routeUnavailable": "Aucun parcours GPS enregistré pour cet entraînement.",
+ "hrChartTitle": "Fréquence cardiaque",
+ "hrChartUnavailable": "Les échantillons de fréquence cardiaque à la seconde ne sont pas stockés pour cet entraînement.",
+ "notFound": "Entraînement introuvable.",
+ "loading": "Chargement de l'entraînement…"
+ },
+ "sport": {
+ "walking": "Marche",
+ "running": "Course",
+ "cycling": "Vélo",
+ "hiking": "Randonnée",
+ "swimming": "Natation",
+ "rowing": "Aviron",
+ "elliptical": "Elliptique",
+ "stairClimber": "Stepper",
+ "yoga": "Yoga",
+ "mindAndBody": "Mind and Body",
+ "strength": "Musculation",
+ "hiit": "HIIT",
+ "dance": "Danse",
+ "golf": "Golf",
+ "tennis": "Tennis",
+ "basketball": "Basket",
+ "soccer": "Football",
+ "crossTraining": "Cross-training",
+ "mixedCardio": "Cardio mixte",
+ "other": "Autre"
+ }
}
},
"thresholds": {
diff --git a/messages/it.json b/messages/it.json
index f62456c04..457227f4c 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1103,6 +1103,11 @@
"navMedication": "Farmaco",
"navBmi": "IMC",
"navSleep": "Sonno",
+ "navHrv": "HRV",
+ "navRestingHr": "FC riposo",
+ "navOxygenSaturation": "Ossigeno",
+ "navBodyTemperature": "Temperatura",
+ "navActiveEnergy": "Energia attiva",
"navAriaLabel": "Skip to section",
"subPage": {
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.",
@@ -1202,6 +1207,62 @@
"description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.",
"cta": "Collega Withings"
}
+ },
+ "navWorkouts": "Allenamenti",
+ "workouts": {
+ "title": "Allenamenti",
+ "description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
+ "emptyState": {
+ "title": "Nessun allenamento",
+ "description": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti. L'import di attività di Withings porta dentro anche camminate e uscite in bici."
+ },
+ "row": {
+ "duration": "Durata",
+ "distance": "Distanza",
+ "energy": "Energia attiva"
+ },
+ "detail": {
+ "backToList": "Torna agli allenamenti",
+ "statsTitle": "Statistiche",
+ "statsDuration": "Durata",
+ "statsDistance": "Distanza",
+ "statsActiveEnergy": "Energia attiva",
+ "statsAvgHr": "FC media",
+ "statsMaxHr": "FC max",
+ "statsMinHr": "FC min",
+ "statsStepCount": "Passi",
+ "statsElevation": "Dislivello",
+ "statsPace": "Passo",
+ "statsSource": "Fonte",
+ "routeTitle": "Percorso",
+ "routeUnavailable": "Nessun tracciato GPS registrato per questo allenamento.",
+ "hrChartTitle": "Frequenza cardiaca",
+ "hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.",
+ "notFound": "Allenamento non trovato.",
+ "loading": "Caricamento allenamento…"
+ },
+ "sport": {
+ "walking": "Camminata",
+ "running": "Corsa",
+ "cycling": "Bici",
+ "hiking": "Escursionismo",
+ "swimming": "Nuoto",
+ "rowing": "Canottaggio",
+ "elliptical": "Ellittica",
+ "stairClimber": "Stepper",
+ "yoga": "Yoga",
+ "mindAndBody": "Mente e corpo",
+ "strength": "Forza",
+ "hiit": "HIIT",
+ "dance": "Danza",
+ "golf": "Golf",
+ "tennis": "Tennis",
+ "basketball": "Pallacanestro",
+ "soccer": "Calcio",
+ "crossTraining": "Cross-training",
+ "mixedCardio": "Cardio misto",
+ "other": "Altro"
+ }
}
},
"thresholds": {
diff --git a/messages/pl.json b/messages/pl.json
index a0faed162..865e2cb50 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1103,6 +1103,11 @@
"navMedication": "Lek",
"navBmi": "BMI",
"navSleep": "Sen",
+ "navHrv": "HRV",
+ "navRestingHr": "Tętno spoczynkowe",
+ "navOxygenSaturation": "Tlen",
+ "navBodyTemperature": "Temperatura",
+ "navActiveEnergy": "Energia czynna",
"navAriaLabel": "Skip to section",
"subPage": {
"blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.",
@@ -1202,6 +1207,62 @@
"description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.",
"cta": "Połącz Withings"
}
+ },
+ "navWorkouts": "Treningi",
+ "workouts": {
+ "title": "Treningi",
+ "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.",
+ "emptyState": {
+ "title": "Brak treningów",
+ "description": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi. Import aktywności z Withings także dorzuca marsze i przejażdżki."
+ },
+ "row": {
+ "duration": "Czas trwania",
+ "distance": "Dystans",
+ "energy": "Energia czynna"
+ },
+ "detail": {
+ "backToList": "Powrót do treningów",
+ "statsTitle": "Statystyki",
+ "statsDuration": "Czas trwania",
+ "statsDistance": "Dystans",
+ "statsActiveEnergy": "Energia czynna",
+ "statsAvgHr": "Średnie tętno",
+ "statsMaxHr": "Maks. tętno",
+ "statsMinHr": "Min. tętno",
+ "statsStepCount": "Kroki",
+ "statsElevation": "Przewyższenie",
+ "statsPace": "Tempo",
+ "statsSource": "Źródło",
+ "routeTitle": "Trasa",
+ "routeUnavailable": "Dla tego treningu nie zapisano trasy GPS.",
+ "hrChartTitle": "Tętno",
+ "hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.",
+ "notFound": "Nie znaleziono treningu.",
+ "loading": "Wczytywanie treningu…"
+ },
+ "sport": {
+ "walking": "Marsz",
+ "running": "Bieg",
+ "cycling": "Rower",
+ "hiking": "Górska wędrówka",
+ "swimming": "Pływanie",
+ "rowing": "Wioślarstwo",
+ "elliptical": "Orbitrek",
+ "stairClimber": "Stepper",
+ "yoga": "Joga",
+ "mindAndBody": "Mind and body",
+ "strength": "Siła",
+ "hiit": "HIIT",
+ "dance": "Taniec",
+ "golf": "Golf",
+ "tennis": "Tenis",
+ "basketball": "Koszykówka",
+ "soccer": "Piłka nożna",
+ "crossTraining": "Cross-trening",
+ "mixedCardio": "Cardio mieszane",
+ "other": "Inne"
+ }
}
},
"thresholds": {
diff --git a/src/app/insights/workouts/page.tsx b/src/app/insights/workouts/page.tsx
new file mode 100644
index 000000000..ca9f07abb
--- /dev/null
+++ b/src/app/insights/workouts/page.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Activity } from "lucide-react";
+
+import { useTranslations } from "@/lib/i18n/context";
+import { useWorkouts } from "@/hooks/use-workouts";
+import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
+import { MetricEmptyState } from "@/components/insights/metric-empty-state";
+import { SubPageShell } from "@/components/insights/sub-page-shell";
+import { WorkoutList } from "@/components/insights/workout-list";
+import { Skeleton } from "@/components/ui/skeleton";
+
+/**
+ * v1.4.32 — `/insights/workouts`.
+ *
+ * Workouts list sub-page. Pairs with the iOS HKWorkout ingest path
+ * (`POST /api/workouts/batch`) and the v1.4.30 cross-source picker so
+ * twin workouts from Apple Watch + Withings ScanWatch collapse to a
+ * single row. The page is the user-visible surface for the workout
+ * data the iOS client already syncs.
+ *
+ * Renders three states:
+ * - loading shell while the workouts query resolves,
+ * - empty state when the user has no workouts yet (CTA points at
+ * the iOS / Apple Health onboarding cue — there is no manual
+ * workout-entry form today),
+ * - the deduped list otherwise. Each row links to
+ * `/insights/workouts/[id]` for the detail surface.
+ */
+export default function InsightsWorkoutsPage() {
+ const { t } = useTranslations();
+ const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
+
+ return (
+
+ {isLoading ? (
+
+
+
+
+
+ ) : isEmpty ? (
+ }
+ title={t("insights.workouts.emptyState.title")}
+ description={t("insights.workouts.emptyState.description")}
+ cta={null}
+ coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?"
+ />
+ ) : data ? (
+ <>
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx
new file mode 100644
index 000000000..98b664ded
--- /dev/null
+++ b/src/components/insights/__tests__/workout-list.test.tsx
@@ -0,0 +1,71 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { WorkoutList } from "../workout-list";
+import type { WorkoutListEntry } from "@/hooks/use-workouts";
+
+/**
+ * v1.4.32 — `` unit tests.
+ *
+ * The list primitive renders one row per workout with the sport icon,
+ * the date, the duration, and the optional distance + active-energy
+ * chips. The test pins the visible labels + the deep-link href shape.
+ */
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+const ROW: WorkoutListEntry = {
+ 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,
+ source: "APPLE_HEALTH",
+ externalId: "ext-w-1",
+};
+
+describe("", () => {
+ it("renders each workout as a deep-link row", () => {
+ const html = render();
+ expect(html).toContain('href="/insights/workouts/w-1"');
+ expect(html).toContain("Running");
+ expect(html).toContain("30m");
+ });
+
+ it("renders the distance and energy chips when present", () => {
+ const html = render();
+ expect(html).toContain("5.20 km");
+ expect(html).toContain("320 kcal");
+ });
+
+ it("omits the chip when the field is null", () => {
+ const noDistance: WorkoutListEntry = {
+ ...ROW,
+ distanceM: null,
+ activeEnergyKcal: null,
+ };
+ const html = render();
+ expect(html).not.toContain('data-slot="workout-list-distance"');
+ expect(html).not.toContain('data-slot="workout-list-energy"');
+ });
+
+ it("falls back to the canonical sport string for unknown sport types", () => {
+ const wild: WorkoutListEntry = { ...ROW, sportType: "freediving" };
+ const html = render();
+ // No `insights.workouts.sport.freediving` key in the locale; the
+ // helper falls back to the canonical string so a new HK enum
+ // value never paints the raw `insights.workouts.sport.x`
+ // placeholder.
+ expect(html).toContain("freediving");
+ expect(html).not.toContain("insights.workouts.sport.");
+ });
+});
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index 9618d1c70..3b2342a52 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -4,6 +4,7 @@ import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
+import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import type { InsightInputs } from "@/lib/insights/metric-availability";
@@ -69,6 +70,12 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
enabled: isAuthenticated,
});
+ // v1.4.32 — workout-presence gate. The workouts pill gates on at
+ // least one canonical row in the list-endpoint response. The
+ // single-row probe shares its cache slot with the list page +
+ // dashboard tile so navigation between surfaces is a free cache hit.
+ const workoutsProbe = useWorkouts({ limit: 1 });
+
// v1.4.31 — memoise the `availability` prop so an unchanged
// payload doesn't recreate the object on every cache-write of
// analytics or comprehensive. The strip is wrapped in
@@ -80,10 +87,11 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0;
const hasMedication =
(comprehensiveQuery.data?.medications?.length ?? 0) > 0;
+ const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0;
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
- return { summaries, hasMood, hasMedication };
- }, [isAuthenticated, summaries, hasMood, hasMedication]);
+ return { summaries, hasMood, hasMedication, hasWorkouts };
+ }, [isAuthenticated, summaries, hasMood, hasMedication, hasWorkouts]);
return (
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index 5e8087b69..6c62c5302 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -94,6 +94,29 @@ const SUB_PAGE_TABS: Record<
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
+ // v1.4.32 — workouts pill. Gate is event-driven; the
+ // availability helper reads `inputs.hasWorkouts` rather than a
+ // `summaries[…].count`.
+ workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
+ // v1.4.32 — five wave-A HealthKit pills. Each one reads its own
+ // MeasurementType count from the analytics summaries.
+ hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
+ ruhepuls: {
+ labelKey: "insights.navRestingHr",
+ metric: "RESTING_HEART_RATE",
+ },
+ sauerstoff: {
+ labelKey: "insights.navOxygenSaturation",
+ metric: "OXYGEN_SATURATION",
+ },
+ koerpertemperatur: {
+ labelKey: "insights.navBodyTemperature",
+ metric: "BODY_TEMPERATURE",
+ },
+ "aktive-energie": {
+ labelKey: "insights.navActiveEnergy",
+ metric: "ACTIVE_ENERGY_BURNED",
+ },
};
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx
new file mode 100644
index 000000000..44d0dce3f
--- /dev/null
+++ b/src/components/insights/workout-list.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import Link from "next/link";
+import {
+ Activity,
+ Bike,
+ Dumbbell,
+ Footprints,
+ Heart,
+ Mountain,
+ PersonStanding,
+ type LucideIcon,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+import type { WorkoutListEntry } from "@/hooks/use-workouts";
+
+/**
+ * v1.4.32 — workout list row primitive.
+ *
+ * Renders the canonical workouts list returned by `GET /api/workouts`.
+ * Each row links to `/insights/workouts/[id]`. The visual shape mirrors
+ * the dashboard tile primitives — single-line metadata strip with the
+ * sport icon, the date, the duration, and (when present) the distance
+ * + active-energy chips. The Apple Watch + Withings cross-source merge
+ * runs server-side, so the list is already deduped by the time it
+ * lands here.
+ */
+
+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: Heart,
+ 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);
+ if (h > 0) return `${h}h ${m.toString().padStart(2, "0")}m`;
+ return `${m}m`;
+}
+
+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 formatEnergy(kcal: number, locale: string): string {
+ return new Intl.NumberFormat(locale, {
+ maximumFractionDigits: 0,
+ }).format(Math.round(kcal));
+}
+
+function formatDate(iso: string, locale: string): string {
+ const d = new Date(iso);
+ return new Intl.DateTimeFormat(locale, {
+ weekday: "short",
+ day: "2-digit",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(d);
+}
+
+export interface WorkoutListProps {
+ workouts: WorkoutListEntry[];
+ className?: string;
+}
+
+export function WorkoutList({ workouts, className }: WorkoutListProps) {
+ const { t, locale } = useTranslations();
+
+ return (
+
+ {workouts.map((workout) => {
+ const Icon = iconForSport(workout.sportType);
+ const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
+ const sportLabel = t(sportLabelKey);
+ // Translation keys missing from one locale fall back to the
+ // canonical sport-type string so a new HK enum value never
+ // renders the raw `insights.workouts.sport.x` placeholder.
+ const sportName = sportLabel === sportLabelKey
+ ? workout.sportType
+ : sportLabel;
+
+ return (
+