profile-stats: lifetime roll-up header on the profile

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

- getActivityStats(ownerId, { publicOnly }) in activities.server.ts: one indexed
  aggregate over stored columns (count, sum distance/ascent/elapsed duration) +
  a rolling last-4-weeks count. No cache table, no schema change, no GPX parsing
  (design §D1).
- Profile loader computes it scoped to the viewer (public-only for visitors,
  full totals for the owner); ProfileStats header renders count · distance ·
  ascent · time + "N in the last 4 weeks" via the shared StatRow + stats.ts
  formatters; hidden when there are no visible activities.
- i18n journal.profileStats.* in en + de.

Tests: ProfileStats component (jsdom: totals, empty, last-4-weeks toggle);
e2e asserts the owner roll-up counts their activities. typecheck + lint + unit
(journal 318) green; verified in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-13 07:25:06 +02:00
parent 9845718dee
commit 3d3c56aaf4
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
10 changed files with 194 additions and 10 deletions

View file

@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { ProfileStats } from "./ProfileStats.tsx";
afterEach(cleanup);
describe("ProfileStats", () => {
it("renders nothing when there are no activities", () => {
const { container } = render(
<ProfileStats stats={{ count: 0, distance: 0, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.firstChild).toBeNull();
});
it("renders formatted totals", () => {
const { container, getByText } = render(
<ProfileStats
stats={{ count: 42, distance: 123_400, elevationGain: 5120, duration: 9000, last4Weeks: 3 }}
/>,
);
// Values come from the formatters (i18n-independent).
expect(getByText("42")).toBeTruthy();
expect(getByText("123 km")).toBeTruthy(); // >= 100 km → integer
expect(getByText("↑ 5120 m")).toBeTruthy();
expect(getByText("2h 30m")).toBeTruthy();
// last-4-weeks line present when > 0
expect(container.querySelector("p")).not.toBeNull();
});
it("omits the last-4-weeks line when zero", () => {
const { container } = render(
<ProfileStats stats={{ count: 5, distance: 1000, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
);
expect(container.querySelector("p")).toBeNull();
});
});

View file

@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import { StatRow } from "./StatRow.tsx";
import { formatDistanceKm, formatElevationM, formatDuration } from "~/lib/stats";
// Structural shape (mirrors ActivityStats from activities.server) so this
// presentational component doesn't import from a `.server` module.
export interface ProfileStatsData {
count: number;
distance: number;
elevationGain: number;
duration: number;
last4Weeks: number;
}
/**
* Lifetime roll-up header on the profile: activity count · distance · ascent ·
* elapsed time, plus a "N in the last 4 weeks" line. Renders nothing when the
* (viewer-scoped) count is zero.
*/
export function ProfileStats({ stats, className }: { stats: ProfileStatsData; className?: string }) {
const { t } = useTranslation("journal");
if (stats.count === 0) return null;
return (
<div className={className}>
<StatRow
size="lg"
items={[
{ label: t("profileStats.activities"), value: String(stats.count) },
{ label: t("profileStats.distance"), value: formatDistanceKm(stats.distance) },
{ label: t("profileStats.ascent"), value: `${formatElevationM(stats.elevationGain)}` },
{ label: t("profileStats.time"), value: formatDuration(stats.duration) },
]}
/>
{stats.last4Weeks > 0 && (
<p className="mt-2 text-sm text-gray-500">
{t("profileStats.last4Weeks", { count: stats.last4Weeks })}
</p>
)}
</div>
);
}