@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 +90 -1
- package/package.json +3 -3
- package/src/analysis.test.ts +180 -0
- package/src/analysis.ts +158 -0
- package/src/cli.test.ts +776 -0
- package/src/commands/data.ts +172 -0
- package/src/commands/docs.ts +184 -0
- package/src/commands/export.ts +21 -7
- package/src/commands/log.ts +42 -11
- package/src/commands/note.ts +237 -0
- package/src/commands/report.ts +232 -9
- package/src/commands/setup.ts +60 -3
- package/src/commands/show.ts +120 -0
- package/src/commands/stats.ts +196 -0
- package/src/commands/status.ts +39 -22
- package/src/commands/sync.ts +61 -13
- package/src/commands/trend.ts +18 -7
- package/src/config.test.ts +1 -1
- package/src/config.ts +18 -14
- package/src/data.test.ts +236 -0
- package/src/data.ts +200 -0
- package/src/display.test.ts +0 -2
- package/src/display.ts +31 -2
- package/src/doctor.ts +174 -0
- package/src/envelope.ts +17 -0
- package/src/index.ts +13 -6
- package/src/models.test.ts +1 -8
- package/src/models.ts +39 -16
- package/src/notes.test.ts +509 -0
- package/src/notes.ts +270 -0
- package/src/paths.test.ts +47 -0
- package/src/paths.ts +66 -0
- package/src/sessions.ts +2 -3
- package/src/stats.test.ts +31 -20
- package/src/stats.ts +60 -7
- package/src/storage.ts +66 -28
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
|
+
"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.
|
|
35
|
-
"@types/bun": "^1.3.
|
|
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
|
+
});
|
package/src/analysis.ts
ADDED
|
@@ -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
|
+
}
|