@richhaase/c2 0.3.0 → 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 +14 -12
- 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
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
buildWeekSummaries,
|
|
12
12
|
computeGoalProgress,
|
|
13
13
|
type GoalProgress,
|
|
14
|
+
mondayOf,
|
|
14
15
|
type WeekSummary,
|
|
16
|
+
workoutsInRange,
|
|
15
17
|
} from "../stats.ts";
|
|
16
18
|
import { readWorkouts } from "../storage.ts";
|
|
17
19
|
|
|
@@ -192,7 +194,6 @@ ${rows}
|
|
|
192
194
|
}
|
|
193
195
|
|
|
194
196
|
function buildWeeklyTrends(summaries: WeekSummary[]): string {
|
|
195
|
-
// Find best volume week and best pace week for highlighting
|
|
196
197
|
let bestVolume = 0;
|
|
197
198
|
let bestPace = Infinity;
|
|
198
199
|
for (const ws of summaries) {
|
|
@@ -222,7 +223,6 @@ function buildWeeklyTrends(summaries: WeekSummary[]): string {
|
|
|
222
223
|
})
|
|
223
224
|
.join("\n");
|
|
224
225
|
|
|
225
|
-
// Pace trend summary
|
|
226
226
|
const firstPace = summaries.find((w) => w.paceCount > 0);
|
|
227
227
|
const lastPace = [...summaries].reverse().find((w) => w.paceCount > 0);
|
|
228
228
|
let trendNote = "";
|
|
@@ -259,7 +259,6 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
|
|
|
259
259
|
const sorted = [...workouts].sort((a, b) => b.date.localeCompare(a.date));
|
|
260
260
|
const recent = sorted.slice(0, count).reverse();
|
|
261
261
|
|
|
262
|
-
// Detect same-day groups for session-aware formatting
|
|
263
262
|
const dayCounts = new Map<string, number>();
|
|
264
263
|
for (const w of recent) {
|
|
265
264
|
const day = calendarDay(w);
|
|
@@ -276,7 +275,6 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
|
|
|
276
275
|
const spm = w.stroke_rate ?? "-";
|
|
277
276
|
const hr = w.heart_rate?.average ?? "-";
|
|
278
277
|
|
|
279
|
-
// If multiple workouts on same day, annotate
|
|
280
278
|
const multiDay = (dayCounts.get(day) || 0) > 1;
|
|
281
279
|
if (!multiDay) {
|
|
282
280
|
return ` <tr>
|
|
@@ -288,16 +286,14 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
|
|
|
288
286
|
</tr>`;
|
|
289
287
|
}
|
|
290
288
|
|
|
291
|
-
// Session-aware: short warmup/cooldown get muted style
|
|
292
289
|
const isShort = w.distance <= 1500;
|
|
293
|
-
const isHard = paceS > 0 && paceS < 160; // sub-2:40
|
|
290
|
+
const isHard = paceS > 0 && paceS < 160; // sub-2:40
|
|
294
291
|
let annotation = "";
|
|
295
292
|
let rowStyle = "";
|
|
296
293
|
let paceStyle = "";
|
|
297
294
|
let hrStyle = "";
|
|
298
295
|
|
|
299
296
|
if (isShort && !isHard) {
|
|
300
|
-
// Likely warmup or cooldown — check position
|
|
301
297
|
const dayWorkouts = recent
|
|
302
298
|
.filter((r) => calendarDay(r) === day)
|
|
303
299
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -393,11 +389,12 @@ function buildHTML(
|
|
|
393
389
|
goal: GoalProgress,
|
|
394
390
|
summaries: WeekSummary[],
|
|
395
391
|
allWorkouts: Workout[],
|
|
392
|
+
windowedWorkouts: Workout[],
|
|
396
393
|
recentCount: number,
|
|
397
394
|
): string {
|
|
398
|
-
const sessions = sessionCount(
|
|
399
|
-
const avgPace = avgPaceForWorkouts(
|
|
400
|
-
const avgHR = avgHRForWorkouts(
|
|
395
|
+
const sessions = sessionCount(windowedWorkouts);
|
|
396
|
+
const avgPace = avgPaceForWorkouts(windowedWorkouts);
|
|
397
|
+
const avgHR = avgHRForWorkouts(windowedWorkouts);
|
|
401
398
|
const today = new Date();
|
|
402
399
|
|
|
403
400
|
return `<!DOCTYPE html>
|
|
@@ -681,8 +678,13 @@ export function registerReport(program: Command): void {
|
|
|
681
678
|
console.error("Error: --weeks must be a positive integer.");
|
|
682
679
|
process.exit(1);
|
|
683
680
|
}
|
|
684
|
-
const
|
|
685
|
-
const
|
|
681
|
+
const now = new Date();
|
|
682
|
+
const summaries = buildWeekSummaries(workouts, now, weeks);
|
|
683
|
+
const thisMonday = mondayOf(now);
|
|
684
|
+
const cutoff = new Date(thisMonday);
|
|
685
|
+
cutoff.setDate(cutoff.getDate() - (weeks - 1) * 7);
|
|
686
|
+
const windowedWorkouts = workoutsInRange(workouts, cutoff, now);
|
|
687
|
+
const html = buildHTML(goal, summaries, workouts, windowedWorkouts, 10);
|
|
686
688
|
|
|
687
689
|
let outPath: string;
|
|
688
690
|
if (opts.output) {
|
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
|
|