Say how many workouts the list holds and let a sport narrow it

The list hook already returned the canonical total and the page threw it
away, so the surface never said how much history it was showing. Render it
as a meta line above the rows.

The route and the hook already accepted a sport, with nothing to reach it.
Add the chip row. Its vocabulary comes from the unfiltered read, which is
the same query the list itself runs when no sport is chosen, so the row
costs no extra request and choosing a sport can never empty the row it was
chosen from. A filtered view falls outside the server prefetch and pays one
client fetch; a filter that matches nothing says so instead of borrowing the
first-run empty state.
This commit is contained in:
Marc-André Bombeck committed 2026-07-30 23:23:37 +02:00
1 parent 52cccb3835
commit 0784cbcbb1
8 files changed
+172 -18

No files matched your search

+40
View File
@@ -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}`) {
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+90 -12
View File
@@ -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<string | undefined>(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() {
<Skeleton className="h-14 w-full rounded-lg" />
<Skeleton className="h-14 w-full rounded-lg" />
</div>
) : isEmpty ? (
) : isEmpty && sportType === undefined ? (
<MetricEmptyState
icon={<Activity className="size-6" />}
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 ? (
<WorkoutList
workouts={workouts}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetchNextPageError={isFetchNextPageError}
onLoadMore={fetchNextPage}
/>
) : null}
) : (
<>
{sportOptions.length > 1 ? (
<div
data-slot="workout-sport-filter"
role="group"
aria-label={t("insights.workouts.filterLabel")}
className="flex flex-wrap gap-2"
>
{[undefined, ...sportOptions].map((sport) => {
const active = sport === sportType;
return (
<button
key={sport ?? "all"}
type="button"
aria-pressed={active}
onClick={() => setSportType(sport)}
className={cn(
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{sport === undefined
? t("insights.workouts.filterAll")
: sportLabel(sport)}
</button>
);
})}
</div>
) : null}
{workouts.length > 0 ? (
<>
<p
data-slot="workout-list-count"
className="text-muted-foreground text-xs"
>
{tCount("insights.workouts.count", total, { count: total })}
</p>
<WorkoutList
workouts={workouts}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isFetchNextPageError={isFetchNextPageError}
onLoadMore={fetchNextPage}
/>
</>
) : (
<p
data-slot="workout-list-filter-empty"
className="text-muted-foreground text-sm"
>
{t("insights.workouts.filterEmpty")}
</p>
)}
</>
)}
</SubPageShell>
);
}