@richhaase/c2 0.3.2 → 0.4.0

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/src/notes.ts ADDED
@@ -0,0 +1,270 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isValidYMD } from "./models.ts";
4
+ import type { DataPaths } from "./paths.ts";
5
+
6
+ export const NOTE_TYPES = ["subjective", "observation", "lesson"] as const;
7
+ export type NoteType = (typeof NOTE_TYPES)[number];
8
+
9
+ export const NOTE_AUTHORS = ["athlete", "coach"] as const;
10
+ export type NoteAuthor = (typeof NOTE_AUTHORS)[number];
11
+
12
+ export interface NoteRecord {
13
+ id: string;
14
+ date: string;
15
+ type: NoteType;
16
+ workout_id?: number;
17
+ tags?: string[];
18
+ body: string;
19
+ author: NoteAuthor;
20
+ }
21
+
22
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
23
+
24
+ export function ulid(now: Date): string {
25
+ let ms = now.getTime();
26
+ let time = "";
27
+ for (let i = 0; i < 10; i++) {
28
+ time = CROCKFORD[ms % 32] + time;
29
+ ms = Math.floor(ms / 32);
30
+ }
31
+ const bytes = new Uint8Array(16);
32
+ crypto.getRandomValues(bytes);
33
+ let rand = "";
34
+ for (const b of bytes) {
35
+ rand += CROCKFORD[b % 32];
36
+ }
37
+ return time + rand;
38
+ }
39
+
40
+ export function localISO(d: Date): string {
41
+ const pad = (n: number, width = 2) => String(Math.abs(n)).padStart(width, "0");
42
+ const offsetMinutes = -d.getTimezoneOffset();
43
+ const sign = offsetMinutes >= 0 ? "+" : "-";
44
+ const offset = `${sign}${pad(Math.floor(Math.abs(offsetMinutes) / 60))}:${pad(Math.abs(offsetMinutes) % 60)}`;
45
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${offset}`;
46
+ }
47
+
48
+ export function serializeNote(note: NoteRecord): string {
49
+ const ordered: Record<string, unknown> = { id: note.id, date: note.date, type: note.type };
50
+ if (note.workout_id != null) ordered.workout_id = note.workout_id;
51
+ if (note.tags != null && note.tags.length > 0) ordered.tags = note.tags;
52
+ ordered.body = note.body;
53
+ ordered.author = note.author;
54
+ return JSON.stringify(ordered);
55
+ }
56
+
57
+ export function isNoteShaped(parsed: unknown): parsed is NoteRecord {
58
+ const note = parsed as NoteRecord;
59
+ return (
60
+ typeof note?.id === "string" &&
61
+ typeof note?.date === "string" &&
62
+ typeof note?.body === "string" &&
63
+ (NOTE_TYPES as readonly string[]).includes(note?.type) &&
64
+ (NOTE_AUTHORS as readonly string[]).includes(note?.author) &&
65
+ /^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(note.date) &&
66
+ isValidYMD(note.date.slice(0, 10)) &&
67
+ !Number.isNaN(new Date(note.date).getTime()) &&
68
+ (note.workout_id === undefined ||
69
+ (typeof note.workout_id === "number" && Number.isFinite(note.workout_id))) &&
70
+ (note.tags === undefined ||
71
+ (Array.isArray(note.tags) && note.tags.every((t) => typeof t === "string")))
72
+ );
73
+ }
74
+
75
+ function parseNote(raw: string): NoteRecord | null {
76
+ try {
77
+ const parsed = JSON.parse(raw) as unknown;
78
+ return isNoteShaped(parsed) ? parsed : null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ function compareNotes(a: NoteRecord, b: NoteRecord): number {
85
+ const aMs = new Date(a.date).getTime();
86
+ const bMs = new Date(b.date).getTime();
87
+ if (aMs !== bMs) return aMs < bMs ? -1 : 1;
88
+ if (a.id !== b.id) return a.id < b.id ? -1 : 1;
89
+ return 0;
90
+ }
91
+
92
+ export async function writeNote(paths: DataPaths, note: NoteRecord): Promise<void> {
93
+ await mkdir(paths.notesDir, { recursive: true });
94
+ await writeFile(join(paths.notesDir, `${note.id}.json`), `${serializeNote(note)}\n`, "utf-8");
95
+ }
96
+
97
+ interface LooseEntry {
98
+ note: NoteRecord;
99
+ file: string;
100
+ }
101
+
102
+ async function readLooseEntries(paths: DataPaths): Promise<LooseEntry[]> {
103
+ const entries: LooseEntry[] = [];
104
+ let files: string[];
105
+ try {
106
+ files = await readdir(paths.notesDir);
107
+ } catch {
108
+ return entries;
109
+ }
110
+ for (const f of files.sort()) {
111
+ if (!f.endsWith(".json")) continue;
112
+ try {
113
+ const note = parseNote(await readFile(join(paths.notesDir, f), "utf-8"));
114
+ if (note != null) entries.push({ note, file: f });
115
+ } catch {}
116
+ }
117
+ return entries;
118
+ }
119
+
120
+ async function readLooseNotes(paths: DataPaths): Promise<Map<string, NoteRecord>> {
121
+ const notes = new Map<string, NoteRecord>();
122
+ for (const entry of await readLooseEntries(paths)) {
123
+ notes.set(entry.note.id, entry.note);
124
+ }
125
+ return notes;
126
+ }
127
+
128
+ async function readArchivedNotes(paths: DataPaths): Promise<Map<string, NoteRecord>> {
129
+ const notes = new Map<string, NoteRecord>();
130
+ let files: string[];
131
+ try {
132
+ files = await readdir(paths.archiveDir);
133
+ } catch {
134
+ return notes;
135
+ }
136
+ for (const f of files) {
137
+ if (!f.endsWith(".jsonl")) continue;
138
+ let text: string;
139
+ try {
140
+ text = await readFile(join(paths.archiveDir, f), "utf-8");
141
+ } catch {
142
+ continue;
143
+ }
144
+ for (const line of text.split("\n")) {
145
+ if (line.trim() === "") continue;
146
+ const note = parseNote(line);
147
+ if (note != null && !notes.has(note.id)) notes.set(note.id, note);
148
+ }
149
+ }
150
+ return notes;
151
+ }
152
+
153
+ export async function readAllNotes(paths: DataPaths): Promise<NoteRecord[]> {
154
+ const archived = await readArchivedNotes(paths);
155
+ const loose = await readLooseNotes(paths);
156
+ for (const [id, note] of loose) {
157
+ archived.set(id, note);
158
+ }
159
+ return [...archived.values()].sort(compareNotes);
160
+ }
161
+
162
+ export interface NoteFilter {
163
+ type?: string;
164
+ since?: string;
165
+ workoutId?: number;
166
+ }
167
+
168
+ export function filterNotes(notes: NoteRecord[], filter: NoteFilter): NoteRecord[] {
169
+ return notes.filter((n) => {
170
+ if (filter.type && n.type !== filter.type) return false;
171
+ if (filter.since && n.date.slice(0, 10) < filter.since) return false;
172
+ if (filter.workoutId != null && n.workout_id !== filter.workoutId) return false;
173
+ return true;
174
+ });
175
+ }
176
+
177
+ const COMPACT_AGE_DAYS = 7;
178
+
179
+ async function readArchiveYear(
180
+ paths: DataPaths,
181
+ year: number,
182
+ ): Promise<{ notes: NoteRecord[]; safeToRewrite: boolean }> {
183
+ const notes: NoteRecord[] = [];
184
+ let text: string;
185
+ try {
186
+ text = await readFile(paths.archiveFile(year), "utf-8");
187
+ } catch (err: unknown) {
188
+ const code = (err as NodeJS.ErrnoException).code;
189
+ return { notes, safeToRewrite: code === "ENOENT" };
190
+ }
191
+ const seen = new Set<string>();
192
+ for (const line of text.split("\n")) {
193
+ if (line.trim() === "") continue;
194
+ const note = parseNote(line);
195
+ if (note == null) return { notes, safeToRewrite: false };
196
+ if (seen.has(note.id)) return { notes, safeToRewrite: false };
197
+ seen.add(note.id);
198
+ notes.push(note);
199
+ }
200
+ return { notes, safeToRewrite: true };
201
+ }
202
+
203
+ export interface CompactResult {
204
+ archived: number;
205
+ years: number[];
206
+ skippedYears: number[];
207
+ }
208
+
209
+ export async function compactNotes(paths: DataPaths, now: Date): Promise<CompactResult> {
210
+ const cutoff = new Date(now);
211
+ cutoff.setDate(cutoff.getDate() - COMPACT_AGE_DAYS);
212
+ const cutoffMs = cutoff.getTime();
213
+
214
+ const entries = await readLooseEntries(paths);
215
+ const deduped = new Map<string, NoteRecord>();
216
+ const contentById = new Map<string, string>();
217
+ const divergent = new Set<string>();
218
+ for (const e of entries) {
219
+ const content = serializeNote(e.note);
220
+ const prior = contentById.get(e.note.id);
221
+ if (prior != null && prior !== content) divergent.add(e.note.id);
222
+ contentById.set(e.note.id, content);
223
+ deduped.set(e.note.id, e.note);
224
+ }
225
+ const eligible = [...deduped.values()].filter(
226
+ (n) => !divergent.has(n.id) && new Date(n.date).getTime() < cutoffMs,
227
+ );
228
+ if (eligible.length === 0) return { archived: 0, years: [], skippedYears: [] };
229
+
230
+ const byYear = new Map<number, NoteRecord[]>();
231
+ for (const note of eligible) {
232
+ const year = Number(note.date.slice(0, 4));
233
+ if (!byYear.has(year)) byYear.set(year, []);
234
+ byYear.get(year)!.push(note);
235
+ }
236
+
237
+ await mkdir(paths.archiveDir, { recursive: true });
238
+ let archivedCount = 0;
239
+ const years: number[] = [];
240
+ const skippedYears: number[] = [];
241
+ for (const [year, notes] of byYear) {
242
+ const existing = await readArchiveYear(paths, year);
243
+ if (!existing.safeToRewrite) {
244
+ skippedYears.push(year);
245
+ continue;
246
+ }
247
+ const merged = new Map<string, NoteRecord>();
248
+ for (const n of existing.notes) merged.set(n.id, n);
249
+ for (const n of notes) merged.set(n.id, n);
250
+ const lines = [...merged.values()].sort(compareNotes).map(serializeNote);
251
+ try {
252
+ await writeFile(paths.archiveFile(year), `${lines.join("\n")}\n`, "utf-8");
253
+ } catch {
254
+ skippedYears.push(year);
255
+ continue;
256
+ }
257
+ const archivedIds = new Set(notes.map((n) => n.id));
258
+ for (const e of entries) {
259
+ if (archivedIds.has(e.note.id)) {
260
+ try {
261
+ await rm(join(paths.notesDir, e.file), { force: true });
262
+ } catch {}
263
+ }
264
+ }
265
+ archivedCount += notes.length;
266
+ years.push(year);
267
+ }
268
+
269
+ return { archived: archivedCount, years: years.sort(), skippedYears: skippedYears.sort() };
270
+ }
@@ -0,0 +1,47 @@
1
+ import { expect, test } from "bun:test";
2
+ import { chmod, mkdir, mkdtemp } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { canonicalRoot, expandTilde, pathsFor } from "./paths.ts";
6
+
7
+ test("expandTilde resolves home shorthand", () => {
8
+ expect(expandTilde("~")).toBe(homedir());
9
+ expect(expandTilde("~/Documents/kb")).toBe(join(homedir(), "Documents", "kb"));
10
+ expect(expandTilde("/absolute/path")).toBe("/absolute/path");
11
+ });
12
+
13
+ test("pathsFor derives every path from the root", () => {
14
+ const p = pathsFor("/tmp/c2-store");
15
+ expect(p.root).toBe("/tmp/c2-store");
16
+ expect(p.meta).toBe("/tmp/c2-store/meta.json");
17
+ expect(p.workouts).toBe("/tmp/c2-store/workouts.jsonl");
18
+ expect(p.strokeFile(42)).toBe("/tmp/c2-store/strokes/42.jsonl");
19
+ expect(p.archiveFile(2026)).toBe("/tmp/c2-store/notes/archive/2026.jsonl");
20
+ expect(p.narrativeFile("2026-07-05")).toBe("/tmp/c2-store/reports/2026-07-05.md");
21
+ });
22
+
23
+ test("pathsFor expands tilde roots", () => {
24
+ const p = pathsFor("~/kb/c2");
25
+ expect(p.root).toBe(join(homedir(), "kb", "c2"));
26
+ });
27
+
28
+ test("canonicalRoot keeps missing tail components", async () => {
29
+ const base = await mkdtemp(join(tmpdir(), "c2-canon-"));
30
+ const target = join(base, "a", "b", "c");
31
+ const out = await canonicalRoot(target);
32
+ expect(out.endsWith(join("a", "b", "c"))).toBe(true);
33
+ });
34
+
35
+ test("canonicalRoot preserves the full path when an ancestor is unreadable", async () => {
36
+ const base = await mkdtemp(join(tmpdir(), "c2-canon-eacces-"));
37
+ const locked = join(base, "locked");
38
+ await mkdir(locked);
39
+ await chmod(locked, 0o000);
40
+ try {
41
+ const target = join(locked, "x", "y");
42
+ const out = await canonicalRoot(target);
43
+ expect(out.endsWith(join("locked", "x", "y"))).toBe(true);
44
+ } finally {
45
+ await chmod(locked, 0o755);
46
+ }
47
+ });
package/src/paths.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import type { Config } from "./config.ts";
5
+
6
+ export interface DataPaths {
7
+ root: string;
8
+ meta: string;
9
+ workouts: string;
10
+ strokesDir: string;
11
+ strokeFile(id: number): string;
12
+ notesDir: string;
13
+ archiveDir: string;
14
+ archiveFile(year: number): string;
15
+ plan: string;
16
+ playbook: string;
17
+ reportsDir: string;
18
+ narrativeFile(date: string): string;
19
+ }
20
+
21
+ export function expandTilde(p: string): string {
22
+ if (p === "~") return homedir();
23
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
24
+ return p;
25
+ }
26
+
27
+ export function pathsFor(root: string): DataPaths {
28
+ const abs = resolve(expandTilde(root));
29
+ return {
30
+ root: abs,
31
+ meta: join(abs, "meta.json"),
32
+ workouts: join(abs, "workouts.jsonl"),
33
+ strokesDir: join(abs, "strokes"),
34
+ strokeFile: (id: number) => join(abs, "strokes", `${id}.jsonl`),
35
+ notesDir: join(abs, "notes"),
36
+ archiveDir: join(abs, "notes", "archive"),
37
+ archiveFile: (year: number) => join(abs, "notes", "archive", `${year}.jsonl`),
38
+ plan: join(abs, "plan.md"),
39
+ playbook: join(abs, "playbook.md"),
40
+ reportsDir: join(abs, "reports"),
41
+ narrativeFile: (date: string) => join(abs, "reports", `${date}.md`),
42
+ };
43
+ }
44
+
45
+ export function dataPaths(cfg: Config): DataPaths {
46
+ return pathsFor(cfg.data_dir);
47
+ }
48
+
49
+ export async function canonicalRoot(p: string): Promise<string> {
50
+ let base = resolve(expandTilde(p));
51
+ const rest: string[] = [];
52
+ for (;;) {
53
+ try {
54
+ const real = await realpath(base);
55
+ return rest.length > 0 ? join(real, ...rest) : real;
56
+ } catch (err: unknown) {
57
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
58
+ return rest.length > 0 ? join(base, ...rest) : base;
59
+ }
60
+ rest.unshift(basename(base));
61
+ const parent = dirname(base);
62
+ if (parent === base) return resolve(expandTilde(p));
63
+ base = parent;
64
+ }
65
+ }
66
+ }
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
@@ -1,7 +1,7 @@
1
1
  import type { Config } from "./config.ts";
2
2
  import { parseGoalDate } from "./config.ts";
3
3
  import type { Workout } from "./models.ts";
4
- import { calendarDay, pace500mSeconds, parsedDate } from "./models.ts";
4
+ import { calendarDay, formatSeconds, pace500mSeconds, parsedDate } from "./models.ts";
5
5
 
6
6
  export interface WeekSummary {
7
7
  weekStart: Date;
@@ -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
 
@@ -105,6 +105,58 @@ export function buildWeekSummaries(workouts: Workout[], now: Date, weeks: number
105
105
  return summaries;
106
106
  }
107
107
 
108
+ export interface RecentWeek {
109
+ weekStart: Date;
110
+ meters: number;
111
+ sessions: number;
112
+ }
113
+
114
+ export function recentWeeks(workouts: Workout[], now: Date, count: number): RecentWeek[] {
115
+ const out: RecentWeek[] = [];
116
+ for (let i = 0; i < count; i++) {
117
+ const weekStart = mondayOf(now);
118
+ weekStart.setDate(weekStart.getDate() - i * 7);
119
+ const weekEnd = new Date(weekStart);
120
+ weekEnd.setDate(weekEnd.getDate() + 7);
121
+ const weekWorkouts = workoutsInRange(workouts, weekStart, weekEnd);
122
+ out.push({
123
+ weekStart,
124
+ meters: weekWorkouts.reduce((sum, w) => sum + w.distance, 0),
125
+ sessions: new Set(weekWorkouts.map(calendarDay)).size,
126
+ });
127
+ }
128
+ return out;
129
+ }
130
+
131
+ export interface WeekSummaryData {
132
+ week_start: string;
133
+ meters: number;
134
+ sessions: number;
135
+ avg_pace_500m_seconds: number | null;
136
+ avg_pace_500m: string | null;
137
+ avg_spm: number | null;
138
+ avg_hr: number | null;
139
+ }
140
+
141
+ export function localYMD(d: Date): string {
142
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
143
+ const dd = String(d.getDate()).padStart(2, "0");
144
+ return `${d.getFullYear()}-${mm}-${dd}`;
145
+ }
146
+
147
+ export function weekSummaryData(ws: WeekSummary): WeekSummaryData {
148
+ const avgPace = ws.paceCount > 0 ? ws.paceSum / ws.paceCount : null;
149
+ return {
150
+ week_start: localYMD(ws.weekStart),
151
+ meters: ws.meters,
152
+ sessions: ws.sessions,
153
+ avg_pace_500m_seconds: avgPace != null ? Math.round(avgPace * 10) / 10 : null,
154
+ avg_pace_500m: avgPace != null ? formatSeconds(avgPace) : null,
155
+ avg_spm: ws.spmCount > 0 ? Math.round((ws.spmSum / ws.spmCount) * 10) / 10 : null,
156
+ avg_hr: ws.hrCount > 0 ? Math.round(ws.hrSum / ws.hrCount) : null,
157
+ };
158
+ }
159
+
108
160
  export function computeGoalProgress(workouts: Workout[], cfg: Config, now?: Date): GoalProgress {
109
161
  const target = cfg.goal.target_meters;
110
162
  const start = parseGoalDate(cfg.goal.start_date);
@@ -138,16 +190,17 @@ export function computeGoalProgress(workouts: Workout[], cfg: Config, now?: Date
138
190
  if (weeksElapsed > 0) {
139
191
  const thisMonday = mondayOf(today);
140
192
  const recentStart = new Date(thisMonday);
141
- recentStart.setDate(recentStart.getDate() - (RECENT_WEEKS - 1) * 7);
193
+ recentStart.setDate(recentStart.getDate() - RECENT_WEEKS * 7);
142
194
  const windowStart = recentStart < start ? start : recentStart;
195
+ const windowMs = thisMonday.getTime() - windowStart.getTime();
196
+ const weeksInWindow = Math.max(1, Math.round(windowMs / (1000 * 60 * 60 * 24 * 7)));
143
197
  let recentMeters = 0;
144
198
  for (const w of workouts) {
145
199
  const t = parsedDate(w);
146
- if (t >= windowStart && t <= today) {
200
+ if (t >= windowStart && t < thisMonday) {
147
201
  recentMeters += w.distance;
148
202
  }
149
203
  }
150
- const weeksInWindow = Math.min(RECENT_WEEKS, Math.max(1, weeksElapsed));
151
204
  currentAvgPace = Math.floor(recentMeters / weeksInWindow);
152
205
  }
153
206
  const targetWeekly = target / totalWeeks;