@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.
- 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 +231 -8
- 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.ts +18 -14
- package/src/data.test.ts +236 -0
- package/src/data.ts +200 -0
- package/src/display.ts +30 -1
- package/src/doctor.ts +174 -0
- package/src/envelope.ts +17 -0
- package/src/index.ts +13 -6
- package/src/models.ts +28 -0
- 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/stats.ts +53 -1
- package/src/storage.ts +66 -28
|
@@ -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
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
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
|
-
.
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
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 (
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
54
|
-
console.log(`Current avg: ${formatMetersPerWeek(goal.currentAvgPace)}
|
|
70
|
+
const indicator = goal.onPace ? "on pace ✓" : "behind pace ✗";
|
|
71
|
+
console.log(`Current avg: ${formatMetersPerWeek(goal.currentAvgPace)} — ${indicator}`);
|
|
55
72
|
}
|
|
56
73
|
});
|
|
57
74
|
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import { C2Client } from "../api/client.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { loadConfig } from "../config.ts";
|
|
4
|
+
import { initStore, inspectDataDir } from "../data.ts";
|
|
4
5
|
import type { Workout } from "../models.ts";
|
|
5
|
-
import {
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
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
|
}
|
package/src/commands/trend.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
.
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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);
|
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
|
|
17
|
+
export function defaultDataDir(): string {
|
|
26
18
|
return join(configDir(), "data");
|
|
27
19
|
}
|
|
28
20
|
|
|
29
|
-
export
|
|
30
|
-
|
|
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 {
|