@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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { loadConfig, saveConfig } from "../config.ts";
|
|
4
|
+
import { inspectDataDir, moveStore, storeSummary } from "../data.ts";
|
|
5
|
+
import { formatMeters } from "../display.ts";
|
|
6
|
+
import { runDoctor } from "../doctor.ts";
|
|
7
|
+
import { printJSON } from "../envelope.ts";
|
|
8
|
+
import { compactNotes } from "../notes.ts";
|
|
9
|
+
import { canonicalRoot, dataPaths, pathsFor } from "../paths.ts";
|
|
10
|
+
|
|
11
|
+
export function registerData(program: Command): void {
|
|
12
|
+
const data = program.command("data").description("Manage the c2 data store");
|
|
13
|
+
|
|
14
|
+
data
|
|
15
|
+
.command("info")
|
|
16
|
+
.description("Show data store location and contents")
|
|
17
|
+
.option("--json", "output as JSON")
|
|
18
|
+
.action(async (opts: { json?: boolean }) => {
|
|
19
|
+
const cfg = await loadConfig();
|
|
20
|
+
const paths = dataPaths(cfg);
|
|
21
|
+
const inspection = await inspectDataDir(paths);
|
|
22
|
+
|
|
23
|
+
if (inspection.state === "missing") {
|
|
24
|
+
console.error(`No data store at ${paths.root}. Run \`c2 setup\` or \`c2 sync\` first.`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
if (inspection.state === "foreign") {
|
|
28
|
+
console.error(
|
|
29
|
+
`${paths.root} exists but is not a c2 data store. Fix data_dir via \`c2 setup\`.`,
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
if (inspection.state === "empty") {
|
|
34
|
+
console.error(
|
|
35
|
+
`${paths.root} is an empty directory, not yet a data store. Run \`c2 sync\` to initialize it.`,
|
|
36
|
+
);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const summary = await storeSummary(paths);
|
|
41
|
+
const legacyStore = summary.schemaVersion == null && summary.workouts > 0;
|
|
42
|
+
const lastSync = summary.lastSync ?? (legacyStore ? (cfg.sync.last_sync ?? null) : null);
|
|
43
|
+
if (opts.json) {
|
|
44
|
+
printJSON("c2.data.info.v1", {
|
|
45
|
+
root: paths.root,
|
|
46
|
+
state: inspection.state,
|
|
47
|
+
writable: inspection.writable,
|
|
48
|
+
schema_version: summary.schemaVersion,
|
|
49
|
+
last_sync: lastSync,
|
|
50
|
+
workouts: summary.workouts,
|
|
51
|
+
first_date: summary.firstDate || null,
|
|
52
|
+
last_date: summary.lastDate || null,
|
|
53
|
+
stroke_files: summary.strokeFiles,
|
|
54
|
+
notes: summary.notes,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log(`Data store: ${paths.root}`);
|
|
60
|
+
console.log(`Schema version: ${summary.schemaVersion ?? "(no meta.json — legacy store)"}`);
|
|
61
|
+
console.log(`Last sync: ${lastSync ?? "never"}`);
|
|
62
|
+
console.log(
|
|
63
|
+
`Workouts: ${formatMeters(summary.workouts)}${summary.firstDate ? ` (${summary.firstDate} → ${summary.lastDate})` : ""}`,
|
|
64
|
+
);
|
|
65
|
+
console.log(`Stroke files: ${formatMeters(summary.strokeFiles)}`);
|
|
66
|
+
console.log(`Notes: ${formatMeters(summary.notes)}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
data
|
|
70
|
+
.command("compact")
|
|
71
|
+
.description("Archive notes older than 7 days into yearly files")
|
|
72
|
+
.action(async () => {
|
|
73
|
+
const cfg = await loadConfig();
|
|
74
|
+
const paths = dataPaths(cfg);
|
|
75
|
+
const inspection = await inspectDataDir(paths);
|
|
76
|
+
if (inspection.state !== "store") {
|
|
77
|
+
console.error(`${paths.root} is not a c2 data store; nothing to compact.`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
if (!inspection.writable) {
|
|
81
|
+
console.error(`Cannot write to ${paths.root}.`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
const result = await compactNotes(paths, new Date());
|
|
85
|
+
for (const year of result.skippedYears) {
|
|
86
|
+
console.error(
|
|
87
|
+
`Warning: notes/archive/${year}.jsonl could not be safely rewritten; notes left loose (run \`c2 data doctor\`).`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
if (result.archived > 0) {
|
|
91
|
+
console.log(
|
|
92
|
+
`Compacted ${result.archived} note${result.archived === 1 ? "" : "s"} into ${result.years.map((y) => `notes/archive/${y}.jsonl`).join(", ")}.`,
|
|
93
|
+
);
|
|
94
|
+
} else if (result.skippedYears.length === 0) {
|
|
95
|
+
console.log("Nothing to compact.");
|
|
96
|
+
}
|
|
97
|
+
if (result.skippedYears.length > 0) {
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
data
|
|
103
|
+
.command("doctor")
|
|
104
|
+
.description("Validate the data store and report problems")
|
|
105
|
+
.action(async () => {
|
|
106
|
+
const cfg = await loadConfig();
|
|
107
|
+
const paths = dataPaths(cfg);
|
|
108
|
+
const inspection = await inspectDataDir(paths);
|
|
109
|
+
if (inspection.state !== "store") {
|
|
110
|
+
console.error(`No data store at ${paths.root}.`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const report = await runDoctor(paths);
|
|
114
|
+
if (report.issues.length === 0) {
|
|
115
|
+
console.log(`OK — ${report.checkedFiles} files checked, no problems found.`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
console.error(
|
|
119
|
+
`${report.issues.length} problem${report.issues.length === 1 ? "" : "s"} found:`,
|
|
120
|
+
);
|
|
121
|
+
for (const issue of report.issues) {
|
|
122
|
+
console.error(` - ${issue}`);
|
|
123
|
+
}
|
|
124
|
+
process.exit(1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
data
|
|
128
|
+
.command("move <dir>")
|
|
129
|
+
.description("Relocate the data store and update config")
|
|
130
|
+
.action(async (dir: string) => {
|
|
131
|
+
const cfg = await loadConfig();
|
|
132
|
+
const from = pathsFor(await canonicalRoot(cfg.data_dir));
|
|
133
|
+
const source = await inspectDataDir(from);
|
|
134
|
+
if (source.state !== "store") {
|
|
135
|
+
console.error(`${from.root} is not a c2 data store; nothing to move.`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const to = pathsFor(await canonicalRoot(dir));
|
|
140
|
+
if (to.root === from.root) {
|
|
141
|
+
console.error("Target is the current data directory.");
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
if (to.root.startsWith(from.root + sep) || from.root.startsWith(to.root + sep)) {
|
|
145
|
+
console.error("Target must not be inside the current data directory (or contain it).");
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let copied: { files: number; bytes: number };
|
|
150
|
+
try {
|
|
151
|
+
copied = await moveStore(from, to);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error(`Error: ${(err as Error).message}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
cfg.data_dir = to.root;
|
|
158
|
+
await saveConfig(cfg);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(`Error: copy completed but config update failed: ${(err as Error).message}`);
|
|
161
|
+
console.error(
|
|
162
|
+
`Set data_dir to ${to.root} in ~/.config/c2/config.json manually, or remove ${to.root} and retry.`,
|
|
163
|
+
);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
console.log(
|
|
167
|
+
`Copied ${copied.files} files (${formatMeters(copied.bytes)} bytes) to ${to.root}`,
|
|
168
|
+
);
|
|
169
|
+
console.log(`Config updated: data_dir = ${to.root}`);
|
|
170
|
+
console.log(`Old data left at ${from.root} — remove it manually once satisfied.`);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import { loadConfig } from "../config.ts";
|
|
4
|
+
import { ensureStoreForWrite, rejectForeignStore } from "../data.ts";
|
|
5
|
+
import { printJSON } from "../envelope.ts";
|
|
6
|
+
import { isValidYMD } from "../models.ts";
|
|
7
|
+
import type { DataPaths } from "../paths.ts";
|
|
8
|
+
import { dataPaths } from "../paths.ts";
|
|
9
|
+
|
|
10
|
+
async function readContent(source: string | undefined): Promise<string> {
|
|
11
|
+
if (source != null && source !== "-") {
|
|
12
|
+
return readFile(source, "utf-8");
|
|
13
|
+
}
|
|
14
|
+
return new Response(Bun.stdin.stream()).text();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readIfExists(path: string): Promise<string | null> {
|
|
18
|
+
try {
|
|
19
|
+
return await readFile(path, "utf-8");
|
|
20
|
+
} catch (err: unknown) {
|
|
21
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
22
|
+
if (code === "ENOENT" || code === "ENOTDIR") return null;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function registerDoc(
|
|
28
|
+
program: Command,
|
|
29
|
+
name: "plan" | "playbook",
|
|
30
|
+
description: string,
|
|
31
|
+
pathOf: (paths: DataPaths) => string,
|
|
32
|
+
): void {
|
|
33
|
+
const doc = program.command(name).description(description);
|
|
34
|
+
|
|
35
|
+
doc
|
|
36
|
+
.command("show")
|
|
37
|
+
.description(`Print the ${name}`)
|
|
38
|
+
.action(async () => {
|
|
39
|
+
const cfg = await loadConfig();
|
|
40
|
+
const paths = dataPaths(cfg);
|
|
41
|
+
const foreign = await rejectForeignStore(paths);
|
|
42
|
+
if (foreign != null) {
|
|
43
|
+
console.error(foreign);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
const content = await readIfExists(pathOf(paths));
|
|
47
|
+
if (content == null) {
|
|
48
|
+
console.error(`No ${name} recorded yet. Set one with \`c2 ${name} set <file|->\`.`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(content);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
doc
|
|
55
|
+
.command("set [file]")
|
|
56
|
+
.description(`Replace the ${name} from a file or stdin`)
|
|
57
|
+
.action(async (file: string | undefined) => {
|
|
58
|
+
const content = await readContent(file);
|
|
59
|
+
if (content.trim() === "") {
|
|
60
|
+
console.error(`Error: refusing to save an empty ${name}.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
const cfg = await loadConfig();
|
|
64
|
+
const paths = dataPaths(cfg);
|
|
65
|
+
const storeError = await ensureStoreForWrite(paths, new Date());
|
|
66
|
+
if (storeError != null) {
|
|
67
|
+
console.error(storeError);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
const target = pathOf(paths);
|
|
71
|
+
await writeFile(target, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
|
|
72
|
+
console.log(`${name} updated (${content.length} chars).`);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerDocs(program: Command): void {
|
|
77
|
+
registerDoc(program, "plan", "Training plan (managed document)", (p) => p.plan);
|
|
78
|
+
registerDoc(
|
|
79
|
+
program,
|
|
80
|
+
"playbook",
|
|
81
|
+
"Coaching knowledge playbook (managed document)",
|
|
82
|
+
(p) => p.playbook,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const narrative = program.command("narrative").description("Dated coaching report narratives");
|
|
86
|
+
|
|
87
|
+
narrative
|
|
88
|
+
.command("add <date> [file]")
|
|
89
|
+
.description("Save the narrative for a date (YYYY-MM-DD) from a file or stdin")
|
|
90
|
+
.action(async (date: string, file: string | undefined) => {
|
|
91
|
+
if (!isValidYMD(date)) {
|
|
92
|
+
console.error(`Error: invalid date "${date}" (expected YYYY-MM-DD).`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
const content = await readContent(file);
|
|
96
|
+
if (content.trim() === "") {
|
|
97
|
+
console.error("Error: refusing to save an empty narrative.");
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const cfg = await loadConfig();
|
|
101
|
+
const paths = dataPaths(cfg);
|
|
102
|
+
const storeError = await ensureStoreForWrite(paths, new Date());
|
|
103
|
+
if (storeError != null) {
|
|
104
|
+
console.error(storeError);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
await writeFile(
|
|
108
|
+
paths.narrativeFile(date),
|
|
109
|
+
content.endsWith("\n") ? content : `${content}\n`,
|
|
110
|
+
"utf-8",
|
|
111
|
+
);
|
|
112
|
+
console.log(`Narrative saved for ${date}.`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
narrative
|
|
116
|
+
.command("show [date]")
|
|
117
|
+
.description("Print the narrative for a date (latest if omitted)")
|
|
118
|
+
.action(async (date: string | undefined) => {
|
|
119
|
+
const cfg = await loadConfig();
|
|
120
|
+
const paths = dataPaths(cfg);
|
|
121
|
+
const foreign = await rejectForeignStore(paths);
|
|
122
|
+
if (foreign != null) {
|
|
123
|
+
console.error(foreign);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
let target = date;
|
|
127
|
+
if (target != null && !isValidYMD(target)) {
|
|
128
|
+
console.error(`Error: invalid date "${target}" (expected YYYY-MM-DD).`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
if (target == null) {
|
|
132
|
+
const dates = await listNarratives(paths);
|
|
133
|
+
target = dates[dates.length - 1];
|
|
134
|
+
if (target == null) {
|
|
135
|
+
console.error("No narratives recorded yet.");
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const content = await readIfExists(paths.narrativeFile(target));
|
|
140
|
+
if (content == null) {
|
|
141
|
+
console.error(`No narrative for ${target}.`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
process.stdout.write(content);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
narrative
|
|
148
|
+
.command("list")
|
|
149
|
+
.description("List narrative dates")
|
|
150
|
+
.option("--json", "output as JSON")
|
|
151
|
+
.action(async (opts: { json?: boolean }) => {
|
|
152
|
+
const cfg = await loadConfig();
|
|
153
|
+
const paths = dataPaths(cfg);
|
|
154
|
+
const foreign = await rejectForeignStore(paths);
|
|
155
|
+
if (foreign != null) {
|
|
156
|
+
console.error(foreign);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
const dates = await listNarratives(paths);
|
|
160
|
+
if (opts.json) {
|
|
161
|
+
printJSON("c2.narratives.v1", { count: dates.length, dates });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (dates.length === 0) {
|
|
165
|
+
console.log("No narratives recorded yet.");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const d of dates) {
|
|
169
|
+
console.log(d);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function listNarratives(paths: DataPaths): Promise<string[]> {
|
|
175
|
+
try {
|
|
176
|
+
return (await readdir(paths.reportsDir))
|
|
177
|
+
.filter((f) => f.endsWith(".md"))
|
|
178
|
+
.map((f) => f.slice(0, -3))
|
|
179
|
+
.filter((d) => isValidYMD(d))
|
|
180
|
+
.sort();
|
|
181
|
+
} catch {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
}
|
package/src/commands/export.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
+
import { loadConfig } from "../config.ts";
|
|
3
|
+
import { printJSON } from "../envelope.ts";
|
|
2
4
|
import type { Workout } from "../models.ts";
|
|
3
|
-
import { pace500m } from "../models.ts";
|
|
5
|
+
import { isValidYMD, pace500m } from "../models.ts";
|
|
6
|
+
import { dataPaths } from "../paths.ts";
|
|
4
7
|
import { readWorkouts } from "../storage.ts";
|
|
5
8
|
|
|
6
9
|
export function filterByDate(workouts: Workout[], from: string, to: string): Workout[] {
|
|
@@ -72,7 +75,7 @@ function exportCSV(workouts: Workout[]): void {
|
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
function exportJSON(workouts: Workout[]): void {
|
|
75
|
-
|
|
78
|
+
printJSON("c2.export.v1", { count: workouts.length, workouts });
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
function exportJSONL(workouts: Workout[]): void {
|
|
@@ -89,7 +92,22 @@ export function registerExport(program: Command): void {
|
|
|
89
92
|
.option("--from <date>", "filter workouts from date (YYYY-MM-DD)")
|
|
90
93
|
.option("--to <date>", "filter workouts to date (YYYY-MM-DD)")
|
|
91
94
|
.action(async (opts: { format: string; from?: string; to?: string }) => {
|
|
92
|
-
|
|
95
|
+
for (const [flag, value] of [
|
|
96
|
+
["--from", opts.from],
|
|
97
|
+
["--to", opts.to],
|
|
98
|
+
] as const) {
|
|
99
|
+
if (value && !isValidYMD(value)) {
|
|
100
|
+
console.error(`Error: invalid ${flag} date "${value}" (expected YYYY-MM-DD).`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!["csv", "json", "jsonl"].includes(opts.format)) {
|
|
105
|
+
console.error(`Unsupported format "${opts.format}": must be csv, json, or jsonl`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const cfg = await loadConfig();
|
|
110
|
+
let workouts = await readWorkouts(dataPaths(cfg));
|
|
93
111
|
if (workouts.length === 0) {
|
|
94
112
|
console.error("No workouts found. Run `c2 sync` first.");
|
|
95
113
|
process.exit(1);
|
|
@@ -98,7 +116,6 @@ export function registerExport(program: Command): void {
|
|
|
98
116
|
workouts = filterByDate(workouts, opts.from ?? "", opts.to ?? "");
|
|
99
117
|
if (workouts.length === 0) {
|
|
100
118
|
console.error("No workouts match the specified date range.");
|
|
101
|
-
process.exit(1);
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
workouts.sort((a, b) => a.date.localeCompare(b.date));
|
|
@@ -113,9 +130,6 @@ export function registerExport(program: Command): void {
|
|
|
113
130
|
case "jsonl":
|
|
114
131
|
exportJSONL(workouts);
|
|
115
132
|
break;
|
|
116
|
-
default:
|
|
117
|
-
console.error(`Unsupported format "${opts.format}": must be csv, json, or jsonl`);
|
|
118
|
-
process.exit(1);
|
|
119
133
|
}
|
|
120
134
|
});
|
|
121
135
|
}
|
package/src/commands/log.ts
CHANGED
|
@@ -1,31 +1,62 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { loadConfig } from "../config.ts";
|
|
3
|
-
import { formatWorkoutLine } from "../display.ts";
|
|
3
|
+
import { formatWorkoutLine, workoutJSON } from "../display.ts";
|
|
4
|
+
import { printJSON } from "../envelope.ts";
|
|
5
|
+
import { isValidYMD } from "../models.ts";
|
|
6
|
+
import { dataPaths } from "../paths.ts";
|
|
4
7
|
import { readWorkouts } from "../storage.ts";
|
|
8
|
+
import { filterByDate } from "./export.ts";
|
|
5
9
|
|
|
6
10
|
export function registerLog(program: Command): void {
|
|
7
11
|
program
|
|
8
12
|
.command("log")
|
|
9
13
|
.description("Show recent workouts")
|
|
10
14
|
.option("-n, --count <n>", "number of workouts to display", "10")
|
|
11
|
-
.
|
|
15
|
+
.option("--from <date>", "only workouts on or after date (YYYY-MM-DD)")
|
|
16
|
+
.option("--to <date>", "only workouts on or before date (YYYY-MM-DD)")
|
|
17
|
+
.option("--json", "output as JSON")
|
|
18
|
+
.action(async (opts: { count: string; from?: string; to?: string; json?: boolean }) => {
|
|
12
19
|
const cfg = await loadConfig();
|
|
13
|
-
const
|
|
14
|
-
if (workouts.length === 0) {
|
|
15
|
-
console.log("No workouts found. Run `c2 sync` first.");
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
20
|
+
const paths = dataPaths(cfg);
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
for (const [flag, value] of [
|
|
23
|
+
["--from", opts.from],
|
|
24
|
+
["--to", opts.to],
|
|
25
|
+
] as const) {
|
|
26
|
+
if (value && !isValidYMD(value)) {
|
|
27
|
+
console.error(`Error: invalid ${flag} date "${value}" (expected YYYY-MM-DD).`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
20
31
|
|
|
21
32
|
const count = parseInt(opts.count, 10);
|
|
22
33
|
if (Number.isNaN(count) || count < 1) {
|
|
23
34
|
console.error("Error: --count must be a positive integer.");
|
|
24
35
|
process.exit(1);
|
|
25
36
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
37
|
+
|
|
38
|
+
const all = await readWorkouts(paths);
|
|
39
|
+
const workouts = filterByDate(all, opts.from ?? "", opts.to ?? "");
|
|
40
|
+
workouts.sort((a, b) => b.date.localeCompare(a.date));
|
|
41
|
+
const shown = workouts.slice(0, Math.min(count, workouts.length));
|
|
42
|
+
|
|
43
|
+
if (opts.json) {
|
|
44
|
+
printJSON("c2.log.v1", { count: shown.length, workouts: shown.map(workoutJSON) });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (all.length === 0) {
|
|
49
|
+
console.log("No workouts found. Run `c2 sync` first.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (shown.length === 0) {
|
|
53
|
+
console.log("No workouts match the specified date range.");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const w of shown) {
|
|
58
|
+
const line = formatWorkoutLine(w, cfg.display.date_format);
|
|
59
|
+
console.log(w.comments ? `${line} — ${w.comments}` : line);
|
|
29
60
|
}
|
|
30
61
|
});
|
|
31
62
|
}
|