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>
71 lines
3.5 KiB
Markdown
71 lines
3.5 KiB
Markdown
## 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.
|