mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
52cccb3835
commit
0784cbcbb1
8 files changed
+172
-18
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user