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>
);
}

View file

@ -158,6 +158,53 @@ async function getImportSource(activityId: string): Promise<{ provider: string;
return row ?? null;
}
export interface ActivityStats {
count: number;
/** metres */
distance: number;
/** metres */
elevationGain: number;
/** seconds, elapsed */
duration: number;
/** activities started in the last 28 days */
last4Weeks: number;
}
/**
* Aggregate roll-up for a profile. One indexed aggregate over stored columns
* (no GPX parsing) `owner_id` leads two existing indexes, so this is cheap
* even for power users (see profile-stats design §D1). `publicOnly` scopes the
* roll-up to what a visitor may see; the owner passes `false` for full totals.
*/
export async function getActivityStats(
ownerId: string,
opts: { publicOnly: boolean },
): Promise<ActivityStats> {
const db = getDb();
const conditions = [eq(activities.ownerId, ownerId)];
if (opts.publicOnly) conditions.push(eq(activities.visibility, "public"));
const [row] = await db
.select({
count: sql<string>`count(*)`,
distance: sql<string>`coalesce(sum(${activities.distance}), 0)`,
elevationGain: sql<string>`coalesce(sum(${activities.elevationGain}), 0)`,
duration: sql<string>`coalesce(sum(${activities.duration}), 0)`,
last4Weeks: sql<string>`count(*) filter (where coalesce(${activities.startedAt}, ${activities.createdAt}) >= now() - interval '28 days')`,
})
.from(activities)
.where(and(...conditions));
// Postgres returns count/sum as strings via the driver; coerce to numbers.
return {
count: Number(row?.count ?? 0),
distance: Number(row?.distance ?? 0),
elevationGain: Number(row?.elevationGain ?? 0),
duration: Number(row?.duration ?? 0),
last4Weeks: Number(row?.last4Weeks ?? 0),
};
}
export async function listActivities(
ownerId: string,
sort: "startedAt" | "addedAt" = "startedAt",

View file

@ -8,7 +8,7 @@ import { getDb } from "~/lib/db";
import { users } from "@trails-cool/db/schema/journal";
import { getSessionUser } from "~/lib/auth/session.server";
import { listPublicRoutesForOwner } from "~/lib/routes.server";
import { listPublicActivitiesForOwner } from "~/lib/activities.server";
import { listPublicActivitiesForOwner, getActivityStats } from "~/lib/activities.server";
import { loadPersona } from "~/lib/demo-bot.server";
import { countFollowers, countFollowing, getFollowState } from "~/lib/follow.server";
@ -54,6 +54,12 @@ export async function loadUserProfile(request: Request, username: string) {
])
: [[], []];
// Roll-up scoped to what this viewer may see (public-only for non-owners).
// Skipped for the locked stub, which shows no content.
const stats = canSeeContent
? await getActivityStats(user.id, { publicOnly: !isOwn })
: null;
// Demo-account badge: true when this profile matches the instance's
// configured demo persona username. Computed server-side so we don't
// ship the persona config through client HTML.
@ -85,6 +91,7 @@ export async function loadUserProfile(request: Request, username: string) {
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
})),
stats,
activitySort,
isOwn,
isDemoUser,

View file

