diff --git a/apps/journal/app/components/ProfileStats.test.tsx b/apps/journal/app/components/ProfileStats.test.tsx new file mode 100644 index 0000000..f4077e0 --- /dev/null +++ b/apps/journal/app/components/ProfileStats.test.tsx @@ -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( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders formatted totals", () => { + const { container, getByText } = render( + , + ); + // 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( + , + ); + expect(container.querySelector("p")).toBeNull(); + }); +}); diff --git a/apps/journal/app/components/ProfileStats.tsx b/apps/journal/app/components/ProfileStats.tsx new file mode 100644 index 0000000..349d18d --- /dev/null +++ b/apps/journal/app/components/ProfileStats.tsx @@ -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 ( +
+ + {stats.last4Weeks > 0 && ( +

+ {t("profileStats.last4Weeks", { count: stats.last4Weeks })} +

+ )} +
+ ); +} diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index 2848c61..af42f05 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -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 { + 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`count(*)`, + distance: sql`coalesce(sum(${activities.distance}), 0)`, + elevationGain: sql`coalesce(sum(${activities.elevationGain}), 0)`, + duration: sql`coalesce(sum(${activities.duration}), 0)`, + last4Weeks: sql`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", diff --git a/apps/journal/app/routes/users.$username.server.ts b/apps/journal/app/routes/users.$username.server.ts index 61e6bc4..2ee2714 100644 --- a/apps/journal/app/routes/users.$username.server.ts +++ b/apps/journal/app/routes/users.$username.server.ts @@ -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, diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index f630ac9..dd5f8fe 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -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) { )} + {stats && } + {!canSeeContent && (
diff --git a/e2e/profile-stats.test.ts b/e2e/profile-stats.test.ts new file mode 100644 index 0000000..6ad986b --- /dev/null +++ b/e2e/profile-stats.test.ts @@ -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(); + }); +}); diff --git a/openspec/changes/profile-stats/tasks.md b/openspec/changes/profile-stats/tasks.md index 18df5f9..f311dd5 100644 --- a/openspec/changes/profile-stats/tasks.md +++ b/openspec/changes/profile-stats/tasks.md @@ -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. diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 07291df..b6d6118 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index bb263ab..ac6c4fb 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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: { diff --git a/playwright.config.ts b/playwright.config.ts index 14dd779..65c222f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 ? [