@richhaase/c2 0.2.1 → 0.3.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 +18 -0
- package/package.json +3 -3
- package/src/commands/export.ts +45 -38
- package/src/commands/report.ts +0 -4
- package/src/display.test.ts +102 -0
- package/src/display.ts +20 -2
- package/src/export.test.ts +79 -1
- package/src/models.test.ts +102 -1
- package/src/models.ts +60 -7
package/README.md
CHANGED
|
@@ -60,6 +60,20 @@ c2 log
|
|
|
60
60
|
c2 log -n 25
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
Interval workouts are tagged with `[IVL rest M:SS.S]` so they're visually
|
|
64
|
+
distinct from continuous pieces. The rest duration is surfaced because, for
|
|
65
|
+
interval workouts, the displayed time (`time_formatted`) is elapsed time
|
|
66
|
+
including rest, while the displayed pace is correctly computed from work
|
|
67
|
+
time only. For example:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
04/11 5,000m 28:35.4 2:51.5/500m 24spm 112bpm 107df
|
|
71
|
+
04/11 3,000m 20:22.6 2:23.8/500m 30spm 152bpm 108df [IVL rest 6:00.0]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The second row is 6x500m with ~1 min rest between reps: 20:22.6 elapsed =
|
|
75
|
+
14:22.6 work + 6:00 rest. The `2:23.8/500m` pace is the work pace.
|
|
76
|
+
|
|
63
77
|
### Goal Progress
|
|
64
78
|
|
|
65
79
|
Track progress toward your distance goal:
|
|
@@ -116,6 +130,10 @@ c2 export --from 2026-01-01 --to 2026-03-01
|
|
|
116
130
|
c2 export -f jsonl > workouts.jsonl
|
|
117
131
|
```
|
|
118
132
|
|
|
133
|
+
The CSV export includes `workout_type`, `rest_time_tenths`, and
|
|
134
|
+
`rest_distance` columns so interval workouts are fully distinguishable from
|
|
135
|
+
continuous pieces without having to consult the full JSON export.
|
|
136
|
+
|
|
119
137
|
## Configuration
|
|
120
138
|
|
|
121
139
|
Config lives at `~/.config/c2/config.json`. Created automatically on `c2 setup`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@richhaase/c2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "CLI tool for syncing and analyzing Concept2 rowing data",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"concept2",
|
|
@@ -28,10 +28,10 @@
|
|
|
28
28
|
"release": "bun run check && npm version"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"commander": "^14.0.
|
|
31
|
+
"commander": "^14.0.3"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@biomejs/biome": "^2.4.
|
|
34
|
+
"@biomejs/biome": "^2.4.10",
|
|
35
35
|
"@types/bun": "^1.3.9"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
package/src/commands/export.ts
CHANGED
|
@@ -20,47 +20,54 @@ export function escapeCSV(s: string): string {
|
|
|
20
20
|
return s;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
23
|
+
export const CSV_HEADER = [
|
|
24
|
+
"id",
|
|
25
|
+
"date",
|
|
26
|
+
"distance",
|
|
27
|
+
"time_tenths",
|
|
28
|
+
"time_formatted",
|
|
29
|
+
"pace_500m",
|
|
30
|
+
"stroke_rate",
|
|
31
|
+
"stroke_count",
|
|
32
|
+
"calories",
|
|
33
|
+
"drag_factor",
|
|
34
|
+
"hr_avg",
|
|
35
|
+
"hr_min",
|
|
36
|
+
"hr_max",
|
|
37
|
+
"workout_type",
|
|
38
|
+
"rest_time_tenths",
|
|
39
|
+
"rest_distance",
|
|
40
|
+
"machine_type",
|
|
41
|
+
"comments",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export function buildCSVRow(w: Workout): string[] {
|
|
45
|
+
return [
|
|
46
|
+
String(w.id),
|
|
47
|
+
w.date,
|
|
48
|
+
String(w.distance),
|
|
49
|
+
String(w.time),
|
|
50
|
+
w.time_formatted,
|
|
51
|
+
pace500m(w),
|
|
52
|
+
String(w.stroke_rate ?? ""),
|
|
53
|
+
String(w.stroke_count ?? ""),
|
|
54
|
+
String(w.calories_total ?? ""),
|
|
55
|
+
String(w.drag_factor ?? ""),
|
|
56
|
+
w.heart_rate?.average ? String(w.heart_rate.average) : "",
|
|
57
|
+
w.heart_rate?.min ? String(w.heart_rate.min) : "",
|
|
58
|
+
w.heart_rate?.max ? String(w.heart_rate.max) : "",
|
|
59
|
+
w.workout_type ?? "",
|
|
60
|
+
w.rest_time != null ? String(w.rest_time) : "",
|
|
61
|
+
w.rest_distance != null ? String(w.rest_distance) : "",
|
|
62
|
+
w.type ?? "",
|
|
63
|
+
escapeCSV(w.comments ?? ""),
|
|
41
64
|
];
|
|
42
|
-
|
|
65
|
+
}
|
|
43
66
|
|
|
67
|
+
function exportCSV(workouts: Workout[]): void {
|
|
68
|
+
console.log(CSV_HEADER.join(","));
|
|
44
69
|
for (const w of workouts) {
|
|
45
|
-
|
|
46
|
-
String(w.id),
|
|
47
|
-
w.date,
|
|
48
|
-
String(w.distance),
|
|
49
|
-
String(w.time),
|
|
50
|
-
w.time_formatted,
|
|
51
|
-
pace500m(w),
|
|
52
|
-
String(w.stroke_rate ?? ""),
|
|
53
|
-
String(w.stroke_count ?? ""),
|
|
54
|
-
String(w.calories_total ?? ""),
|
|
55
|
-
String(w.drag_factor ?? ""),
|
|
56
|
-
w.heart_rate?.average ? String(w.heart_rate.average) : "",
|
|
57
|
-
w.heart_rate?.min ? String(w.heart_rate.min) : "",
|
|
58
|
-
w.heart_rate?.max ? String(w.heart_rate.max) : "",
|
|
59
|
-
w.workout_type ?? "",
|
|
60
|
-
w.type ?? "",
|
|
61
|
-
escapeCSV(w.comments ?? ""),
|
|
62
|
-
];
|
|
63
|
-
console.log(row.join(","));
|
|
70
|
+
console.log(buildCSVRow(w).join(","));
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
package/src/commands/report.ts
CHANGED
|
@@ -386,10 +386,6 @@ function buildProjection(goal: GoalProgress, workouts: Workout[]): string {
|
|
|
386
386
|
</div>
|
|
387
387
|
</div>
|
|
388
388
|
</div>
|
|
389
|
-
<div style="margin-top: 16px; padding: 12px; background: #21262d; border-radius: 6px; font-size: 13px; line-height: 1.8;">
|
|
390
|
-
<strong style="color: #f0f6fc;">The path forward:</strong>
|
|
391
|
-
${sessionsPerWeek} sessions/week at ${formatMeters(avgSessionDist)}m avg = ${formatMeters(Math.round(parseFloat(sessionsPerWeek as string) * avgSessionDist))}m/week.
|
|
392
|
-
</div>
|
|
393
389
|
</div>`;
|
|
394
390
|
}
|
|
395
391
|
|
package/src/display.test.ts
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
+
formatIntervalTag,
|
|
3
4
|
formatMeters,
|
|
4
5
|
formatMetersPerWeek,
|
|
5
6
|
formatPercent,
|
|
7
|
+
formatWorkoutLine,
|
|
6
8
|
paceArrow,
|
|
7
9
|
sparkBar,
|
|
8
10
|
trendArrow,
|
|
9
11
|
} from "./display.ts";
|
|
12
|
+
import type { Workout } from "./models.ts";
|
|
13
|
+
|
|
14
|
+
function makeWorkout(overrides: Partial<Workout> = {}): Workout {
|
|
15
|
+
return {
|
|
16
|
+
id: 1,
|
|
17
|
+
user_id: 1,
|
|
18
|
+
date: "2026-04-09 07:00:00",
|
|
19
|
+
distance: 5000,
|
|
20
|
+
type: "rower",
|
|
21
|
+
time: 17155,
|
|
22
|
+
time_formatted: "28:35.4",
|
|
23
|
+
workout_type: "FixedDistanceSplits",
|
|
24
|
+
stroke_rate: 24,
|
|
25
|
+
heart_rate: { average: 112 },
|
|
26
|
+
drag_factor: 107,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
10
30
|
|
|
11
31
|
describe("formatMeters", () => {
|
|
12
32
|
test.each([
|
|
@@ -82,3 +102,85 @@ describe("paceArrow", () => {
|
|
|
82
102
|
expect(paceArrow(170, 180)).toBe("\u2193");
|
|
83
103
|
});
|
|
84
104
|
});
|
|
105
|
+
|
|
106
|
+
describe("formatIntervalTag", () => {
|
|
107
|
+
test("returns empty string for a continuous piece", () => {
|
|
108
|
+
const w = makeWorkout({ workout_type: "FixedDistanceSplits" });
|
|
109
|
+
expect(formatIntervalTag(w)).toBe("");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns tag with rest duration for interval workout", () => {
|
|
113
|
+
const w = makeWorkout({
|
|
114
|
+
workout_type: "FixedDistanceInterval",
|
|
115
|
+
rest_time: 3600,
|
|
116
|
+
});
|
|
117
|
+
expect(formatIntervalTag(w)).toBe("[IVL rest 6:00.0]");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns bare [IVL] when rest_time is missing but type is Interval", () => {
|
|
121
|
+
const w = makeWorkout({ workout_type: "FixedDistanceInterval" });
|
|
122
|
+
expect(formatIntervalTag(w)).toBe("[IVL]");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("detects interval via rest_distance alone", () => {
|
|
126
|
+
const w = makeWorkout({
|
|
127
|
+
workout_type: undefined,
|
|
128
|
+
rest_distance: 660,
|
|
129
|
+
});
|
|
130
|
+
expect(formatIntervalTag(w)).toBe("[IVL]");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("formatWorkoutLine", () => {
|
|
135
|
+
test("formats a continuous piece without an interval tag", () => {
|
|
136
|
+
const w = makeWorkout({
|
|
137
|
+
date: "2026-04-09 07:00:00",
|
|
138
|
+
distance: 5000,
|
|
139
|
+
time: 17155,
|
|
140
|
+
time_formatted: "28:35.4",
|
|
141
|
+
workout_type: "FixedDistanceSplits",
|
|
142
|
+
stroke_rate: 24,
|
|
143
|
+
heart_rate: { average: 112 },
|
|
144
|
+
drag_factor: 107,
|
|
145
|
+
});
|
|
146
|
+
const line = formatWorkoutLine(w, "01/02");
|
|
147
|
+
expect(line).toContain("04/09");
|
|
148
|
+
expect(line).toContain("5,000m");
|
|
149
|
+
expect(line).toContain("28:35.4");
|
|
150
|
+
expect(line).toContain("2:51.6/500m");
|
|
151
|
+
expect(line).toContain("24spm");
|
|
152
|
+
expect(line).toContain("112bpm");
|
|
153
|
+
expect(line).toContain("107df");
|
|
154
|
+
expect(line).not.toContain("[IVL");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("appends [IVL rest M:SS.S] tag for interval workouts", () => {
|
|
158
|
+
// Real 2026-04-11 session: 6x500m, 14:22.6 work, 6:00 rest.
|
|
159
|
+
const w = makeWorkout({
|
|
160
|
+
date: "2026-04-11 09:14:00",
|
|
161
|
+
distance: 3000,
|
|
162
|
+
time: 8626,
|
|
163
|
+
time_formatted: "20:22.6",
|
|
164
|
+
workout_type: "FixedDistanceInterval",
|
|
165
|
+
rest_time: 3600,
|
|
166
|
+
rest_distance: 660,
|
|
167
|
+
stroke_rate: 30,
|
|
168
|
+
heart_rate: { average: 152 },
|
|
169
|
+
drag_factor: 108,
|
|
170
|
+
});
|
|
171
|
+
const line = formatWorkoutLine(w, "01/02");
|
|
172
|
+
expect(line).toContain("[IVL rest 6:00.0]");
|
|
173
|
+
// Pace comes from work time, not elapsed — must be 2:23.8, not 3:23.8.
|
|
174
|
+
expect(line).toContain("2:23.8/500m");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("handles missing stroke_rate / heart_rate / drag_factor gracefully", () => {
|
|
178
|
+
const w = makeWorkout({
|
|
179
|
+
stroke_rate: undefined,
|
|
180
|
+
heart_rate: undefined,
|
|
181
|
+
drag_factor: undefined,
|
|
182
|
+
});
|
|
183
|
+
const line = formatWorkoutLine(w, "01/02");
|
|
184
|
+
expect(line).toContain(" -");
|
|
185
|
+
});
|
|
186
|
+
});
|
package/src/display.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Workout } from "./models.ts";
|
|
2
|
-
import { pace500m, parsedDate } from "./models.ts";
|
|
2
|
+
import { formatSeconds, isIntervalWorkout, pace500m, parsedDate, restSeconds } from "./models.ts";
|
|
3
3
|
|
|
4
4
|
export function formatMeters(m: number): string {
|
|
5
5
|
return m.toLocaleString("en-US");
|
|
@@ -32,6 +32,22 @@ export function formatDate(d: Date, fmt: string): string {
|
|
|
32
32
|
return `${mm}/${dd}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Format an interval-workout marker suffix for `formatWorkoutLine`.
|
|
37
|
+
*
|
|
38
|
+
* For interval workouts, appends a compact `[IVL rest M:SS.S]` tag that
|
|
39
|
+
* flags the workout as intervals and reports the total rest duration so
|
|
40
|
+
* the reader can reconcile `time_formatted` (elapsed including rest)
|
|
41
|
+
* against `pace500m` (work-only pace). For non-interval workouts, returns
|
|
42
|
+
* an empty string.
|
|
43
|
+
*/
|
|
44
|
+
export function formatIntervalTag(w: Workout): string {
|
|
45
|
+
if (!isIntervalWorkout(w)) return "";
|
|
46
|
+
const rest = restSeconds(w);
|
|
47
|
+
if (rest > 0) return `[IVL rest ${formatSeconds(rest)}]`;
|
|
48
|
+
return "[IVL]";
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
export function formatWorkoutLine(w: Workout, dateFormat: string): string {
|
|
36
52
|
const d = parsedDate(w);
|
|
37
53
|
const dateStr = formatDate(d, dateFormat);
|
|
@@ -40,8 +56,10 @@ export function formatWorkoutLine(w: Workout, dateFormat: string): string {
|
|
|
40
56
|
const spm = w.stroke_rate ? `${w.stroke_rate}spm` : "-";
|
|
41
57
|
const hr = w.heart_rate?.average && w.heart_rate.average > 0 ? `${w.heart_rate.average}bpm` : "-";
|
|
42
58
|
const df = w.drag_factor ? `${w.drag_factor}df` : "-";
|
|
59
|
+
const tag = formatIntervalTag(w);
|
|
60
|
+
const tagSuffix = tag ? ` ${tag}` : "";
|
|
43
61
|
|
|
44
|
-
return `${dateStr} ${distance.padStart(7)} ${w.time_formatted.padStart(8)} ${pace.padStart(7)}/500m ${spm.padStart(5)} ${hr.padStart(6)} ${df.padStart(4)}`;
|
|
62
|
+
return `${dateStr} ${distance.padStart(7)} ${w.time_formatted.padStart(8)} ${pace.padStart(7)}/500m ${spm.padStart(5)} ${hr.padStart(6)} ${df.padStart(4)}${tagSuffix}`;
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
export function sparkBar(value: number, max: number): string {
|
package/src/export.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { escapeCSV, filterByDate } from "./commands/export.ts";
|
|
2
|
+
import { buildCSVRow, CSV_HEADER, escapeCSV, filterByDate } from "./commands/export.ts";
|
|
3
3
|
import type { Workout } from "./models.ts";
|
|
4
4
|
|
|
5
5
|
function makeWorkout(id: number, date: string, distance: number): Workout {
|
|
@@ -68,3 +68,81 @@ describe("filterByDate", () => {
|
|
|
68
68
|
expect(result).toHaveLength(3);
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("CSV_HEADER", () => {
|
|
73
|
+
test("includes rest_time_tenths and rest_distance columns", () => {
|
|
74
|
+
expect(CSV_HEADER).toContain("rest_time_tenths");
|
|
75
|
+
expect(CSV_HEADER).toContain("rest_distance");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("includes workout_type column", () => {
|
|
79
|
+
expect(CSV_HEADER).toContain("workout_type");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("buildCSVRow", () => {
|
|
84
|
+
test("row length matches header length", () => {
|
|
85
|
+
const w = makeWorkout(1, "2026-01-15 10:00:00", 5000);
|
|
86
|
+
const row = buildCSVRow(w);
|
|
87
|
+
expect(row).toHaveLength(CSV_HEADER.length);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("leaves rest columns empty for a continuous piece", () => {
|
|
91
|
+
const w: Workout = {
|
|
92
|
+
id: 1,
|
|
93
|
+
user_id: 1,
|
|
94
|
+
date: "2026-04-09 07:00:00",
|
|
95
|
+
distance: 5000,
|
|
96
|
+
type: "rower",
|
|
97
|
+
time: 17155,
|
|
98
|
+
time_formatted: "28:35.4",
|
|
99
|
+
workout_type: "FixedDistanceSplits",
|
|
100
|
+
};
|
|
101
|
+
const row = buildCSVRow(w);
|
|
102
|
+
const restTimeIdx = CSV_HEADER.indexOf("rest_time_tenths");
|
|
103
|
+
const restDistanceIdx = CSV_HEADER.indexOf("rest_distance");
|
|
104
|
+
expect(row[restTimeIdx]).toBe("");
|
|
105
|
+
expect(row[restDistanceIdx]).toBe("");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("populates rest columns for an interval workout", () => {
|
|
109
|
+
const w: Workout = {
|
|
110
|
+
id: 2,
|
|
111
|
+
user_id: 1,
|
|
112
|
+
date: "2026-04-11 09:14:00",
|
|
113
|
+
distance: 3000,
|
|
114
|
+
type: "rower",
|
|
115
|
+
time: 8626,
|
|
116
|
+
time_formatted: "20:22.6",
|
|
117
|
+
workout_type: "FixedDistanceInterval",
|
|
118
|
+
rest_time: 3600,
|
|
119
|
+
rest_distance: 660,
|
|
120
|
+
};
|
|
121
|
+
const row = buildCSVRow(w);
|
|
122
|
+
const workoutTypeIdx = CSV_HEADER.indexOf("workout_type");
|
|
123
|
+
const restTimeIdx = CSV_HEADER.indexOf("rest_time_tenths");
|
|
124
|
+
const restDistanceIdx = CSV_HEADER.indexOf("rest_distance");
|
|
125
|
+
expect(row[workoutTypeIdx]).toBe("FixedDistanceInterval");
|
|
126
|
+
expect(row[restTimeIdx]).toBe("3600");
|
|
127
|
+
expect(row[restDistanceIdx]).toBe("660");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("preserves explicit zero rest_time", () => {
|
|
131
|
+
const w: Workout = {
|
|
132
|
+
id: 3,
|
|
133
|
+
user_id: 1,
|
|
134
|
+
date: "2026-04-09 07:00:00",
|
|
135
|
+
distance: 5000,
|
|
136
|
+
type: "rower",
|
|
137
|
+
time: 17155,
|
|
138
|
+
time_formatted: "28:35.4",
|
|
139
|
+
rest_time: 0,
|
|
140
|
+
rest_distance: 0,
|
|
141
|
+
};
|
|
142
|
+
const row = buildCSVRow(w);
|
|
143
|
+
const restTimeIdx = CSV_HEADER.indexOf("rest_time_tenths");
|
|
144
|
+
const restDistanceIdx = CSV_HEADER.indexOf("rest_distance");
|
|
145
|
+
expect(row[restTimeIdx]).toBe("0");
|
|
146
|
+
expect(row[restDistanceIdx]).toBe("0");
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/models.test.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { Workout } from "./models.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
calendarDay,
|
|
5
|
+
formatSeconds,
|
|
6
|
+
isIntervalWorkout,
|
|
7
|
+
pace500m,
|
|
8
|
+
pace500mSeconds,
|
|
9
|
+
parsedDate,
|
|
10
|
+
restSeconds,
|
|
11
|
+
workSeconds,
|
|
12
|
+
} from "./models.ts";
|
|
4
13
|
|
|
5
14
|
function makeWorkout(overrides: Partial<Workout> = {}): Workout {
|
|
6
15
|
return {
|
|
@@ -69,4 +78,96 @@ describe("pace500m", () => {
|
|
|
69
78
|
// 3706 / 10 * 500 / 1000 = 185.3
|
|
70
79
|
expect(pace500m(w)).toBe("3:05.3");
|
|
71
80
|
});
|
|
81
|
+
|
|
82
|
+
test("uses work time (not elapsed) for interval workout pace", () => {
|
|
83
|
+
// Real 2026-04-11 session: 6x500m, 14:22.6 work, 6:00 rest.
|
|
84
|
+
// API returns `time: 8626` tenths (work), `time_formatted: "20:22.6"` (elapsed).
|
|
85
|
+
// Pace must be computed from `time`, not `time_formatted`.
|
|
86
|
+
const w = makeWorkout({
|
|
87
|
+
distance: 3000,
|
|
88
|
+
time: 8626,
|
|
89
|
+
time_formatted: "20:22.6",
|
|
90
|
+
workout_type: "FixedDistanceInterval",
|
|
91
|
+
rest_time: 3600,
|
|
92
|
+
rest_distance: 660,
|
|
93
|
+
});
|
|
94
|
+
// 8626 / 10 * 500 / 3000 = 143.77
|
|
95
|
+
expect(pace500m(w)).toBe("2:23.8");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("isIntervalWorkout", () => {
|
|
100
|
+
test("returns false for a continuous piece", () => {
|
|
101
|
+
const w = makeWorkout({
|
|
102
|
+
workout_type: "FixedDistanceSplits",
|
|
103
|
+
});
|
|
104
|
+
expect(isIntervalWorkout(w)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("returns true for FixedDistanceInterval workout_type", () => {
|
|
108
|
+
const w = makeWorkout({ workout_type: "FixedDistanceInterval" });
|
|
109
|
+
expect(isIntervalWorkout(w)).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns true for FixedTimeInterval workout_type", () => {
|
|
113
|
+
const w = makeWorkout({ workout_type: "FixedTimeInterval" });
|
|
114
|
+
expect(isIntervalWorkout(w)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns true when rest_time > 0 even without workout_type", () => {
|
|
118
|
+
const w = makeWorkout({ rest_time: 3600 });
|
|
119
|
+
expect(isIntervalWorkout(w)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("returns true when rest_distance > 0 even without workout_type", () => {
|
|
123
|
+
const w = makeWorkout({ rest_distance: 660 });
|
|
124
|
+
expect(isIntervalWorkout(w)).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("returns false when rest_time is explicitly 0", () => {
|
|
128
|
+
const w = makeWorkout({
|
|
129
|
+
workout_type: "FixedDistanceSplits",
|
|
130
|
+
rest_time: 0,
|
|
131
|
+
rest_distance: 0,
|
|
132
|
+
});
|
|
133
|
+
expect(isIntervalWorkout(w)).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("returns false when workout_type is missing and no rest", () => {
|
|
137
|
+
const w = makeWorkout({});
|
|
138
|
+
expect(isIntervalWorkout(w)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("restSeconds", () => {
|
|
143
|
+
test("returns 0 when rest_time is undefined", () => {
|
|
144
|
+
const w = makeWorkout({});
|
|
145
|
+
expect(restSeconds(w)).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("converts tenths to seconds", () => {
|
|
149
|
+
const w = makeWorkout({ rest_time: 3600 });
|
|
150
|
+
expect(restSeconds(w)).toBe(360);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("workSeconds", () => {
|
|
155
|
+
test("converts time tenths to seconds", () => {
|
|
156
|
+
// 8626 tenths = 862.6 seconds = 14:22.6
|
|
157
|
+
const w = makeWorkout({ time: 8626 });
|
|
158
|
+
expect(workSeconds(w)).toBe(862.6);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("formatSeconds", () => {
|
|
163
|
+
test.each([
|
|
164
|
+
[0, "0:00.0"],
|
|
165
|
+
[-5, "0:00.0"],
|
|
166
|
+
[5.5, "0:05.5"],
|
|
167
|
+
[65.3, "1:05.3"],
|
|
168
|
+
[360, "6:00.0"],
|
|
169
|
+
[862.6, "14:22.6"],
|
|
170
|
+
])("formatSeconds(%f) = %s", (input, expected) => {
|
|
171
|
+
expect(formatSeconds(input)).toBe(expected);
|
|
172
|
+
});
|
|
72
173
|
});
|
package/src/models.ts
CHANGED
|
@@ -4,17 +4,30 @@ export interface HeartRate {
|
|
|
4
4
|
max?: number;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* A single result from the Concept2 Logbook API.
|
|
9
|
+
*
|
|
10
|
+
* Time fields are in tenths of a second. For interval workouts (those with
|
|
11
|
+
* `rest_time > 0` or a `workout_type` containing "Interval"):
|
|
12
|
+
* - `time` is **work time only** (total across all reps, excluding rest)
|
|
13
|
+
* - `time_formatted` is **elapsed time including rest**
|
|
14
|
+
* - `distance` is total work meters (excluding rest rowing)
|
|
15
|
+
*
|
|
16
|
+
* This means pace (`time / distance`) is correctly work pace, but
|
|
17
|
+
* `time_formatted` cannot be used for pace math on interval workouts.
|
|
18
|
+
* Use `isIntervalWorkout()` to classify and `restSeconds()` to recover
|
|
19
|
+
* elapsed vs work time when needed.
|
|
20
|
+
*/
|
|
8
21
|
export interface Workout {
|
|
9
22
|
id: number;
|
|
10
23
|
user_id: number;
|
|
11
24
|
date: string; // "2026-03-02 17:41:00"
|
|
12
25
|
timezone?: string;
|
|
13
|
-
distance: number;
|
|
26
|
+
distance: number; // meters of work (rest rowing NOT included)
|
|
14
27
|
type: string; // "rower"
|
|
15
|
-
time: number; // tenths of a second
|
|
16
|
-
time_formatted: string;
|
|
17
|
-
workout_type?: string;
|
|
28
|
+
time: number; // tenths of a second of work time (rest NOT included)
|
|
29
|
+
time_formatted: string; // elapsed time including rest for interval workouts
|
|
30
|
+
workout_type?: string; // e.g., "FixedDistanceSplits", "FixedDistanceInterval"
|
|
18
31
|
source?: string;
|
|
19
32
|
weight_class?: string;
|
|
20
33
|
stroke_rate?: number;
|
|
@@ -23,6 +36,10 @@ export interface Workout {
|
|
|
23
36
|
drag_factor?: number;
|
|
24
37
|
heart_rate?: HeartRate;
|
|
25
38
|
stroke_data?: boolean;
|
|
39
|
+
/** Total rest time across all rest intervals, in tenths of a second. */
|
|
40
|
+
rest_time?: number;
|
|
41
|
+
/** Total meters rowed during rest intervals (easy rowing between reps). */
|
|
42
|
+
rest_distance?: number;
|
|
26
43
|
comments?: string;
|
|
27
44
|
}
|
|
28
45
|
|
|
@@ -87,7 +104,43 @@ export function pace500mSeconds(w: Workout): number {
|
|
|
87
104
|
export function pace500m(w: Workout): string {
|
|
88
105
|
const secs = pace500mSeconds(w);
|
|
89
106
|
if (secs === 0) return "-";
|
|
90
|
-
|
|
91
|
-
|
|
107
|
+
return formatSeconds(secs);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Returns true if this workout is an interval workout — i.e., multiple
|
|
112
|
+
* work reps separated by rest. Interval workouts have `time` as work time
|
|
113
|
+
* only and `time_formatted` as elapsed time including rest.
|
|
114
|
+
*
|
|
115
|
+
* Detection is inclusive: any of the following is sufficient.
|
|
116
|
+
* - `workout_type` contains "Interval" (the Concept2 API's own classification)
|
|
117
|
+
* - `rest_time` is > 0
|
|
118
|
+
* - `rest_distance` is > 0
|
|
119
|
+
*/
|
|
120
|
+
export function isIntervalWorkout(w: Workout): boolean {
|
|
121
|
+
if (w.workout_type?.includes("Interval")) return true;
|
|
122
|
+
if (w.rest_time != null && w.rest_time > 0) return true;
|
|
123
|
+
if (w.rest_distance != null && w.rest_distance > 0) return true;
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Total rest time in seconds (0 when not an interval workout). */
|
|
128
|
+
export function restSeconds(w: Workout): number {
|
|
129
|
+
return (w.rest_time ?? 0) / TENTHS_PER_SECOND;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Total work time (excluding rest) in seconds. */
|
|
133
|
+
export function workSeconds(w: Workout): number {
|
|
134
|
+
return w.time / TENTHS_PER_SECOND;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Format a duration in seconds as `M:SS.S` (matching the Concept2 API's
|
|
139
|
+
* `time_formatted` style). Returns `"0:00.0"` for zero.
|
|
140
|
+
*/
|
|
141
|
+
export function formatSeconds(totalSeconds: number): string {
|
|
142
|
+
if (totalSeconds <= 0) return "0:00.0";
|
|
143
|
+
const mins = Math.floor(totalSeconds / 60);
|
|
144
|
+
const rem = totalSeconds - mins * 60;
|
|
92
145
|
return `${mins}:${rem.toFixed(1).padStart(4, "0")}`;
|
|
93
146
|
}
|