From 977e0d8f5fc297cf08407c99e78d36ab0ffb4e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 13 Jun 2026 06:56:06 +0200 Subject: [PATCH] openspec: propose profile-stats (profile roll-ups) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- openspec/changes/profile-stats/.openspec.yaml | 2 + openspec/changes/profile-stats/design.md | 71 +++++++++++++++++++ openspec/changes/profile-stats/proposal.md | 44 ++++++++++++ .../profile-stats/specs/profile-stats/spec.md | 37 ++++++++++ openspec/changes/profile-stats/tasks.md | 19 +++++ 5 files changed, 173 insertions(+) create mode 100644 openspec/changes/profile-stats/.openspec.yaml create mode 100644 openspec/changes/profile-stats/design.md create mode 100644 openspec/changes/profile-stats/proposal.md create mode 100644 openspec/changes/profile-stats/specs/profile-stats/spec.md create mode 100644 openspec/changes/profile-stats/tasks.md diff --git a/openspec/changes/profile-stats/.openspec.yaml b/openspec/changes/profile-stats/.openspec.yaml new file mode 100644 index 0000000..64b47c4 --- /dev/null +++ b/openspec/changes/profile-stats/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-13 diff --git a/openspec/changes/profile-stats/design.md b/openspec/changes/profile-stats/design.md new file mode 100644 index 0000000..08a100c --- /dev/null +++ b/openspec/changes/profile-stats/design.md @@ -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. diff --git a/openspec/changes/profile-stats/proposal.md b/openspec/changes/profile-stats/proposal.md new file mode 100644 index 0000000..541246e --- /dev/null +++ b/openspec/changes/profile-stats/proposal.md @@ -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 + + + +## 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. diff --git a/openspec/changes/profile-stats/specs/profile-stats/spec.md b/openspec/changes/profile-stats/specs/profile-stats/spec.md new file mode 100644 index 0000000..7a34cc2 --- /dev/null +++ b/openspec/changes/profile-stats/specs/profile-stats/spec.md @@ -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 diff --git a/openspec/changes/profile-stats/tasks.md b/openspec/changes/profile-stats/tasks.md new file mode 100644 index 0000000..18df5f9 --- /dev/null +++ b/openspec/changes/profile-stats/tasks.md @@ -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.