@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/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.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ describe("parsedDate", () => {
|
|
|
29
29
|
const w = makeWorkout({ date: "2026-03-07 09:21:00" });
|
|
30
30
|
const d = parsedDate(w);
|
|
31
31
|
expect(d.getFullYear()).toBe(2026);
|
|
32
|
-
expect(d.getMonth()).toBe(2);
|
|
32
|
+
expect(d.getMonth()).toBe(2);
|
|
33
33
|
expect(d.getDate()).toBe(7);
|
|
34
34
|
expect(d.getHours()).toBe(9);
|
|
35
35
|
expect(d.getMinutes()).toBe(21);
|
|
@@ -47,7 +47,6 @@ describe("pace500mSeconds", () => {
|
|
|
47
47
|
test("computes pace for normal workout", () => {
|
|
48
48
|
const w = makeWorkout({ distance: 5500, time: 19122 });
|
|
49
49
|
const pace = pace500mSeconds(w);
|
|
50
|
-
// 19122 / 10 * 500 / 5500 = 173.836...
|
|
51
50
|
expect(pace).toBeCloseTo(173.836, 2);
|
|
52
51
|
});
|
|
53
52
|
|
|
@@ -75,14 +74,10 @@ describe("pace500m", () => {
|
|
|
75
74
|
|
|
76
75
|
test("formats 3+ minute pace correctly", () => {
|
|
77
76
|
const w = makeWorkout({ distance: 1000, time: 3706 });
|
|
78
|
-
// 3706 / 10 * 500 / 1000 = 185.3
|
|
79
77
|
expect(pace500m(w)).toBe("3:05.3");
|
|
80
78
|
});
|
|
81
79
|
|
|
82
80
|
test("uses work time (not elapsed) for interval workout pace", () => {
|
|
83
|
-
// Real 2026-04-11 session: 6x500m, 14:22.6 work, 6:00 rest.
|
|
84
|
-
// API returns `time: 8626` tenths (work), `time_formatted: "20:22.6"` (elapsed).
|
|
85
|
-
// Pace must be computed from `time`, not `time_formatted`.
|
|
86
81
|
const w = makeWorkout({
|
|
87
82
|
distance: 3000,
|
|
88
83
|
time: 8626,
|
|
@@ -91,7 +86,6 @@ describe("pace500m", () => {
|
|
|
91
86
|
rest_time: 3600,
|
|
92
87
|
rest_distance: 660,
|
|
93
88
|
});
|
|
94
|
-
// 8626 / 10 * 500 / 3000 = 143.77
|
|
95
89
|
expect(pace500m(w)).toBe("2:23.8");
|
|
96
90
|
});
|
|
97
91
|
});
|
|
@@ -153,7 +147,6 @@ describe("restSeconds", () => {
|
|
|
153
147
|
|
|
154
148
|
describe("workSeconds", () => {
|
|
155
149
|
test("converts time tenths to seconds", () => {
|
|
156
|
-
// 8626 tenths = 862.6 seconds = 14:22.6
|
|
157
150
|
const w = makeWorkout({ time: 8626 });
|
|
158
151
|
expect(workSeconds(w)).toBe(862.6);
|
|
159
152
|
});
|
package/src/models.ts
CHANGED
|
@@ -2,18 +2,33 @@ 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 {
|
|
8
23
|
id: number;
|
|
9
24
|
user_id: number;
|
|
10
|
-
date: string;
|
|
25
|
+
date: string;
|
|
11
26
|
timezone?: string;
|
|
12
|
-
distance: number;
|
|
13
|
-
type: string;
|
|
14
|
-
time: number;
|
|
15
|
-
time_formatted: string;
|
|
16
|
-
workout_type?: string;
|
|
27
|
+
distance: number;
|
|
28
|
+
type: string;
|
|
29
|
+
time: number;
|
|
30
|
+
time_formatted: string;
|
|
31
|
+
workout_type?: string;
|
|
17
32
|
source?: string;
|
|
18
33
|
weight_class?: string;
|
|
19
34
|
stroke_rate?: number;
|
|
@@ -22,19 +37,18 @@ export interface Workout {
|
|
|
22
37
|
drag_factor?: number;
|
|
23
38
|
heart_rate?: HeartRate;
|
|
24
39
|
stroke_data?: boolean;
|
|
25
|
-
/** Total rest time across all rest intervals, in tenths of a second. */
|
|
26
40
|
rest_time?: number;
|
|
27
|
-
/** Total meters rowed during rest intervals (easy rowing between reps). */
|
|
28
41
|
rest_distance?: number;
|
|
29
42
|
comments?: string;
|
|
43
|
+
workout?: WorkoutDetail;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
export interface StrokeData {
|
|
33
|
-
t?: number;
|
|
34
|
-
d?: number;
|
|
35
|
-
p?: number;
|
|
36
|
-
spm?: number;
|
|
37
|
-
hr?: number;
|
|
47
|
+
t?: number;
|
|
48
|
+
d?: number;
|
|
49
|
+
p?: number;
|
|
50
|
+
spm?: number;
|
|
51
|
+
hr?: number;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
export interface UserProfile {
|
|
@@ -66,7 +80,6 @@ export interface ResultsResponse {
|
|
|
66
80
|
meta?: ResultsMeta;
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
/** API wraps stroke data in {"data": [...]} */
|
|
70
83
|
export interface StrokeDataResponse {
|
|
71
84
|
data: StrokeData[];
|
|
72
85
|
}
|
|
@@ -100,12 +113,10 @@ export function isIntervalWorkout(w: Workout): boolean {
|
|
|
100
113
|
return false;
|
|
101
114
|
}
|
|
102
115
|
|
|
103
|
-
/** Total rest time in seconds (0 when not an interval workout). */
|
|
104
116
|
export function restSeconds(w: Workout): number {
|
|
105
117
|
return (w.rest_time ?? 0) / TENTHS_PER_SECOND;
|
|
106
118
|
}
|
|
107
119
|
|
|
108
|
-
/** Total work time (excluding rest) in seconds. */
|
|
109
120
|
export function workSeconds(w: Workout): number {
|
|
110
121
|
return w.time / TENTHS_PER_SECOND;
|
|
111
122
|
}
|
|
@@ -116,3 +127,15 @@ export function formatSeconds(totalSeconds: number): string {
|
|
|
116
127
|
const rem = totalSeconds - mins * 60;
|
|
117
128
|
return `${mins}:${rem.toFixed(1).padStart(4, "0")}`;
|
|
118
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
|
+
});
|