diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index bcce6030e..03b200203 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -111,6 +111,8 @@ const workoutDetail = { }, aiInsight: null, canonicalId: A11Y_WORKOUT_ID, + dayKey: "2026-07-20", + previousWorkoutId: null, }; const medication = { @@ -363,6 +365,44 @@ async function installA11yMocks(page: Page) { }), ); + // The workout detail page's day context reads the day's pulse shape and + // the night the person woke from into it. + await page.route("**/api/insights/pulse/intraday*", (route) => + fulfilJson(route, { + dateKey: "2026-07-20", + bucketMinutes: 10, + series: [ + { startMinute: 420, mean: 62, count: 6, min: 58, max: 70 }, + { startMinute: 430, mean: 118, count: 6, min: 96, max: 141 }, + ], + baseline: 58, + baselineSource: "resting", + tension: null, + resolution: "dense", + }), + ); + + await page.route("**/api/sleep/night*", (route) => + fulfilJson(route, { + night: "2026-07-20", + main: { + night: "2026-07-20", + source: "APPLE_HEALTH", + start: "2026-07-19T22:10:00.000Z", + end: "2026-07-20T05:45:00.000Z", + asleepMinutes: 415, + inBedMinutes: 455, + awakeMinutes: 40, + awakenings: 2, + reconstructed: false, + stages: {}, + segments: [], + sourceDiscrepancy: null, + }, + naps: [], + }), + ); + await page.route("**/api/workouts**", (route) => { const url = new URL(route.request().url()); if (url.pathname === `/api/workouts/${A11Y_WORKOUT_ID}`) { diff --git a/messages/de.json b/messages/de.json index 27f059216..f160729ef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3139,7 +3139,13 @@ "loadError": "Deine Workouts konnten nicht geladen werden", "loadMore": "Weitere Workouts laden", "loadingMore": "Weitere Workouts werden geladen", - "loadMoreError": "Weitere Workouts konnten nicht geladen werden" + "loadMoreError": "Weitere Workouts konnten nicht geladen werden", + "countOne": "{count} Workout", + "countOther": "{count} Workouts", + "countFew": "{count} Workouts", + "filterLabel": "Nach Sportart filtern", + "filterAll": "Alle Sportarten", + "filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf." }, "bloodGlucose": { "title": "Blutzucker", diff --git a/messages/en.json b/messages/en.json index 44fe331ae..8f7151461 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3139,7 +3139,13 @@ "loadError": "Your workouts could not be loaded", "loadMore": "Load more workouts", "loadingMore": "Loading more workouts", - "loadMoreError": "More workouts could not be loaded" + "loadMoreError": "More workouts could not be loaded", + "countOne": "{count} workout", + "countOther": "{count} workouts", + "countFew": "{count} workouts", + "filterLabel": "Filter by sport", + "filterAll": "All sports", + "filterEmpty": "No sessions of this sport in your history." }, "bloodGlucose": { "title": "Blood Glucose", diff --git a/messages/es.json b/messages/es.json index 44bc7720f..b1fcfe95a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3139,7 +3139,13 @@ "loadError": "No se pudieron cargar tus entrenamientos", "loadMore": "Cargar más entrenamientos", "loadingMore": "Cargando más entrenamientos", - "loadMoreError": "No se pudieron cargar más entrenamientos" + "loadMoreError": "No se pudieron cargar más entrenamientos", + "countOne": "{count} entrenamiento", + "countOther": "{count} entrenamientos", + "countFew": "{count} entrenamientos", + "filterLabel": "Filtrar por deporte", + "filterAll": "Todos los deportes", + "filterEmpty": "No hay sesiones de este deporte en tu historial." }, "bloodGlucose": { "title": "Glucosa en sangre", diff --git a/messages/fr.json b/messages/fr.json index 12f32134a..7a1347db0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3139,7 +3139,13 @@ "loadError": "Tes séances n'ont pas pu être chargées", "loadMore": "Charger plus de séances", "loadingMore": "Chargement de séances supplémentaires", - "loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées" + "loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées", + "countOne": "{count} séance", + "countOther": "{count} séances", + "countFew": "{count} séances", + "filterLabel": "Filtrer par sport", + "filterAll": "Tous les sports", + "filterEmpty": "Aucune séance de ce sport dans votre historique." }, "bloodGlucose": { "title": "Glycémie", diff --git a/messages/it.json b/messages/it.json index 58730aeee..8e10f6b2e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3139,7 +3139,13 @@ "loadError": "Non è stato possibile caricare i tuoi allenamenti", "loadMore": "Carica altri allenamenti", "loadingMore": "Caricamento di altri allenamenti", - "loadMoreError": "Non è stato possibile caricare altri allenamenti" + "loadMoreError": "Non è stato possibile caricare altri allenamenti", + "countOne": "{count} allenamento", + "countOther": "{count} allenamenti", + "countFew": "{count} allenamenti", + "filterLabel": "Filtra per sport", + "filterAll": "Tutti gli sport", + "filterEmpty": "Nessuna sessione di questo sport nella tua cronologia." }, "bloodGlucose": { "title": "Glicemia", diff --git a/messages/pl.json b/messages/pl.json index aa015a58b..80e659f65 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3139,7 +3139,13 @@ "loadError": "Nie udało się wczytać treningów", "loadMore": "Wczytaj więcej treningów", "loadingMore": "Wczytywanie kolejnych treningów", - "loadMoreError": "Nie udało się wczytać kolejnych treningów" + "loadMoreError": "Nie udało się wczytać kolejnych treningów", + "countOne": "{count} trening", + "countOther": "{count} treningów", + "countFew": "{count} treningi", + "filterLabel": "Filtruj według sportu", + "filterAll": "Wszystkie sporty", + "filterEmpty": "Brak treningów tego sportu w Twojej historii." }, "bloodGlucose": { "title": "Poziom glukozy", diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index 13e13e4d1..61d879e45 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -1,7 +1,9 @@ "use client"; +import { useMemo, useState } from "react"; import { Activity, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useInfiniteWorkouts } from "@/hooks/use-workouts"; import { useModulePageGuard } from "@/hooks/use-module-page-guard"; @@ -31,12 +33,20 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; * workout-entry form today), * - the deduped list otherwise. Each row links to * `/insights/workouts/[id]` for the detail surface. + * + * A sport filter narrows the list server-side through the `sportType` + * parameter the route and the hook already accept. The chips offer only + * sports the account actually has, captured from the unfiltered read, so + * choosing one never empties the row it was chosen from. The RSC prefetch + * seeds the unfiltered key only, so a filtered view costs one client fetch. */ export default function InsightsWorkoutsPageClient() { - const { t } = useTranslations(); + const { t, tCount } = useTranslations(); const { ready } = useModulePageGuard("workouts"); + const [sportType, setSportType] = useState(undefined); const { workouts, + total, isLoading, isEmpty, isError, @@ -45,7 +55,26 @@ export default function InsightsWorkoutsPageClient() { hasNextPage, fetchNextPage, refetch, - } = useInfiniteWorkouts({ limit: 100 }); + } = useInfiniteWorkouts({ limit: 100, sportType }); + + // The chip row's vocabulary comes from the UNFILTERED read: deriving it + // from the filtered list would collapse the row to the one sport already + // chosen, leaving no way back. With no filter active this resolves to the + // very same query key as the list above, so the two share one query and + // the row costs nothing; under a filter it keeps serving that cache. + const { workouts: allSports } = useInfiniteWorkouts({ limit: 100 }); + const sportOptions = useMemo( + () => Array.from(new Set(allSports.map((w) => w.sportType))).sort(), + [allSports], + ); + + const sportLabel = (sport: string) => { + // A sport enum the bundle has no copy for renders as its canonical + // string rather than a raw key — same fallback the list rows use. + const key = `insights.workouts.sport.${sport}`; + const label = t(key); + return label === key ? sport : label; + }; // v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account. if (!ready) { @@ -77,7 +106,7 @@ export default function InsightsWorkoutsPageClient() { - ) : isEmpty ? ( + ) : isEmpty && sportType === undefined ? ( } title={t("insights.workouts.emptyState.title")} @@ -85,15 +114,64 @@ export default function InsightsWorkoutsPageClient() { cta={null} coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?" /> - ) : workouts.length > 0 ? ( - - ) : null} + ) : ( + <> + {sportOptions.length > 1 ? ( +
+ {[undefined, ...sportOptions].map((sport) => { + const active = sport === sportType; + return ( + + ); + })} +
+ ) : null} + {workouts.length > 0 ? ( + <> +

+ {tCount("insights.workouts.count", total, { count: total })} +

+ + + ) : ( +

+ {t("insights.workouts.filterEmpty")} +

+ )} + + )} ); }