@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richhaase/c2",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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;
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 {
@@ -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
@@ -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); // reversed: lower pace is better
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));
@@ -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
@@ -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; // "2026-03-02 17:41:00"
10
+ date: string;
25
11
  timezone?: string;
26
- distance: number; // meters of work (rest rowing NOT included)
27
- type: string; // "rower"
28
- time: number; // tenths of a second of work time (rest NOT included)
29
- time_formatted: string; // elapsed time including rest for interval workouts
30
- workout_type?: string; // e.g., "FixedDistanceSplits", "FixedDistanceInterval"
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; // cumulative time
48
- d?: number; // cumulative distance
49
- p?: number; // pace
50
- spm?: number; // strokes per minute
51
- hr?: number; // heart rate
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; // "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,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); // 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);
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; // 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 so far
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
- 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 * 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