Merge pull request #540 from trails-cool/propose-profile-weekly-distance
openspec: propose profile-weekly-distance (weekly bars)
This commit is contained in:
commit
e0a92b56aa
5 changed files with 151 additions and 0 deletions
2
openspec/changes/profile-weekly-distance/.openspec.yaml
Normal file
2
openspec/changes/profile-weekly-distance/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
schema: spec-driven
|
||||||
|
created: 2026-06-13
|
||||||
56
openspec/changes/profile-weekly-distance/design.md
Normal file
56
openspec/changes/profile-weekly-distance/design.md
Normal file
|
|
@ -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.
|
||||||
40
openspec/changes/profile-weekly-distance/proposal.md
Normal file
40
openspec/changes/profile-weekly-distance/proposal.md
Normal file
|
|
@ -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
|
||||||
20
openspec/changes/profile-weekly-distance/tasks.md
Normal file
20
openspec/changes/profile-weekly-distance/tasks.md
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
## 1. Aggregate query
|
||||||
|
|
||||||
|
- [ ] 1.1 Add `getWeeklyDistance(ownerId, { publicOnly, weeks = 12 })` to `activities.server.ts`: `sum(distance)` grouped by `date_trunc('week', coalesce(started_at, created_at))` over the last `weeks` weeks, viewer-scoped.
|
||||||
|
- [ ] 1.2 Gap-fill to a contiguous oldest→newest series with `0` for empty weeks; return `{ weekStart, distance }[]`.
|
||||||
|
|
||||||
|
## 2. Loader & view
|
||||||
|
|
||||||
|
- [ ] 2.1 Profile loader passes the series (same `publicOnly: !isOwn` scoping; only when content is visible).
|
||||||
|
- [ ] 2.2 `WeeklyDistanceChart` component: SVG bars normalized to the max week, per-bar `title` distance (via `stats.ts`), localized label; renders nothing when every week is 0. Mount under `ProfileStats`.
|
||||||
|
|
||||||
|
## 3. i18n
|
||||||
|
|
||||||
|
- [ ] 3.1 Add `journal.profileStats.weeklyDistance*` (label + per-bar readout) to en + de.
|
||||||
|
|
||||||
|
## 4. Tests & checks
|
||||||
|
|
||||||
|
- [ ] 4.1 Unit: gap-fill produces 12 contiguous weeks with zeros in the right slots.
|
||||||
|
- [ ] 4.2 Component (jsdom): renders 12 bars for a series; nothing when all zero; tallest bar = busiest week.
|
||||||
|
- [ ] 4.3 E2E: create activities, assert the weekly chart renders on the profile.
|
||||||
|
- [ ] 4.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue