@richhaase/c2 0.3.1 → 0.3.2
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/package.json +1 -1
- package/src/commands/export.ts +0 -1
- package/src/commands/report.ts +1 -7
- package/src/commands/setup.ts +0 -5
- package/src/display.ts +0 -11
- package/src/index.ts +0 -1
- package/src/models.ts +0 -28
- package/src/stats.test.ts +28 -0
- package/src/stats.ts +19 -2
package/package.json
CHANGED
package/src/commands/export.ts
CHANGED
package/src/commands/report.ts
CHANGED
|
@@ -194,7 +194,6 @@ ${rows}
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
function buildWeeklyTrends(summaries: WeekSummary[]): string {
|
|
197
|
-
// Find best volume week and best pace week for highlighting
|
|
198
197
|
let bestVolume = 0;
|
|
199
198
|
let bestPace = Infinity;
|
|
200
199
|
for (const ws of summaries) {
|
|
@@ -224,7 +223,6 @@ function buildWeeklyTrends(summaries: WeekSummary[]): string {
|
|
|
224
223
|
})
|
|
225
224
|
.join("\n");
|
|
226
225
|
|
|
227
|
-
// Pace trend summary
|
|
228
226
|
const firstPace = summaries.find((w) => w.paceCount > 0);
|
|
229
227
|
const lastPace = [...summaries].reverse().find((w) => w.paceCount > 0);
|
|
230
228
|
let trendNote = "";
|
|
@@ -261,7 +259,6 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
|
|
|
261
259
|
const sorted = [...workouts].sort((a, b) => b.date.localeCompare(a.date));
|
|
262
260
|
const recent = sorted.slice(0, count).reverse();
|
|
263
261
|
|
|
264
|
-
// Detect same-day groups for session-aware formatting
|
|
265
262
|
const dayCounts = new Map<string, number>();
|
|
266
263
|
for (const w of recent) {
|
|
267
264
|
const day = calendarDay(w);
|
|
@@ -278,7 +275,6 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
|
|
|
278
275
|
const spm = w.stroke_rate ?? "-";
|
|
279
276
|
const hr = w.heart_rate?.average ?? "-";
|
|
280
277
|
|
|
281
|
-
// If multiple workouts on same day, annotate
|
|
282
278
|
const multiDay = (dayCounts.get(day) || 0) > 1;
|
|
283
279
|
if (!multiDay) {
|
|
284
280
|
return ` <tr>
|
|
@@ -290,16 +286,14 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
|
|
|
290
286
|
</tr>`;
|
|
291
287
|
}
|
|
292
288
|
|
|
293
|
-
// Session-aware: short warmup/cooldown get muted style
|
|
294
289
|
const isShort = w.distance <= 1500;
|
|
295
|
-
const isHard = paceS > 0 && paceS < 160; // sub-2:40
|
|
290
|
+
const isHard = paceS > 0 && paceS < 160; // sub-2:40
|
|
296
291
|
let annotation = "";
|
|
297
292
|
let rowStyle = "";
|
|
298
293
|
let paceStyle = "";
|
|
299
294
|
let hrStyle = "";
|
|
300
295
|
|
|
301
296
|
if (isShort && !isHard) {
|
|
302
|
-
// Likely warmup or cooldown — check position
|
|
303
297
|
const dayWorkouts = recent
|
|
304
298
|
.filter((r) => calendarDay(r) === day)
|
|
305
299
|
.sort((a, b) => a.date.localeCompare(b.date));
|
package/src/commands/setup.ts
CHANGED
|
@@ -39,11 +39,9 @@ export function registerSetup(program: Command): void {
|
|
|
39
39
|
|
|
40
40
|
console.log("Concept2 CLI Setup\n");
|
|
41
41
|
|
|
42
|
-
// Token
|
|
43
42
|
const token = promptValue("API token (from log.concept2.com)", cfg.api.token, true);
|
|
44
43
|
cfg.api.token = token;
|
|
45
44
|
|
|
46
|
-
// Goal target
|
|
47
45
|
const targetDisplay = formatMeters(cfg.goal.target_meters);
|
|
48
46
|
const targetInput = promptValue("Goal target meters", targetDisplay);
|
|
49
47
|
const parsed = parseInt(targetInput.replace(/,/g, ""), 10);
|
|
@@ -51,7 +49,6 @@ export function registerSetup(program: Command): void {
|
|
|
51
49
|
cfg.goal.target_meters = parsed;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
// Goal dates
|
|
55
52
|
const startInput = promptValue("Goal start date (YYYY-MM-DD)", cfg.goal.start_date);
|
|
56
53
|
try {
|
|
57
54
|
parseGoalDate(startInput);
|
|
@@ -68,7 +65,6 @@ export function registerSetup(program: Command): void {
|
|
|
68
65
|
console.log(`Invalid date "${endInput}", keeping previous value.`);
|
|
69
66
|
}
|
|
70
67
|
|
|
71
|
-
// Save
|
|
72
68
|
await ensureDirs();
|
|
73
69
|
await saveConfig(cfg);
|
|
74
70
|
console.log(`\nConfig written to ${join(configDir(), "config.json")}`);
|
|
@@ -79,7 +75,6 @@ export function registerSetup(program: Command): void {
|
|
|
79
75
|
);
|
|
80
76
|
}
|
|
81
77
|
|
|
82
|
-
// Verify token
|
|
83
78
|
if (cfg.api.token) {
|
|
84
79
|
console.log("Verifying token...");
|
|
85
80
|
try {
|
package/src/display.ts
CHANGED
|
@@ -14,7 +14,6 @@ export function formatMetersPerWeek(m: number): string {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function formatDate(d: Date, fmt: string): string {
|
|
17
|
-
// Go-style reference time: "01/02" means month/day
|
|
18
17
|
if (fmt === "01/02" || fmt === "%m/%d") {
|
|
19
18
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
20
19
|
const dd = String(d.getDate()).padStart(2, "0");
|
|
@@ -26,21 +25,11 @@ export function formatDate(d: Date, fmt: string): string {
|
|
|
26
25
|
const dd = String(d.getDate()).padStart(2, "0");
|
|
27
26
|
return `${y}-${mm}-${dd}`;
|
|
28
27
|
}
|
|
29
|
-
// Fallback: MM/DD
|
|
30
28
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
31
29
|
const dd = String(d.getDate()).padStart(2, "0");
|
|
32
30
|
return `${mm}/${dd}`;
|
|
33
31
|
}
|
|
34
32
|
|
|
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
33
|
export function formatIntervalTag(w: Workout): string {
|
|
45
34
|
if (!isIntervalWorkout(w)) return "";
|
|
46
35
|
const rest = restSeconds(w);
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,6 @@ registerTrend(program);
|
|
|
23
23
|
registerExport(program);
|
|
24
24
|
registerReport(program);
|
|
25
25
|
|
|
26
|
-
// Default to `report` when run with no subcommand
|
|
27
26
|
const args = process.argv.slice(2);
|
|
28
27
|
const knownCommands = program.commands.map((c) => c.name());
|
|
29
28
|
const hasSubcommand = args.some((a) => knownCommands.includes(a));
|
package/src/models.ts
CHANGED
|
@@ -4,20 +4,6 @@ export interface HeartRate {
|
|
|
4
4
|
max?: number;
|
|
5
5
|
}
|
|
6
6
|
|
|
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
|
-
*/
|
|
21
7
|
export interface Workout {
|
|
22
8
|
id: number;
|
|
23
9
|
user_id: number;
|
|
@@ -107,16 +93,6 @@ export function pace500m(w: Workout): string {
|
|
|
107
93
|
return formatSeconds(secs);
|
|
108
94
|
}
|
|
109
95
|
|
|
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
96
|
export function isIntervalWorkout(w: Workout): boolean {
|
|
121
97
|
if (w.workout_type?.includes("Interval")) return true;
|
|
122
98
|
if (w.rest_time != null && w.rest_time > 0) return true;
|
|
@@ -134,10 +110,6 @@ export function workSeconds(w: Workout): number {
|
|
|
134
110
|
return w.time / TENTHS_PER_SECOND;
|
|
135
111
|
}
|
|
136
112
|
|
|
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
113
|
export function formatSeconds(totalSeconds: number): string {
|
|
142
114
|
if (totalSeconds <= 0) return "0:00.0";
|
|
143
115
|
const mins = Math.floor(totalSeconds / 60);
|
package/src/stats.test.ts
CHANGED
|
@@ -158,4 +158,32 @@ describe("computeGoalProgress", () => {
|
|
|
158
158
|
expect(goal.weeksElapsed).toBe(0);
|
|
159
159
|
expect(goal.currentAvgPace).toBe(0);
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
test("currentAvgPace uses recent 4-week window, not lifetime", () => {
|
|
163
|
+
const cfg = makeGoalConfig();
|
|
164
|
+
const workouts = [
|
|
165
|
+
makeWorkout(1, "2026-01-05 10:00:00", 5_000),
|
|
166
|
+
makeWorkout(2, "2026-01-12 10:00:00", 8_000),
|
|
167
|
+
makeWorkout(3, "2026-01-19 10:00:00", 11_000),
|
|
168
|
+
makeWorkout(4, "2026-03-09 10:00:00", 20_000),
|
|
169
|
+
makeWorkout(5, "2026-03-16 10:00:00", 20_000),
|
|
170
|
+
makeWorkout(6, "2026-03-23 10:00:00", 20_000),
|
|
171
|
+
makeWorkout(7, "2026-03-30 10:00:00", 20_000),
|
|
172
|
+
];
|
|
173
|
+
const now = new Date(2026, 2, 30, 18);
|
|
174
|
+
const goal = computeGoalProgress(workouts, cfg, now);
|
|
175
|
+
expect(goal.currentAvgPace).toBe(20_000);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("currentAvgPace ignores workouts before the recent window", () => {
|
|
179
|
+
const cfg = makeGoalConfig();
|
|
180
|
+
const workouts = [
|
|
181
|
+
makeWorkout(1, "2026-01-05 10:00:00", 100_000),
|
|
182
|
+
makeWorkout(2, "2026-03-23 10:00:00", 15_000),
|
|
183
|
+
makeWorkout(3, "2026-03-30 10:00:00", 15_000),
|
|
184
|
+
];
|
|
185
|
+
const now = new Date(2026, 2, 30, 18);
|
|
186
|
+
const goal = computeGoalProgress(workouts, cfg, now);
|
|
187
|
+
expect(goal.currentAvgPace).toBe(7_500);
|
|
188
|
+
});
|
|
161
189
|
});
|
package/src/stats.ts
CHANGED
|
@@ -24,10 +24,12 @@ export interface GoalProgress {
|
|
|
24
24
|
remainingMeters: number;
|
|
25
25
|
remainingWeeks: number;
|
|
26
26
|
requiredPace: number; // meters/week needed going forward
|
|
27
|
-
currentAvgPace: number; // meters/week
|
|
27
|
+
currentAvgPace: number; // meters/week over the recent window
|
|
28
28
|
onPace: boolean;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
const RECENT_WEEKS = 4;
|
|
32
|
+
|
|
31
33
|
export function mondayOf(t: Date): Date {
|
|
32
34
|
const d = new Date(t.getFullYear(), t.getMonth(), t.getDate());
|
|
33
35
|
const offset = (d.getDay() + 6) % 7;
|
|
@@ -132,7 +134,22 @@ export function computeGoalProgress(workouts: Workout[], cfg: Config, now?: Date
|
|
|
132
134
|
if (remainingWeeks < 1) remainingWeeks = 1;
|
|
133
135
|
const requiredPace = Math.floor(remainingMeters / remainingWeeks);
|
|
134
136
|
|
|
135
|
-
|
|
137
|
+
let currentAvgPace = 0;
|
|
138
|
+
if (weeksElapsed > 0) {
|
|
139
|
+
const thisMonday = mondayOf(today);
|
|
140
|
+
const recentStart = new Date(thisMonday);
|
|
141
|
+
recentStart.setDate(recentStart.getDate() - (RECENT_WEEKS - 1) * 7);
|
|
142
|
+
const windowStart = recentStart < start ? start : recentStart;
|
|
143
|
+
let recentMeters = 0;
|
|
144
|
+
for (const w of workouts) {
|
|
145
|
+
const t = parsedDate(w);
|
|
146
|
+
if (t >= windowStart && t <= today) {
|
|
147
|
+
recentMeters += w.distance;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const weeksInWindow = Math.min(RECENT_WEEKS, Math.max(1, weeksElapsed));
|
|
151
|
+
currentAvgPace = Math.floor(recentMeters / weeksInWindow);
|
|
152
|
+
}
|
|
136
153
|
const targetWeekly = target / totalWeeks;
|
|
137
154
|
const onPace = currentAvgPace >= targetWeekly;
|
|
138
155
|
|