feat(insights): workouts list page at /insights/workouts

Adds the user-visible surface for the workout rows the iOS client
already syncs through `POST /api/workouts/batch`. The page lives at
`/insights/workouts`, consumes the v1.4.32 list endpoint through a
new `useWorkouts()` hook, and renders each row as a deep-link into
the detail page with the sport icon, the date, the duration, and
the optional distance + active-energy chips.

The insights tab strip gains a `workouts` pill driven by an
event-style `hasWorkouts` availability signal — the layout shell
probes the list endpoint with `limit=1` so the pill self-gates on
zero workouts. Empty state mounts the shared `MetricEmptyState`
primitive with a Coach prefill that steers the conversation toward
the Apple Health sync onboarding cue. Six locales updated.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 15:38:07 +02:00
1 parent 8d514ed127
commit beaee5791a
15 files changed
+887 -3

No files matched your search

+61
View File
@@ -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": {
+61
View File
@@ -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": {
+61
View File
@@ -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": {
+61
View File
@@ -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": {
+61
View File
@@ -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": {
+61
View File
@@ -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": {
+61
View File
@@ -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 (
<SubPageShell
title={t("insights.workouts.title")}
description={t("insights.workouts.description")}
>
{isLoading ? (
<div data-slot="workouts-loading" className="space-y-2">
<Skeleton className="h-14 w-full rounded-lg" />
<Skeleton className="h-14 w-full rounded-lg" />
<Skeleton className="h-14 w-full rounded-lg" />
</div>
) : isEmpty ? (
<MetricEmptyState
icon={<Activity className="size-6" />}
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 ? (
<>
<WorkoutList workouts={data.workouts} />
<CoachLaunchButton />
</>
) : null}
</SubPageShell>
);
}
@@ -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 — `<WorkoutList>` 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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
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("<WorkoutList>", () => {
it("renders each workout as a deep-link row", () => {
const html = render(<WorkoutList workouts={[ROW]} />);
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(<WorkoutList workouts={[ROW]} />);
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(<WorkoutList workouts={[noDistance]} />);
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(<WorkoutList workouts={[wild]} />);
// 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.");
});
});
@@ -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 (
<div className="space-y-8">
@@ -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[] {
+159
View File
@@ -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<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: 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 (
<ul
data-slot="workout-list"
className={cn("divide-y divide-border rounded-lg border", className)}
>
{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 (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="workout-list-row"
className={cn(
"flex items-center gap-3 px-3 py-3 text-sm transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground"
>
<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="text-muted-foreground truncate text-xs">
{formatDate(workout.startedAt, locale)}
</span>
</div>
<div className="flex flex-col items-end gap-0.5 text-xs">
<span className="font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
<span className="text-muted-foreground flex flex-wrap justify-end gap-x-2 tabular-nums">
{workout.distanceM != null ? (
<span data-slot="workout-list-distance">
{formatDistanceKm(workout.distanceM, locale)} km
</span>
) : null}
{workout.activeEnergyKcal != null ? (
<span data-slot="workout-list-energy">
{formatEnergy(workout.activeEnergyKcal, locale)} kcal
</span>
) : null}
</span>
</div>
</Link>
</li>
);
})}
</ul>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.4.32 — TanStack Query wrapper for `GET /api/workouts`.
*
* Consumers:
* - `/insights/workouts` — the workout list page reads the most
* recent N workouts and renders them as one-line rows.
* - `<RecentWorkoutsTile>` — the dashboard tile reads the top 3.
*
* Cache key shape:
* - `["workouts", "recent", { limit, since }]` — keyed on the
* pagination + filter inputs so the recent-tile and the list page
* keep separate cache slots without colliding.
* - `["workouts", id]` — single-workout detail (the workout-detail
* page consumes this separately via `useWorkoutDetail`).
*/
export interface WorkoutListEntry {
id: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
distanceM: number | null;
activeEnergyKcal: number | null;
avgHr: number | null;
maxHr: number | null;
source: string;
externalId: string | null;
}
export interface WorkoutListMeta {
total: number;
limit: number;
offset: number;
droppedDuplicates: number;
}
export interface WorkoutListPayload {
workouts: WorkoutListEntry[];
meta: WorkoutListMeta;
}
export interface UseWorkoutsOptions {
/** Page size — defaults to 50, capped at 200 by the server. */
limit?: number;
/** Page offset — defaults to 0. */
offset?: number;
/**
* ISO timestamp — only workouts with `startedAt >= since` are
* returned. The dashboard tile passes a 7-day window; the list page
* leaves it open so the user can scroll back through history.
*/
since?: string;
/** Optional sport-type filter. Pass through the canonical string. */
sportType?: string;
}
async function fetchWorkouts(
opts: UseWorkoutsOptions,
): Promise<WorkoutListPayload> {
const params = new URLSearchParams();
if (opts.limit != null) params.set("limit", String(opts.limit));
if (opts.offset != null) params.set("offset", String(opts.offset));
if (opts.since) params.set("since", opts.since);
if (opts.sportType) params.set("sportType", opts.sportType);
const query = params.toString();
const url = query ? `/api/workouts?${query}` : "/api/workouts";
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to load workouts (${res.status})`);
}
const json = (await res.json()) as { data: WorkoutListPayload };
return json.data;
}
export interface UseWorkoutsResult {
data: WorkoutListPayload | undefined;
isLoading: boolean;
isEmpty: boolean;
error: Error | null;
}
export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
const { isAuthenticated } = useAuth();
const query = useQuery({
queryKey: [
...queryKeys.workoutsRecent(),
{ limit: opts.limit, offset: opts.offset, since: opts.since, sportType: opts.sportType },
] as const,
queryFn: () => fetchWorkouts(opts),
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const isEmpty =
Boolean(isAuthenticated) &&
query.data !== undefined &&
query.data.workouts.length === 0;
return {
data: query.data,
isLoading: query.isLoading,
isEmpty,
error: query.error as Error | null,
};
}
export interface WorkoutDetailPayload extends WorkoutListEntry {
minHr: number | null;
stepCount: number | null;
elevationM: number | null;
pauseDurationSec: number | null;
metadata: unknown;
route:
| {
geometry: unknown;
sampleTimestamps: string[] | null;
}
| null;
canonicalId: string;
}
async function fetchWorkoutDetail(id: string): Promise<WorkoutDetailPayload> {
const res = await fetch(`/api/workouts/${encodeURIComponent(id)}`);
if (!res.ok) {
throw new Error(`Failed to load workout (${res.status})`);
}
const json = (await res.json()) as { data: WorkoutDetailPayload };
return json.data;
}
export interface UseWorkoutDetailResult {
data: WorkoutDetailPayload | undefined;
isLoading: boolean;
error: Error | null;
}
export function useWorkoutDetail(id: string): UseWorkoutDetailResult {
const { isAuthenticated } = useAuth();
const query = useQuery({
queryKey: queryKeys.workoutDetail(id),
queryFn: () => fetchWorkoutDetail(id),
enabled: isAuthenticated && id.length > 0,
staleTime: 60 * 1000,
});
return {
data: query.data,
isLoading: query.isLoading,
error: query.error as Error | null,
};
}
+18 -1
View File
@@ -51,7 +51,16 @@ export type InsightMetric =
| "SLEEP_DURATION"
| "VO2_MAX"
| "STEPS"
| "ACTIVE_ENERGY";
| "ACTIVE_ENERGY"
// v1.4.32 — wave-A HealthKit metrics promoted to first-class sub-pages.
| "HEART_RATE_VARIABILITY"
| "RESTING_HEART_RATE"
| "OXYGEN_SATURATION"
| "BODY_TEMPERATURE"
| "ACTIVE_ENERGY_BURNED"
// v1.4.32 — workouts surface; gate is event-driven (workouts table
// row count, not a Measurement series) and threaded via `hasWorkouts`.
| "WORKOUTS";
/**
* Inputs the gating helper consumes. The `summaries` shape mirrors
@@ -66,6 +75,13 @@ export interface InsightInputs {
hasMood: boolean;
/** Whether the user has at least one active medication. */
hasMedication: boolean;
/**
* v1.4.32 — whether the user has at least one workout row. Drives
* the workouts pill + the workout list page's empty-state gate.
* Optional so legacy mounts that pre-date v1.4.32 keep type-checking;
* the helper treats `undefined` as "not available".
*/
hasWorkouts?: boolean;
}
/**
@@ -80,6 +96,7 @@ export function hasMetricData(
): boolean {
if (metric === "MOOD") return inputs.hasMood;
if (metric === "MEDICATION") return inputs.hasMedication;
if (metric === "WORKOUTS") return inputs.hasWorkouts === true;
if (metric === "BMI") {
// BMI is derived from WEIGHT + the profile height. The chart
// mounts even at one weight reading; the gate matches.
+11
View File
@@ -34,6 +34,17 @@ export const SUB_PAGE_METRIC = {
// BMI is derived from WEIGHT + profile height (no separate series).
bmi: ["WEIGHT"],
schlaf: ["SLEEP_DURATION"],
// v1.4.32 — workouts surface; the page reads `Workout` rows directly
// through `useWorkouts()` rather than the `summaries` map, so the
// metric list stays empty.
workouts: [],
// v1.4.32 — five new HealthKit wave-A sub-pages. Each one reads its
// own MeasurementType series via the standard analytics path.
hrv: ["HEART_RATE_VARIABILITY"],
ruhepuls: ["RESTING_HEART_RATE"],
sauerstoff: ["OXYGEN_SATURATION"],
koerpertemperatur: ["BODY_TEMPERATURE"],
"aktive-energie": ["ACTIVE_ENERGY_BURNED"],
} as const satisfies Record<string, readonly string[]>;
export type SubPageSlug = keyof typeof SUB_PAGE_METRIC;
+8
View File
@@ -64,6 +64,14 @@ export const queryKeys = {
telegramSettings: () => ["telegram", "settings"] as const,
withings: () => ["withings"] as const,
// v1.4.32 — workout list + detail caches. `workouts()` is the
// root key invalidated by the batch-ingest mutation; the recent +
// detail sub-keys ride underneath so the dashboard tile and the
// detail page share a cache slot with the list page.
workouts: () => ["workouts"] as const,
workoutsRecent: () => ["workouts", "recent"] as const,
workoutDetail: (id: string) => ["workouts", id] as const,
adminSettings: () => ["admin", "settings"] as const,
adminStatus: () => ["admin", "status"] as const,
adminUsers: () => ["admin", "users"] as const,