@richhaase/c2 0.3.3 → 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 +231 -8
- 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.ts +18 -14
- package/src/data.test.ts +236 -0
- package/src/data.ts +200 -0
- package/src/display.ts +30 -1
- package/src/doctor.ts +174 -0
- package/src/envelope.ts +17 -0
- package/src/index.ts +13 -6
- package/src/models.ts +28 -0
- 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/stats.ts +53 -1
- 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/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;
|
|
@@ -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);
|
package/src/storage.ts
CHANGED
|
@@ -1,75 +1,113 @@
|
|
|
1
1
|
import { appendFile, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { dataDir } from "./config.ts";
|
|
4
2
|
import type { StrokeData, Workout } from "./models.ts";
|
|
3
|
+
import type { DataPaths } from "./paths.ts";
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
export interface StoreMeta {
|
|
6
|
+
schema_version: number;
|
|
7
|
+
created: string;
|
|
8
|
+
last_sync?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
return join(dataDir(), "strokes", `${workoutId}.jsonl`);
|
|
12
|
-
}
|
|
11
|
+
export const SCHEMA_VERSION = 1;
|
|
13
12
|
|
|
14
|
-
export async function readWorkouts(): Promise<Workout[]> {
|
|
13
|
+
export async function readWorkouts(paths: DataPaths): Promise<Workout[]> {
|
|
15
14
|
try {
|
|
16
|
-
const text = await readFile(
|
|
15
|
+
const text = await readFile(paths.workouts, "utf-8");
|
|
17
16
|
return text
|
|
18
17
|
.split("\n")
|
|
19
18
|
.filter((line) => line.trim() !== "")
|
|
20
19
|
.map((line) => JSON.parse(line) as Workout);
|
|
21
20
|
} catch (err: unknown) {
|
|
22
|
-
|
|
21
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
22
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
23
23
|
throw err;
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export async function appendWorkouts(newWorkouts: Workout[]): Promise<number> {
|
|
28
|
-
const existing = await readWorkouts();
|
|
27
|
+
export async function appendWorkouts(paths: DataPaths, newWorkouts: Workout[]): Promise<number> {
|
|
28
|
+
const existing = await readWorkouts(paths);
|
|
29
29
|
const seen = new Set(existing.map((w) => w.id));
|
|
30
30
|
|
|
31
31
|
const toWrite = newWorkouts.filter((w) => !seen.has(w.id));
|
|
32
32
|
if (toWrite.length === 0) return 0;
|
|
33
33
|
|
|
34
34
|
const lines = `${toWrite.map((w) => JSON.stringify(w)).join("\n")}\n`;
|
|
35
|
-
await appendFile(
|
|
35
|
+
await appendFile(paths.workouts, lines, "utf-8");
|
|
36
36
|
return toWrite.length;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function workoutCount(): Promise<number> {
|
|
39
|
+
export async function workoutCount(paths: DataPaths): Promise<number> {
|
|
40
40
|
try {
|
|
41
|
-
const text = await readFile(
|
|
41
|
+
const text = await readFile(paths.workouts, "utf-8");
|
|
42
42
|
return text.split("\n").filter((line) => line.trim() !== "").length;
|
|
43
43
|
} catch (err: unknown) {
|
|
44
|
-
|
|
44
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
45
|
+
if (code === "ENOENT" || code === "ENOTDIR") return 0;
|
|
45
46
|
throw err;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
export async function hasStrokeData(workoutId: number): Promise<boolean> {
|
|
50
|
+
export async function hasStrokeData(paths: DataPaths, workoutId: number): Promise<boolean> {
|
|
50
51
|
try {
|
|
51
|
-
await stat(
|
|
52
|
+
await stat(paths.strokeFile(workoutId));
|
|
52
53
|
return true;
|
|
53
54
|
} catch (err: unknown) {
|
|
54
|
-
|
|
55
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
56
|
+
if (code === "ENOENT" || code === "ENOTDIR") return false;
|
|
55
57
|
throw err;
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
export async function writeStrokeData(
|
|
61
|
+
export async function writeStrokeData(
|
|
62
|
+
paths: DataPaths,
|
|
63
|
+
workoutId: number,
|
|
64
|
+
strokes: StrokeData[],
|
|
65
|
+
): Promise<void> {
|
|
60
66
|
const lines = `${strokes.map((s) => JSON.stringify(s)).join("\n")}\n`;
|
|
61
|
-
await writeFile(
|
|
67
|
+
await writeFile(paths.strokeFile(workoutId), lines, "utf-8");
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
export async function readStrokeData(workoutId: number): Promise<StrokeData[]> {
|
|
70
|
+
export async function readStrokeData(paths: DataPaths, workoutId: number): Promise<StrokeData[]> {
|
|
71
|
+
let text: string;
|
|
65
72
|
try {
|
|
66
|
-
|
|
67
|
-
return text
|
|
68
|
-
.split("\n")
|
|
69
|
-
.filter((line) => line.trim() !== "")
|
|
70
|
-
.map((line) => JSON.parse(line) as StrokeData);
|
|
73
|
+
text = await readFile(paths.strokeFile(workoutId), "utf-8");
|
|
71
74
|
} catch (err: unknown) {
|
|
72
|
-
|
|
75
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
76
|
+
if (code === "ENOENT" || code === "ENOTDIR") return [];
|
|
73
77
|
throw err;
|
|
74
78
|
}
|
|
79
|
+
const strokes: StrokeData[] = [];
|
|
80
|
+
for (const line of text.split("\n")) {
|
|
81
|
+
if (line.trim() === "") continue;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(line) as unknown;
|
|
84
|
+
if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
85
|
+
strokes.push(parsed as StrokeData);
|
|
86
|
+
}
|
|
87
|
+
} catch {}
|
|
88
|
+
}
|
|
89
|
+
return strokes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function readMeta(paths: DataPaths): Promise<StoreMeta | null> {
|
|
93
|
+
let text: string;
|
|
94
|
+
try {
|
|
95
|
+
text = await readFile(paths.meta, "utf-8");
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
98
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
99
|
+
console.error(`Warning: ${paths.meta} is unreadable and will be ignored.`);
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(text) as StoreMeta;
|
|
105
|
+
} catch {
|
|
106
|
+
console.error(`Warning: ${paths.meta} is corrupt and will be ignored.`);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function writeMeta(paths: DataPaths, meta: StoreMeta): Promise<void> {
|
|
112
|
+
await writeFile(paths.meta, `${JSON.stringify(meta, null, 2)}\n`, "utf-8");
|
|
75
113
|
}
|