@richhaase/c2 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,7 +96,12 @@ c2 trend -w 12
96
96
 
97
97
  ### HTML Report
98
98
 
99
- Generate a self-contained HTML progress report:
99
+ Generate a self-contained HTML progress report. Coaching content from the
100
+ store renders automatically when present: the latest narrative (as "Coach's
101
+ Report"), notes from the last 14 days, and the training-plan excerpt.
102
+ `c2 report --data` emits the entire report as structured JSON
103
+ (`c2.report.v1`) — the machine-readable twin of the HTML, for agents and
104
+ custom rendering surfaces:
100
105
 
101
106
  ```bash
102
107
  # Generate report.html in current directory
@@ -134,12 +139,96 @@ The CSV export includes `workout_type`, `rest_time_tenths`, and
134
139
  `rest_distance` columns so interval workouts are fully distinguishable from
135
140
  continuous pieces without having to consult the full JSON export.
136
141
 
142
+ ### Workout Detail & Derived Stats
143
+
144
+ ```bash
145
+ # Full detail for one workout: splits, stroke summary, comments
146
+ c2 show last
147
+ c2 show 118212501
148
+
149
+ # Derived analytics (the arithmetic, so coaches only interpret)
150
+ c2 stats weekly -w 12 # volume/pace/SPM/HR per week
151
+ c2 stats goal # trajectory + projection
152
+ c2 stats splits last # per-split pace/HR + shape (even/negative/positive)
153
+ c2 stats hr-pace -w 8 # avg HR by steady pace band, with early→late drift
154
+ ```
155
+
156
+ All of these accept `--json`.
157
+
158
+ ### Coaching Data
159
+
160
+ The store holds coaching content alongside workout data, so any coach — human
161
+ or AI, on any machine — reads and writes the same facts:
162
+
163
+ ```bash
164
+ # Subjective report after a row (athlete voice, linked to the workout)
165
+ c2 note add --type subjective --workout last "felt slow early, opened up late"
166
+
167
+ # Coach observations and durable lessons
168
+ c2 note add --type lesson --author coach "HR cap is the rule, not pace"
169
+
170
+ # Review
171
+ c2 note list --since 2026-06-01
172
+ c2 note list --workout 118212501 --json
173
+ c2 show last # linked notes appear in workout detail
174
+
175
+ # Training plan and coaching playbook (whole-document set/show)
176
+ c2 plan set plan.md
177
+ c2 playbook show
178
+
179
+ # Dated coaching report narratives
180
+ c2 narrative add 2026-07-05 report.md
181
+ c2 narrative show
182
+ ```
183
+
184
+ Notes are stored one file each for the most recent 7 days (safe under iCloud/
185
+ Dropbox sync), then automatically compacted into one archive file per year
186
+ (`c2 data compact` runs after every sync). `c2 data doctor` validates the
187
+ whole store.
188
+
189
+ ### Machine-Readable Output
190
+
191
+ `log`, `status`, `trend`, `show`, `stats`, and `data info` accept `--json` and emit a stable
192
+ versioned envelope for scripts and AI agents:
193
+
194
+ ```json
195
+ { "schema": "c2.status.v1", "generated_at": "2026-07-05T18:00:00.000Z", "data": { } }
196
+ ```
197
+
198
+ `export -f json` emits the same envelope (`c2.export.v1`) with the full
199
+ workout records under `data.workouts`. `export -f jsonl` stays one raw
200
+ workout per line for streaming.
201
+
202
+ ### Data Store
203
+
204
+ Workout data and (soon) coaching data live in a single data directory,
205
+ chosen during `c2 setup` and stored as `data_dir` in config. Point it at a
206
+ synced folder (iCloud, Dropbox, a git repo) to share one store across
207
+ machines — the config file with your API token always stays machine-local
208
+ in `~/.config/c2/` (mode 600).
209
+
210
+ ```bash
211
+ # Where is my data, and what's in it?
212
+ c2 data info
213
+
214
+ # Relocate the store (copies, verifies, updates config)
215
+ c2 data move ~/Documents/KnowledgeBase/c2-data
216
+ ```
217
+
218
+ Setup validates the directory you pick: it creates it if missing (with
219
+ confirmation), checks writability, adopts an existing c2 store it finds
220
+ there, and refuses to save a path that fails validation.
221
+
222
+ Note: bare `c2` prints help. Unknown commands error instead of falling
223
+ through to a default.
224
+
137
225
  ## Configuration
138
226
 
139
227
  Config lives at `~/.config/c2/config.json`. Created automatically on `c2 setup`.
140
228
 
141
229
  ```json
142
230
  {
231
+ "data_dir": "~/.config/c2/data",
143
232
  "api": {
144
233
  "base_url": "https://log.concept2.com",
145
234
  "token": "YOUR_TOKEN"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richhaase/c2",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "CLI tool for syncing and analyzing Concept2 rowing data",
5
5
  "keywords": [
6
6
  "concept2",
@@ -31,8 +31,8 @@
31
31
  "commander": "^14.0.3"
32
32
  },
33
33
  "devDependencies": {
34
- "@biomejs/biome": "^2.4.10",
35
- "@types/bun": "^1.3.9"
34
+ "@biomejs/biome": "^2.4.11",
35
+ "@types/bun": "^1.3.12"
36
36
  },
37
37
  "files": [
38
38
  "src"
@@ -0,0 +1,180 @@
1
+ import { expect, test } from "bun:test";
2
+ import { hrAtPace, splitShape, splitTable, strokeSummary } from "./analysis.ts";
3
+ import { projectGoal } from "./commands/stats.ts";
4
+ import type { StrokeData, Workout, WorkoutSplit } from "./models.ts";
5
+ import type { GoalProgress } from "./stats.ts";
6
+
7
+ function split(timeTenths: number, distance: number, spm: number, hr: number): WorkoutSplit {
8
+ return {
9
+ type: "distance",
10
+ time: timeTenths,
11
+ distance,
12
+ stroke_rate: spm,
13
+ heart_rate: { average: hr, max: hr + 8 },
14
+ };
15
+ }
16
+
17
+ function workoutWithSplits(splits: WorkoutSplit[]): Workout {
18
+ return {
19
+ id: 1,
20
+ user_id: 1,
21
+ date: "2026-07-03 12:00:00",
22
+ distance: splits.reduce((s, x) => s + (x.distance ?? 0), 0),
23
+ type: "rower",
24
+ time: splits.reduce((s, x) => s + x.time, 0),
25
+ time_formatted: "34:39.1",
26
+ workout: { targets: { pace: 1750 }, splits },
27
+ };
28
+ }
29
+
30
+ const NEGATIVE_SPLIT_WORKOUT = workoutWithSplits([
31
+ split(4280, 1200, 24, 93),
32
+ split(4210, 1200, 25, 107),
33
+ split(4140, 1200, 25, 115),
34
+ split(4076, 1200, 26, 120),
35
+ split(4088, 1200, 26, 122),
36
+ ]);
37
+
38
+ test("splitTable computes per-split pace from tenths", () => {
39
+ const rows = splitTable(NEGATIVE_SPLIT_WORKOUT);
40
+ expect(rows.length).toBe(5);
41
+ expect(rows[0]!.pace_500m_seconds).toBe(178.3);
42
+ expect(rows[0]!.pace_500m).toBe("2:58.3");
43
+ expect(rows[3]!.pace_500m).toBe("2:49.8");
44
+ expect(rows[0]!.hr_avg).toBe(93);
45
+ expect(rows[0]!.hr_max).toBe(101);
46
+ });
47
+
48
+ test("splitShape detects the 07/03-style negative split", () => {
49
+ expect(splitShape(splitTable(NEGATIVE_SPLIT_WORKOUT))).toBe("negative");
50
+ });
51
+
52
+ test("splitShape detects even, positive, variable, and unknown", () => {
53
+ const even = workoutWithSplits([
54
+ split(4200, 1200, 25, 110),
55
+ split(4210, 1200, 25, 112),
56
+ split(4195, 1200, 25, 113),
57
+ split(4205, 1200, 25, 114),
58
+ ]);
59
+ expect(splitShape(splitTable(even))).toBe("even");
60
+
61
+ const positive = workoutWithSplits([
62
+ split(4000, 1200, 26, 118),
63
+ split(4100, 1200, 25, 120),
64
+ split(4250, 1200, 24, 121),
65
+ split(4350, 1200, 23, 122),
66
+ ]);
67
+ expect(splitShape(splitTable(positive))).toBe("positive");
68
+
69
+ const variable = workoutWithSplits([
70
+ split(4000, 1200, 26, 118),
71
+ split(4400, 1200, 22, 110),
72
+ split(3950, 1200, 27, 125),
73
+ split(4380, 1200, 22, 112),
74
+ ]);
75
+ expect(splitShape(splitTable(variable))).toBe("variable");
76
+
77
+ expect(splitShape(splitTable(workoutWithSplits([split(4200, 1200, 25, 110)])))).toBe("unknown");
78
+ expect(splitShape([])).toBe("unknown");
79
+ });
80
+
81
+ test("strokeSummary aggregates samples", () => {
82
+ const strokes: StrokeData[] = [
83
+ { t: 100, d: 500, p: 1750, spm: 24, hr: 110 },
84
+ { t: 200, d: 1000, p: 1730, spm: 25, hr: 118 },
85
+ { t: 300, d: 1500, p: 1710, spm: 26, hr: 126 },
86
+ { t: 400, d: 2000, p: 0, spm: 0, hr: 0 },
87
+ ];
88
+ const s = strokeSummary(strokes);
89
+ expect(s.samples).toBe(4);
90
+ expect(s.avg_pace_500m_seconds).toBe(173);
91
+ expect(s.avg_pace_500m).toBe("2:53.0");
92
+ expect(s.avg_spm).toBe(25);
93
+ expect(s.avg_hr).toBe(118);
94
+ expect(s.max_hr).toBe(126);
95
+ });
96
+
97
+ function goalFixture(overrides: Partial<GoalProgress>): GoalProgress {
98
+ return {
99
+ target: 1_000_000,
100
+ totalMeters: 900_000,
101
+ progress: 0.9,
102
+ weeksElapsed: 26,
103
+ totalWeeks: 52,
104
+ remainingMeters: 100_000,
105
+ remainingWeeks: 26,
106
+ requiredPace: 3846,
107
+ currentAvgPace: 20_000,
108
+ onPace: true,
109
+ ...overrides,
110
+ };
111
+ }
112
+
113
+ const PROJECT_NOW = new Date("2026-07-06T12:00:00");
114
+
115
+ function daysAfterNow(n: number): Date {
116
+ return new Date(PROJECT_NOW.getTime() + n * 24 * 60 * 60 * 1000);
117
+ }
118
+
119
+ test("projectGoal projects over actual remaining time", () => {
120
+ const active = projectGoal(goalFixture({}), daysAfterNow(26 * 7), PROJECT_NOW);
121
+ expect(active.projected_total_meters).toBe(900_000 + 26 * 20_000);
122
+ expect(active.shortfall_meters).toBe(0);
123
+ });
124
+
125
+ test("projectGoal adds nothing after the goal window ends", () => {
126
+ const expired = projectGoal(
127
+ goalFixture({ weeksElapsed: 60, remainingWeeks: 1 }),
128
+ daysAfterNow(-2),
129
+ PROJECT_NOW,
130
+ );
131
+ expect(expired.projected_total_meters).toBe(900_000);
132
+ expect(expired.projected_pct).toBe(90);
133
+ expect(expired.shortfall_meters).toBe(100_000);
134
+ });
135
+
136
+ test("projectGoal handles fractional final weeks without over-projecting", () => {
137
+ const halfWeek = projectGoal(goalFixture({}), daysAfterNow(3.5), PROJECT_NOW);
138
+ expect(halfWeek.projected_total_meters).toBe(900_000 + 10_000);
139
+ });
140
+
141
+ function steadyWorkout(id: number, date: string, paceSecs: number, hr: number): Workout {
142
+ const distance = 6000;
143
+ return {
144
+ id,
145
+ user_id: 1,
146
+ date,
147
+ distance,
148
+ type: "rower",
149
+ time: Math.round(paceSecs * (distance / 500) * 10),
150
+ time_formatted: "x",
151
+ heart_rate: { average: hr },
152
+ };
153
+ }
154
+
155
+ const NOW = new Date("2026-07-05T12:00:00");
156
+
157
+ test("hrAtPace buckets steady work and reports early-late drift", () => {
158
+ const workouts: Workout[] = [
159
+ steadyWorkout(1, "2026-05-20 08:00:00", 173, 120),
160
+ steadyWorkout(2, "2026-05-27 08:00:00", 174, 118),
161
+ steadyWorkout(3, "2026-06-24 08:00:00", 172, 112),
162
+ steadyWorkout(4, "2026-07-01 08:00:00", 171, 110),
163
+ steadyWorkout(5, "2026-07-03 08:00:00", 168, 115),
164
+ { ...steadyWorkout(6, "2026-07-02 08:00:00", 150, 140), rest_time: 600 },
165
+ steadyWorkout(7, "2026-01-01 08:00:00", 173, 130),
166
+ ];
167
+ const bands = hrAtPace(workouts, NOW, 8);
168
+
169
+ expect(bands.length).toBe(2);
170
+ const band170 = bands.find((b) => b.band_start_seconds === 170)!;
171
+ expect(band170.workouts).toBe(4);
172
+ expect(band170.early_avg_hr).toBe(119);
173
+ expect(band170.late_avg_hr).toBe(111);
174
+ expect(band170.hr_delta).toBe(-8);
175
+ expect(band170.band).toBe("2:50.0–2:55.0");
176
+
177
+ const band165 = bands.find((b) => b.band_start_seconds === 165)!;
178
+ expect(band165.workouts).toBe(1);
179
+ expect(band165.hr_delta).toBeNull();
180
+ });
@@ -0,0 +1,158 @@
1
+ import type { StrokeData, Workout } from "./models.ts";
2
+ import { formatSeconds, isIntervalWorkout, pace500mSeconds, parsedDate } from "./models.ts";
3
+
4
+ const TENTHS_PER_SECOND = 10;
5
+ const PACE_DISTANCE = 500;
6
+
7
+ export interface SplitRow {
8
+ index: number;
9
+ distance: number | null;
10
+ time_seconds: number;
11
+ pace_500m_seconds: number | null;
12
+ pace_500m: string | null;
13
+ stroke_rate: number | null;
14
+ hr_avg: number | null;
15
+ hr_max: number | null;
16
+ }
17
+
18
+ export type SplitShape = "even" | "negative" | "positive" | "variable" | "unknown";
19
+
20
+ const SHAPE_DRIFT_THRESHOLD_SECONDS = 1.5;
21
+ const SHAPE_SPREAD_THRESHOLD_SECONDS = 6;
22
+
23
+ export function splitTable(w: Workout): SplitRow[] {
24
+ const splits = w.workout?.splits ?? [];
25
+ return splits.map((s, i) => {
26
+ const seconds = s.time / TENTHS_PER_SECOND;
27
+ const pace =
28
+ s.distance != null && s.distance > 0 ? seconds * (PACE_DISTANCE / s.distance) : null;
29
+ return {
30
+ index: i + 1,
31
+ distance: s.distance ?? null,
32
+ time_seconds: Math.round(seconds * 10) / 10,
33
+ pace_500m_seconds: pace != null ? Math.round(pace * 10) / 10 : null,
34
+ pace_500m: pace != null ? formatSeconds(pace) : null,
35
+ stroke_rate: s.stroke_rate ?? null,
36
+ hr_avg: s.heart_rate?.average ?? null,
37
+ hr_max: s.heart_rate?.max ?? null,
38
+ };
39
+ });
40
+ }
41
+
42
+ export function splitShape(rows: SplitRow[]): SplitShape {
43
+ const paces = rows.map((r) => r.pace_500m_seconds).filter((p): p is number => p != null);
44
+ if (paces.length < 2) return "unknown";
45
+
46
+ const mid = Math.floor(paces.length / 2);
47
+ const firstHalf = paces.slice(0, mid);
48
+ const secondHalf = paces.slice(paces.length - mid);
49
+ const avg = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
50
+ const drift = avg(firstHalf) - avg(secondHalf);
51
+ const spread = Math.max(...paces) - Math.min(...paces);
52
+
53
+ if (Math.abs(drift) <= SHAPE_DRIFT_THRESHOLD_SECONDS) {
54
+ return spread > SHAPE_SPREAD_THRESHOLD_SECONDS ? "variable" : "even";
55
+ }
56
+ return drift > 0 ? "negative" : "positive";
57
+ }
58
+
59
+ export interface StrokeSummary {
60
+ samples: number;
61
+ avg_pace_500m_seconds: number | null;
62
+ avg_pace_500m: string | null;
63
+ avg_spm: number | null;
64
+ avg_hr: number | null;
65
+ max_hr: number | null;
66
+ }
67
+
68
+ export function strokeSummary(strokes: StrokeData[]): StrokeSummary {
69
+ let paceSum = 0;
70
+ let paceCount = 0;
71
+ let spmSum = 0;
72
+ let spmCount = 0;
73
+ let hrSum = 0;
74
+ let hrCount = 0;
75
+ let maxHR = 0;
76
+ for (const s of strokes) {
77
+ if (s.p != null && s.p > 0) {
78
+ paceSum += s.p / TENTHS_PER_SECOND;
79
+ paceCount++;
80
+ }
81
+ if (s.spm != null && s.spm > 0) {
82
+ spmSum += s.spm;
83
+ spmCount++;
84
+ }
85
+ if (s.hr != null && s.hr > 0) {
86
+ hrSum += s.hr;
87
+ hrCount++;
88
+ if (s.hr > maxHR) maxHR = s.hr;
89
+ }
90
+ }
91
+ const avgPace = paceCount > 0 ? paceSum / paceCount : null;
92
+ return {
93
+ samples: strokes.length,
94
+ avg_pace_500m_seconds: avgPace != null ? Math.round(avgPace * 10) / 10 : null,
95
+ avg_pace_500m: avgPace != null ? formatSeconds(avgPace) : null,
96
+ avg_spm: spmCount > 0 ? Math.round((spmSum / spmCount) * 10) / 10 : null,
97
+ avg_hr: hrCount > 0 ? Math.round(hrSum / hrCount) : null,
98
+ max_hr: maxHR > 0 ? maxHR : null,
99
+ };
100
+ }
101
+
102
+ export interface HRPaceBand {
103
+ band_start_seconds: number;
104
+ band: string;
105
+ workouts: number;
106
+ avg_hr: number;
107
+ early_avg_hr: number | null;
108
+ late_avg_hr: number | null;
109
+ hr_delta: number | null;
110
+ }
111
+
112
+ const BAND_WIDTH_SECONDS = 5;
113
+ const MIN_STEADY_DISTANCE = 1000;
114
+
115
+ export function hrAtPace(workouts: Workout[], now: Date, weeks: number): HRPaceBand[] {
116
+ const cutoff = new Date(now);
117
+ cutoff.setDate(cutoff.getDate() - weeks * 7);
118
+ const midpoint = new Date(cutoff.getTime() + (now.getTime() - cutoff.getTime()) / 2);
119
+
120
+ const steady = workouts.filter((w) => {
121
+ if (isIntervalWorkout(w)) return false;
122
+ if (w.distance < MIN_STEADY_DISTANCE) return false;
123
+ if (!w.heart_rate?.average || w.heart_rate.average <= 0) return false;
124
+ const t = parsedDate(w);
125
+ return t >= cutoff && t <= now && pace500mSeconds(w) > 0;
126
+ });
127
+
128
+ const bands = new Map<number, { all: number[]; early: number[]; late: number[] }>();
129
+ for (const w of steady) {
130
+ const pace = pace500mSeconds(w);
131
+ const bandStart = Math.floor(pace / BAND_WIDTH_SECONDS) * BAND_WIDTH_SECONDS;
132
+ if (!bands.has(bandStart)) bands.set(bandStart, { all: [], early: [], late: [] });
133
+ const bucket = bands.get(bandStart)!;
134
+ const hr = w.heart_rate!.average!;
135
+ bucket.all.push(hr);
136
+ if (parsedDate(w) < midpoint) bucket.early.push(hr);
137
+ else bucket.late.push(hr);
138
+ }
139
+
140
+ const avg = (xs: number[]) =>
141
+ xs.length > 0 ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : null;
142
+
143
+ return [...bands.entries()]
144
+ .sort(([a], [b]) => a - b)
145
+ .map(([bandStart, bucket]) => {
146
+ const early = avg(bucket.early);
147
+ const late = avg(bucket.late);
148
+ return {
149
+ band_start_seconds: bandStart,
150
+ band: `${formatSeconds(bandStart)}–${formatSeconds(bandStart + BAND_WIDTH_SECONDS)}`,
151
+ workouts: bucket.all.length,
152
+ avg_hr: avg(bucket.all)!,
153
+ early_avg_hr: early,
154
+ late_avg_hr: late,
155
+ hr_delta: early != null && late != null ? late - early : null,
156
+ };
157
+ });
158
+ }