@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.
@@ -0,0 +1,120 @@
1
+ import type { Command } from "commander";
2
+ import { splitShape, splitTable, strokeSummary } from "../analysis.ts";
3
+ import { loadConfig } from "../config.ts";
4
+ import { formatMeters, formatWorkoutLine, workoutJSON } from "../display.ts";
5
+ import { printJSON } from "../envelope.ts";
6
+ import type { Workout } from "../models.ts";
7
+ import { formatSeconds } from "../models.ts";
8
+ import { filterNotes, readAllNotes } from "../notes.ts";
9
+ import { dataPaths } from "../paths.ts";
10
+ import { readStrokeData, readWorkouts } from "../storage.ts";
11
+
12
+ export function resolveWorkout(workouts: Workout[], ref: string): Workout | null {
13
+ if (ref === "last") {
14
+ if (workouts.length === 0) return null;
15
+ return workouts.reduce((latest, w) => (w.date > latest.date ? w : latest));
16
+ }
17
+ const id = Number(ref);
18
+ if (!Number.isInteger(id)) return null;
19
+ return workouts.find((w) => w.id === id) ?? null;
20
+ }
21
+
22
+ export function registerShow(program: Command): void {
23
+ program
24
+ .command("show <id>")
25
+ .description("Show full detail for one workout (use a workout id or 'last')")
26
+ .option("--json", "output as JSON")
27
+ .action(async (ref: string, opts: { json?: boolean }) => {
28
+ const cfg = await loadConfig();
29
+ const paths = dataPaths(cfg);
30
+ const workouts = await readWorkouts(paths);
31
+ const w = resolveWorkout(workouts, ref);
32
+ if (w == null) {
33
+ console.error(
34
+ ref === "last"
35
+ ? "No workouts found. Run `c2 sync` first."
36
+ : `No workout with id ${ref}. Use \`c2 log --json\` to list ids.`,
37
+ );
38
+ process.exit(1);
39
+ }
40
+
41
+ const splits = splitTable(w);
42
+ const shape = splitShape(splits);
43
+ const strokes = await readStrokeData(paths, w.id);
44
+ const strokesSummary = strokes.length > 0 ? strokeSummary(strokes) : null;
45
+ const linkedNotes = filterNotes(await readAllNotes(paths), { workoutId: w.id });
46
+
47
+ if (opts.json) {
48
+ printJSON("c2.show.v1", {
49
+ workout: workoutJSON(w),
50
+ raw: w,
51
+ target_pace_500m_seconds: w.workout?.targets?.pace ? w.workout.targets.pace / 10 : null,
52
+ splits,
53
+ split_shape: shape,
54
+ stroke_summary: strokesSummary,
55
+ notes: linkedNotes,
56
+ });
57
+ return;
58
+ }
59
+
60
+ console.log(formatWorkoutLine(w, cfg.display.date_format));
61
+ console.log();
62
+ console.log(`Id: ${w.id}`);
63
+ console.log(`Date: ${w.date}${w.timezone ? ` (${w.timezone})` : ""}`);
64
+ if (w.workout_type) console.log(`Type: ${w.workout_type}`);
65
+ if (w.source) console.log(`Source: ${w.source}`);
66
+ if (w.stroke_count) console.log(`Strokes: ${formatMeters(w.stroke_count)}`);
67
+ if (w.calories_total) console.log(`Calories: ${w.calories_total}`);
68
+ if (w.heart_rate?.average) {
69
+ const hr = w.heart_rate;
70
+ const parts = [
71
+ hr.min != null ? `min ${hr.min}` : null,
72
+ `avg ${hr.average}`,
73
+ hr.max != null ? `max ${hr.max}` : null,
74
+ hr.ending != null ? `ending ${hr.ending}` : null,
75
+ ].filter(Boolean);
76
+ console.log(`Heart rate: ${parts.join(", ")}`);
77
+ }
78
+ if (w.rest_time != null && w.rest_time > 0) {
79
+ console.log(`Interval rest: ${formatSeconds(w.rest_time / 10)}`);
80
+ }
81
+ if (w.rest_distance != null && w.rest_distance > 0) {
82
+ console.log(`Interval rest distance: ${formatMeters(w.rest_distance)}m`);
83
+ }
84
+ if (w.workout?.targets?.pace) {
85
+ console.log(`Target pace: ${formatSeconds(w.workout.targets.pace / 10)}/500m`);
86
+ }
87
+ if (w.comments) console.log(`Comments: ${w.comments}`);
88
+
89
+ if (splits.length > 0) {
90
+ console.log();
91
+ console.log(`Splits (${shape}):`);
92
+ console.log(" # dist time pace/500m spm hr");
93
+ for (const s of splits) {
94
+ const dist = s.distance != null ? `${formatMeters(s.distance)}m` : "-";
95
+ const pace = s.pace_500m ?? "-";
96
+ const spm = s.stroke_rate ?? "-";
97
+ const hr =
98
+ s.hr_avg != null ? `${s.hr_avg}${s.hr_max != null ? `/${s.hr_max}` : ""}` : "-";
99
+ console.log(
100
+ ` ${String(s.index).padStart(3)} ${dist.padStart(8)} ${formatSeconds(s.time_seconds).padStart(8)} ${pace.padStart(10)} ${String(spm).padStart(4)} ${hr.padStart(7)}`,
101
+ );
102
+ }
103
+ }
104
+
105
+ if (strokesSummary != null) {
106
+ console.log();
107
+ console.log(
108
+ `Stroke data: ${strokesSummary.samples} samples, avg ${strokesSummary.avg_pace_500m ?? "-"}/500m, ${strokesSummary.avg_spm ?? "-"}spm, HR avg ${strokesSummary.avg_hr ?? "-"} max ${strokesSummary.max_hr ?? "-"}`,
109
+ );
110
+ }
111
+
112
+ if (linkedNotes.length > 0) {
113
+ console.log();
114
+ console.log("Notes:");
115
+ for (const n of linkedNotes) {
116
+ console.log(` ${n.date.slice(0, 10)} [${n.type}/${n.author}] ${n.body}`);
117
+ }
118
+ }
119
+ });
120
+ }
@@ -0,0 +1,196 @@
1
+ import type { Command } from "commander";
2
+ import { hrAtPace, splitShape, splitTable } from "../analysis.ts";
3
+ import { loadConfig, parseGoalDate } from "../config.ts";
4
+ import { formatMeters } from "../display.ts";
5
+ import { printJSON } from "../envelope.ts";
6
+ import { formatSeconds } from "../models.ts";
7
+ import { dataPaths } from "../paths.ts";
8
+ import type { GoalProgress } from "../stats.ts";
9
+ import {
10
+ buildWeekSummaries,
11
+ computeGoalProgress,
12
+ localYMD,
13
+ recentWeeks,
14
+ weekSummaryData,
15
+ } from "../stats.ts";
16
+ import { readWorkouts } from "../storage.ts";
17
+ import { resolveWorkout } from "./show.ts";
18
+
19
+ export interface GoalProjection {
20
+ projected_total_meters: number;
21
+ projected_pct: number;
22
+ shortfall_meters: number;
23
+ }
24
+
25
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
26
+
27
+ export function projectGoal(goal: GoalProgress, end: Date, now: Date): GoalProjection {
28
+ const weeksLeft = Math.max(0, (end.getTime() - now.getTime()) / WEEK_MS);
29
+ const projected = Math.round(goal.totalMeters + goal.currentAvgPace * weeksLeft);
30
+ return {
31
+ projected_total_meters: projected,
32
+ projected_pct: Math.round((projected / goal.target) * 1000) / 10,
33
+ shortfall_meters: Math.max(0, goal.target - projected),
34
+ };
35
+ }
36
+
37
+ function parseWeeks(raw: string): number {
38
+ const weeks = parseInt(raw, 10);
39
+ if (Number.isNaN(weeks) || weeks < 1) {
40
+ console.error("Error: --weeks must be a positive integer.");
41
+ process.exit(1);
42
+ }
43
+ return weeks;
44
+ }
45
+
46
+ export function registerStats(program: Command): void {
47
+ const stats = program.command("stats").description("Derived training statistics");
48
+
49
+ stats
50
+ .command("weekly")
51
+ .description("Weekly volume, sessions, pace, SPM, and HR")
52
+ .option("-w, --weeks <n>", "number of weeks", "12")
53
+ .option("--json", "output as JSON")
54
+ .action(async (opts: { weeks: string; json?: boolean }) => {
55
+ const weeks = parseWeeks(opts.weeks);
56
+ const cfg = await loadConfig();
57
+ const workouts = await readWorkouts(dataPaths(cfg));
58
+ const summaries = buildWeekSummaries(workouts, new Date(), weeks).map(weekSummaryData);
59
+
60
+ if (opts.json) {
61
+ printJSON("c2.stats.weekly.v1", { weeks: summaries });
62
+ return;
63
+ }
64
+ console.log("week meters sess pace/500m spm hr");
65
+ for (const s of summaries) {
66
+ console.log(
67
+ `${s.week_start} ${formatMeters(s.meters).padStart(8)} ${String(s.sessions).padStart(4)} ${(s.avg_pace_500m ?? "-").padStart(9)} ${String(s.avg_spm ?? "-").padStart(4)} ${String(s.avg_hr ?? "-").padStart(4)}`,
68
+ );
69
+ }
70
+ });
71
+
72
+ stats
73
+ .command("goal")
74
+ .description("Goal trajectory and projection")
75
+ .option("--json", "output as JSON")
76
+ .action(async (opts: { json?: boolean }) => {
77
+ const cfg = await loadConfig();
78
+ if (!cfg.goal.start_date || !cfg.goal.end_date) {
79
+ console.error("Goal dates not configured. Run `c2 setup` to set start and end dates.");
80
+ process.exit(1);
81
+ }
82
+ const workouts = await readWorkouts(dataPaths(cfg));
83
+ const now = new Date();
84
+ const goal = computeGoalProgress(workouts, cfg, now);
85
+ const projection = projectGoal(goal, parseGoalDate(cfg.goal.end_date), now);
86
+ const weeks = recentWeeks(workouts, now, 4);
87
+ const thisWeek = weeks[0]!;
88
+
89
+ if (opts.json) {
90
+ printJSON("c2.stats.goal.v1", {
91
+ goal,
92
+ projection,
93
+ this_week: {
94
+ week_start: localYMD(thisWeek.weekStart),
95
+ meters: thisWeek.meters,
96
+ sessions: thisWeek.sessions,
97
+ },
98
+ });
99
+ return;
100
+ }
101
+ console.log(
102
+ `Progress: ${formatMeters(goal.totalMeters)} / ${formatMeters(goal.target)} (${((goal.progress ?? 0) * 100).toFixed(1)}%)`,
103
+ );
104
+ console.log(`Required pace: ${formatMeters(goal.requiredPace)} m/wk`);
105
+ console.log(`Recent average: ${formatMeters(goal.currentAvgPace)} m/wk`);
106
+ console.log(
107
+ `Projection at current pace: ${formatMeters(projection.projected_total_meters)} m (${projection.projected_pct}%)`,
108
+ );
109
+ if (projection.shortfall_meters > 0) {
110
+ console.log(`Projected shortfall: ${formatMeters(projection.shortfall_meters)} m`);
111
+ } else {
112
+ console.log("On track to exceed goal.");
113
+ }
114
+ console.log(
115
+ `This week so far: ${formatMeters(thisWeek.meters)} m (${thisWeek.sessions} sessions)`,
116
+ );
117
+ });
118
+
119
+ stats
120
+ .command("splits <id>")
121
+ .description("Split analysis for one workout (id or 'last')")
122
+ .option("--json", "output as JSON")
123
+ .action(async (ref: string, opts: { json?: boolean }) => {
124
+ const cfg = await loadConfig();
125
+ const workouts = await readWorkouts(dataPaths(cfg));
126
+ const w = resolveWorkout(workouts, ref);
127
+ if (w == null) {
128
+ console.error(
129
+ ref === "last" ? "No workouts found. Run `c2 sync` first." : `No workout with id ${ref}.`,
130
+ );
131
+ process.exit(1);
132
+ }
133
+ const rows = splitTable(w);
134
+ const shape = splitShape(rows);
135
+
136
+ if (opts.json) {
137
+ printJSON("c2.stats.splits.v1", {
138
+ workout_id: w.id,
139
+ date: w.date,
140
+ distance: w.distance,
141
+ split_shape: shape,
142
+ splits: rows,
143
+ });
144
+ return;
145
+ }
146
+ if (rows.length === 0) {
147
+ console.log(`Workout ${w.id} has no split data.`);
148
+ return;
149
+ }
150
+ console.log(`Workout ${w.id} — ${w.date} — ${formatMeters(w.distance)}m — ${shape} splits`);
151
+ for (const s of rows) {
152
+ console.log(
153
+ ` ${s.index}: ${s.distance != null ? `${formatMeters(s.distance)}m` : "-"} ${s.pace_500m ?? "-"}/500m ${s.stroke_rate ?? "-"}spm HR ${s.hr_avg ?? "-"}`,
154
+ );
155
+ }
156
+ });
157
+
158
+ stats
159
+ .command("hr-pace")
160
+ .description("Average heart rate by steady pace band (fitness proxy)")
161
+ .option("-w, --weeks <n>", "window in weeks", "8")
162
+ .option("--json", "output as JSON")
163
+ .action(async (opts: { weeks: string; json?: boolean }) => {
164
+ const weeks = parseWeeks(opts.weeks);
165
+ const cfg = await loadConfig();
166
+ const workouts = await readWorkouts(dataPaths(cfg));
167
+ const bands = hrAtPace(workouts, new Date(), weeks);
168
+
169
+ if (opts.json) {
170
+ printJSON("c2.stats.hr-pace.v1", { weeks, bands });
171
+ return;
172
+ }
173
+ if (bands.length === 0) {
174
+ console.log("No steady workouts with heart rate data in the window.");
175
+ return;
176
+ }
177
+ console.log(`Steady pace bands over the last ${weeks} weeks (HR avg, early→late half):`);
178
+ for (const b of bands) {
179
+ const trend =
180
+ b.hr_delta == null
181
+ ? ""
182
+ : b.hr_delta < 0
183
+ ? ` ↓${Math.abs(b.hr_delta)} (improving)`
184
+ : b.hr_delta > 0
185
+ ? ` ↑${b.hr_delta} (watch)`
186
+ : " → flat";
187
+ const halves =
188
+ b.early_avg_hr != null && b.late_avg_hr != null
189
+ ? ` (${b.early_avg_hr}→${b.late_avg_hr})`
190
+ : "";
191
+ console.log(
192
+ ` ${b.band}/500m: HR ${b.avg_hr}${halves} across ${b.workouts} workout${b.workouts === 1 ? "" : "s"}${trend}`,
193
+ );
194
+ }
195
+ });
196
+ }
@@ -1,26 +1,50 @@
1
1
  import type { Command } from "commander";
