@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/README.md +90 -1
- package/package.json +3 -3
- package/src/analysis.test.ts +180 -0
- package/src/analysis.ts +158 -0
- package/src/cli.test.ts +776 -0
- package/src/commands/data.ts +172 -0
- package/src/commands/docs.ts +184 -0
- package/src/commands/export.ts +21 -7
- package/src/commands/log.ts +42 -11
- package/src/commands/note.ts +237 -0
- package/src/commands/report.ts +232 -9
- package/src/commands/setup.ts +60 -3
- package/src/commands/show.ts +120 -0
- package/src/commands/stats.ts +196 -0
- package/src/commands/status.ts +39 -22
- package/src/commands/sync.ts +61 -13
- package/src/commands/trend.ts +18 -7
- package/src/config.test.ts +1 -1
- package/src/config.ts +18 -14
- package/src/data.test.ts +236 -0
- package/src/data.ts +200 -0
- package/src/display.test.ts +0 -2
- package/src/display.ts +31 -2
- package/src/doctor.ts +174 -0
- package/src/envelope.ts +17 -0
- package/src/index.ts +13 -6
- package/src/models.test.ts +1 -8
- package/src/models.ts +39 -16
- package/src/notes.test.ts +509 -0
- package/src/notes.ts +270 -0
- package/src/paths.test.ts +47 -0
- package/src/paths.ts +66 -0
- package/src/sessions.ts +2 -3
- package/src/stats.test.ts +31 -20
- package/src/stats.ts +60 -7
- package/src/storage.ts +66 -28
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;
|
|
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
|
@@ -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;
|
|
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
|
|
|
@@ -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() -
|
|
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
|
|
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;
|