openspec: archive profile-stats + profile-weekly-distance, sync specs
Both changes are implemented and merged (#539, #541). Promote their deltas into openspec/specs/, move the changes to changes/archive/2026-06-14-*, and add the two capabilities to the CAPABILITIES index. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7e76bbd447
commit
2ad9898f9c
13 changed files with 80 additions and 0 deletions
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-13
|
||||
71
openspec/changes/archive/2026-06-14-profile-stats/design.md
Normal file
71
openspec/changes/archive/2026-06-14-profile-stats/design.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
19
openspec/changes/archive/2026-06-14-profile-stats/tasks.md
Normal file
19
openspec/changes/archive/2026-06-14-profile-stats/tasks.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
## 1. Aggregate query
|
||||
|
||||
- [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
|
||||
|
||||
- [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
|
||||
|
||||
- [x] 3.1 Added `journal.profileStats.*` (activities, distance, ascent, time, last4Weeks) to en + de.
|
||||
|
||||
## 4. Tests & checks
|
||||
|
||||
- [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.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-13
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
## Context
|
||||
|
||||
`profile-stats` added `getActivityStats` (a single indexed aggregate over stored
|
||||
columns) and a `ProfileStats` header. This change adds a per-week breakdown
|
||||
beneath it. `activities` has `distance`, `started_at`, `created_at`,
|
||||
`visibility`, and `owner_id`-leading indexes.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
- A compact weekly-distance trend on the profile, cheap and viewer-scoped.
|
||||
|
||||
**Non-Goals**
|
||||
- No per-week elevation/time toggle, no selectable window in v1 (fixed 12
|
||||
weeks).
|
||||
- No hover tooltip framework — a native `title` per bar is enough.
|
||||
- No "where I've been" map (separate idea).
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: One grouped aggregate — still no cache
|
||||
`SELECT date_trunc('week', coalesce(started_at, created_at)) AS wk,
|
||||
sum(distance) AS m
|
||||
FROM journal.activities
|
||||
WHERE owner_id = $1 [AND visibility = 'public']
|
||||
AND coalesce(started_at, created_at) >= now() - interval '12 weeks'
|
||||
GROUP BY wk`.
|
||||
Bounded to 12 weeks and filtered on the `owner_id` index, this scans a tiny
|
||||
recent slice and returns ≤ 12 rows — same cost class as the lifetime aggregate
|
||||
(`profile-stats` §D1). No cache table; the documented escape hatch there covers
|
||||
this too.
|
||||
|
||||
### D2: Gap-fill in JS
|
||||
The query returns only weeks that have activities. The server fills the result
|
||||
to a contiguous 12-week series (oldest → newest) with `0` for empty weeks, so
|
||||
the chart always has a stable 12-bar axis. Week boundaries follow Postgres
|
||||
`date_trunc('week', …)` (ISO weeks, Monday start).
|
||||
|
||||
### D3: Viewer scoping
|
||||
Identical to `profile-stats`: `publicOnly: !isOwn`. Same WHERE clause shape;
|
||||
remote-ingested rows (`owner_id NULL`) never appear.
|
||||
|
||||
### D4: Presentation
|
||||
A small SVG bar chart (`WeeklyDistanceChart`) under the stats header: 12 bars,
|
||||
heights normalized to the max week, each bar carrying a `title` with its week
|
||||
distance (formatted via `stats.ts`). Renders nothing when every week is 0
|
||||
(no distance in the window). Read-only, no interactivity beyond the native
|
||||
title.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Fixed 12-week window** — a power user with a long history sees only the
|
||||
recent quarter. Acceptable for a cadence glance; a selectable range is a
|
||||
future enhancement.
|
||||
- **Distance-only** — elevation/time trends are deferred to keep the chart
|
||||
legible and the query single-column.
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
## Why
|
||||
|
||||
The profile roll-up header (`profile-stats`) shows lifetime totals and a
|
||||
"last 4 weeks" count, but no sense of *cadence* — is this person ramping up,
|
||||
tapering, or consistent? Strava's weekly bars are the at-a-glance answer. A
|
||||
small weekly-distance bar chart turns the static totals into a trend, and the
|
||||
data is the same cheap aggregate we already lean on.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a **weekly distance bar chart** to the profile, under the stats header:
|
||||
one bar per week for the **last 12 weeks**, heights normalized to the busiest
|
||||
week.
|
||||
- Compute it with one indexed aggregate (`sum(distance)` grouped by week) over
|
||||
the same viewer scoping as `profile-stats` (public-only for visitors, full
|
||||
for the owner) — **no cache table, no schema change**.
|
||||
- Hide the chart when the owner has no distance in the window.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `profile-weekly-distance`: the weekly-distance bar chart on the profile — the
|
||||
window, how empty weeks are shown, and how it is computed and scoped.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
<!-- None at the requirement level. Builds alongside `profile-stats` (the
|
||||
header it sits under); that capability's totals are unchanged. -->
|
||||
|
||||
## Impact
|
||||
|
||||
- **Depends on `profile-stats`** (PR #539) — the chart renders beneath that
|
||||
header.
|
||||
- **Journal app**: a `getWeeklyDistance(ownerId, { publicOnly, weeks })` query
|
||||
in `activities.server.ts`; the profile loader passes the series; a small
|
||||
`WeeklyDistanceChart` component (SVG bars, reusing `stats.ts` formatters).
|
||||
- **i18n**: `journal.profileStats.weeklyDistance*` labels (en + de).
|
||||
- **No schema change, no GPX parsing** — `sum(distance)` grouped by
|
||||
`date_trunc('week', …)` over the `owner_id`-leading indexes.
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Weekly distance chart on the profile
|
||||
The profile page SHALL show a weekly-distance bar chart beneath the stats header, with one bar per week for the last 12 weeks and bar heights normalized to the busiest week in the window.
|
||||
|
||||
#### Scenario: Chart shown when there is recent distance
|
||||
- **WHEN** a user views a profile whose owner has distance in the last 12 weeks
|
||||
- **THEN** a 12-week bar chart is shown, the tallest bar being the week with the most distance
|
||||
|
||||
#### Scenario: Chart hidden without recent distance
|
||||
- **WHEN** the owner has no (viewer-visible) distance in the last 12 weeks
|
||||
- **THEN** no weekly chart is shown
|
||||
|
||||
### Requirement: Contiguous weekly axis
|
||||
The chart SHALL render a contiguous run of weeks, including weeks with no activity as zero-height bars, so the axis is stable.
|
||||
|
||||
#### Scenario: Empty weeks appear as gaps
|
||||
- **WHEN** the owner was active in some weeks of the window but not others
|
||||
- **THEN** the inactive weeks render as zero-height bars in their correct position (not omitted)
|
||||
|
||||
### Requirement: Viewer-scoped weekly distance
|
||||
The weekly distance SHALL be scoped to what the viewer may 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 weeks
|
||||
- **WHEN** a visitor (not the owner) views a profile
|
||||
- **THEN** each week's bar reflects only the owner's public activities
|
||||
|
||||
### Requirement: Localized chart labels
|
||||
The chart SHALL render its label and per-bar distance readouts through localized strings.
|
||||
|
||||
#### Scenario: German labels
|
||||
- **WHEN** the UI locale is German
|
||||
- **THEN** the chart label and distance readouts render in German
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
## 1. Aggregate query
|
||||
|
||||
- [x] 1.1 Added `getWeeklyDistance(ownerId, { publicOnly, weeks = 12 })` to `activities.server.ts`: `sum(distance)` per week, viewer-scoped.
|
||||
- [x] 1.2 Gap-fill done **in SQL** (`generate_series` of week-starts LEFT JOINed to the activities) — returns exactly N contiguous `{ weekStart, distance }` rows, oldest→newest, and guarantees the week boundaries match Postgres `date_trunc('week', …)` (no JS/Postgres boundary drift). Filter conditions live in the JOIN's ON clause so empty weeks survive.
|
||||
|
||||
## 2. Loader & view
|
||||
|
||||
- [x] 2.1 Profile loader fetches it in parallel with the stats, same `publicOnly: !isOwn` scoping, only when content is visible.
|
||||
- [x] 2.2 `WeeklyDistanceChart`: SVG bars normalized to the busiest week, per-bar `title` distance (via `stats.ts`), localized label; renders nothing when every week is 0. Mounted under `ProfileStats`.
|
||||
|
||||
## 3. i18n
|
||||
|
||||
- [x] 3.1 Added `journal.profileStats.weeklyDistance` (label) to en + de. Per-bar readout is the language-neutral km distance.
|
||||
|
||||
## 4. Tests & checks
|
||||
|
||||
- [x] 4.1 Gap-fill is in SQL (no JS helper to unit-test) → covered by the e2e (a real activity produces a non-zero bar over a full 12-week axis).
|
||||
- [x] 4.2 Component (jsdom): one bar per week incl. zero weeks; nothing when all zero; heights normalized to the busiest week.
|
||||
- [x] 4.3 E2E `profile-weekly-distance.test.ts`: create an activity with distance → the weekly chart renders on the profile. Passes locally.
|
||||
- [x] 4.4 typecheck + lint + unit (journal 321) green; new e2e passes locally. Full `pnpm test:e2e` runs in CI.
|
||||
Loading…
Add table
Add a link
Reference in a new issue