mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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.
221 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|