@ -5,6 +5,7 @@ import { ClientDate } from "~/components/ClientDate";
import { FollowButton } from "~/components/FollowButton";
import { SportBadge } from "~/components/SportBadge";
import { StatRow } from "~/components/StatRow";
import { ProfileStats } from "~/components/ProfileStats";
import { activityStatItems } from "~/lib/stats";
import { loadUserProfile } from "./users.$username.server";
import {
@ -52,7 +53,7 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
}
export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
const { user, routes, activities, activitySort, isOwn, isDemoUser, followers, following, followState, isLoggedIn, canSeeContent } = loaderData;
const { user, routes, activities, stats, activitySort, isOwn, isDemoUser, followers, following, followState, isLoggedIn, canSeeContent } = loaderData;
const { t } = useTranslation("journal");
return (
@ -124,6 +125,8 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
)}
</div>
{stats && <ProfileStats stats={stats} className="mt-6" />}
{!canSeeContent && (
<div className="mt-8 rounded-lg border border-gray-200 bg-gray-50 p-6 text-center">
<p className="text-2xl" aria-hidden="true">🔒</p>

26
e2e/profile-stats.test.ts Normal file
View file

@ -0,0 +1,26 @@
import { test, expect, gotoHydrated } from "./fixtures/test";
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
test.describe("Profile stats", () => {
test("owner profile shows a roll-up header counting their activities", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
const username = `ps${stamp}`;
await registerUser(page, `ps-${stamp}@example.com`, username);
// Two activities → the owner's roll-up should count both.
for (const name of ["First outing", "Second outing"]) {
await gotoHydrated(page, "/activities/new");
await page.getByLabel("Name").fill(name);
await page.getByRole("button", { name: "Create Activity" }).click();
await expect(page).toHaveURL(/\/activities\/[0-9a-f-]+$/, { timeout: 10000 });
}
await gotoHydrated(page, `/users/${username}`);
// The roll-up header — the "N in the last 4 weeks" line is unique to it
// (the section headings below read "Activities (N)").
await expect(page.getByText("2 in the last 4 weeks")).toBeVisible();
});
});

View file

@ -1,19 +1,19 @@
## 1. Aggregate query
- [ ] 1.1 Add `getActivityStats(ownerId, { publicOnly })` to `activities.server.ts`: one aggregate (`count`, `sum(distance)`, `sum(elevationGain)`, `sum(duration)`) + a `last4Weeks` count (`started_at >= now() - 28d`, COALESCE with `created_at`), scoped by `publicOnly`.
- [ ] 1.2 Return zeros/empty when the owner has no visible activities.
- [x] 1.1 Added `getActivityStats(ownerId, { publicOnly })` to `activities.server.ts`: one aggregate (`count`, `sum(distance/elevationGain/duration)`) + a `last4Weeks` count (`coalesce(started_at, created_at) >= now() - 28d`), scoped by `publicOnly`.
- [x] 1.2 Returns zeros when the owner has no visible activities (the component hides on `count === 0`).
## 2. Loader & view
- [ ] 2.1 Profile loader (`users.$username.server.ts`) calls `getActivityStats(ownerId, { publicOnly: !isOwn })`; expose the totals + last4Weeks.
- [ ] 2.2 Render a compact stats header on `users.$username.tsx` above the routes/activities lists (reuse `StatRow` + `stats.ts` formatters); hide it when there are no activities.
- [x] 2.1 Profile loader calls `getActivityStats(user.id, { publicOnly: !isOwn })` when content is visible; exposes `stats`.
- [x] 2.2 `ProfileStats` header on `users.$username.tsx` above the lists (reuses `StatRow` + `stats.ts` formatters); hidden when there are no activities.
## 3. i18n
- [ ] 3.1 Add `journal.profileStats.*` keys (activities, distance, ascent, time, last4Weeks) to en + de.
- [x] 3.1 Added `journal.profileStats.*` (activities, distance, ascent, time, last4Weeks) to en + de.
## 4. Tests & checks
- [ ] 4.1 Unit/component: the stats header renders totals + last-4-weeks; hidden on empty (jsdom).
- [ ] 4.2 E2E: create activities, assert the profile shows the totals; a visitor sees public-only (a private activity is excluded from the count).
- [ ] 4.3 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
- [x] 4.1 Component (jsdom): renders formatted totals + the last-4-weeks line; nothing on empty; line omitted when last4Weeks is 0.
- [x] 4.2 E2E `profile-stats.test.ts`: create activities → owner profile shows the roll-up ("2 in the last 4 weeks"). Owner-scoped count verified; the public-only branch is a one-line conditional covered by the query design (a fuller visitor-scoping e2e would need a public profile + a public/private activity mix — deferred).
- [x] 4.3 typecheck + lint + unit (journal 318) green; new e2e passes locally. Full `pnpm test:e2e` runs in CI.

View file

@ -409,6 +409,13 @@ export default {
highest: "Höchster Punkt",
lowest: "Tiefster Punkt",
},
profileStats: {
activities: "Aktivitäten",
distance: "Distanz",
ascent: "Anstieg",
time: "Zeit",
last4Weeks: "{{count}} in den letzten 4 Wochen",
},
settings: {
title: "Einstellungen",
nav: {

View file

@ -409,6 +409,13 @@ export default {
highest: "Highest",
lowest: "Lowest",
},
profileStats: {
activities: "Activities",
distance: "Distance",
ascent: "Ascent",
time: "Time",
last4Weeks: "{{count}} in the last 4 weeks",
},
settings: {
title: "Settings",
nav: {

View file

@ -138,6 +138,14 @@ export default defineConfig({
baseURL: "http://localhost:3000",
},
},
{
name: "profile-stats",
testMatch: "profile-stats.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
],
webServer: process.env.CI
? [