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:
parent
9845718dee
commit
3d3c56aaf4
10 changed files with 194 additions and 10 deletions
37
apps/journal/app/components/ProfileStats.test.tsx
Normal file
37
apps/journal/app/components/ProfileStats.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
42
apps/journal/app/components/ProfileStats.tsx
Normal file
42
apps/journal/app/components/ProfileStats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue