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:
parent
9845718dee
commit
d975cc7402
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