@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,237 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { loadConfig } from "../config.ts";
|
|
3
|
+
import { ensureStoreForWrite, rejectForeignStore } from "../data.ts";
|
|
4
|
+
import { printJSON } from "../envelope.ts";
|
|
5
|
+
import { isValidYMD } from "../models.ts";
|
|
6
|
+
import {
|
|
7
|
+
filterNotes,
|
|
8
|
+
isNoteShaped,
|
|
9
|
+
localISO,
|
|
10
|
+
NOTE_AUTHORS,
|
|
11
|
+
NOTE_TYPES,
|
|
12
|
+
type NoteAuthor,
|
|
13
|
+
type NoteRecord,
|
|
14
|
+
type NoteType,
|
|
15
|
+
readAllNotes,
|
|
16
|
+
ulid,
|
|
17
|
+
writeNote,
|
|
18
|
+
} from "../notes.ts";
|
|
19
|
+
import { dataPaths } from "../paths.ts";
|
|
20
|
+
import { readWorkouts } from "../storage.ts";
|
|
21
|
+
import { resolveWorkout } from "./show.ts";
|
|
22
|
+
|
|
23
|
+
async function readBody(bodyArg: string | undefined): Promise<string> {
|
|
24
|
+
if (bodyArg != null && bodyArg !== "-") return bodyArg.trim();
|
|
25
|
+
const text = await new Response(Bun.stdin.stream()).text();
|
|
26
|
+
return text.trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseNoteDate(raw: string): string | null {
|
|
30
|
+
const prefix = /^(\d{4}-\d{2}-\d{2})([T ].+)?$/.exec(raw);
|
|
31
|
+
if (prefix == null || !isValidYMD(prefix[1]!)) return null;
|
|
32
|
+
if (/(?:[+-]\d{2}:\d{2}|Z)$/.test(raw)) {
|
|
33
|
+
let normalized = raw.replace(" ", "T");
|
|
34
|
+
if (normalized.endsWith("Z")) normalized = `${normalized.slice(0, -1)}+00:00`;
|
|
35
|
+
if (!/^\d{4}-\d{2}-\d{2}T\S+$/.test(normalized)) return null;
|
|
36
|
+
if (Number.isNaN(new Date(normalized).getTime())) return null;
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
const d = prefix[2] == null ? new Date(`${raw}T12:00:00`) : new Date(raw);
|
|
40
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
41
|
+
return localISO(d);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function noteLine(n: NoteRecord): string {
|
|
45
|
+
const workout = n.workout_id != null ? ` w:${n.workout_id}` : "";
|
|
46
|
+
const tags = n.tags && n.tags.length > 0 ? ` #${n.tags.join(" #")}` : "";
|
|
47
|
+
return `${n.date.slice(0, 10)} [${n.type}/${n.author}]${workout}${tags} ${n.body}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerNote(program: Command): void {
|
|
51
|
+
const note = program.command("note").description("Coaching notes and subjective reports");
|
|
52
|
+
|
|
53
|
+
note
|
|
54
|
+
.command("add [body]")
|
|
55
|
+
.description("Record a note (body as argument, or '-' / omitted to read stdin)")
|
|
56
|
+
.option("--type <type>", "subjective, observation, or lesson", "observation")
|
|
57
|
+
.option("--workout <id>", "link to a workout id or 'last'")
|
|
58
|
+
.option("--tags <tags>", "comma-separated tags")
|
|
59
|
+
.option("--author <author>", "athlete or coach", "athlete")
|
|
60
|
+
.option("--date <date>", "backdate the note (YYYY-MM-DD or ISO timestamp)")
|
|
61
|
+
.action(
|
|
62
|
+
async (
|
|
63
|
+
bodyArg: string | undefined,
|
|
64
|
+
opts: { type: string; workout?: string; tags?: string; author: string; date?: string },
|
|
65
|
+
) => {
|
|
66
|
+
if (!(NOTE_TYPES as readonly string[]).includes(opts.type)) {
|
|
67
|
+
console.error(`Error: --type must be one of ${NOTE_TYPES.join(", ")}.`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (!(NOTE_AUTHORS as readonly string[]).includes(opts.author)) {
|
|
71
|
+
console.error(`Error: --author must be one of ${NOTE_AUTHORS.join(", ")}.`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let backdate: string | null = null;
|
|
76
|
+
if (opts.date) {
|
|
77
|
+
backdate = parseNoteDate(opts.date);
|
|
78
|
+
if (backdate == null) {
|
|
79
|
+
console.error(`Error: invalid --date "${opts.date}".`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const body = await readBody(bodyArg);
|
|
85
|
+
if (body === "") {
|
|
86
|
+
console.error("Error: note body is empty.");
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const cfg = await loadConfig();
|
|
91
|
+
const paths = dataPaths(cfg);
|
|
92
|
+
const now = new Date();
|
|
93
|
+
const foreign = await rejectForeignStore(paths);
|
|
94
|
+
if (foreign != null) {
|
|
95
|
+
console.error(foreign);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let workoutId: number | undefined;
|
|
100
|
+
if (opts.workout) {
|
|
101
|
+
const workouts = await readWorkouts(paths);
|
|
102
|
+
const w = resolveWorkout(workouts, opts.workout);
|
|
103
|
+
if (w == null) {
|
|
104
|
+
console.error(`Error: no workout matching "${opts.workout}".`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
workoutId = w.id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const storeError = await ensureStoreForWrite(paths, now);
|
|
111
|
+
if (storeError != null) {
|
|
112
|
+
console.error(storeError);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const date = backdate ?? localISO(now);
|
|
117
|
+
|
|
118
|
+
const record: NoteRecord = {
|
|
119
|
+
id: ulid(now),
|
|
120
|
+
date,
|
|
121
|
+
type: opts.type as NoteType,
|
|
122
|
+
workout_id: workoutId,
|
|
123
|
+
tags: opts.tags
|
|
124
|
+
? opts.tags
|
|
125
|
+
.split(",")
|
|
126
|
+
.map((t) => t.trim())
|
|
127
|
+
.filter((t) => t !== "")
|
|
128
|
+
: undefined,
|
|
129
|
+
body,
|
|
130
|
+
author: opts.author as NoteAuthor,
|
|
131
|
+
};
|
|
132
|
+
if (!isNoteShaped(record)) {
|
|
133
|
+
console.error("Error: note would not round-trip; check --date and field values.");
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
await writeNote(paths, record);
|
|
137
|
+
console.log(record.id);
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
note
|
|
142
|
+
.command("list")
|
|
143
|
+
.description("List notes, newest last")
|
|
144
|
+
.option("--type <type>", "filter by type")
|
|
145
|
+
.option("--since <date>", "only notes on or after date (YYYY-MM-DD)")
|
|
146
|
+
.option("--workout <id>", "only notes linked to a workout id")
|
|
147
|
+
.option("-n, --count <n>", "show only the most recent n notes")
|
|
148
|
+
.option("--json", "output as JSON")
|
|
149
|
+
.action(
|
|
150
|
+
async (opts: {
|
|
151
|
+
type?: string;
|
|
152
|
+
since?: string;
|
|
153
|
+
workout?: string;
|
|
154
|
+
count?: string;
|
|
155
|
+
json?: boolean;
|
|
156
|
+
}) => {
|
|
157
|
+
if (opts.since && !isValidYMD(opts.since)) {
|
|
158
|
+
console.error(`Error: invalid --since date "${opts.since}" (expected YYYY-MM-DD).`);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
if (opts.type && !(NOTE_TYPES as readonly string[]).includes(opts.type)) {
|
|
162
|
+
console.error(`Error: --type must be one of ${NOTE_TYPES.join(", ")}.`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
let workoutId: number | undefined;
|
|
166
|
+
if (opts.workout != null) {
|
|
167
|
+
workoutId = Number(opts.workout);
|
|
168
|
+
if (!Number.isInteger(workoutId)) {
|
|
169
|
+
console.error(`Error: invalid --workout id "${opts.workout}".`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const cfg = await loadConfig();
|
|
174
|
+
const paths = dataPaths(cfg);
|
|
175
|
+
const foreign = await rejectForeignStore(paths);
|
|
176
|
+
if (foreign != null) {
|
|
177
|
+
console.error(foreign);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
let notes = filterNotes(await readAllNotes(paths), {
|
|
181
|
+
type: opts.type,
|
|
182
|
+
since: opts.since,
|
|
183
|
+
workoutId,
|
|
184
|
+
});
|
|
185
|
+
if (opts.count != null) {
|
|
186
|
+
const n = parseInt(opts.count, 10);
|
|
187
|
+
if (Number.isNaN(n) || n < 1) {
|
|
188
|
+
console.error("Error: --count must be a positive integer.");
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
notes = notes.slice(-n);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (opts.json) {
|
|
195
|
+
printJSON("c2.notes.v1", { count: notes.length, notes });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (notes.length === 0) {
|
|
199
|
+
console.log("No notes found.");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const n of notes) {
|
|
203
|
+
console.log(noteLine(n));
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
note
|
|
209
|
+
.command("show <id>")
|
|
210
|
+
.description("Show one note in full")
|
|
211
|
+
.option("--json", "output as JSON")
|
|
212
|
+
.action(async (id: string, opts: { json?: boolean }) => {
|
|
213
|
+
const cfg = await loadConfig();
|
|
214
|
+
const paths = dataPaths(cfg);
|
|
215
|
+
const foreign = await rejectForeignStore(paths);
|
|
216
|
+
if (foreign != null) {
|
|
217
|
+
console.error(foreign);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
const match = (await readAllNotes(paths)).find((n) => n.id === id);
|
|
221
|
+
if (match == null) {
|
|
222
|
+
console.error(`No note with id ${id}.`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
if (opts.json) {
|
|
226
|
+
printJSON("c2.note.v1", match);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
console.log(`Id: ${match.id}`);
|
|
230
|
+
console.log(`Date: ${match.date}`);
|
|
231
|
+
console.log(`Type: ${match.type} (${match.author})`);
|
|
232
|
+
if (match.workout_id != null) console.log(`Workout: ${match.workout_id}`);
|
|
233
|
+
if (match.tags && match.tags.length > 0) console.log(`Tags: ${match.tags.join(", ")}`);
|
|
234
|
+
console.log();
|
|
235
|
+
console.log(match.body);
|
|
236
|
+
});
|
|
237
|
+
}
|
package/src/commands/report.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import type { Command } from "commander";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { splitShape, splitTable } from "../analysis.ts";
|
|
6
|
+
import { loadConfig, parseGoalDate } from "../config.ts";
|
|
7
|
+
import { rejectForeignStore } from "../data.ts";
|
|
8
|
+
import { formatMeters, workoutJSON } from "../display.ts";
|
|
9
|
+
import { printJSON } from "../envelope.ts";
|
|
7
10
|
import type { Workout } from "../models.ts";
|
|
8
11
|
import { calendarDay, pace500m, pace500mSeconds } from "../models.ts";
|
|
12
|
+
import { filterNotes, type NoteRecord, readAllNotes } from "../notes.ts";
|
|
13
|
+
import type { DataPaths } from "../paths.ts";
|
|
14
|
+
import { dataPaths } from "../paths.ts";
|
|
9
15
|
import { sessionCount } from "../sessions.ts";
|
|
10
16
|
import {
|
|
11
17
|
buildWeekSummaries,
|
|
@@ -13,9 +19,12 @@ import {
|
|
|
13
19
|
type GoalProgress,
|
|
14
20
|
mondayOf,
|
|
15
21
|
type WeekSummary,
|
|
22
|
+
weekSummaryData,
|
|
16
23
|
workoutsInRange,
|
|
17
24
|
} from "../stats.ts";
|
|
18
25
|
import { readWorkouts } from "../storage.ts";
|
|
26
|
+
import { listNarratives } from "./docs.ts";
|
|
27
|
+
import { projectGoal } from "./stats.ts";
|
|
19
28
|
|
|
20
29
|
const MONTH_NAMES = [
|
|
21
30
|
"Jan",
|
|
@@ -287,7 +296,7 @@ function buildRecentWorkouts(workouts: Workout[], count: number): string {
|
|
|
287
296
|
}
|
|
288
297
|
|
|
289
298
|
const isShort = w.distance <= 1500;
|
|
290
|
-
const isHard = paceS > 0 && paceS < 160;
|
|
299
|
+
const isHard = paceS > 0 && paceS < 160;
|
|
291
300
|
let annotation = "";
|
|
292
301
|
let rowStyle = "";
|
|
293
302
|
let paceStyle = "";
|
|
@@ -385,12 +394,158 @@ function buildProjection(goal: GoalProgress, workouts: Workout[]): string {
|
|
|
385
394
|
</div>`;
|
|
386
395
|
}
|
|
387
396
|
|
|
397
|
+
export interface CoachingContent {
|
|
398
|
+
narrative: { date: string; text: string } | null;
|
|
399
|
+
notes: NoteRecord[];
|
|
400
|
+
planExcerpt: string | null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const EMPTY_COACHING: CoachingContent = { narrative: null, notes: [], planExcerpt: null };
|
|
404
|
+
|
|
405
|
+
const RECENT_NOTE_DAYS = 14;
|
|
406
|
+
const MAX_RECENT_NOTES = 20;
|
|
407
|
+
const PLAN_EXCERPT_MAX_CHARS = 1500;
|
|
408
|
+
|
|
409
|
+
async function readIfExists(path: string): Promise<string | null> {
|
|
410
|
+
try {
|
|
411
|
+
return await readFile(path, "utf-8");
|
|
412
|
+
} catch (err: unknown) {
|
|
413
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
414
|
+
if (code === "ENOENT" || code === "ENOTDIR") return null;
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function gatherCoaching(paths: DataPaths, now: Date): Promise<CoachingContent> {
|
|
420
|
+
const since = new Date(now);
|
|
421
|
+
since.setDate(since.getDate() - RECENT_NOTE_DAYS);
|
|
422
|
+
const sinceKey = `${since.getFullYear()}-${String(since.getMonth() + 1).padStart(2, "0")}-${String(since.getDate()).padStart(2, "0")}`;
|
|
423
|
+
const notes = filterNotes(await readAllNotes(paths), { since: sinceKey }).slice(
|
|
424
|
+
-MAX_RECENT_NOTES,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
let narrative: CoachingContent["narrative"] = null;
|
|
428
|
+
const dates = await listNarratives(paths);
|
|
429
|
+
const latest = dates[dates.length - 1];
|
|
430
|
+
if (latest != null) {
|
|
431
|
+
const text = await readIfExists(paths.narrativeFile(latest));
|
|
432
|
+
if (text != null && text.trim() !== "") narrative = { date: latest, text };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
let planExcerpt: string | null = null;
|
|
436
|
+
const plan = await readIfExists(paths.plan);
|
|
437
|
+
if (plan != null && plan.trim() !== "") {
|
|
438
|
+
const sections = plan.split(/\n(?=## )/);
|
|
439
|
+
const substantive = (s: string) =>
|
|
440
|
+
s.split("\n").some((l) => {
|
|
441
|
+
const t = l.trim();
|
|
442
|
+
return t !== "" && !/^#{1,4}\s/.test(t) && t !== "---";
|
|
443
|
+
});
|
|
444
|
+
let end = 1;
|
|
445
|
+
let excerpt = sections[0]!.trim();
|
|
446
|
+
while (!substantive(excerpt) && end < sections.length) {
|
|
447
|
+
excerpt = `${excerpt}\n\n${sections[end]!.trim()}`;
|
|
448
|
+
end++;
|
|
449
|
+
}
|
|
450
|
+
if (excerpt.length > PLAN_EXCERPT_MAX_CHARS) {
|
|
451
|
+
excerpt = `${excerpt.slice(0, PLAN_EXCERPT_MAX_CHARS)}…`;
|
|
452
|
+
} else if (sections.length > end) {
|
|
453
|
+
excerpt = `${excerpt}\n\n_(full plan: \`c2 plan show\`)_`;
|
|
454
|
+
}
|
|
455
|
+
planExcerpt = excerpt;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { narrative, notes, planExcerpt };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function mdLite(text: string): string {
|
|
462
|
+
const blocks: string[] = [];
|
|
463
|
+
let paragraph: string[] = [];
|
|
464
|
+
let list: string[] = [];
|
|
465
|
+
|
|
466
|
+
const flushParagraph = () => {
|
|
467
|
+
if (paragraph.length > 0) {
|
|
468
|
+
blocks.push(`<p>${paragraph.join(" ")}</p>`);
|
|
469
|
+
paragraph = [];
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
const flushList = () => {
|
|
473
|
+
if (list.length > 0) {
|
|
474
|
+
blocks.push(`<ul>${list.map((i) => `<li>${i}</li>`).join("")}</ul>`);
|
|
475
|
+
list = [];
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
for (const rawLine of text.split("\n")) {
|
|
480
|
+
const line = esc(rawLine.trim());
|
|
481
|
+
if (line === "") {
|
|
482
|
+
flushParagraph();
|
|
483
|
+
flushList();
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const heading = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
487
|
+
if (heading != null) {
|
|
488
|
+
flushParagraph();
|
|
489
|
+
flushList();
|
|
490
|
+
const level = heading[1]!.length <= 2 ? "h3" : "h4";
|
|
491
|
+
blocks.push(`<${level}>${heading[2]}</${level}>`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const item = /^[-*]\s+(.*)$/.exec(line);
|
|
495
|
+
if (item != null) {
|
|
496
|
+
flushParagraph();
|
|
497
|
+
list.push(item[1]!);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
flushList();
|
|
501
|
+
paragraph.push(line);
|
|
502
|
+
}
|
|
503
|
+
flushParagraph();
|
|
504
|
+
flushList();
|
|
505
|
+
return blocks.join("\n");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function buildNarrativeSection(narrative: { date: string; text: string }): string {
|
|
509
|
+
return `<div class="section">
|
|
510
|
+
<h2>Coach's Report — ${esc(narrative.date)}</h2>
|
|
511
|
+
<div class="prose">
|
|
512
|
+
${mdLite(narrative.text)}
|
|
513
|
+
</div>
|
|
514
|
+
</div>`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function buildNotesSection(notes: NoteRecord[]): string {
|
|
518
|
+
const rows = notes
|
|
519
|
+
.map((n) => {
|
|
520
|
+
const workout = n.workout_id != null ? ` · workout ${n.workout_id}` : "";
|
|
521
|
+
return ` <div class="note-row">
|
|
522
|
+
<div class="note-meta">${esc(n.date.slice(0, 10))} · ${esc(n.type)} (${esc(n.author)})${workout}</div>
|
|
523
|
+
<div class="note-body">${esc(n.body)}</div>
|
|
524
|
+
</div>`;
|
|
525
|
+
})
|
|
526
|
+
.join("\n");
|
|
527
|
+
return `<div class="section">
|
|
528
|
+
<h2>Recent Notes</h2>
|
|
529
|
+
${rows}
|
|
530
|
+
</div>`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function buildPlanSection(excerpt: string): string {
|
|
534
|
+
return `<div class="section">
|
|
535
|
+
<h2>Training Plan</h2>
|
|
536
|
+
<div class="prose">
|
|
537
|
+
${mdLite(excerpt)}
|
|
538
|
+
</div>
|
|
539
|
+
</div>`;
|
|
540
|
+
}
|
|
541
|
+
|
|
388
542
|
function buildHTML(
|
|
389
543
|
goal: GoalProgress,
|
|
390
544
|
summaries: WeekSummary[],
|
|
391
545
|
allWorkouts: Workout[],
|
|
392
546
|
windowedWorkouts: Workout[],
|
|
393
547
|
recentCount: number,
|
|
548
|
+
coaching: CoachingContent,
|
|
394
549
|
): string {
|
|
395
550
|
const sessions = sessionCount(windowedWorkouts);
|
|
396
551
|
const avgPace = avgPaceForWorkouts(windowedWorkouts);
|
|
@@ -463,6 +618,18 @@ function buildHTML(
|
|
|
463
618
|
margin-bottom: 24px;
|
|
464
619
|
}
|
|
465
620
|
|
|
621
|
+
.prose { font-size: 14px; }
|
|
622
|
+
.prose p { margin-bottom: 10px; }
|
|
623
|
+
.prose h3 { color: #f0f6fc; font-size: 15px; font-weight: 600; margin: 14px 0 6px; }
|
|
624
|
+
.prose h4 { color: #c9d1d9; font-size: 13px; font-weight: 600; margin: 12px 0 4px; }
|
|
625
|
+
.prose ul { margin: 0 0 10px 20px; }
|
|
626
|
+
.prose li { margin-bottom: 4px; }
|
|
627
|
+
|
|
628
|
+
.note-row { padding: 10px 0; border-bottom: 1px solid #21262d; }
|
|
629
|
+
.note-row:last-child { border-bottom: none; }
|
|
630
|
+
.note-meta { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px; margin-bottom: 3px; }
|
|
631
|
+
.note-body { font-size: 13px; }
|
|
632
|
+
|
|
466
633
|
.progress-container {
|
|
467
634
|
position: relative;
|
|
468
635
|
background: #21262d;
|
|
@@ -636,14 +803,20 @@ ${buildStatsCards(goal, sessions, avgPace, avgHR)}
|
|
|
636
803
|
|
|
637
804
|
${buildGoalProgress(goal)}
|
|
638
805
|
|
|
806
|
+
${coaching.narrative != null ? buildNarrativeSection(coaching.narrative) : ""}
|
|
807
|
+
|
|
639
808
|
${buildWeeklyVolume(summaries, goal.requiredPace)}
|
|
640
809
|
|
|
641
810
|
${buildWeeklyTrends(summaries)}
|
|
642
811
|
|
|
643
812
|
${buildRecentWorkouts(allWorkouts, recentCount)}
|
|
644
813
|
|
|
814
|
+
${coaching.notes.length > 0 ? buildNotesSection(coaching.notes) : ""}
|
|
815
|
+
|
|
645
816
|
${buildProjection(goal, allWorkouts)}
|
|
646
817
|
|
|
818
|
+
${coaching.planExcerpt != null ? buildPlanSection(coaching.planExcerpt) : ""}
|
|
819
|
+
|
|
647
820
|
<div style="text-align: center; color: #484f58; font-size: 12px; margin-top: 32px; padding-bottom: 16px;">
|
|
648
821
|
Generated by c2 · Data from Concept2 Logbook · ${fullDate(today)}
|
|
649
822
|
</div>
|
|
@@ -658,33 +831,83 @@ export function registerReport(program: Command): void {
|
|
|
658
831
|
.description("Generate HTML progress report and open in browser")
|
|
659
832
|
.option("-o, --output <file>", "save to a specific file instead of a temp file")
|
|
660
833
|
.option("-w, --weeks <n>", "weeks of history to show", "12")
|
|
834
|
+
.option("--data", "emit the report content as JSON instead of HTML")
|
|
661
835
|
.option("--no-open", "don't open in browser")
|
|
662
|
-
.action(async (opts: { output?: string; weeks: string; open: boolean }) => {
|
|
836
|
+
.action(async (opts: { output?: string; weeks: string; data?: boolean; open: boolean }) => {
|
|
663
837
|
const cfg = await loadConfig();
|
|
664
838
|
if (!cfg.goal.start_date || !cfg.goal.end_date) {
|
|
665
839
|
console.error("Goal dates not configured. Run `c2 setup` to set start and end dates.");
|
|
666
840
|
process.exit(1);
|
|
667
841
|
}
|
|
668
|
-
const
|
|
842
|
+
const paths = dataPaths(cfg);
|
|
843
|
+
const foreign = await rejectForeignStore(paths);
|
|
844
|
+
if (foreign != null) {
|
|
845
|
+
console.error(foreign);
|
|
846
|
+
process.exit(1);
|
|
847
|
+
}
|
|
848
|
+
const workouts = await readWorkouts(paths);
|
|
669
849
|
|
|
670
|
-
if (workouts.length === 0) {
|
|
850
|
+
if (workouts.length === 0 && !opts.data) {
|
|
671
851
|
console.log("No workouts found. Run `c2 sync` first.");
|
|
672
852
|
return;
|
|
673
853
|
}
|
|
674
854
|
|
|
675
|
-
const goal = computeGoalProgress(workouts, cfg);
|
|
676
855
|
const weeks = parseInt(opts.weeks, 10);
|
|
677
856
|
if (Number.isNaN(weeks) || weeks < 1) {
|
|
678
857
|
console.error("Error: --weeks must be a positive integer.");
|
|
679
858
|
process.exit(1);
|
|
680
859
|
}
|
|
681
860
|
const now = new Date();
|
|
861
|
+
const goal = computeGoalProgress(workouts, cfg, now);
|
|
682
862
|
const summaries = buildWeekSummaries(workouts, now, weeks);
|
|
683
863
|
const thisMonday = mondayOf(now);
|
|
684
864
|
const cutoff = new Date(thisMonday);
|
|
685
865
|
cutoff.setDate(cutoff.getDate() - (weeks - 1) * 7);
|
|
686
866
|
const windowedWorkouts = workoutsInRange(workouts, cutoff, now);
|
|
687
|
-
const
|
|
867
|
+
const coaching = await gatherCoaching(paths, now);
|
|
868
|
+
|
|
869
|
+
if (opts.data) {
|
|
870
|
+
const sorted = [...workouts].sort((a, b) => b.date.localeCompare(a.date));
|
|
871
|
+
const latest = sorted[0] ?? null;
|
|
872
|
+
let splitsSource: Workout | null = null;
|
|
873
|
+
if (latest != null) {
|
|
874
|
+
const day = calendarDay(latest);
|
|
875
|
+
splitsSource =
|
|
876
|
+
sorted
|
|
877
|
+
.filter((w) => calendarDay(w) === day && (w.workout?.splits?.length ?? 0) > 0)
|
|
878
|
+
.sort((a, b) => b.distance - a.distance)[0] ?? null;
|
|
879
|
+
}
|
|
880
|
+
const latestSplits = splitsSource != null ? splitTable(splitsSource) : [];
|
|
881
|
+
printJSON("c2.report.v1", {
|
|
882
|
+
period: { weeks, to: latest != null ? calendarDay(latest) : null },
|
|
883
|
+
summary: {
|
|
884
|
+
total_meters: goal.totalMeters,
|
|
885
|
+
sessions: sessionCount(windowedWorkouts),
|
|
886
|
+
avg_pace_500m_seconds:
|
|
887
|
+
Math.round(avgPaceForWorkouts(windowedWorkouts) * 10) / 10 || null,
|
|
888
|
+
avg_hr: avgHRForWorkouts(windowedWorkouts) || null,
|
|
889
|
+
},
|
|
890
|
+
goal,
|
|
891
|
+
projection: projectGoal(goal, parseGoalDate(cfg.goal.end_date), now),
|
|
892
|
+
weekly: summaries.map(weekSummaryData),
|
|
893
|
+
recent_workouts: sorted.slice(0, 10).map(workoutJSON),
|
|
894
|
+
latest_splits:
|
|
895
|
+
splitsSource != null && latestSplits.length > 0
|
|
896
|
+
? {
|
|
897
|
+
workout_id: splitsSource.id,
|
|
898
|
+
date: splitsSource.date,
|
|
899
|
+
split_shape: splitShape(latestSplits),
|
|
900
|
+
splits: latestSplits,
|
|
901
|
+
}
|
|
902
|
+
: null,
|
|
903
|
+
narrative: coaching.narrative,
|
|
904
|
+
notes: coaching.notes,
|
|
905
|
+
plan_excerpt: coaching.planExcerpt,
|
|
906
|
+
});
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const html = buildHTML(goal, summaries, workouts, windowedWorkouts, 10, coaching);
|
|
688
911
|
|
|
689
912
|
let outPath: string;
|
|
690
913
|
if (opts.output) {
|
package/src/commands/setup.ts
CHANGED
|
@@ -5,12 +5,13 @@ import {
|
|
|
5
5
|
type Config,
|
|
6
6
|
configDir,
|
|
7
7
|
defaultConfig,
|
|
8
|
-
ensureDirs,
|
|
9
8
|
loadConfig,
|
|
10
9
|
parseGoalDate,
|
|
11
10
|
saveConfig,
|
|
12
11
|
} from "../config.ts";
|
|
12
|
+
import { initStore, inspectDataDir, storeSummary } from "../data.ts";
|
|
13
13
|
import { formatMeters } from "../display.ts";
|
|
14
|
+
import { canonicalRoot, pathsFor } from "../paths.ts";
|
|
14
15
|
|
|
15
16
|
function maskToken(token: string): string {
|
|
16
17
|
if (token.length <= 4) return token;
|
|
@@ -23,6 +24,60 @@ function promptValue(label: string, current: string, mask = false): string {
|
|
|
23
24
|
return (input ?? "").trim() || current;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
function confirm(question: string, defaultYes: boolean): boolean {
|
|
28
|
+
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
|
29
|
+
const input = (prompt(`${question} ${suffix}`) ?? "").trim().toLowerCase();
|
|
30
|
+
if (input === "") return defaultYes;
|
|
31
|
+
return input === "y" || input === "yes";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function chooseDataDir(current: string): Promise<string> {
|
|
35
|
+
const input = promptValue("Data directory", current);
|
|
36
|
+
const paths = pathsFor(input);
|
|
37
|
+
const inspection = await inspectDataDir(paths);
|
|
38
|
+
|
|
39
|
+
if (!inspection.writable) {
|
|
40
|
+
console.error(`Cannot write to ${paths.root}; keeping ${current}.`);
|
|
41
|
+
return current;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
switch (inspection.state) {
|
|
45
|
+
case "missing":
|
|
46
|
+
if (!confirm(`${paths.root} does not exist. Create it?`, true)) {
|
|
47
|
+
console.log(`Keeping ${current}.`);
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
break;
|
|
51
|
+
case "store": {
|
|
52
|
+
const summary = await storeSummary(paths);
|
|
53
|
+
if (summary.workouts > 0) {
|
|
54
|
+
console.log(
|
|
55
|
+
`Found existing c2 store: ${summary.workouts} workouts (${summary.firstDate} → ${summary.lastDate}), ${summary.strokeFiles} stroke files, ${summary.notes} notes.`,
|
|
56
|
+
);
|
|
57
|
+
} else {
|
|
58
|
+
console.log("Found existing empty c2 store.");
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case "empty":
|
|
63
|
+
break;
|
|
64
|
+
case "foreign":
|
|
65
|
+
if (
|
|
66
|
+
!confirm(
|
|
67
|
+
`${paths.root} is not empty and not a c2 store. Initialize a store here anyway?`,
|
|
68
|
+
false,
|
|
69
|
+
)
|
|
70
|
+
) {
|
|
71
|
+
console.log(`Keeping ${current}.`);
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await initStore(paths, new Date());
|
|
78
|
+
return canonicalRoot(paths.root);
|
|
79
|
+
}
|
|
80
|
+
|
|
26
81
|
export function registerSetup(program: Command): void {
|
|
27
82
|
program
|
|
28
83
|
.command("setup")
|
|
@@ -65,9 +120,11 @@ export function registerSetup(program: Command): void {
|
|
|
65
120
|
console.log(`Invalid date "${endInput}", keeping previous value.`);
|
|
66
121
|
}
|
|
67
122
|
|
|
68
|
-
await
|
|
123
|
+
cfg.data_dir = await chooseDataDir(cfg.data_dir);
|
|
124
|
+
|
|
69
125
|
await saveConfig(cfg);
|
|
70
|
-
console.log(`\nConfig written to ${join(configDir(), "config.json")}`);
|
|
126
|
+
console.log(`\nConfig written to ${join(configDir(), "config.json")} (mode 600)`);
|
|
127
|
+
console.log(`Data directory: ${pathsFor(cfg.data_dir).root}`);
|
|
71
128
|
|
|
72
129
|
if (!cfg.goal.start_date || !cfg.goal.end_date) {
|
|
73
130
|
console.log(
|