Merge pull request #538 from trails-cool/propose-profile-stats

openspec: propose profile-stats (profile roll-ups)
This commit is contained in:
Ullrich Schäfer 2026-06-13 07:13:39 +02:00 committed by GitHub
commit 9845718dee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 173 additions and 0 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-13

View file

@ -0,0 +1,71 @@
## 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.

View file

@ -0,0 +1,44 @@
## Why
A profile (`/users/:username`) is a list of routes and activities with no
sense of the person behind it — no "how far have they gone", no recent
cadence. Komoot leads its profile with lifetime totals (tours · distance ·
time); Strava with a "last 4 weeks" activity count. A small stats header makes
a profile feel like someone's outdoor life rather than a table, and every
number is already in the `activities` table.
## What Changes
- Add a stats header to the profile page showing **lifetime totals**: activity
count, total distance, total ascent, total time (elapsed).
- Add a **recent-activity** summary: the count of activities in the last 4
weeks (rolling).
- Scope the roll-up to what the viewer may see: a visitor sees **public-only**
totals; the owner viewing their own profile sees totals over **all** their
activities.
- Compute on the fly with a single indexed aggregate query — **no cache table,
no schema change** (see design for the cost analysis and the future-cache
escape hatch).
## Capabilities
### New Capabilities
- `profile-stats`: the profile stats header — which totals are shown, how they
are scoped by viewer/visibility, and how they are computed.
### Modified Capabilities
<!-- None at the requirement level. `public-profiles` owns the page shell and
the full-vs-locked-stub access rule; this is an additive header it renders. -->
## Impact
- **Journal app**: a new aggregate query in `activities.server.ts`
(`getActivityStats(ownerId, { publicOnly })`); the profile loader
(`users.$username.server.ts`) calls it and the page renders a stats header
(reusing the shared `StatRow` where it fits).
- **i18n**: `journal.profileStats.*` labels (en + de).
- **No schema change, no migration, no GPX parsing** — aggregates use the
stored `distance` / `elevation_gain` / `duration` columns over the existing
`owner_id`-leading indexes.

View file

@ -0,0 +1,37 @@
## ADDED Requirements
### Requirement: Lifetime totals on the profile
The profile page SHALL show a stats header with the owner's lifetime totals: activity count, total distance, total ascent, and total elapsed time.
#### Scenario: Totals shown for an athlete with activities
- **WHEN** a user views a profile whose owner has activities
- **THEN** a stats header shows the activity count, total distance, total ascent, and total elapsed time
#### Scenario: Header hidden when there are none
- **WHEN** the profile owner has no activities the viewer may see
- **THEN** no stats header is shown
### Requirement: Viewer-scoped roll-ups
The roll-up SHALL count only the activities the viewer is permitted to see: public-only for other viewers, and all of the owner's activities when the owner views their own profile.
#### Scenario: Visitor sees public-only totals
- **WHEN** a visitor (not the owner) views a profile
- **THEN** the totals aggregate only the owner's public activities (unlisted and private are excluded)
#### Scenario: Owner sees their full totals
- **WHEN** the owner views their own profile
- **THEN** the totals aggregate all of the owner's activities regardless of visibility
### Requirement: Recent-activity summary
The stats header SHALL show the count of activities in the last 4 weeks (rolling), under the same viewer scoping as the totals.
#### Scenario: Last-4-weeks count
- **WHEN** the stats header is shown
- **THEN** it includes the number of the owner's (viewer-scoped) activities started in the last 28 days
### Requirement: Localized stat labels
The stats header SHALL render its labels and units through localized strings.
#### Scenario: German labels
- **WHEN** the UI locale is German
- **THEN** the stat labels render in German

View file

@ -0,0 +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.
## 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.
## 3. i18n
- [ ] 3.1 Add `journal.profileStats.*` keys (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.