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

+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>
);
}