diff --git a/openspec/changes/profile-weekly-distance/.openspec.yaml b/openspec/changes/profile-weekly-distance/.openspec.yaml new file mode 100644 index 0000000..64b47c4 --- /dev/null +++ b/openspec/changes/profile-weekly-distance/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-13 diff --git a/openspec/changes/profile-weekly-distance/design.md b/openspec/changes/profile-weekly-distance/design.md new file mode 100644 index 0000000..85e9bab --- /dev/null +++ b/openspec/changes/profile-weekly-distance/design.md @@ -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. diff --git a/openspec/changes/profile-weekly-distance/proposal.md b/openspec/changes/profile-weekly-distance/proposal.md new file mode 100644 index 0000000..5335a7a --- /dev/null +++ b/openspec/changes/profile-weekly-distance/proposal.md @@ -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 + + + +## 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. diff --git a/openspec/changes/profile-weekly-distance/specs/profile-weekly-distance/spec.md b/openspec/changes/profile-weekly-distance/specs/profile-weekly-distance/spec.md new file mode 100644 index 0000000..44d7b6d --- /dev/null +++ b/openspec/changes/profile-weekly-distance/specs/profile-weekly-distance/spec.md @@ -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 diff --git a/openspec/changes/profile-weekly-distance/tasks.md b/openspec/changes/profile-weekly-distance/tasks.md new file mode 100644 index 0000000..01afd7e --- /dev/null +++ b/openspec/changes/profile-weekly-distance/tasks.md @@ -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.