@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/index.ts
CHANGED
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { Command } from "commander";
|
|
4
4
|
import pkg from "../package.json";
|
|
5
|
+
import { registerData } from "./commands/data.ts";
|
|
6
|
+
import { registerDocs } from "./commands/docs.ts";
|
|
5
7
|
import { registerExport } from "./commands/export.ts";
|
|
6
8
|
import { registerLog } from "./commands/log.ts";
|
|
9
|
+
import { registerNote } from "./commands/note.ts";
|
|
7
10
|
import { registerReport } from "./commands/report.ts";
|
|
8
11
|
import { registerSetup } from "./commands/setup.ts";
|
|
12
|
+
import { registerShow } from "./commands/show.ts";
|
|
13
|
+
import { registerStats } from "./commands/stats.ts";
|
|
9
14
|
import { registerStatus } from "./commands/status.ts";
|
|
10
15
|
import { registerSync } from "./commands/sync.ts";
|
|
11
16
|
import { registerTrend } from "./commands/trend.ts";
|
|
@@ -22,13 +27,15 @@ registerStatus(program);
|
|
|
22
27
|
registerTrend(program);
|
|
23
28
|
registerExport(program);
|
|
24
29
|
registerReport(program);
|
|
30
|
+
registerData(program);
|
|
31
|
+
registerShow(program);
|
|
32
|
+
registerStats(program);
|
|
33
|
+
registerNote(program);
|
|
34
|
+
registerDocs(program);
|
|
25
35
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const hasHelpOrVersion = args.some((a) => ["-h", "--help", "-v", "--version"].includes(a));
|
|
30
|
-
if (args.length === 0 || (!hasSubcommand && !hasHelpOrVersion)) {
|
|
31
|
-
process.argv.splice(2, 0, "report");
|
|
36
|
+
if (process.argv.length <= 2) {
|
|
37
|
+
program.outputHelp();
|
|
38
|
+
process.exit(0);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
program.parseAsync(process.argv).catch((err: Error) => {
|
package/src/models.ts
CHANGED
|
@@ -2,6 +2,21 @@ export interface HeartRate {
|
|
|
2
2
|
average?: number;
|
|
3
3
|
min?: number;
|
|
4
4
|
max?: number;
|
|
5
|
+
ending?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WorkoutSplit {
|
|
9
|
+
type?: string;
|
|
10
|
+
time: number;
|
|
11
|
+
distance?: number;
|
|
12
|
+
calories_total?: number;
|
|
13
|
+
stroke_rate?: number;
|
|
14
|
+
heart_rate?: HeartRate;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WorkoutDetail {
|
|
18
|
+
targets?: { pace?: number };
|
|
19
|
+
splits?: WorkoutSplit[];
|
|
5
20
|
}
|
|
6
21
|
|
|
7
22
|
export interface Workout {
|
|
@@ -25,6 +40,7 @@ export interface Workout {
|
|
|
25
40
|
rest_time?: number;
|
|
26
41
|
rest_distance?: number;
|
|
27
42
|
comments?: string;
|
|
43
|
+
workout?: WorkoutDetail;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
export interface StrokeData {
|
|
@@ -111,3 +127,15 @@ export function formatSeconds(totalSeconds: number): string {
|
|
|
111
127
|
const rem = totalSeconds - mins * 60;
|
|
112
128
|
return `${mins}:${rem.toFixed(1).padStart(4, "0")}`;
|
|
113
129
|
}
|
|
130
|
+
|
|
131
|
+
export function isValidYMD(s: string): boolean {
|
|
132
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
133
|
+
if (!m) return false;
|
|
134
|
+
const d = new Date(`${s}T00:00:00`);
|
|
135
|
+
if (Number.isNaN(d.getTime())) return false;
|
|
136
|
+
return (
|
|
137
|
+
d.getFullYear() === Number(m[1]) &&
|
|
138
|
+
d.getMonth() + 1 === Number(m[2]) &&
|
|
139
|
+
d.getDate() === Number(m[3])
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
compactNotes,
|
|
7
|
+
filterNotes,
|
|
8
|
+
localISO,
|
|
9
|
+
type NoteRecord,
|
|
10
|
+
readAllNotes,
|
|
11
|
+
serializeNote,
|
|
12
|
+
ulid,
|
|
13
|
+
writeNote,
|
|
14
|
+
} from "./notes.ts";
|
|
15
|
+
import { pathsFor } from "./paths.ts";
|
|
16
|
+
|
|
17
|
+
const NOW = new Date("2026-07-06T12:00:00");
|
|
18
|
+
|
|
19
|
+
async function tempStore() {
|
|
20
|
+
const base = await mkdtemp(join(tmpdir(), "c2-notes-test-"));
|
|
21
|
+
const paths = pathsFor(join(base, "store"));
|
|
22
|
+
await mkdir(paths.archiveDir, { recursive: true });
|
|
23
|
+
return paths;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function record(id: string, date: string, body: string, type = "observation"): NoteRecord {
|
|
27
|
+
return { id, date, type: type as NoteRecord["type"], body, author: "athlete" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("ulid is 26 chars and time-ordered", () => {
|
|
31
|
+
const a = ulid(new Date("2026-07-01T00:00:00Z"));
|
|
32
|
+
const b = ulid(new Date("2026-07-02T00:00:00Z"));
|
|
33
|
+
expect(a.length).toBe(26);
|
|
34
|
+
expect(b.length).toBe(26);
|
|
35
|
+
expect(a < b).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("localISO renders the local calendar day with offset", () => {
|
|
39
|
+
const iso = localISO(new Date(2026, 6, 6, 21, 30, 0));
|
|
40
|
+
expect(iso.startsWith("2026-07-06T21:30:00")).toBe(true);
|
|
41
|
+
expect(/[+-]\d{2}:\d{2}$/.test(iso)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("notes sort by instant across mixed offsets", async () => {
|
|
45
|
+
const paths = await tempStore();
|
|
46
|
+
await writeNote(paths, record("MINUS6", "2026-07-05T23:30:00-06:00", "later instant"));
|
|
47
|
+
await writeNote(paths, record("PLUS2", "2026-07-06T01:00:00+02:00", "earlier instant"));
|
|
48
|
+
const notes = await readAllNotes(paths);
|
|
49
|
+
expect(notes.map((n) => n.id)).toEqual(["PLUS2", "MINUS6"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("mixed-offset archives pass doctor after compaction", async () => {
|
|
53
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
54
|
+
const paths = await tempStore();
|
|
55
|
+
await writeNote(paths, record("TZ1", "2026-06-05T23:30:00-06:00", "denver"));
|
|
56
|
+
await writeNote(paths, record("TZ2", "2026-06-06T01:00:00+02:00", "europe"));
|
|
57
|
+
const result = await compactNotes(paths, NOW);
|
|
58
|
+
expect(result.archived).toBe(2);
|
|
59
|
+
const report = await runDoctor(paths);
|
|
60
|
+
expect(report.issues).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("notes round-trip and sort by date then id", async () => {
|
|
64
|
+
const paths = await tempStore();
|
|
65
|
+
await writeNote(paths, record("B", "2026-07-05T10:00:00-06:00", "second"));
|
|
66
|
+
await writeNote(paths, record("A", "2026-07-05T10:00:00-06:00", "first"));
|
|
67
|
+
await writeNote(paths, record("C", "2026-07-01T08:00:00-06:00", "oldest"));
|
|
68
|
+
|
|
69
|
+
const notes = await readAllNotes(paths);
|
|
70
|
+
expect(notes.map((n) => n.id)).toEqual(["C", "A", "B"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("corrupt loose notes are skipped", async () => {
|
|
74
|
+
const paths = await tempStore();
|
|
75
|
+
await writeNote(paths, record("GOOD", "2026-07-05T10:00:00-06:00", "fine"));
|
|
76
|
+
await writeFile(join(paths.notesDir, "BAD.json"), "{ nope", "utf-8");
|
|
77
|
+
await writeFile(join(paths.notesDir, "SHAPE.json"), '{"id":"SHAPE"}', "utf-8");
|
|
78
|
+
|
|
79
|
+
const notes = await readAllNotes(paths);
|
|
80
|
+
expect(notes.length).toBe(1);
|
|
81
|
+
expect(notes[0]!.id).toBe("GOOD");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("filterNotes filters by type, since, and workout", () => {
|
|
85
|
+
const notes = [
|
|
86
|
+
{ ...record("A", "2026-07-01T08:00:00-06:00", "x", "subjective"), workout_id: 7 },
|
|
87
|
+
record("B", "2026-07-03T08:00:00-06:00", "y", "lesson"),
|
|
88
|
+
record("C", "2026-07-05T08:00:00-06:00", "z", "observation"),
|
|
89
|
+
];
|
|
90
|
+
expect(filterNotes(notes, { type: "lesson" }).map((n) => n.id)).toEqual(["B"]);
|
|
91
|
+
expect(filterNotes(notes, { since: "2026-07-03" }).map((n) => n.id)).toEqual(["B", "C"]);
|
|
92
|
+
expect(filterNotes(notes, { workoutId: 7 }).map((n) => n.id)).toEqual(["A"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("compaction archives old notes by year, keeps the hot set, dedups reads", async () => {
|
|
96
|
+
const paths = await tempStore();
|
|
97
|
+
await writeNote(paths, record("OLD1", "2026-06-20T08:00:00-06:00", "old june"));
|
|
98
|
+
await writeNote(paths, record("OLD2", "2025-12-30T08:00:00-07:00", "old last year"));
|
|
99
|
+
await writeNote(paths, record("NEW1", "2026-07-05T08:00:00-06:00", "recent"));
|
|
100
|
+
|
|
101
|
+
const result = await compactNotes(paths, NOW);
|
|
102
|
+
expect(result.archived).toBe(2);
|
|
103
|
+
expect(result.years).toEqual([2025, 2026]);
|
|
104
|
+
|
|
105
|
+
const looseFiles = (await readdir(paths.notesDir)).filter((f) => f.endsWith(".json"));
|
|
106
|
+
expect(looseFiles).toEqual(["NEW1.json"]);
|
|
107
|
+
|
|
108
|
+
const all = await readAllNotes(paths);
|
|
109
|
+
expect(all.map((n) => n.id)).toEqual(["OLD2", "OLD1", "NEW1"]);
|
|
110
|
+
|
|
111
|
+
const again = await compactNotes(paths, NOW);
|
|
112
|
+
expect(again.archived).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("compaction is deterministic and merge-idempotent", async () => {
|
|
116
|
+
const storeA = await tempStore();
|
|
117
|
+
const storeB = await tempStore();
|
|
118
|
+
const notes = [
|
|
119
|
+
record("01A", "2026-06-01T08:00:00-06:00", "one"),
|
|
120
|
+
record("01B", "2026-06-15T08:00:00-06:00", "two"),
|
|
121
|
+
record("01C", "2026-05-20T08:00:00-06:00", "three"),
|
|
122
|
+
];
|
|
123
|
+
for (const store of [storeA, storeB]) {
|
|
124
|
+
for (const n of [...notes].reverse()) {
|
|
125
|
+
await writeNote(store, n);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await compactNotes(storeA, NOW);
|
|
130
|
+
await compactNotes(storeB, NOW);
|
|
131
|
+
const a = await readFile(storeA.archiveFile(2026), "utf-8");
|
|
132
|
+
const b = await readFile(storeB.archiveFile(2026), "utf-8");
|
|
133
|
+
expect(a).toBe(b);
|
|
134
|
+
|
|
135
|
+
await writeNote(storeA, record("01D", "2026-06-20T08:00:00-06:00", "late arrival"));
|
|
136
|
+
await compactNotes(storeA, NOW);
|
|
137
|
+
const merged = await readAllNotes(storeA);
|
|
138
|
+
expect(merged.filter((n) => n.date.startsWith("2026-06")).length).toBe(3);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("notes with invalid authors are skipped by the reader", async () => {
|
|
142
|
+
const paths = await tempStore();
|
|
143
|
+
await writeNote(paths, record("GOOD", "2026-07-05T10:00:00-06:00", "fine"));
|
|
144
|
+
await writeFile(
|
|
145
|
+
join(paths.notesDir, "LLM.json"),
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
id: "LLM",
|
|
148
|
+
date: "2026-07-05T10:00:00-06:00",
|
|
149
|
+
type: "observation",
|
|
150
|
+
body: "x",
|
|
151
|
+
author: "llm",
|
|
152
|
+
}),
|
|
153
|
+
"utf-8",
|
|
154
|
+
);
|
|
155
|
+
await writeFile(
|
|
156
|
+
join(paths.notesDir, "BADTAGS.json"),
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
id: "BADTAGS",
|
|
159
|
+
date: "2026-07-05T10:00:00-06:00",
|
|
160
|
+
type: "observation",
|
|
161
|
+
tags: "not-an-array",
|
|
162
|
+
body: "x",
|
|
163
|
+
author: "athlete",
|
|
164
|
+
}),
|
|
165
|
+
"utf-8",
|
|
166
|
+
);
|
|
167
|
+
await writeFile(
|
|
168
|
+
join(paths.notesDir, "BADWID.json"),
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
id: "BADWID",
|
|
171
|
+
date: "2026-07-05T10:00:00-06:00",
|
|
172
|
+
type: "observation",
|
|
173
|
+
workout_id: "seven",
|
|
174
|
+
body: "x",
|
|
175
|
+
author: "athlete",
|
|
176
|
+
}),
|
|
177
|
+
"utf-8",
|
|
178
|
+
);
|
|
179
|
+
const notes = await readAllNotes(paths);
|
|
180
|
+
expect(notes.map((n) => n.id)).toEqual(["GOOD"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("unreadable meta.json degrades instead of crashing inspection", async () => {
|
|
184
|
+
const { inspectDataDir } = await import("./data.ts");
|
|
185
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
186
|
+
const paths = await tempStore();
|
|
187
|
+
const { initStore } = await import("./data.ts");
|
|
188
|
+
await initStore(paths, NOW);
|
|
189
|
+
await chmod(paths.meta, 0o000);
|
|
190
|
+
try {
|
|
191
|
+
const insp = await inspectDataDir(paths);
|
|
192
|
+
expect(insp.state).toBe("store");
|
|
193
|
+
const report = await runDoctor(paths);
|
|
194
|
+
expect(report.issues.some((i) => i.startsWith("meta.json: unreadable"))).toBe(true);
|
|
195
|
+
} finally {
|
|
196
|
+
await chmod(paths.meta, 0o644);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("compaction refuses to rewrite archives containing corrupt lines", async () => {
|
|
201
|
+
const paths = await tempStore();
|
|
202
|
+
const garbage = "{ this is not json";
|
|
203
|
+
await writeFile(
|
|
204
|
+
paths.archiveFile(2026),
|
|
205
|
+
`${serializeNote(record("OLDARCH", "2026-01-01T08:00:00-07:00", "archived"))}\n${garbage}\n`,
|
|
206
|
+
"utf-8",
|
|
207
|
+
);
|
|
208
|
+
await writeNote(paths, record("OLDLOOSE", "2026-06-01T08:00:00-06:00", "wants archiving"));
|
|
209
|
+
|
|
210
|
+
const result = await compactNotes(paths, NOW);
|
|
211
|
+
expect(result.archived).toBe(0);
|
|
212
|
+
expect(result.skippedYears).toEqual([2026]);
|
|
213
|
+
|
|
214
|
+
const archiveText = await readFile(paths.archiveFile(2026), "utf-8");
|
|
215
|
+
expect(archiveText).toContain(garbage);
|
|
216
|
+
const looseFiles = (await readdir(paths.notesDir)).filter((f) => f.endsWith(".json"));
|
|
217
|
+
expect(looseFiles).toEqual(["OLDLOOSE.json"]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("parseNoteDate uses the target date's own offset and rejects partial dates", async () => {
|
|
221
|
+
const { parseNoteDate } = await import("./commands/note.ts");
|
|
222
|
+
expect(parseNoteDate("2026-01-15")).toBe(localISO(new Date("2026-01-15T12:00:00")));
|
|
223
|
+
expect(parseNoteDate("2026-07-15")).toBe(localISO(new Date("2026-07-15T12:00:00")));
|
|
224
|
+
expect(parseNoteDate("2026-02-31")).toBeNull();
|
|
225
|
+
expect(parseNoteDate("2026-07")).toBeNull();
|
|
226
|
+
expect(parseNoteDate("2026")).toBeNull();
|
|
227
|
+
expect(parseNoteDate("2026-07-15T08:30:00")).toBe(localISO(new Date("2026-07-15T08:30:00")));
|
|
228
|
+
expect(parseNoteDate("2026-07-05T10:00:00+02:00")).toBe("2026-07-05T10:00:00+02:00");
|
|
229
|
+
expect(parseNoteDate("2026-07-05T10:00:00Z")).toBe("2026-07-05T10:00:00+00:00");
|
|
230
|
+
expect(parseNoteDate("2026-07-05 10:00:00 +02:00")).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("doctor validates stroke row shapes and reader skips junk rows", async () => {
|
|
234
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
235
|
+
const { readStrokeData } = await import("./storage.ts");
|
|
236
|
+
const paths = await tempStore();
|
|
237
|
+
await mkdir(paths.strokesDir, { recursive: true });
|
|
238
|
+
await writeFile(
|
|
239
|
+
paths.strokeFile(42),
|
|
240
|
+
`${JSON.stringify({ t: 100, d: 500, p: 1750, spm: 24, hr: 110 })}\nnull\n${JSON.stringify({ t: "x" })}\n`,
|
|
241
|
+
"utf-8",
|
|
242
|
+
);
|
|
243
|
+
const report = await runDoctor(paths);
|
|
244
|
+
expect(report.issues).toContain("strokes/42.jsonl: line 2 malformed stroke record");
|
|
245
|
+
expect(report.issues).toContain("strokes/42.jsonl: line 3 malformed stroke record");
|
|
246
|
+
|
|
247
|
+
const strokes = await readStrokeData(paths, 42);
|
|
248
|
+
expect(strokes.length).toBe(2);
|
|
249
|
+
expect(strokes[0]!.p).toBe(1750);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("compaction compares note age as instants, not offset strings", async () => {
|
|
253
|
+
const paths = await tempStore();
|
|
254
|
+
const cutoffInstant = new Date(NOW);
|
|
255
|
+
cutoffInstant.setDate(cutoffInstant.getDate() - 7);
|
|
256
|
+
const oldInstant = new Date(cutoffInstant.getTime() - 11 * 60 * 60 * 1000);
|
|
257
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
258
|
+
const inPlus13 = new Date(oldInstant.getTime() + 13 * 60 * 60 * 1000);
|
|
259
|
+
const farOffsetDate = `${inPlus13.getUTCFullYear()}-${pad(inPlus13.getUTCMonth() + 1)}-${pad(inPlus13.getUTCDate())}T${pad(inPlus13.getUTCHours())}:${pad(inPlus13.getUTCMinutes())}:00+13:00`;
|
|
260
|
+
|
|
261
|
+
await writeNote(paths, record("FAROFF", farOffsetDate, "written abroad"));
|
|
262
|
+
const result = await compactNotes(paths, NOW);
|
|
263
|
+
expect(result.archived).toBe(1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("notes with unparseable or non-ISO dates are skipped and never reach compaction", async () => {
|
|
267
|
+
const paths = await tempStore();
|
|
268
|
+
await writeFile(
|
|
269
|
+
join(paths.notesDir, "BADDATE.json"),
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
id: "BADDATE",
|
|
272
|
+
date: "sometime last spring",
|
|
273
|
+
type: "observation",
|
|
274
|
+
body: "x",
|
|
275
|
+
author: "athlete",
|
|
276
|
+
}),
|
|
277
|
+
"utf-8",
|
|
278
|
+
);
|
|
279
|
+
await writeFile(
|
|
280
|
+
join(paths.notesDir, "PROSEDATE.json"),
|
|
281
|
+
JSON.stringify({
|
|
282
|
+
id: "PROSEDATE",
|
|
283
|
+
date: "July 1, 2026",
|
|
284
|
+
type: "observation",
|
|
285
|
+
body: "parseable but not ISO",
|
|
286
|
+
author: "athlete",
|
|
287
|
+
}),
|
|
288
|
+
"utf-8",
|
|
289
|
+
);
|
|
290
|
+
expect((await readAllNotes(paths)).length).toBe(0);
|
|
291
|
+
const result = await compactNotes(paths, NOW);
|
|
292
|
+
expect(result.archived).toBe(0);
|
|
293
|
+
const looseFiles = (await readdir(paths.notesDir)).filter((f) => f.endsWith(".json"));
|
|
294
|
+
expect(looseFiles.sort()).toEqual(["BADDATE.json", "PROSEDATE.json"]);
|
|
295
|
+
const archives = await readdir(paths.archiveDir);
|
|
296
|
+
expect(archives).not.toContain("NaN.jsonl");
|
|
297
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
298
|
+
const report = await runDoctor(paths);
|
|
299
|
+
expect(report.issues).toContain("notes/BADDATE.json: malformed note record");
|
|
300
|
+
expect(report.issues).toContain("notes/PROSEDATE.json: malformed note record");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("doctor accepts conflict-copy filenames and survives null workout rows", async () => {
|
|
304
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
305
|
+
const paths = await tempStore();
|
|
306
|
+
const n = record("CONF", "2026-07-05T10:00:00-06:00", "conflict copy content");
|
|
307
|
+
await writeFile(join(paths.notesDir, "CONF conflict copy 2.json"), serializeNote(n), "utf-8");
|
|
308
|
+
const clean = await runDoctor(paths);
|
|
309
|
+
expect(clean.issues).toEqual([]);
|
|
310
|
+
|
|
311
|
+
await writeFile(paths.workouts, 'null\n"just a string"\n', "utf-8");
|
|
312
|
+
const report = await runDoctor(paths);
|
|
313
|
+
expect(report.issues).toContain("workouts.jsonl: line 1 malformed workout record");
|
|
314
|
+
expect(report.issues).toContain("workouts.jsonl: line 2 malformed workout record");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("doctor flags malformed workout records", async () => {
|
|
318
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
319
|
+
const paths = await tempStore();
|
|
320
|
+
await writeFile(
|
|
321
|
+
paths.workouts,
|
|
322
|
+
`${JSON.stringify({ id: 1, date: "2026-07-01 08:00:00", distance: 8000, time: 12000 })}\n${JSON.stringify({ id: "two", date: 5 })}\n`,
|
|
323
|
+
"utf-8",
|
|
324
|
+
);
|
|
325
|
+
const report = await runDoctor(paths);
|
|
326
|
+
expect(report.issues).toContain("workouts.jsonl: line 2 malformed workout record");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("archives with duplicate ids are never rewritten", async () => {
|
|
330
|
+
const paths = await tempStore();
|
|
331
|
+
const dup = record("ARCHDUP", "2026-01-05T08:00:00-07:00", "version one");
|
|
332
|
+
await writeFile(
|
|
333
|
+
paths.archiveFile(2026),
|
|
334
|
+
`${serializeNote(dup)}\n${serializeNote({ ...dup, body: "version two" })}\n`,
|
|
335
|
+
"utf-8",
|
|
336
|
+
);
|
|
337
|
+
await writeNote(paths, record("NEWOLD", "2026-06-01T08:00:00-06:00", "wants archiving"));
|
|
338
|
+
|
|
339
|
+
const result = await compactNotes(paths, NOW);
|
|
340
|
+
expect(result.archived).toBe(0);
|
|
341
|
+
expect(result.skippedYears).toEqual([2026]);
|
|
342
|
+
const archiveText = await readFile(paths.archiveFile(2026), "utf-8");
|
|
343
|
+
expect(archiveText).toContain("version one");
|
|
344
|
+
expect(archiveText).toContain("version two");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("loose-note deletion failures do not fail compaction", async () => {
|
|
348
|
+
const paths = await tempStore();
|
|
349
|
+
await writeNote(paths, record("LOCKED", "2026-06-01T08:00:00-06:00", "old note"));
|
|
350
|
+
await chmod(paths.notesDir, 0o555);
|
|
351
|
+
try {
|
|
352
|
+
const result = await compactNotes(paths, NOW);
|
|
353
|
+
expect(result.archived).toBe(1);
|
|
354
|
+
} finally {
|
|
355
|
+
await chmod(paths.notesDir, 0o755);
|
|
356
|
+
}
|
|
357
|
+
const all = await readAllNotes(paths);
|
|
358
|
+
expect(all.filter((n) => n.id === "LOCKED").length).toBe(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("offset-less note timestamps are rejected everywhere", async () => {
|
|
362
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
363
|
+
const paths = await tempStore();
|
|
364
|
+
await writeFile(
|
|
365
|
+
join(paths.notesDir, "NOOFFSET.json"),
|
|
366
|
+
JSON.stringify({
|
|
367
|
+
id: "NOOFFSET",
|
|
368
|
+
date: "2026-07-05T10:00:00",
|
|
369
|
+
type: "observation",
|
|
370
|
+
body: "parses as local time",
|
|
371
|
+
author: "athlete",
|
|
372
|
+
}),
|
|
373
|
+
"utf-8",
|
|
374
|
+
);
|
|
375
|
+
expect((await readAllNotes(paths)).length).toBe(0);
|
|
376
|
+
const report = await runDoctor(paths);
|
|
377
|
+
expect(report.issues).toContain("notes/NOOFFSET.json: malformed note record");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("compaction skips read-only archives without failing", async () => {
|
|
381
|
+
const paths = await tempStore();
|
|
382
|
+
await writeFile(
|
|
383
|
+
paths.archiveFile(2026),
|
|
384
|
+
`${serializeNote(record("RO", "2026-01-01T08:00:00-07:00", "existing"))}\n`,
|
|
385
|
+
"utf-8",
|
|
386
|
+
);
|
|
387
|
+
await chmod(paths.archiveFile(2026), 0o444);
|
|
388
|
+
await writeNote(paths, record("ROLOOSE", "2026-06-01T08:00:00-06:00", "wants in"));
|
|
389
|
+
try {
|
|
390
|
+
const result = await compactNotes(paths, NOW);
|
|
391
|
+
expect(result.archived).toBe(0);
|
|
392
|
+
expect(result.skippedYears).toEqual([2026]);
|
|
393
|
+
} finally {
|
|
394
|
+
await chmod(paths.archiveFile(2026), 0o644);
|
|
395
|
+
}
|
|
396
|
+
const looseFiles = (await readdir(paths.notesDir)).filter((f) => f.endsWith(".json"));
|
|
397
|
+
expect(looseFiles).toEqual(["ROLOOSE.json"]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("divergent conflict copies are preserved and flagged, identical ones compact", async () => {
|
|
401
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
402
|
+
const paths = await tempStore();
|
|
403
|
+
const original = record("DIV", "2026-06-01T08:00:00-06:00", "original text");
|
|
404
|
+
await writeNote(paths, original);
|
|
405
|
+
await writeFile(
|
|
406
|
+
join(paths.notesDir, "DIV conflict.json"),
|
|
407
|
+
serializeNote({ ...original, body: "edited on the other machine" }),
|
|
408
|
+
"utf-8",
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const report = await runDoctor(paths);
|
|
412
|
+
expect(report.issues.some((i) => i.includes("divergent copies of note DIV"))).toBe(true);
|
|
413
|
+
|
|
414
|
+
const result = await compactNotes(paths, NOW);
|
|
415
|
+
expect(result.archived).toBe(0);
|
|
416
|
+
const looseFiles = (await readdir(paths.notesDir)).filter((f) => f.endsWith(".json"));
|
|
417
|
+
expect(looseFiles.length).toBe(2);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("compaction deletes the files it read, including conflict copies", async () => {
|
|
421
|
+
const paths = await tempStore();
|
|
422
|
+
const old = record("OLDX", "2026-06-01T08:00:00-06:00", "original");
|
|
423
|
+
await writeNote(paths, old);
|
|
424
|
+
await writeFile(join(paths.notesDir, "OLDX conflict 2.json"), serializeNote(old), "utf-8");
|
|
425
|
+
await writeFile(
|
|
426
|
+
join(paths.notesDir, "WEIRD NAME.json"),
|
|
427
|
+
serializeNote(record("OLDY", "2026-06-02T08:00:00-06:00", "odd filename")),
|
|
428
|
+
"utf-8",
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const result = await compactNotes(paths, NOW);
|
|
432
|
+
expect(result.archived).toBe(2);
|
|
433
|
+
|
|
434
|
+
const looseFiles = (await readdir(paths.notesDir)).filter((f) => f.endsWith(".json"));
|
|
435
|
+
expect(looseFiles).toEqual([]);
|
|
436
|
+
const all = await readAllNotes(paths);
|
|
437
|
+
expect(all.map((n) => n.id)).toEqual(["OLDX", "OLDY"]);
|
|
438
|
+
|
|
439
|
+
const again = await compactNotes(paths, NOW);
|
|
440
|
+
expect(again.archived).toBe(0);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("compaction skips unreadable archives instead of truncating them", async () => {
|
|
444
|
+
const paths = await tempStore();
|
|
445
|
+
await writeFile(
|
|
446
|
+
paths.archiveFile(2026),
|
|
447
|
+
`${serializeNote(record("SAFE", "2026-01-01T08:00:00-07:00", "already archived"))}\n`,
|
|
448
|
+
"utf-8",
|
|
449
|
+
);
|
|
450
|
+
await chmod(paths.archiveFile(2026), 0o000);
|
|
451
|
+
await writeNote(paths, record("OLDLOOSE2", "2026-06-01T08:00:00-06:00", "wants archiving"));
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
const result = await compactNotes(paths, NOW);
|
|
455
|
+
expect(result.archived).toBe(0);
|
|
456
|
+
expect(result.skippedYears).toEqual([2026]);
|
|
457
|
+
} finally {
|
|
458
|
+
await chmod(paths.archiveFile(2026), 0o644);
|
|
459
|
+
}
|
|
460
|
+
const archiveText = await readFile(paths.archiveFile(2026), "utf-8");
|
|
461
|
+
expect(archiveText).toContain("SAFE");
|
|
462
|
+
const looseFiles = (await readdir(paths.notesDir)).filter((f) => f.endsWith(".json"));
|
|
463
|
+
expect(looseFiles).toEqual(["OLDLOOSE2.json"]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("doctor flags unreadable note directories", async () => {
|
|
467
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
468
|
+
const base = await mkdtemp(join(tmpdir(), "c2-notes-test-"));
|
|
469
|
+
const paths = pathsFor(join(base, "store"));
|
|
470
|
+
await mkdir(paths.root, { recursive: true });
|
|
471
|
+
await writeFile(paths.notesDir, "a file where a directory should be", "utf-8");
|
|
472
|
+
const report = await runDoctor(paths);
|
|
473
|
+
expect(report.issues.some((i) => i.startsWith("notes/: unreadable"))).toBe(true);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("doctor tolerates loose/archive duplicates but flags malformed records", async () => {
|
|
477
|
+
const { runDoctor } = await import("./doctor.ts");
|
|
478
|
+
const paths = await tempStore();
|
|
479
|
+
const dup = record("DUP", "2026-06-01T08:00:00-06:00", "both places");
|
|
480
|
+
await writeNote(paths, dup);
|
|
481
|
+
await writeFile(paths.archiveFile(2026), `${serializeNote(dup)}\n`, "utf-8");
|
|
482
|
+
|
|
483
|
+
const clean = await runDoctor(paths);
|
|
484
|
+
expect(clean.issues).toEqual([]);
|
|
485
|
+
|
|
486
|
+
await writeFile(
|
|
487
|
+
paths.archiveFile(2027),
|
|
488
|
+
`${JSON.stringify({ id: "X", date: "2027-01-01T00:00:00-07:00" })}\n`,
|
|
489
|
+
"utf-8",
|
|
490
|
+
);
|
|
491
|
+
await writeFile(join(paths.notesDir, "BADSHAPE.json"), '{"id":"BADSHAPE"}', "utf-8");
|
|
492
|
+
const dirty = await runDoctor(paths);
|
|
493
|
+
expect(dirty.issues).toContain("notes/archive/2027.jsonl: line 1 malformed note record");
|
|
494
|
+
expect(dirty.issues).toContain("notes/BADSHAPE.json: malformed note record");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("a note both loose and archived reads once", async () => {
|
|
498
|
+
const paths = await tempStore();
|
|
499
|
+
const n = record("DUP", "2026-06-01T08:00:00-06:00", "loose wins");
|
|
500
|
+
await writeNote(paths, n);
|
|
501
|
+
await writeFile(
|
|
502
|
+
paths.archiveFile(2026),
|
|
503
|
+
`${serializeNote({ ...n, body: "archived copy" })}\n`,
|
|
504
|
+
"utf-8",
|
|
505
|
+
);
|
|
506
|
+
const all = await readAllNotes(paths);
|
|
507
|
+
expect(all.length).toBe(1);
|
|
508
|
+
expect(all[0]!.body).toBe("loose wins");
|
|
509
|
+
});
|