@richhaase/c2 0.3.2 → 0.3.3

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.2",
3
+ "version": "0.3.3",
4
4
  "description": "CLI tool for syncing and analyzing Concept2 rowing data",
5
5
  "keywords": [
6
6
  "concept2",
@@ -287,7 +287,7 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
287
287
  }
288
288
 
289
289
  const isShort = w.distance <= 1500;
290
- const isHard = paceS > 0 && paceS < 160; // sub-2:40
290
+ const isHard = paceS > 0 && paceS < 160;
291
291
  let annotation = "";
292
292
  let rowStyle = "";
293
293
  let paceStyle = "";
@@ -5,7 +5,7 @@ describe("parseGoalDate", () => {
5
5
  test("parses valid date string", () => {
6
6
  const d = parseGoalDate("2026-03-07");
7
7
  expect(d.getFullYear()).toBe(2026);
8
- expect(d.getMonth()).toBe(2); // March = 2
8
+ expect(d.getMonth()).toBe(2);
9
9
  expect(d.getDate()).toBe(7);
10
10
  });
11
11
 
@@ -155,7 +155,6 @@ describe("formatWorkoutLine", () => {
155
155
  });
156
156
 
157
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
158
  const w = makeWorkout({
160
159
  date: "2026-04-11 09:14:00",
161
160
  distance: 3000,
@@ -170,7 +169,6 @@ describe("formatWorkoutLine", () => {
170
169
  });
171
170
  const line = formatWorkoutLine(w, "01/02");
172
171
  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
172
  expect(line).toContain("2:23.8/500m");
175
173
  });
176
174
 
package/src/display.ts CHANGED
@@ -70,5 +70,5 @@ export function trendArrow(prev: number, curr: number): string {
70
70
 
71
71
  export function paceArrow(prev: number, curr: number): string {
72
72
  if (prev === 0) return " ";
73
- return trendArrow(curr, prev); // reversed: lower pace is better
73
+ return trendArrow(curr, prev);
74
74
  }
@@ -29,7 +29,7 @@ describe("parsedDate", () => {
29
29
  const w = makeWorkout({ date: "2026-03-07 09:21:00" });
30
30
  const d = parsedDate(w);
31
31
  expect(d.getFullYear()).toBe(2026);
32
- expect(d.getMonth()).toBe(2); // March = 2 (0-indexed)
32
+ expect(d.getMonth()).toBe(2);
33
33
  expect(d.getDate()).toBe(7);
34
34
  expect(d.getHours()).toBe(9);
35
35
  expect(d.getMinutes()).toBe(21);
@@ -47,7 +47,6 @@ describe("pace500mSeconds", () => {
47
47
  test("computes pace for normal workout", () => {
48
48
  const w = makeWorkout({ distance: 5500, time: 19122 });
49
49
  const pace = pace500mSeconds(w);
50
- // 19122 / 10 * 500 / 5500 = 173.836...
51
50
  expect(pace).toBeCloseTo(173.836, 2);
52
51
  });
53
52
 
@@ -75,14 +74,10 @@ describe("pace500m", () => {
75
74
 
76
75
  test("formats 3+ minute pace correctly", () => {
77
76
  const w = makeWorkout({ distance: 1000, time: 3706 });
78
- // 3706 / 10 * 500 / 1000 = 185.3
79
77
  expect(pace500m(w)).toBe("3:05.3");
80
78
  });
81
79
 
82
80
  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
81
  const w = makeWorkout({
87
82
  distance: 3000,
88
83
  time: 8626,
@@ -91,7 +86,6 @@ describe("pace500m", () => {
91
86
  rest_time: 3600,
92
87
  rest_distance: 660,
93
88
  });
94
- // 8626 / 10 * 500 / 3000 = 143.77
95
89
  expect(pace500m(w)).toBe("2:23.8");
96
90
  });
97
91
  });
@@ -153,7 +147,6 @@ describe("restSeconds", () => {
153
147
 
154
148
  describe("workSeconds", () => {
155
149
  test("converts time tenths to seconds", () => {
156
- // 8626 tenths = 862.6 seconds = 14:22.6
157
150
  const w = makeWorkout({ time: 8626 });
158
151
  expect(workSeconds(w)).toBe(862.6);
159
152
  });
package/src/models.ts CHANGED
@@ -7,13 +7,13 @@ export interface HeartRate {
7
7
  export interface Workout {
8
8
  id: number;
9
9
  user_id: number;
10
- date: string; // "2026-03-02 17:41:00"
10
+ date: string;
11
11
  timezone?: string;
12
- distance: number; // meters of work (rest rowing NOT included)
13
- type: string; // "rower"
14
- time: number; // tenths of a second of work time (rest NOT included)
15
- time_formatted: string; // elapsed time including rest for interval workouts
16
- workout_type?: string; // e.g., "FixedDistanceSplits", "FixedDistanceInterval"
12
+ distance: number;
13
+ type: string;
14
+ time: number;
15
+ time_formatted: string;
16
+ workout_type?: string;
17
17
  source?: string;
18
18
  weight_class?: string;
19
19
  stroke_rate?: number;
@@ -22,19 +22,17 @@ export interface Workout {
22
22
  drag_factor?: number;
23
23
  heart_rate?: HeartRate;
24
24
  stroke_data?: boolean;
25
- /** Total rest time across all rest intervals, in tenths of a second. */
26
25
  rest_time?: number;
27
- /** Total meters rowed during rest intervals (easy rowing between reps). */
28
26
  rest_distance?: number;
29
27
  comments?: string;
30
28
  }
31
29
 
32
30
  export interface StrokeData {
33
- t?: number; // cumulative time
34
- d?: number; // cumulative distance
35
- p?: number; // pace
36
- spm?: number; // strokes per minute
37
- hr?: number; // heart rate
31
+ t?: number;
32
+ d?: number;
33
+ p?: number;
34
+ spm?: number;
35
+ hr?: number;
38
36
  }
39
37
 
40
38
  export interface UserProfile {
@@ -66,7 +64,6 @@ export interface ResultsResponse {
66
64
  meta?: ResultsMeta;
67
65
  }
68
66
 
69
- /** API wraps stroke data in {"data": [...]} */
70
67
  export interface StrokeDataResponse {
71
68
  data: StrokeData[];
72
69
  }
@@ -100,12 +97,10 @@ export function isIntervalWorkout(w: Workout): boolean {
100
97
  return false;
101
98
  }
102
99
 
103
- /** Total rest time in seconds (0 when not an interval workout). */
104
100
  export function restSeconds(w: Workout): number {
105
101
  return (w.rest_time ?? 0) / TENTHS_PER_SECOND;
106
102
  }
107
103
 
108
- /** Total work time (excluding rest) in seconds. */
109
104
  export function workSeconds(w: Workout): number {
110
105
  return w.time / TENTHS_PER_SECOND;
111
106
  }
package/src/sessions.ts CHANGED
@@ -2,10 +2,10 @@ import type { Workout } from "./models.ts";
2
2
  import { calendarDay } from "./models.ts";
3
3
 
4
4
  export interface Session {
5
- date: string; // "2026-03-07"
5
+ date: string;
6
6
  workouts: Workout[];
7
7
  totalDistance: number;
8
- totalTime: number; // tenths of seconds
8
+ totalTime: number;
9
9
  }
10
10
 
11
11
  export function groupIntoSessions(workouts: Workout[]): Session[] {
@@ -31,7 +31,6 @@ export function groupIntoSessions(workouts: Workout[]): Session[] {
31
31
  .sort((a, b) => a.date.localeCompare(b.date));
32
32
  }
33
33
 
34
- /** Count unique calendar days (sessions) in a list of workouts. */
35
34
  export function sessionCount(workouts: Workout[]): number {
36
35
  const days = new Set(workouts.map(calendarDay));
37
36
  return days.size;
package/src/stats.test.ts CHANGED
@@ -30,28 +30,28 @@ function makeGoalConfig(overrides: Partial<Config["goal"]> = {}): Config {
30
30
 
31
31
  describe("mondayOf", () => {
32
32
  test("monday returns same date", () => {
33
- const d = new Date(2026, 2, 2); // Mon Mar 2
33
+ const d = new Date(2026, 2, 2);
34
34
  const m = mondayOf(d);
35
- expect(m.getDay()).toBe(1); // Monday
35
+ expect(m.getDay()).toBe(1);
36
36
  expect(m.getDate()).toBe(2);
37
37
  });
38
38
 
39
39
  test("wednesday returns previous monday", () => {
40
- const d = new Date(2026, 2, 4); // Wed Mar 4
40
+ const d = new Date(2026, 2, 4);
41
41
  const m = mondayOf(d);
42
42
  expect(m.getDay()).toBe(1);
43
43
  expect(m.getDate()).toBe(2);
44
44
  });
45
45
 
46
46
  test("sunday returns previous monday", () => {
47
- const d = new Date(2026, 2, 8); // Sun Mar 8
47
+ const d = new Date(2026, 2, 8);
48
48
  const m = mondayOf(d);
49
49
  expect(m.getDay()).toBe(1);
50
50
  expect(m.getDate()).toBe(2);
51
51
  });
52
52
 
53
53
  test("handles month boundary", () => {
54
- const d = new Date(2026, 2, 1); // Mar 1
54
+ const d = new Date(2026, 2, 1);
55
55
  const m = mondayOf(d);
56
56
  expect(m.getDay()).toBe(1);
57
57
  expect(m.getTime()).toBeLessThanOrEqual(d.getTime());
@@ -65,8 +65,8 @@ describe("workoutsInRange", () => {
65
65
  makeWorkout(2, "2026-02-15 10:00:00", 5000),
66
66
  makeWorkout(3, "2026-03-15 10:00:00", 5000),
67
67
  ];
68
- const from = new Date(2026, 1, 1); // Feb 1
69
- const to = new Date(2026, 2, 1); // Mar 1
68
+ const from = new Date(2026, 1, 1);
69
+ const to = new Date(2026, 2, 1);
70
70
  const result = workoutsInRange(workouts, from, to);
71
71
  expect(result).toHaveLength(1);
72
72
  expect(result[0]!.id).toBe(2);
@@ -82,10 +82,10 @@ describe("workoutsInRange", () => {
82
82
 
83
83
  describe("buildWeekSummaries", () => {
84
84
  test("buckets workouts into correct weeks", () => {
85
- const now = new Date(2026, 2, 7); // Sat Mar 7
85
+ const now = new Date(2026, 2, 7);
86
86
  const workouts = [
87
- makeWorkout(1, "2026-03-02 10:00:00", 5000), // Mon of current week
88
- makeWorkout(2, "2026-02-23 10:00:00", 6000), // Previous week
87
+ makeWorkout(1, "2026-03-02 10:00:00", 5000),
88
+ makeWorkout(2, "2026-02-23 10:00:00", 6000),
89
89
  ];
90
90
  const summaries = buildWeekSummaries(workouts, now, 2);
91
91
  expect(summaries).toHaveLength(2);
@@ -97,12 +97,12 @@ describe("buildWeekSummaries", () => {
97
97
  const now = new Date(2026, 2, 7);
98
98
  const workouts = [
99
99
  makeWorkout(1, "2026-03-02 09:00:00", 1000),
100
- makeWorkout(2, "2026-03-02 10:00:00", 2000), // same day
101
- makeWorkout(3, "2026-03-04 10:00:00", 3000), // different day
100
+ makeWorkout(2, "2026-03-02 10:00:00", 2000),
101
+ makeWorkout(3, "2026-03-04 10:00:00", 3000),
102
102
  ];
103
103
  const summaries = buildWeekSummaries(workouts, now, 1);
104
104
  expect(summaries[0]!.meters).toBe(6000);
105
- expect(summaries[0]!.sessions).toBe(2); // 2 unique days
105
+ expect(summaries[0]!.sessions).toBe(2);
106
106
  });
107
107
 
108
108
  test("returns empty summaries for no workouts", () => {
@@ -120,7 +120,7 @@ describe("computeGoalProgress", () => {
120
120
  makeWorkout(1, "2026-03-01 10:00:00", 100_000),
121
121
  makeWorkout(2, "2026-02-01 10:00:00", 100_000),
122
122
  ];
123
- const now = new Date(2026, 2, 7); // Mar 7
123
+ const now = new Date(2026, 2, 7);
124
124
  const goal = computeGoalProgress(workouts, cfg, now);
125
125
  expect(goal.totalMeters).toBe(200_000);
126
126
  expect(goal.target).toBe(1_000_000);
@@ -141,9 +141,9 @@ describe("computeGoalProgress", () => {
141
141
  test("excludes workouts outside goal date range", () => {
142
142
  const cfg = makeGoalConfig();
143
143
  const workouts = [
144
- makeWorkout(1, "2025-12-01 10:00:00", 50_000), // before start
145
- makeWorkout(2, "2026-03-01 10:00:00", 100_000), // in range
146
- makeWorkout(3, "2027-02-01 10:00:00", 50_000), // after end
144
+ makeWorkout(1, "2025-12-01 10:00:00", 50_000),
145
+ makeWorkout(2, "2026-03-01 10:00:00", 100_000),
146
+ makeWorkout(3, "2027-02-01 10:00:00", 50_000),
147
147
  ];
148
148
  const now = new Date(2026, 2, 7);
149
149
  const goal = computeGoalProgress(workouts, cfg, now);
@@ -153,7 +153,7 @@ describe("computeGoalProgress", () => {
153
153
  test("before start date: weeksElapsed is 0", () => {
154
154
  const cfg = makeGoalConfig({ start_date: "2026-06-01" });
155
155
  const workouts: Workout[] = [];
156
- const now = new Date(2026, 2, 7); // before start
156
+ const now = new Date(2026, 2, 7);
157
157
  const goal = computeGoalProgress(workouts, cfg, now);
158
158
  expect(goal.weeksElapsed).toBe(0);
159
159
  expect(goal.currentAvgPace).toBe(0);
@@ -170,7 +170,7 @@ describe("computeGoalProgress", () => {
170
170
  makeWorkout(6, "2026-03-23 10:00:00", 20_000),
171
171
  makeWorkout(7, "2026-03-30 10:00:00", 20_000),
172
172
  ];
173
- const now = new Date(2026, 2, 30, 18);
173
+ const now = new Date(2026, 3, 6, 18);
174
174
  const goal = computeGoalProgress(workouts, cfg, now);
175
175
  expect(goal.currentAvgPace).toBe(20_000);
176
176
  });
@@ -182,8 +182,19 @@ describe("computeGoalProgress", () => {
182
182
  makeWorkout(2, "2026-03-23 10:00:00", 15_000),
183
183
  makeWorkout(3, "2026-03-30 10:00:00", 15_000),
184
184
  ];
185
- const now = new Date(2026, 2, 30, 18);
185
+ const now = new Date(2026, 3, 6, 18);
186
186
  const goal = computeGoalProgress(workouts, cfg, now);
187
187
  expect(goal.currentAvgPace).toBe(7_500);
188
188
  });
189
+
190
+ test("currentAvgPace excludes the current in-progress week", () => {
191
+ const cfg = makeGoalConfig();
192
+ const workouts = [
193
+ makeWorkout(1, "2026-04-06 10:00:00", 20_000),
194
+ makeWorkout(2, "2026-04-13 06:55:00", 5_500),
195
+ ];
196
+ const now = new Date(2026, 3, 13, 18);
197
+ const goal = computeGoalProgress(workouts, cfg, now);
198
+ expect(goal.currentAvgPace).toBe(5_000);
199
+ });
189
200
  });
package/src/stats.ts CHANGED
@@ -18,13 +18,13 @@ export interface WeekSummary {
18
18
  export interface GoalProgress {
19
19
  target: number;
20
20
  totalMeters: number;
21
- progress: number; // ratio 0–1
21
+ progress: number;
22
22
  weeksElapsed: number;
23
23
  totalWeeks: number;
24
24
  remainingMeters: number;
25
25
  remainingWeeks: number;
26
- requiredPace: number; // meters/week needed going forward
27
- currentAvgPace: number; // meters/week over the recent window
26
+ requiredPace: number;
27
+ currentAvgPace: number;
28
28
  onPace: boolean;
29
29
  }
30
30
 
@@ -138,16 +138,17 @@ export function computeGoalProgress(workouts: Workout[], cfg: Config, now?: Date
138
138
  if (weeksElapsed > 0) {
139
139
  const thisMonday = mondayOf(today);
140
140
  const recentStart = new Date(thisMonday);
141
- recentStart.setDate(recentStart.getDate() - (RECENT_WEEKS - 1) * 7);
141
+ recentStart.setDate(recentStart.getDate() - RECENT_WEEKS * 7);
142
142
  const windowStart = recentStart < start ? start : recentStart;
143
+ const windowMs = thisMonday.getTime() - windowStart.getTime();
144
+ const weeksInWindow = Math.max(1, Math.round(windowMs / (1000 * 60 * 60 * 24 * 7)));
143
145
  let recentMeters = 0;
144
146
  for (const w of workouts) {
145
147
  const t = parsedDate(w);
146
- if (t >= windowStart && t <= today) {
148
+ if (t >= windowStart && t < thisMonday) {
147
149
  recentMeters += w.distance;
148
150
  }
149
151
  }
150
- const weeksInWindow = Math.min(RECENT_WEEKS, Math.max(1, weeksElapsed));
151
152
  currentAvgPace = Math.floor(recentMeters / weeksInWindow);
152
153
  }
153
154
  const targetWeekly = target / totalWeeks;