mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
8d514ed127
commit
beaee5791a
15 files changed
+887
-3
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user