@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 +1 -1
- package/src/commands/report.ts +1 -1
- package/src/config.test.ts +1 -1
- package/src/display.test.ts +0 -2
- package/src/display.ts +1 -1
- package/src/models.test.ts +1 -8
- package/src/models.ts +11 -16
- package/src/sessions.ts +2 -3
- package/src/stats.test.ts +31 -20
- package/src/stats.ts +7 -6
package/package.json
CHANGED
package/src/commands/report.ts
CHANGED
|
@@ -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;
|
|
290
|
+
const isHard = paceS > 0 && paceS < 160;
|
|
291
291
|
let annotation = "";
|
|
292
292
|
let rowStyle = "";
|
|
293
293
|
let paceStyle = "";
|
package/src/config.test.ts
CHANGED
|
@@ -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);
|
|
8
|
+
expect(d.getMonth()).toBe(2);
|
|
9
9
|
expect(d.getDate()).toBe(7);
|
|
10
10
|
});
|
|
11
11
|
|
package/src/display.test.ts
CHANGED
|
@@ -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);
|
|
73
|
+
return trendArrow(curr, prev);
|
|
74
74
|
}
|
package/src/models.test.ts
CHANGED
|
@@ -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);
|
|
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;
|
|
10
|
+
date: string;
|
|
11
11
|
timezone?: string;
|
|
12
|
-
distance: number;
|
|
13
|
-
type: string;
|
|
14
|
-
time: number;
|
|
15
|
-
time_formatted: string;
|
|
16
|
-
workout_type?: string;
|
|
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;
|
|
34
|
-
d?: number;
|
|
35
|
-
p?: number;
|
|
36
|
-
spm?: number;
|
|
37
|
-
hr?: number;
|
|
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;
|
|
5
|
+
date: string;
|
|
6
6
|
workouts: Workout[];
|
|
7
7
|
totalDistance: number;
|
|
8
|
-
totalTime: number;
|
|
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);
|
|
33
|
+
const d = new Date(2026, 2, 2);
|
|
34
34
|
const m = mondayOf(d);
|
|
35
|
-
expect(m.getDay()).toBe(1);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
69
|
-
const to = new Date(2026, 2, 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);
|
|
85
|
+
const now = new Date(2026, 2, 7);
|
|
86
86
|
const workouts = [
|
|
87
|
-
makeWorkout(1, "2026-03-02 10:00:00", 5000),
|
|
88
|
-
makeWorkout(2, "2026-02-23 10:00:00", 6000),
|
|
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),
|
|
101
|
-
makeWorkout(3, "2026-03-04 10:00:00", 3000),
|
|
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);
|
|
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);
|
|
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),
|
|
145
|
-
makeWorkout(2, "2026-03-01 10:00:00", 100_000),
|
|
146
|
-
makeWorkout(3, "2027-02-01 10:00:00", 50_000),
|
|
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);
|
|
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,
|
|
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,
|
|
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;
|
|
21
|
+
progress: number;
|
|
22
22
|
weeksElapsed: number;
|
|
23
23
|
totalWeeks: number;
|
|
24
24
|
remainingMeters: number;
|
|
25
25
|
remainingWeeks: number;
|
|
26
|
-
requiredPace: number;
|
|
27
|
-
currentAvgPace: number;
|
|
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() -
|
|
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
|
|
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;
|