2
2
  import { loadConfig } from "../config.ts";
3
3
  import { formatMeters, formatMetersPerWeek, formatPercent } from "../display.ts";
4
- import { calendarDay } from "../models.ts";
5
- import { computeGoalProgress, mondayOf, workoutsInRange } from "../stats.ts";
4
+ import { printJSON } from "../envelope.ts";
5
+ import { dataPaths } from "../paths.ts";
6
+ import { computeGoalProgress, localYMD, recentWeeks } from "../stats.ts";
6
7
  import { readWorkouts } from "../storage.ts";
7
8
 
8
9
  export function registerStatus(program: Command): void {
9
10
  program
10
11
  .command("status")
11
12
  .description("Show progress toward your distance goal")
12
- .action(async () => {
13
+ .option("--json", "output as JSON")
14
+ .action(async (opts: { json?: boolean }) => {
13
15
  const cfg = await loadConfig();
14
16
  if (!cfg.goal.start_date || !cfg.goal.end_date) {
15
17
  console.error("Goal dates not configured. Run `c2 setup` to set start and end dates.");
16
18
  process.exit(1);
17
19
  }
18
- const workouts = await readWorkouts();
19
- if (workouts.length === 0) {
20
+ const paths = dataPaths(cfg);
21
+ const workouts = await readWorkouts(paths);
22
+ const now = new Date();
23
+ const goal = computeGoalProgress(workouts, cfg, now);
24
+ const weeks = recentWeeks(workouts, now, 4);
25
+ const thisWeek = weeks[0]!;
26
+
27
+ if (!opts.json && workouts.length === 0) {
20
28
  console.log("No workouts found. Run `c2 sync` first.");
21
29
  return;
22
30
  }
23
- const goal = computeGoalProgress(workouts, cfg);
31
+
32
+ if (opts.json) {
33
+ printJSON("c2.status.v1", {
34
+ goal,
35
+ this_week: {
36
+ week_start: localYMD(thisWeek.weekStart),
37
+ meters: thisWeek.meters,
38
+ sessions: thisWeek.sessions,
39
+ },
40
+ recent_weeks: weeks.map((w) => ({
41
+ week_start: localYMD(w.weekStart),
42
+ meters: w.meters,
43
+ sessions: w.sessions,
44
+ })),
45
+ });
46
+ return;
47
+ }
24
48
 
25
49
  console.log(`Goal: ${formatMeters(goal.target)}m`);
26
50
  console.log(`Season start: ${cfg.goal.start_date}`);
@@ -29,29 +53,22 @@ export function registerStatus(program: Command): void {
29
53
  );
30
54
  console.log(`Weeks elapsed: ${goal.weeksElapsed} / ${goal.totalWeeks}`);
31
55
  console.log(`Required pace: ${formatMetersPerWeek(goal.requiredPace)}`);
56
+ console.log(
57
+ `This week so far: ${formatMeters(thisWeek.meters)} (${thisWeek.sessions} sessions)`,
58
+ );
32
59
  console.log();
33
60
 
34
- const today = new Date();
35
61
  console.log("Last 4 weeks:");
36
- for (let i = 0; i < 4; i++) {
37
- const weekStart = mondayOf(today);
38
- weekStart.setDate(weekStart.getDate() - i * 7);
39
- const weekEnd = new Date(weekStart);
40
- weekEnd.setDate(weekEnd.getDate() + 7);
41
-
42
- const weekWorkouts = workoutsInRange(workouts, weekStart, weekEnd);
43
- const meters = weekWorkouts.reduce((sum, w) => sum + w.distance, 0);
44
- const sessions = new Set(weekWorkouts.map(calendarDay)).size;
45
-
46
- const mm = String(weekStart.getMonth() + 1).padStart(2, "0");
47
- const dd = String(weekStart.getDate()).padStart(2, "0");
48
- console.log(` Week of ${mm}/${dd}: ${formatMeters(meters)} (${sessions} sessions)`);
62
+ for (const w of weeks) {
63
+ const mm = String(w.weekStart.getMonth() + 1).padStart(2, "0");
64
+ const dd = String(w.weekStart.getDate()).padStart(2, "0");
65
+ console.log(` Week of ${mm}/${dd}: ${formatMeters(w.meters)} (${w.sessions} sessions)`);
49
66
  }
50
67
  console.log();
51
68
 
52
69
  if (goal.weeksElapsed > 0) {
53
- const indicator = goal.onPace ? "on pace \u2713" : "behind pace \u2717";
54
- console.log(`Current avg: ${formatMetersPerWeek(goal.currentAvgPace)} \u2014 ${indicator}`);
70
+ const indicator = goal.onPace ? "on pace " : "behind pace ";
71
+ console.log(`Current avg: ${formatMetersPerWeek(goal.currentAvgPace)} ${indicator}`);
55
72
  }
56
73
  });
57
74
  }
@@ -1,18 +1,34 @@
1
1
  import type { Command } from "commander";
2
2
  import { C2Client } from "../api/client.ts";
3
- import { ensureDirs, loadConfig, saveConfig } from "../config.ts";
3
+ import { loadConfig } from "../config.ts";
4
+ import { initStore, inspectDataDir } from "../data.ts";
4
5
  import type { Workout } from "../models.ts";
5
- import { appendWorkouts, hasStrokeData, workoutCount, writeStrokeData } from "../storage.ts";
6
+ import { compactNotes } from "../notes.ts";
7
+ import type { DataPaths } from "../paths.ts";
8
+ import { dataPaths } from "../paths.ts";
9
+ import {
10
+ appendWorkouts,
11
+ hasStrokeData,
12
+ readMeta,
13
+ SCHEMA_VERSION,
14
+ workoutCount,
15
+ writeMeta,
16
+ writeStrokeData,
17
+ } from "../storage.ts";
6
18
 
7
- async function syncStrokes(client: C2Client, workouts: Workout[]): Promise<number> {
19
+ async function syncStrokes(
20
+ client: C2Client,
21
+ paths: DataPaths,
22
+ workouts: Workout[],
23
+ ): Promise<number> {
8
24
  let count = 0;
9
25
  let failures = 0;
10
26
  for (const w of workouts) {
11
- if (!w.stroke_data || (await hasStrokeData(w.id))) continue;
27
+ if (!w.stroke_data || (await hasStrokeData(paths, w.id))) continue;
12
28
  try {
13
29
  const strokes = await client.getStrokes(w.id);
14
30
  if (strokes.length > 0) {
15
- await writeStrokeData(w.id, strokes);
31
+ await writeStrokeData(paths, w.id, strokes);
16
32
  count++;
17
33
  }
18
34
  } catch (err) {
@@ -39,30 +55,62 @@ export function registerSync(program: Command): void {
39
55
  console.error("No API token configured. Run `c2 setup` first.");
40
56
  process.exit(1);
41
57
  }
42
- await ensureDirs();
58
+ const paths = dataPaths(cfg);
59
+ const inspection = await inspectDataDir(paths);
60
+ if (inspection.state === "foreign") {
61
+ console.error(
62
+ `${paths.root} exists but is not a c2 data store. Fix data_dir via \`c2 setup\`.`,
63
+ );
64
+ process.exit(1);
65
+ }
66
+ if (!inspection.writable) {
67
+ console.error(`Cannot write to ${paths.root}.`);
68
+ process.exit(1);
69
+ }
70
+ const now = new Date();
71
+ await initStore(paths, now);
43
72
 
44
73
  const client = C2Client.fromConfig(cfg);
45
- const from = cfg.sync.last_sync ?? "";
74
+ const meta = await readMeta(paths);
75
+ let from = meta?.last_sync ?? "";
76
+ if (!from && cfg.sync.last_sync && (await workoutCount(paths)) > 0) {
77
+ from = cfg.sync.last_sync;
78
+ }
46
79
 
47
80
  if (from) {
48
81
  console.log(`Syncing workouts since ${from}...`);
49
82
  } else {
50
- console.log("First sync \u2014 pulling all workouts...");
83
+ console.log("First sync pulling all workouts...");
51
84
  }
52
85
 
53
86
  const workouts = await client.getAllResults(from, "");
54
- const written = await appendWorkouts(workouts);
87
+ const written = await appendWorkouts(paths, workouts);
55
88
  console.log(`Fetched ${workouts.length} workouts, ${written} new.`);
56
89
 
57
- const strokeCount = await syncStrokes(client, workouts);
90
+ const strokeCount = await syncStrokes(client, paths, workouts);
58
91
  if (strokeCount > 0) {
59
92
  console.log(`Fetched stroke data for ${strokeCount} workouts.`);
60
93
  }
61
94
 
62
- cfg.sync.last_sync = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
63
- await saveConfig(cfg);
95
+ await writeMeta(paths, {
96
+ schema_version: meta?.schema_version ?? SCHEMA_VERSION,
97
+ created: meta?.created ?? now.toISOString(),
98
+ last_sync: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
99
+ });
100
+
101
+ const compacted = await compactNotes(paths, now);
102
+ for (const year of compacted.skippedYears) {
103
+ console.error(
104
+ `Warning: notes/archive/${year}.jsonl has corrupt lines; left untouched (run \`c2 data doctor\`).`,
105
+ );
106
+ }
107
+ if (compacted.archived > 0) {
108
+ console.log(
109
+ `Compacted ${compacted.archived} note${compacted.archived === 1 ? "" : "s"} into ${compacted.years.map((y) => `${y}.jsonl`).join(", ")}.`,
110
+ );
111
+ }
64
112
 
65
- const total = await workoutCount();
113
+ const total = await workoutCount(paths);
66
114
  console.log(`Total workouts: ${total}`);
67
115
  });
68
116
  }
@@ -1,6 +1,9 @@
1
1
  import type { Command } from "commander";
2
+ import { loadConfig } from "../config.ts";
2
3
  import { formatMeters, paceArrow, sparkBar, trendArrow } from "../display.ts";
3
- import { buildWeekSummaries, type WeekSummary } from "../stats.ts";
4
+ import { printJSON } from "../envelope.ts";
5
+ import { dataPaths } from "../paths.ts";
6
+ import { buildWeekSummaries, type WeekSummary, weekSummaryData } from "../stats.ts";
4
7
  import { readWorkouts } from "../storage.ts";
5
8
 
6
9
  function fmtDate(d: Date): string {
@@ -87,12 +90,10 @@ export function registerTrend(program: Command): void {
87
90
  .command("trend")
88
91
  .description("Show training trends over time")
89
92
  .option("-w, --weeks <n>", "number of weeks to display", "8")
90
- .action(async (opts: { weeks: string }) => {
91
- const workouts = await readWorkouts();
92
- if (workouts.length === 0) {
93
- console.log("No workouts found. Run `c2 sync` first.");
94
- return;
95
- }
93
+ .option("--json", "output as JSON")
94
+ .action(async (opts: { weeks: string; json?: boolean }) => {
95
+ const cfg = await loadConfig();
96
+ const workouts = await readWorkouts(dataPaths(cfg));
96
97
 
97
98
  const weeks = parseInt(opts.weeks, 10);
98
99
  if (Number.isNaN(weeks) || weeks < 1) {
@@ -101,6 +102,16 @@ export function registerTrend(program: Command): void {
101
102
  }
102
103
  const summaries = buildWeekSummaries(workouts, new Date(), weeks);
103
104
 
105
+ if (opts.json) {
106
+ printJSON("c2.trend.v1", { weeks: summaries.map(weekSummaryData) });
107
+ return;
108
+ }
109
+
110
+ if (workouts.length === 0) {
111
+ console.log("No workouts found. Run `c2 sync` first.");
112
+ return;
113
+ }
114
+
104
115
  printVolumeTrend(summaries);
105
116
  console.log();
106
117
  printPaceTrend(summaries);
@@ -5,7 +5,7 @@ describe("parseGoalDate", () => {
5
5
  test("parses valid date string", () => {
6
6
  const d = parseGoalDate("2026-03-07");
7
7
  expect(d.getFullYear()).toBe(2026);
8
- expect(d.getMonth()).toBe(2); // March = 2
8
+ expect(d.getMonth()).toBe(2);
9
9
  expect(d.getDate()).toBe(7);
10
10
  });
11
11
 
package/src/config.ts CHANGED
@@ -1,33 +1,31 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  export interface Config {
6
+ data_dir: string;
6
7
  api: { base_url: string; token: string };
7
8
  sync: { last_sync?: string; machine_type: string };
8
9
  goal: { target_meters: number; start_date: string; end_date: string };
9
10
  display: { date_format: string };
10
11
  }
11
12
 
12
- export function defaultConfig(): Config {
13
- return {
14
- api: { base_url: "https://log.concept2.com", token: "" },
15
- sync: { machine_type: "rower" },
16
- goal: { target_meters: 1_000_000, start_date: "", end_date: "" },
17
- display: { date_format: "%m/%d" },
18
- };
19
- }
20
-
21
13
  export function configDir(): string {
22
14
  return join(homedir(), ".config", "c2");
23
15
  }
24
16
 
25
- export function dataDir(): string {
17
+ export function defaultDataDir(): string {
26
18
  return join(configDir(), "data");
27
19
  }
28
20
 
29
- export async function ensureDirs(): Promise<void> {
30
- await mkdir(join(dataDir(), "strokes"), { recursive: true });
21
+ export function defaultConfig(): Config {
22
+ return {
23
+ data_dir: defaultDataDir(),
24
+ api: { base_url: "https://log.concept2.com", token: "" },
25
+ sync: { machine_type: "rower" },
26
+ goal: { target_meters: 1_000_000, start_date: "", end_date: "" },
27
+ display: { date_format: "%m/%d" },
28
+ };
31
29
  }
32
30
 
33
31
  export async function loadConfig(): Promise<Config> {
@@ -36,7 +34,12 @@ export async function loadConfig(): Promise<Config> {
36
34
  try {
37
35
  const text = await readFile(path, "utf-8");
38
36
  const parsed = JSON.parse(text) as Partial<Config>;
37
+ const dataDir =
38
+ typeof parsed.data_dir === "string" && parsed.data_dir.trim() !== ""
39
+ ? parsed.data_dir
40
+ : defaults.data_dir;
39
41
  return {
42
+ data_dir: dataDir,
40
43
  api: { ...defaults.api, ...parsed.api },
41
44
  sync: { ...defaults.sync, ...parsed.sync },
42
45
  goal: { ...defaults.goal, ...parsed.goal },
@@ -54,7 +57,8 @@ export async function saveConfig(cfg: Config): Promise<void> {
54
57
  const path = join(configDir(), "config.json");
55
58
  await mkdir(configDir(), { recursive: true });
56
59
  const text = JSON.stringify(cfg, null, 2);
57
- await writeFile(path, `${text}\n`, "utf-8");
60
+ await writeFile(path, `${text}\n`, { encoding: "utf-8", mode: 0o600 });
61
+ await chmod(path, 0o600);
58
62
  }
59
63
 
60
64
  export function parseGoalDate(s: string): Date {