@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richhaase/c2",
3
- "version": "0.3.0",
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) {
@@ -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 is hard
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(allWorkouts);
399
- const avgPace = avgPaceForWorkouts(allWorkouts);
400
- const avgHR = avgHRForWorkouts(allWorkouts);
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 summaries = buildWeekSummaries(workouts, new Date(), weeks);
685
- const html = buildHTML(goal, summaries, workouts, 10);
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) {
@@ -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