activity-stats: shared StatRow + canonical metrics across surfaces

Implements the activity-stats change (specs/activity-stats):

- gpx: `movingTime(tracks)` — moving seconds from trackpoint timestamps,
  excluding stationary spans + long gaps; null when no timestamps.
- stats.ts: pure formatters (distance/elevation/duration/speed/pace),
  sport-aware `deriveRate` (pace for foot sports, speed otherwise, speed
  default), and `activityStatItems` encoding the canonical order
  (distance · time · [moving] · pace/speed · ascent · descent; compact subset).
- StatRow: one shared presentational component (size sm/lg), adopted on the
  activity detail (full set; loader derives moving time), route detail
  (distance/ascent/descent), feed card + profile list (compact).
- i18n: journal.stats.* in en + de.

Tests: movingTime (stationary/gap exclusion, moving ≤ elapsed), deriveRate,
formatters, activityStatItems ordering + compact, StatRow render (jsdom).
typecheck + lint + unit (gpx 63, journal 311) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-12 14:48:36 +02:00
parent ede263d4d1
commit 0325d01bca
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
15 changed files with 482 additions and 67 deletions

View file

@ -0,0 +1,34 @@
import type { StatItem } from "~/lib/stats";
/**
* The one shared headline-stat presentation. Surfaces pass an ordered list of
* {value, label} items (built via `activityStatItems`); the component never
* fetches or formats. `size="lg"` is the detail-page treatment; the default
* compact size is for feed cards and the profile list.
*/
export function StatRow({
items,
size = "sm",
className,
}: {
items: StatItem[];
size?: "sm" | "lg";
className?: string;
}) {
if (items.length === 0) return null;
const valueCls =
size === "lg"
? "text-2xl font-bold text-gray-900"
: "text-sm font-semibold text-gray-900";
const gap = size === "lg" ? "gap-6" : "gap-x-4 gap-y-1";
return (
<dl className={`flex flex-wrap ${gap}${className ? ` ${className}` : ""}`}>
{items.map((it) => (
<div key={it.label}>
<dd className={valueCls}>{it.value}</dd>
<dt className="text-xs text-gray-500">{it.label}</dt>
</div>
))}
</dl>
);
}