Merge pull request #532 from trails-cool/apply-activity-stats
activity-stats: shared StatRow + canonical metrics
This commit is contained in:
commit
5e6fcde281
15 changed files with 482 additions and 67 deletions
29
apps/journal/app/components/StatRow.test.tsx
Normal file
29
apps/journal/app/components/StatRow.test.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { StatRow } from "./StatRow.tsx";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("StatRow", () => {
|
||||||
|
it("renders nothing for an empty item list", () => {
|
||||||
|
const { container } = render(<StatRow items={[]} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders each item's value and label, in order", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<StatRow
|
||||||
|
items={[
|
||||||
|
{ label: "Distance", value: "30.0 km" },
|
||||||
|
{ label: "Time", value: "1h 0m" },
|
||||||
|
{ label: "Avg speed", value: "30.0 km/h" },
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const labels = [...container.querySelectorAll("dt")].map((el) => el.textContent);
|
||||||
|
const values = [...container.querySelectorAll("dd")].map((el) => el.textContent);
|
||||||
|
expect(labels).toEqual(["Distance", "Time", "Avg speed"]);
|
||||||
|
expect(values).toEqual(["30.0 km", "1h 0m", "30.0 km/h"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/journal/app/components/StatRow.tsx
Normal file
34
apps/journal/app/components/StatRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
apps/journal/app/lib/stats.test.ts
Normal file
102
apps/journal/app/lib/stats.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
formatDistanceKm,
|
||||||
|
formatElevationM,
|
||||||
|
formatDuration,
|
||||||
|
formatSpeedKmh,
|
||||||
|
formatPace,
|
||||||
|
deriveRate,
|
||||||
|
activityStatItems,
|
||||||
|
} from "./stats.ts";
|
||||||
|
|
||||||
|
// Identity translate: labels come back as their keys so we can assert order.
|
||||||
|
const t = (k: string) => k;
|
||||||
|
|
||||||
|
describe("formatters", () => {
|
||||||
|
it("formats distance (1 decimal under 100 km, integer above)", () => {
|
||||||
|
expect(formatDistanceKm(12_340)).toBe("12.3 km");
|
||||||
|
expect(formatDistanceKm(123_400)).toBe("123 km");
|
||||||
|
});
|
||||||
|
it("formats elevation as integer metres", () => {
|
||||||
|
expect(formatElevationM(512.6)).toBe("513 m");
|
||||||
|
});
|
||||||
|
it("formats duration h/m", () => {
|
||||||
|
expect(formatDuration(9000)).toBe("2h 30m");
|
||||||
|
expect(formatDuration(2820)).toBe("47m");
|
||||||
|
});
|
||||||
|
it("formats speed and pace", () => {
|
||||||
|
expect(formatSpeedKmh(30.86)).toBe("30.9 km/h");
|
||||||
|
expect(formatPace(318)).toBe("5:18 /km");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveRate", () => {
|
||||||
|
it("returns pace for foot sports", () => {
|
||||||
|
expect(deriveRate(10_000, 3000, "run")).toEqual({ kind: "pace", value: 300 });
|
||||||
|
expect(deriveRate(10_000, 3000, "hike")?.kind).toBe("pace");
|
||||||
|
});
|
||||||
|
it("returns speed for wheeled/ski sports", () => {
|
||||||
|
expect(deriveRate(30_000, 3600, "ride")).toEqual({ kind: "speed", value: 30 });
|
||||||
|
expect(deriveRate(1000, 3600, "mtb")?.kind).toBe("speed");
|
||||||
|
});
|
||||||
|
it("defaults to speed when sport is unset", () => {
|
||||||
|
expect(deriveRate(30_000, 3600, null)?.kind).toBe("speed");
|
||||||
|
});
|
||||||
|
it("returns null without usable distance/time", () => {
|
||||||
|
expect(deriveRate(0, 3600, "run")).toBeNull();
|
||||||
|
expect(deriveRate(1000, 0, "run")).toBeNull();
|
||||||
|
expect(deriveRate(null, null, "run")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("activityStatItems", () => {
|
||||||
|
it("builds the full canonical order", () => {
|
||||||
|
const items = activityStatItems(
|
||||||
|
{
|
||||||
|
distance: 30_000,
|
||||||
|
durationSec: 3600,
|
||||||
|
movingTimeSec: 3300,
|
||||||
|
elevationGain: 500,
|
||||||
|
elevationLoss: 480,
|
||||||
|
sportType: "ride",
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
expect(items.map((i) => i.label)).toEqual([
|
||||||
|
"stats.distance",
|
||||||
|
"stats.duration",
|
||||||
|
"stats.movingTime",
|
||||||
|
"stats.avgSpeed",
|
||||||
|
"stats.ascent",
|
||||||
|
"stats.descent",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compact drops moving time and ascent/descent, keeps order", () => {
|
||||||
|
const items = activityStatItems(
|
||||||
|
{ distance: 10_000, durationSec: 3000, movingTimeSec: 2800, elevationGain: 100, sportType: "run" },
|
||||||
|
t,
|
||||||
|
{ compact: true },
|
||||||
|
);
|
||||||
|
expect(items.map((i) => i.label)).toEqual([
|
||||||
|
"stats.distance",
|
||||||
|
"stats.duration",
|
||||||
|
"stats.avgPace",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits items with no value", () => {
|
||||||
|
const items = activityStatItems({ distance: 5000 }, t);
|
||||||
|
expect(items.map((i) => i.label)).toEqual(["stats.distance"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rate prefers moving time over elapsed", () => {
|
||||||
|
const items = activityStatItems(
|
||||||
|
{ distance: 10_000, durationSec: 4000, movingTimeSec: 3000, sportType: "run" },
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
const pace = items.find((i) => i.label === "stats.avgPace");
|
||||||
|
// 10 km in 3000s moving → 5:00 /km (not 6:40 from elapsed)
|
||||||
|
expect(pace?.value).toBe("5:00 /km");
|
||||||
|
});
|
||||||
|
});
|
||||||
112
apps/journal/app/lib/stats.ts
Normal file
112
apps/journal/app/lib/stats.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import type { SportType } from "@trails-cool/db/schema/journal";
|
||||||
|
|
||||||
|
// Minimal translate signature (the `t` returned by react-i18next satisfies it)
|
||||||
|
// so this stays a plain util with no react-i18next coupling.
|
||||||
|
type Translate = (key: string) => string;
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pure formatters (metric baseline; an imperial toggle is future work) ---
|
||||||
|
|
||||||
|
export function formatDistanceKm(meters: number): string {
|
||||||
|
const km = meters / 1000;
|
||||||
|
return km >= 100 ? `${Math.round(km)} km` : `${km.toFixed(1)} km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatElevationM(meters: number): string {
|
||||||
|
return `${Math.round(meters)} m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.round((seconds % 3600) / 60);
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSpeedKmh(kmh: number): string {
|
||||||
|
return `${kmh.toFixed(1)} km/h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPace(secondsPerKm: number): string {
|
||||||
|
const m = Math.floor(secondsPerKm / 60);
|
||||||
|
const s = Math.round(secondsPerKm % 60);
|
||||||
|
return `${m}:${String(s).padStart(2, "0")} /km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- derived rate (pace vs speed, sport-aware) ---
|
||||||
|
|
||||||
|
const PACE_SPORTS = new Set<SportType>(["hike", "walk", "run"]);
|
||||||
|
|
||||||
|
export function deriveRate(
|
||||||
|
distanceM: number | null | undefined,
|
||||||
|
timeSec: number | null | undefined,
|
||||||
|
sportType: SportType | null | undefined,
|
||||||
|
): { kind: "pace" | "speed"; value: number } | null {
|
||||||
|
if (!distanceM || !timeSec || distanceM <= 0 || timeSec <= 0) return null;
|
||||||
|
const km = distanceM / 1000;
|
||||||
|
if (sportType && PACE_SPORTS.has(sportType)) {
|
||||||
|
return { kind: "pace", value: timeSec / km };
|
||||||
|
}
|
||||||
|
return { kind: "speed", value: km / (timeSec / 3600) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityStatsInput {
|
||||||
|
distance?: number | null;
|
||||||
|
durationSec?: number | null; // elapsed
|
||||||
|
movingTimeSec?: number | null;
|
||||||
|
elevationGain?: number | null;
|
||||||
|
elevationLoss?: number | null;
|
||||||
|
sportType?: SportType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the canonical ordered stat items for an activity or route. Order is
|
||||||
|
* fixed (distance · time · avg pace/speed · ascent · descent); moving time, when
|
||||||
|
* present, is shown right after elapsed time. `compact` drops moving time and
|
||||||
|
* ascent/descent for tight surfaces (feed card, profile list) — the remaining
|
||||||
|
* items keep the canonical order. Items with no value are omitted.
|
||||||
|
*/
|
||||||
|
export function activityStatItems(
|
||||||
|
input: ActivityStatsInput,
|
||||||
|
t: Translate,
|
||||||
|
opts: { compact?: boolean } = {},
|
||||||
|
): StatItem[] {
|
||||||
|
const { compact = false } = opts;
|
||||||
|
const items: StatItem[] = [];
|
||||||
|
|
||||||
|
if (input.distance != null) {
|
||||||
|
items.push({ label: t("stats.distance"), value: formatDistanceKm(input.distance) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = input.durationSec;
|
||||||
|
if (elapsed != null) {
|
||||||
|
items.push({ label: t("stats.duration"), value: formatDuration(elapsed) });
|
||||||
|
}
|
||||||
|
if (!compact && input.movingTimeSec != null) {
|
||||||
|
items.push({ label: t("stats.movingTime"), value: formatDuration(input.movingTimeSec) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate prefers moving time when available, else elapsed.
|
||||||
|
const rate = deriveRate(input.distance, input.movingTimeSec ?? elapsed, input.sportType);
|
||||||
|
if (rate) {
|
||||||
|
items.push(
|
||||||
|
rate.kind === "pace"
|
||||||
|
? { label: t("stats.avgPace"), value: formatPace(rate.value) }
|
||||||
|
: { label: t("stats.avgSpeed"), value: formatSpeedKmh(rate.value) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!compact) {
|
||||||
|
if (input.elevationGain != null) {
|
||||||
|
items.push({ label: t("stats.ascent"), value: `↑ ${formatElevationM(input.elevationGain)}` });
|
||||||
|
}
|
||||||
|
if (input.elevationLoss != null) {
|
||||||
|
items.push({ label: t("stats.descent"), value: `↓ ${formatElevationM(input.elevationLoss)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,8 @@ import {
|
||||||
import { deleteImportByActivity } from "~/lib/sync/imports.server";
|
import { deleteImportByActivity } from "~/lib/sync/imports.server";
|
||||||
import { listRoutes } from "~/lib/routes.server";
|
import { listRoutes } from "~/lib/routes.server";
|
||||||
import { requireOwnedActivity, requireOwnedRoute } from "~/lib/ownership.server";
|
import { requireOwnedActivity, requireOwnedRoute } from "~/lib/ownership.server";
|
||||||
|
import { parseGpxAsync, movingTime } from "@trails-cool/gpx";
|
||||||
|
import { logger } from "~/lib/logger.server";
|
||||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||||
|
|
||||||
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
|
const VISIBILITY_VALUES = new Set<Visibility>(["private", "unlisted", "public"]);
|
||||||
|
|
@ -36,6 +38,18 @@ export async function loadActivityDetail(request: Request, id: string | undefine
|
||||||
|
|
||||||
const userRoutes = isOwner && user ? await listRoutes(user.id) : [];
|
const userRoutes = isOwner && user ? await listRoutes(user.id) : [];
|
||||||
|
|
||||||
|
// Moving time is derived from trackpoint timestamps (null when the GPX has
|
||||||
|
// none). Detail-page only — too costly to parse per row in list views.
|
||||||
|
let movingTimeSec: number | null = null;
|
||||||
|
if (activity.gpx) {
|
||||||
|
try {
|
||||||
|
const parsed = await parseGpxAsync(activity.gpx);
|
||||||
|
movingTimeSec = movingTime(parsed.tracks);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ activityId: activity.id, err }, "moving-time: failed to parse gpx");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activity: {
|
activity: {
|
||||||
id: activity.id,
|
id: activity.id,
|
||||||
|
|
@ -46,6 +60,7 @@ export async function loadActivityDetail(request: Request, id: string | undefine
|
||||||
elevationGain: activity.elevationGain,
|
elevationGain: activity.elevationGain,
|
||||||
elevationLoss: activity.elevationLoss,
|
elevationLoss: activity.elevationLoss,
|
||||||
duration: activity.duration,
|
duration: activity.duration,
|
||||||
|
movingTimeSec,
|
||||||
routeId: activity.routeId,
|
routeId: activity.routeId,
|
||||||
hasGpx: !!activity.gpx,
|
hasGpx: !!activity.gpx,
|
||||||
geojson: activity.geojson ?? null,
|
geojson: activity.geojson ?? null,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import type { Route } from "./+types/activities.$id";
|
||||||
import { ClientDate } from "~/components/ClientDate";
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
import { SportBadge } from "~/components/SportBadge";
|
import { SportBadge } from "~/components/SportBadge";
|
||||||
|
import { StatRow } from "~/components/StatRow";
|
||||||
|
import { activityStatItems } from "~/lib/stats";
|
||||||
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
|
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
|
||||||
import {
|
import {
|
||||||
federationEnabled,
|
federationEnabled,
|
||||||
|
|
@ -79,28 +81,21 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
|
||||||
<ClientDate iso={activity.startedAt ?? activity.createdAt} />
|
<ClientDate iso={activity.startedAt ?? activity.createdAt} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-3 gap-4">
|
<StatRow
|
||||||
{activity.distance != null && (
|
size="lg"
|
||||||
<div className="rounded-md bg-gray-50 p-4">
|
className="mt-6"
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
items={activityStatItems(
|
||||||
{(activity.distance / 1000).toFixed(1)} km
|
{
|
||||||
</p>
|
distance: activity.distance,
|
||||||
<p className="text-sm text-gray-500">Distance</p>
|
durationSec: activity.duration,
|
||||||
</div>
|
movingTimeSec: activity.movingTimeSec,
|
||||||
|
elevationGain: activity.elevationGain,
|
||||||
|
elevationLoss: activity.elevationLoss,
|
||||||
|
sportType: activity.sportType,
|
||||||
|
},
|
||||||
|
t,
|
||||||
)}
|
)}
|
||||||
{activity.elevationGain != null && (
|
/>
|
||||||
<div className="rounded-md bg-gray-50 p-4">
|
|
||||||
<p className="text-2xl font-bold text-gray-900">↑ {activity.elevationGain} m</p>
|
|
||||||
<p className="text-sm text-gray-500">Ascent</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activity.elevationLoss != null && (
|
|
||||||
<div className="rounded-md bg-gray-50 p-4">
|
|
||||||
<p className="text-2xl font-bold text-gray-900">↓ {activity.elevationLoss} m</p>
|
|
||||||
<p className="text-sm text-gray-500">Descent</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activity.geojson && (
|
{activity.geojson && (
|
||||||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import type { Route } from "./+types/feed";
|
||||||
import { ClientDate } from "~/components/ClientDate";
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
import { SportBadge } from "~/components/SportBadge";
|
import { SportBadge } from "~/components/SportBadge";
|
||||||
|
import { StatRow } from "~/components/StatRow";
|
||||||
|
import { activityStatItems } from "~/lib/stats";
|
||||||
import { loadFeed } from "./feed.server";
|
import { loadFeed } from "./feed.server";
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
|
|
@ -142,14 +144,18 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
|
||||||
{" · "}
|
{" · "}
|
||||||
<ClientDate iso={a.startedAt ?? a.createdAt} />
|
<ClientDate iso={a.startedAt ?? a.createdAt} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex gap-4 text-sm text-gray-500">
|
<StatRow
|
||||||
{a.distance != null && (
|
className="mt-1"
|
||||||
<span>{(a.distance / 1000).toFixed(1)} km</span>
|
items={activityStatItems(
|
||||||
|
{
|
||||||
|
distance: a.distance,
|
||||||
|
durationSec: a.duration,
|
||||||
|
sportType: a.sportType,
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
{ compact: true },
|
||||||
)}
|
)}
|
||||||
{a.elevationGain != null && (
|
/>
|
||||||
<span>↑ {Math.round(a.elevationGain)} m</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||||
import type { Route } from "./+types/routes.$id";
|
import type { Route } from "./+types/routes.$id";
|
||||||
import { ClientDate } from "~/components/ClientDate";
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
import { ClientMap } from "~/components/ClientMap";
|
import { ClientMap } from "~/components/ClientMap";
|
||||||
|
import { StatRow } from "~/components/StatRow";
|
||||||
|
import { activityStatItems } from "~/lib/stats";
|
||||||
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
|
import { loadRouteDetail, routeDetailAction } from "./routes.$id.server";
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
|
@ -174,28 +176,18 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<StatRow
|
||||||
{route.distance != null && (
|
size="lg"
|
||||||
<div className="rounded-md bg-gray-50 p-4">
|
className="mt-6"
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
items={activityStatItems(
|
||||||
{(route.distance / 1000).toFixed(1)} km
|
{
|
||||||
</p>
|
distance: route.distance,
|
||||||
<p className="text-sm text-gray-500">{t("routes.distance")}</p>
|
elevationGain: route.elevationGain,
|
||||||
</div>
|
elevationLoss: route.elevationLoss,
|
||||||
|
},
|
||||||
|
t,
|
||||||
)}
|
)}
|
||||||
{route.elevationGain != null && (
|
/>
|
||||||
<div className="rounded-md bg-gray-50 p-4">
|
|
||||||
<p className="text-2xl font-bold text-gray-900">↑ {route.elevationGain} m</p>
|
|
||||||
<p className="text-sm text-gray-500">Ascent</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{route.elevationLoss != null && (
|
|
||||||
<div className="rounded-md bg-gray-50 p-4">
|
|
||||||
<p className="text-2xl font-bold text-gray-900">↓ {route.elevationLoss} m</p>
|
|
||||||
<p className="text-sm text-gray-500">Descent</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dayStats.length > 1 && (
|
{dayStats.length > 1 && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||||
import { ClientDate } from "~/components/ClientDate";
|
import { ClientDate } from "~/components/ClientDate";
|
||||||
import { FollowButton } from "~/components/FollowButton";
|
import { FollowButton } from "~/components/FollowButton";
|
||||||
import { SportBadge } from "~/components/SportBadge";
|
import { SportBadge } from "~/components/SportBadge";
|
||||||
|
import { StatRow } from "~/components/StatRow";
|
||||||
|
import { activityStatItems } from "~/lib/stats";
|
||||||
import { loadUserProfile } from "./users.$username.server";
|
import { loadUserProfile } from "./users.$username.server";
|
||||||
import {
|
import {
|
||||||
federationEnabled,
|
federationEnabled,
|
||||||
|
|
@ -229,11 +231,18 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
|
||||||
<span className="font-medium text-gray-900">{a.name}</span>
|
<span className="font-medium text-gray-900">{a.name}</span>
|
||||||
<SportBadge sportType={a.sportType} />
|
<SportBadge sportType={a.sportType} />
|
||||||
</span>
|
</span>
|
||||||
{a.distance != null && (
|
<StatRow
|
||||||
<span className="shrink-0 text-sm tabular-nums text-gray-600">
|
className="shrink-0 justify-end"
|
||||||
{(a.distance / 1000).toFixed(1)} km
|
items={activityStatItems(
|
||||||
</span>
|
{
|
||||||
|
distance: a.distance,
|
||||||
|
durationSec: a.duration,
|
||||||
|
sportType: a.sportType,
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
{ compact: true },
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{a.description && (
|
{a.description && (
|
||||||
<p className="mt-1 line-clamp-1 text-sm text-gray-500">{a.description}</p>
|
<p className="mt-1 line-clamp-1 text-sm text-gray-500">{a.description}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
## 1. Helpers
|
## 1. Helpers
|
||||||
|
|
||||||
- [ ] 1.1 Add a pure `movingTime(gpx)` helper to `@trails-cool/gpx` (sum inter-point intervals, exclude sub-threshold/stationary segments, max-gap guard); unit-test against fixtures with and without trackpoint times.
|
- [x] 1.1 Add a pure `movingTime(tracks)` helper to `@trails-cool/gpx` (sum inter-point intervals, exclude sub-threshold/stationary segments, max-gap guard); unit-tested with and without trackpoint times (`moving-time.ts` + test).
|
||||||
- [ ] 1.2 Add a `deriveRate({ distance, time, sportType })` helper returning `{ kind: "pace" | "speed", value }` with the sport→kind rule and speed default.
|
- [x] 1.2 Add a `deriveRate(distance, time, sportType)` helper returning `{ kind: "pace" | "speed", value }` with the sport→kind rule and speed default (`stats.ts`).
|
||||||
- [ ] 1.3 Add a `formatStat` helper (distance/elevation/time/speed/pace) routing labels + units through i18n.
|
- [x] 1.3 Add pure `format*` helpers (distance/elevation/duration/speed/pace) in `stats.ts`; labels route through i18n at the call site (`activityStatItems`).
|
||||||
|
|
||||||
## 2. Component
|
## 2. Component
|
||||||
|
|
||||||
- [ ] 2.1 Build the shared `StatRow` (ordered `{ value, label, unit }` items; value-prominent layout; no data fetching).
|
- [x] 2.1 Build the shared `StatRow` (ordered `{ value, label }` items; size sm/lg; no data fetching).
|
||||||
- [ ] 2.2 Define the canonical headline order as a single exported constant the surfaces draw from.
|
- [x] 2.2 Canonical order encoded once in `activityStatItems` (distance · time · [moving] · pace/speed · ascent · descent); surfaces draw from it.
|
||||||
|
|
||||||
## 3. Adopt across surfaces
|
## 3. Adopt across surfaces
|
||||||
|
|
||||||
- [ ] 3.1 Activity detail: replace the hand-rolled stats block with `StatRow` (full set incl. pace/speed + moving/elapsed).
|
- [x] 3.1 Activity detail: replaced the card grid with `StatRow size="lg"` (full set incl. pace/speed + moving/elapsed); loader computes moving time from the GPX.
|
||||||
- [ ] 3.2 Route detail: adopt `StatRow` (distance, ascent, descent; no time/pace).
|
- [x] 3.2 Route detail: adopt `StatRow size="lg"` (distance, ascent, descent; no time/pace).
|
||||||
- [ ] 3.3 Feed card: adopt `StatRow` compact subset (distance · time · pace/speed); expose the needed fields in the feed loader.
|
- [x] 3.3 Feed card: compact `StatRow` (distance · time · pace/speed); feed loader already exposes duration + sportType.
|
||||||
- [ ] 3.4 Profile activity list: adopt `StatRow` compact subset.
|
- [x] 3.4 Profile activity list: compact `StatRow`.
|
||||||
|
|
||||||
## 4. i18n
|
## 4. i18n
|
||||||
|
|
||||||
- [ ] 4.1 Add `journal.stats.*` keys (labels + unit suffixes for distance, elevation, time, speed, pace) to en + de.
|
- [x] 4.1 Add `journal.stats.*` keys (distance, duration, movingTime, avgSpeed, avgPace, ascent, descent) to en + de.
|
||||||
|
|
||||||
## 5. Tests & checks
|
## 5. Tests & checks
|
||||||
|
|
||||||
- [ ] 5.1 Unit: `movingTime` (with/without timestamps; moving ≤ elapsed), `deriveRate` (pace/speed/default), `formatStat`.
|
- [x] 5.1 Unit: `movingTime` (with/without timestamps; moving ≤ elapsed; stationary/gap exclusion), `deriveRate` (pace/speed/default/null), formatters.
|
||||||
- [ ] 5.2 Component: `StatRow` renders ordered items; subset preserves order.
|
- [x] 5.2 Component: `StatRow` renders ordered items (jsdom); `activityStatItems` ordering + compact-subset order asserted.
|
||||||
- [ ] 5.3 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
|
- [x] 5.3 typecheck + lint + unit (gpx 63, journal 311) green. Full `pnpm test:e2e` runs in CI.
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,5 @@ export { generateGpx } from "./generate.ts";
|
||||||
export { extractWaypoints } from "./waypoints.ts";
|
export { extractWaypoints } from "./waypoints.ts";
|
||||||
export { computeDays } from "./compute-days.ts";
|
export { computeDays } from "./compute-days.ts";
|
||||||
export type { DayStage } from "./compute-days.ts";
|
export type { DayStage } from "./compute-days.ts";
|
||||||
|
export { movingTime } from "./moving-time.ts";
|
||||||
export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
export type { GpxData, TrackPoint, ElevationProfile, NoGoArea } from "./types.ts";
|
||||||
|
|
|
||||||
56
packages/gpx/src/moving-time.test.ts
Normal file
56
packages/gpx/src/moving-time.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { movingTime } from "./moving-time.ts";
|
||||||
|
import type { TrackPoint } from "./types.ts";
|
||||||
|
|
||||||
|
// ~0.0001° latitude ≈ 11.1 m. At 11 m per interval, speed depends on dt.
|
||||||
|
function pt(lat: number, lon: number, time?: string): TrackPoint {
|
||||||
|
return { lat, lon, time };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("movingTime", () => {
|
||||||
|
it("returns null when no trackpoints carry timestamps", () => {
|
||||||
|
const tracks = [[pt(0, 0), pt(0, 0.0001), pt(0, 0.0002)]];
|
||||||
|
expect(movingTime(tracks)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums intervals where the athlete is moving", () => {
|
||||||
|
// Three points 10s apart, each ~11 m → ~1.1 m/s (moving). 2 intervals.
|
||||||
|
const tracks = [[
|
||||||
|
pt(0, 0, "2026-06-01T08:00:00Z"),
|
||||||
|
pt(0, 0.0001, "2026-06-01T08:00:10Z"),
|
||||||
|
pt(0, 0.0002, "2026-06-01T08:00:20Z"),
|
||||||
|
]];
|
||||||
|
expect(movingTime(tracks)).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes stationary spans (below the speed threshold)", () => {
|
||||||
|
// Middle interval: no movement over 10s → stopped, excluded.
|
||||||
|
const tracks = [[
|
||||||
|
pt(0, 0, "2026-06-01T08:00:00Z"),
|
||||||
|
pt(0, 0.0001, "2026-06-01T08:00:10Z"), // moving (~11 m / 10s)
|
||||||
|
pt(0, 0.0001, "2026-06-01T08:00:20Z"), // stationary (0 m)
|
||||||
|
pt(0, 0.0002, "2026-06-01T08:00:30Z"), // moving again
|
||||||
|
]];
|
||||||
|
expect(movingTime(tracks)).toBe(20); // 2 moving intervals of 10s
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes long gaps (pauses / signal loss)", () => {
|
||||||
|
const tracks = [[
|
||||||
|
pt(0, 0, "2026-06-01T08:00:00Z"),
|
||||||
|
pt(0, 0.0001, "2026-06-01T08:00:10Z"), // 10s moving
|
||||||
|
pt(0, 0.0002, "2026-06-01T09:00:00Z"), // ~1h gap → excluded
|
||||||
|
]];
|
||||||
|
expect(movingTime(tracks)).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never exceeds elapsed time", () => {
|
||||||
|
const tracks = [[
|
||||||
|
pt(0, 0, "2026-06-01T08:00:00Z"),
|
||||||
|
pt(0, 0.0001, "2026-06-01T08:00:10Z"),
|
||||||
|
pt(0, 0.0001, "2026-06-01T08:00:40Z"), // 30s stationary
|
||||||
|
]];
|
||||||
|
const elapsed = 40;
|
||||||
|
const moving = movingTime(tracks)!;
|
||||||
|
expect(moving).toBeLessThanOrEqual(elapsed);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
packages/gpx/src/moving-time.ts
Normal file
46
packages/gpx/src/moving-time.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { TrackPoint } from "./types.ts";
|
||||||
|
|
||||||
|
// Below this speed we treat the athlete as stopped (traffic lights, photo
|
||||||
|
// stops, regroups). 0.5 m/s ≈ 1.8 km/h — slower than a slow walk.
|
||||||
|
const STATIONARY_SPEED_MS = 0.5;
|
||||||
|
// Intervals longer than this are pauses or GPS gaps, not continuous motion;
|
||||||
|
// they're excluded entirely so a lunch break doesn't inflate moving time.
|
||||||
|
const MAX_GAP_S = 60;
|
||||||
|
|
||||||
|
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||||
|
const R = 6371000;
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving time in seconds, derived from trackpoint timestamps: the sum of
|
||||||
|
* inter-point intervals during which the athlete was actually moving
|
||||||
|
* (excluding stationary spans and long gaps). Returns `null` when the track
|
||||||
|
* has no usable timestamps (planned routes, or recordings without time), so
|
||||||
|
* callers can fall back to elapsed time. Moving time is always ≤ elapsed.
|
||||||
|
*/
|
||||||
|
export function movingTime(tracks: TrackPoint[][]): number | null {
|
||||||
|
let movingSeconds = 0;
|
||||||
|
let timedIntervals = 0;
|
||||||
|
|
||||||
|
for (const seg of tracks) {
|
||||||
|
for (let i = 1; i < seg.length; i++) {
|
||||||
|
const prev = seg[i - 1];
|
||||||
|
const cur = seg[i];
|
||||||
|
if (!prev || !cur || !prev.time || !cur.time) continue;
|
||||||
|
const dt = (Date.parse(cur.time) - Date.parse(prev.time)) / 1000;
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0 || dt > MAX_GAP_S) continue;
|
||||||
|
timedIntervals++;
|
||||||
|
const speed = haversineMeters(prev.lat, prev.lon, cur.lat, cur.lon) / dt;
|
||||||
|
if (speed >= STATIONARY_SPEED_MS) movingSeconds += dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return timedIntervals > 0 ? Math.round(movingSeconds) : null;
|
||||||
|
}
|
||||||
|
|
@ -396,6 +396,15 @@ export default {
|
||||||
public: "Öffentlich (für alle sichtbar)",
|
public: "Öffentlich (für alle sichtbar)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
stats: {
|
||||||
|
distance: "Distanz",
|
||||||
|
duration: "Zeit",
|
||||||
|
movingTime: "Bewegungszeit",
|
||||||
|
avgSpeed: "⌀ Geschwindigkeit",
|
||||||
|
avgPace: "⌀ Pace",
|
||||||
|
ascent: "Anstieg",
|
||||||
|
descent: "Abstieg",
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Einstellungen",
|
title: "Einstellungen",
|
||||||
nav: {
|
nav: {
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,15 @@ export default {
|
||||||
public: "Public (anyone on the internet)",
|
public: "Public (anyone on the internet)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
stats: {
|
||||||
|
distance: "Distance",
|
||||||
|
duration: "Time",
|
||||||
|
movingTime: "Moving time",
|
||||||
|
avgSpeed: "Avg speed",
|
||||||
|
avgPace: "Avg pace",
|
||||||
|
ascent: "Ascent",
|
||||||
|
descent: "Descent",
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
nav: {
|
nav: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue