openspec: propose profile-weekly-distance (weekly bars)

A 12-week distance bar chart under the profile stats header, viewer-scoped,
built on profile-stats (#539). Design records the same no-cache rationale: one
indexed grouped aggregate over a 12-week slice (≤12 rows), gap-filled in JS.

Validates with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-13 07:29:23 +02:00
parent 9845718dee
commit d975cc7402
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 151 additions and 0 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-13

View 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.

View 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.

View file

@ -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

View 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.