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 ( +
  • + + +
    + {sportName} + + {formatDate(workout.startedAt, locale)} + +
    +
    + + {formatDuration(workout.durationSec)} + + + {workout.distanceM != null ? ( + + {formatDistanceKm(workout.distanceM, locale)} km + + ) : null} + {workout.activeEnergyKcal != null ? ( + + {formatEnergy(workout.activeEnergyKcal, locale)} kcal + + ) : null} + +
    + +
  • + ); + })} +
+ ); +} diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts new file mode 100644 index 000000000..08b1e705d --- /dev/null +++ b/src/hooks/use-workouts.ts @@ -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. + * - `` — 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 { + 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 { + 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, + }; +} diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts index b75a873ad..e0206d0af 100644 --- a/src/lib/insights/metric-availability.ts +++ b/src/lib/insights/metric-availability.ts @@ -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. diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts index 4472d7c60..5084aa2a5 100644 --- a/src/lib/insights/sub-page-metric.ts +++ b/src/lib/insights/sub-page-metric.ts @@ -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; export type SubPageSlug = keyof typeof SUB_PAGE_METRIC; diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 7980d0f41..fa5838635 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -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,