trails/openspec/changes/profile-stats/design.md
Ullrich Schäfer 977e0d8f5f
openspec: propose profile-stats (profile roll-ups)
Lifetime totals (count · distance · ascent · elapsed time) + a last-4-weeks
count on the profile, viewer-scoped (public-only for visitors, full for the
owner).

Design records the cost decision: compute on the fly with a single aggregate
over stored columns on the owner_id-leading indexes (cheap even for power
users) — no cache table, no schema change — with a documented escape hatch to
a user_activity_stats cache if profiling ever shows it's hot.

Validates with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 06:56:06 +02:00

3.5 KiB

Context

journal.activities stores distance, elevation_gain, duration (elapsed), started_at, visibility, and owner_id, with owner_id-leading indexes (activities_owner_started_idx, activities_owner_created_idx). The profile page already distinguishes the owner (isOwn) from other viewers and already lists public activities via listPublicActivitiesForOwner.

Goals / Non-Goals

Goals

  • A lifetime-totals + recent-activity header on the profile, scoped correctly by viewer.
  • Cheap, simple computation that scales to power users.

Non-Goals

  • No per-week distance chart / sparkline in v1 (a clean follow-up once the totals land).
  • No "where I've been" heat/cluster map (separate idea; privacy-sensitive).
  • No moving-time roll-up — moving time needs per-activity GPX parsing, far too costly to aggregate; roll-ups use stored elapsed duration only.

Decisions

D1: Compute on the fly — no cache table (the cost question)

Roll-ups are a single aggregate over stored columns: SELECT count(*), sum(distance), sum(elevation_gain), sum(duration) FROM journal.activities WHERE owner_id = $1 [AND visibility = 'public']. Filtered by owner_id (the leading column of two existing indexes) and summing already-materialized columns, this is an index range scan + a streaming aggregate — sub-millisecond to low-single-digit-ms even at tens of thousands of activities. The "expensive with many activities" risk is real for N+1 or unindexed scans, not for one indexed GROUP BY-free aggregate.

A materialized cache table (user_activity_stats, bumped on activity create/update/delete, visibility change, and import) buys nothing at realistic scale and adds invalidation surface + drift risk — against the repo's simplicity principle. Documented escape hatch: if profiling ever shows this is hot (e.g. a heavily-followed instance rendering many profiles), the same query result can be memoized into a user_activity_stats row maintained by the existing mutation paths; the read API (getActivityStats) stays identical, so it's a drop-in with no caller changes.

D2: Viewer scoping

  • Other viewers see public-only totals (visibility = 'public') — consistent with the public activity list already shown and with privacy defaults (unlisted/private never counted).
  • The owner viewing their own profile sees totals over all their activities (the "your outdoor life" number), matching Komoot/Strava. The loader already knows isOwn, so it passes publicOnly: !isOwn to one parameterized query.

D3: Recent-activity window

"Last 4 weeks" = started_at >= now() - interval '28 days' (fall back to created_at when started_at is null, matching the list sort). Counted with the same viewer scoping. A fixed 4-week rolling window (Strava's framing) keeps it a single extra count, no date-bucketing.

D4: Presentation

A compact stats header above the routes/activities lists: activity count, distance, ascent, elapsed time, and a "N in the last 4 weeks" line. Reuse the shared StatRow for the four totals where it fits; format via the existing stats.ts helpers (distance/elevation/duration). Empty state (no activities) hides the header.

Risks / Trade-offs

  • Two query shapes (owner vs. public) — trivially handled by a conditional WHERE; both hit the same index.
  • Remote-ingested activities have owner_id = NULL (authored by a remote actor), so they never count toward a local profile's totals — correct.