Files
HealthLog/src/components/insights/workout-list.tsx
T
Marc-André Bombeck 28458448c2 Bring the day surfaces into one language
The cycle and travel rows of a day read their server sentinels
('cycle', 'travel', a raw flow level) instead of words, so a day said
'cycle · Tag 8'. They are worded in the reader's language now. The
date in the phone measurement row and the workout row wrapped to
'vor 9 Stunden…' because the text column shrank to its value; it takes
the row's width and wraps instead of truncating. The intraday pulse
card drops its coloured identity edge, takes a title that says what it
shows, and says there is not enough for a course instead of drawing one
dot across 24 hours. The day body's top inset moves from the banned
pt-5 to pt-4.
2026-10-08 19:14:22 +02:00

221 lines
8.2 KiB
TypeScript

"use client";
import { DayLinkAt } from "@/components/day/day-link";
import Link from "next/link";
import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context";
import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDistance } from "@/components/insights/workout-detail/format";
/**
* 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.
*/
function formatEnergy(kcal: number, locale: string): string {
return getNumberFormat(locale, {
maximumFractionDigits: 0,
}).format(Math.round(kcal));
}
function formatDate(
iso: string,
locale: string,
timeFormat: TimeFormatPreference,
): string {
const d = new Date(iso);
// Show the year whenever the workout is not from the current year, so a
// "Do., 16. Dez." from a previous year is never mistaken for this year's.
const showYear = d.getFullYear() !== new Date().getFullYear();
return getDateTimeFormat(locale, {
weekday: "short",
day: "2-digit",
month: "short",
...(showYear ? { year: "numeric" as const } : {}),
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
}).format(d);
}
export interface WorkoutListProps {
workouts: WorkoutListEntry[];
className?: string;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
onLoadMore?: () => void;
}
export function WorkoutList({
workouts,
className,
hasNextPage = false,
isFetchingNextPage = false,
isFetchNextPageError = false,
onLoadMore,
}: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const { preference } = useUnitDisplay();
return (
<ul
data-slot="workout-list"
className={cn("divide-border divide-y 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 (
// v1.42 — the row opens the workout and its date opens the day:
// two targets, so the row is a stretched link (the sport name's
// link covers the row) and the date sits above it.
<li key={workout.id} className="relative">
<div
data-slot="workout-list-row"
className={cn(
"flex items-center gap-3 px-3 py-3 text-sm transition-colors",
"hover:bg-accent has-[a[data-slot=workout-list-link]:focus-visible]:ring-ring/50 has-[a[data-slot=workout-list-link]:focus-visible]:ring-2",
)}
>
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full"
>
<Icon className="size-4" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1.5 truncate font-medium">
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="workout-list-link"
className="truncate after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
>
{sportName}
</Link>
{/* #67 — discreet glyphs flag which sessions open rich
(map / HR curve). Muted so they never compete with
the sport name. */}
{workout.hasRoute ? (
<MapIcon
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
data-slot="workout-list-glyph-route"
/>
) : null}
{workout.hasHrSeries ? (
<HeartPulse
className="text-muted-foreground size-3.5 shrink-0"
aria-hidden="true"
data-slot="workout-list-glyph-hr"
/>
) : null}
</span>
{/* Wraps rather than truncates, so the day it opens
stays readable beside the stats column. */}
<span className="text-muted-foreground text-xs">
<DayLinkAt at={workout.startedAt} className="z-10">
{formatDate(workout.startedAt, locale, timeFormat)}
</DayLinkAt>
</span>
</div>
<div className="flex flex-col items-end gap-0.5 text-xs">
<span className="font-medium tabular-nums">
{formatDurationMinutes(
Math.round(workout.durationSec / 60),
t,
)}
</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">
{formatDistance(workout.distanceM, locale, preference)}
</span>
) : null}
{workout.activeEnergyKcal != null ? (
<span data-slot="workout-list-energy">
{formatEnergy(workout.activeEnergyKcal, locale)} kcal
</span>
) : null}
{workout.avgHr != null ? (
<span data-slot="workout-list-avg-hr">
{workout.avgHr} bpm
</span>
) : null}
</span>
</div>
</div>
</li>
);
})}
{hasNextPage ? (
<li
data-slot="workout-list-load-more"
className="flex flex-col items-center gap-2 p-3"
>
{isFetchNextPageError ? (
<p
id="workout-list-load-more-error"
role="alert"
className="text-destructive text-center text-sm"
>
{t("insights.workouts.loadMoreError")}
</p>
) : null}
<Button
type="button"
variant="outline"
className="min-h-11 min-w-44"
disabled={isFetchingNextPage}
aria-busy={isFetchingNextPage || undefined}
aria-describedby={
isFetchNextPageError ? "workout-list-load-more-error" : undefined
}
onClick={onLoadMore}
>
{isFetchingNextPage ? (
<>
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
{t("insights.workouts.loadingMore")}
</>
) : isFetchNextPageError ? (
t("common.retry")
) : (
t("insights.workouts.loadMore")
)}
</Button>
</li>
) : null}
</ul>
);
}