@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richhaase/c2",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "CLI tool for syncing and analyzing Concept2 rowing data",
5
5
  "keywords": [
6
6
  "concept2",
@@ -101,7 +101,6 @@ export function registerExport(program: Command): void {
101
101
  process.exit(1);
102
102
  }
103
103
 
104
- // Sort oldest first for export
105
104
  workouts.sort((a, b) => a.date.localeCompare(b.date));
106
105
 
107
106
  switch (opts.format) {
@@ -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 is hard
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));
@@ -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 so far
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
- const currentAvgPace = weeksElapsed > 0 ? Math.floor(totalMeters / weeksElapsed) : 0;
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