@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.
@@ -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
+ }
@@ -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 { loadConfig } from "../config.ts";
6
- import { formatMeters } from "../display.ts";
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",
@@ -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 &mdash; ${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 ? ` &middot; workout ${n.workout_id}` : "";
521
+ return ` <div class="note-row">
522
+ <div class="note-meta">${esc(n.date.slice(0, 10))} &middot; ${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 &middot; Data from Concept2 Logbook &middot; ${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 workouts = await readWorkouts();
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 html = buildHTML(goal, summaries, workouts, windowedWorkouts, 10);
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) {
@@ -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 ensureDirs();
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(