@richhaase/c2 0.3.1 → 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/export.ts +0 -1
- package/src/commands/report.ts +1 -7
- package/src/commands/setup.ts +0 -5
- package/src/config.test.ts +1 -1
- package/src/display.test.ts +0 -2
- package/src/display.ts +1 -12
- package/src/index.ts +0 -1
- package/src/models.test.ts +1 -8
- package/src/models.ts +11 -44
- package/src/sessions.ts +2 -3
- package/src/stats.test.ts +57 -18
- package/src/stats.ts +22 -4
package/package.json
CHANGED
package/src/commands/export.ts
CHANGED
package/src/commands/report.ts
CHANGED
|
@@ -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;
|
|
290
|
+
const isHard = paceS > 0 && paceS < 160;
|
|
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));
|
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/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
|
@@ -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);
|
|
@@ -81,5 +70,5 @@ export function trendArrow(prev: number, curr: number): string {
|
|
|
81
70
|
|
|
82
71
|
export function paceArrow(prev: number, curr: number): string {
|
|
83
72
|
if (prev === 0) return " ";
|
|
84
|
-
return trendArrow(curr, prev);
|
|
73
|
+
return trendArrow(curr, prev);
|
|
85
74
|
}
|
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.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
|
@@ -4,30 +4,16 @@ 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;
|
|
24
|
-
date: string;
|
|
10
|
+
date: string;
|
|
25
11
|
timezone?: string;
|
|
26
|
-
distance: number;
|
|
27
|
-
type: string;
|
|
28
|
-
time: number;
|
|
29
|
-
time_formatted: string;
|
|
30
|
-
workout_type?: string;
|
|
12
|
+
distance: number;
|
|
13
|
+
type: string;
|
|
14
|
+
time: number;
|
|
15
|
+
time_formatted: string;
|
|
16
|
+
workout_type?: string;
|
|
31
17
|
source?: string;
|
|
32
18
|
weight_class?: string;
|
|
33
19
|
stroke_rate?: number;
|
|
@@ -36,19 +22,17 @@ export interface Workout {
|
|
|
36
22
|
drag_factor?: number;
|
|
37
23
|
heart_rate?: HeartRate;
|
|
38
24
|
stroke_data?: boolean;
|
|
39
|
-
/** Total rest time across all rest intervals, in tenths of a second. */
|
|
40
25
|
rest_time?: number;
|
|
41
|
-
/** Total meters rowed during rest intervals (easy rowing between reps). */
|
|
42
26
|
rest_distance?: number;
|
|
43
27
|
comments?: string;
|
|
44
28
|
}
|
|
45
29
|
|
|
46
30
|
export interface StrokeData {
|
|
47
|
-
t?: number;
|
|
48
|
-
d?: number;
|
|
49
|
-
p?: number;
|
|
50
|
-
spm?: number;
|
|
51
|
-
hr?: number;
|
|
31
|
+
t?: number;
|
|
32
|
+
d?: number;
|
|
33
|
+
p?: number;
|
|
34
|
+
spm?: number;
|
|
35
|
+
hr?: number;
|
|
52
36
|
}
|
|
53
37
|
|
|
54
38
|
export interface UserProfile {
|
|
@@ -80,7 +64,6 @@ export interface ResultsResponse {
|
|
|
80
64
|
meta?: ResultsMeta;
|
|
81
65
|
}
|
|
82
66
|
|
|
83
|
-
/** API wraps stroke data in {"data": [...]} */
|
|
84
67
|
export interface StrokeDataResponse {
|
|
85
68
|
data: StrokeData[];
|
|
86
69
|
}
|
|
@@ -107,16 +90,6 @@ export function pace500m(w: Workout): string {
|
|
|
107
90
|
return formatSeconds(secs);
|
|
108
91
|
}
|
|
109
92
|
|
|
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
93
|
export function isIntervalWorkout(w: Workout): boolean {
|
|
121
94
|
if (w.workout_type?.includes("Interval")) return true;
|
|
122
95
|
if (w.rest_time != null && w.rest_time > 0) return true;
|
|
@@ -124,20 +97,14 @@ export function isIntervalWorkout(w: Workout): boolean {
|
|
|
124
97
|
return false;
|
|
125
98
|
}
|
|
126
99
|
|
|
127
|
-
/** Total rest time in seconds (0 when not an interval workout). */
|
|
128
100
|
export function restSeconds(w: Workout): number {
|
|
129
101
|
return (w.rest_time ?? 0) / TENTHS_PER_SECOND;
|
|
130
102
|
}
|
|
131
103
|
|
|
132
|
-
/** Total work time (excluding rest) in seconds. */
|
|
133
104
|
export function workSeconds(w: Workout): number {
|
|
134
105
|
return w.time / TENTHS_PER_SECOND;
|
|
135
106
|
}
|
|
136
107
|
|
|
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
108
|
export function formatSeconds(totalSeconds: number): string {
|
|
142
109
|
if (totalSeconds <= 0) return "0:00.0";
|
|
143
110
|
const mins = Math.floor(totalSeconds / 60);
|
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,9 +153,48 @@ 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);
|
|
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, 3, 6, 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, 3, 6, 18);
|
|
186
|
+
const goal = computeGoalProgress(workouts, cfg, now);
|
|
187
|
+
expect(goal.currentAvgPace).toBe(7_500);
|
|
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
|
+
});
|
|
161
200
|
});
|
package/src/stats.ts
CHANGED
|
@@ -18,16 +18,18 @@ 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
|
|
|
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,23 @@ 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 * 7);
|
|
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)));
|
|
145
|
+
let recentMeters = 0;
|
|
146
|
+
for (const w of workouts) {
|
|
147
|
+
const t = parsedDate(w);
|
|
148
|
+
if (t >= windowStart && t < thisMonday) {
|
|
149
|
+
recentMeters += w.distance;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
currentAvgPace = Math.floor(recentMeters / weeksInWindow);
|
|
153
|
+
}
|
|
136
154
|
const targetWeekly = target / totalWeeks;
|
|
137
155
|
const onPace = currentAvgPace >= targetWeekly;
|
|
138
156
|
|