@richhaase/c2 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rich Haase
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,161 @@
1
+ # c2 — Concept2 Logbook CLI
2
+
3
+ A CLI tool for syncing and analyzing rowing data from your [Concept2 Logbook](https://log.concept2.com). Built with [Bun](https://bun.sh).
4
+
5
+ ## Install
6
+
7
+ Requires [Bun](https://bun.sh) v1.0+.
8
+
9
+ ```bash
10
+ # Install globally
11
+ bun install -g @richhaase/c2
12
+
13
+ # Or install from source
14
+ git clone https://github.com/richhaase/c2.git
15
+ cd c2cli
16
+ bun install
17
+ bun link
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```bash
23
+ # Configure token and goals
24
+ bun src/index.ts setup
25
+
26
+ # Sync workouts
27
+ bun src/index.ts sync
28
+
29
+ # Check your progress
30
+ bun src/index.ts status
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Setup
36
+
37
+ Configure your token and goal settings:
38
+
39
+ ```bash
40
+ c2 setup
41
+ ```
42
+
43
+ Get your personal access token from [log.concept2.com](https://log.concept2.com) under Settings → Developer. The setup wizard will prompt for your token, goal target, and date range.
44
+
45
+ ### Sync Workouts
46
+
47
+ Pull new workouts from the Concept2 API:
48
+
49
+ ```bash
50
+ c2 sync
51
+ ```
52
+
53
+ ### View Workouts
54
+
55
+ ```bash
56
+ # Show last 10 workouts
57
+ c2 log
58
+
59
+ # Show last 25 workouts
60
+ c2 log -n 25
61
+ ```
62
+
63
+ ### Goal Progress
64
+
65
+ Track progress toward your distance goal:
66
+
67
+ ```bash
68
+ c2 status
69
+ ```
70
+
71
+ ### Training Trends
72
+
73
+ View weekly trends for pace, volume, stroke rate, and heart rate:
74
+
75
+ ```bash
76
+ # Last 8 weeks (default)
77
+ c2 trend
78
+
79
+ # Last 12 weeks
80
+ c2 trend -w 12
81
+ ```
82
+
83
+ ### HTML Report
84
+
85
+ Generate a self-contained HTML progress report:
86
+
87
+ ```bash
88
+ # Generate report.html in current directory
89
+ c2 report
90
+
91
+ # Custom output path
92
+ c2 report -o ~/Desktop/rowing.html
93
+
94
+ # Generate and open in browser
95
+ c2 report --open
96
+
97
+ # Show more weeks of history
98
+ c2 report -w 16
99
+ ```
100
+
101
+ ### Export Data
102
+
103
+ Export workouts to CSV, JSON, or JSONL:
104
+
105
+ ```bash
106
+ # CSV to stdout
107
+ c2 export
108
+
109
+ # JSON format
110
+ c2 export -f json
111
+
112
+ # Filter by date range
113
+ c2 export --from 2026-01-01 --to 2026-03-01
114
+
115
+ # Pipe to file
116
+ c2 export -f jsonl > workouts.jsonl
117
+ ```
118
+
119
+ ## Configuration
120
+
121
+ Config lives at `~/.config/c2cli/config.json`. Created automatically on `c2 setup`.
122
+
123
+ ```json
124
+ {
125
+ "api": {
126
+ "base_url": "https://log.concept2.com",
127
+ "token": "YOUR_TOKEN"
128
+ },
129
+ "sync": {
130
+ "machine_type": "rower"
131
+ },
132
+ "goal": {
133
+ "target_meters": 1000000,
134
+ "start_date": "2026-01-01",
135
+ "end_date": "2026-12-31"
136
+ },
137
+ "display": {
138
+ "date_format": "%m/%d"
139
+ }
140
+ }
141
+ ```
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ # Install dependencies
147
+ bun install
148
+
149
+ # Type check
150
+ bun run check
151
+
152
+ # Run tests
153
+ bun test
154
+
155
+ # Run directly
156
+ bun src/index.ts <command>
157
+ ```
158
+
159
+ ## License
160
+
161
+ MIT — see [LICENSE](LICENSE)
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@richhaase/c2",
3
+ "version": "0.1.1",
4
+ "description": "CLI tool for syncing and analyzing Concept2 rowing data",
5
+ "keywords": [
6
+ "concept2",
7
+ "rowing",
8
+ "erg",
9
+ "fitness",
10
+ "cli"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/richhaase/c2.git"
16
+ },
17
+ "author": "Rich Haase",
18
+ "type": "module",
19
+ "bin": {
20
+ "c2": "src/index.ts"
21
+ },
22
+ "scripts": {
23
+ "dev": "bun src/index.ts",
24
+ "test": "bun test",
25
+ "check": "tsc --noEmit && biome check src/ && bun test",
26
+ "lint": "biome check src/",
27
+ "fmt": "biome check --write src/",
28
+ "release": "bun run check && npm version"
29
+ },
30
+ "dependencies": {
31
+ "commander": "^14.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "^2.4.6",
35
+ "@types/bun": "^1.3.9"
36
+ },
37
+ "files": [
38
+ "src"
39
+ ],
40
+ "engines": {
41
+ "bun": ">=1.0.0"
42
+ }
43
+ }
@@ -0,0 +1,78 @@
1
+ import pkg from "../../package.json";
2
+ import type { Config } from "../config.ts";
3
+ import type {
4
+ ResultsResponse,
5
+ StrokeData,
6
+ StrokeDataResponse,
7
+ UserProfile,
8
+ UserResponse,
9
+ Workout,
10
+ } from "../models.ts";
11
+
12
+ export class C2Client {
13
+ private baseURL: string;
14
+ private token: string;
15
+
16
+ constructor(baseURL: string, token: string) {
17
+ this.baseURL = baseURL;
18
+ this.token = token;
19
+ }
20
+
21
+ static fromConfig(cfg: Config): C2Client {
22
+ return new C2Client(cfg.api.base_url, cfg.api.token);
23
+ }
24
+
25
+ private async get(path: string): Promise<unknown> {
26
+ const url = this.baseURL + path;
27
+ const resp = await fetch(url, {
28
+ headers: {
29
+ Authorization: `Bearer ${this.token}`,
30
+ "User-Agent": `c2/${pkg.version}`,
31
+ },
32
+ signal: AbortSignal.timeout(30_000),
33
+ });
34
+
35
+ if (!resp.ok) {
36
+ throw new Error(`API error (${resp.status}) from ${path}`);
37
+ }
38
+
39
+ return resp.json();
40
+ }
41
+
42
+ async getUser(): Promise<UserProfile> {
43
+ const resp = (await this.get("/api/users/me")) as UserResponse;
44
+ return resp.data;
45
+ }
46
+
47
+ async getResults(from: string, to: string, page: number): Promise<ResultsResponse> {
48
+ const params = new URLSearchParams({ type: "rower", page: String(page) });
49
+ if (from) params.set("from", from);
50
+ if (to) params.set("to", to);
51
+ return (await this.get(`/api/users/me/results?${params}`)) as ResultsResponse;
52
+ }
53
+
54
+ async getAllResults(from: string, to: string): Promise<Workout[]> {
55
+ const all: Workout[] = [];
56
+ let page = 1;
57
+
58
+ for (;;) {
59
+ const resp = await this.getResults(from, to, page);
60
+ all.push(...resp.data);
61
+
62
+ const hasMore =
63
+ resp.meta?.pagination != null &&
64
+ resp.meta.pagination.current_page < resp.meta.pagination.total_pages;
65
+
66
+ if (!hasMore || resp.data.length === 0) break;
67
+ page++;
68
+ }
69
+
70
+ return all;
71
+ }
72
+
73
+ async getStrokes(workoutId: number): Promise<StrokeData[]> {
74
+ const path = `/api/users/me/results/${workoutId}/strokes`;
75
+ const resp = (await this.get(path)) as StrokeDataResponse;
76
+ return resp.data;
77
+ }
78
+ }
@@ -0,0 +1,115 @@
1
+ import type { Command } from "commander";
2
+ import type { Workout } from "../models.ts";
3
+ import { pace500m } from "../models.ts";
4
+ import { readWorkouts } from "../storage.ts";
5
+
6
+ export function filterByDate(workouts: Workout[], from: string, to: string): Workout[] {
7
+ if (!from && !to) return workouts;
8
+ return workouts.filter((w) => {
9
+ const date = w.date.slice(0, 10);
10
+ if (from && date < from) return false;
11
+ if (to && date > to) return false;
12
+ return true;
13
+ });
14
+ }
15
+
16
+ export function escapeCSV(s: string): string {
17
+ if (s.includes(",") || s.includes('"') || s.includes("\n")) {
18
+ return `"${s.replace(/"/g, '""')}"`;
19
+ }
20
+ return s;
21
+ }
22
+
23
+ function exportCSV(workouts: Workout[]): void {
24
+ const header = [
25
+ "id",
26
+ "date",
27
+ "distance",
28
+ "time_tenths",
29
+ "time_formatted",
30
+ "pace_500m",
31
+ "stroke_rate",
32
+ "stroke_count",
33
+ "calories",
34
+ "drag_factor",
35
+ "hr_avg",
36
+ "hr_min",
37
+ "hr_max",
38
+ "workout_type",
39
+ "machine_type",
40
+ "comments",
41
+ ];
42
+ console.log(header.join(","));
43
+
44
+ for (const w of workouts) {
45
+ const row = [
46
+ String(w.id),
47
+ w.date,
48
+ String(w.distance),
49
+ String(w.time),
50
+ w.time_formatted,
51
+ pace500m(w),
52
+ String(w.stroke_rate ?? ""),
53
+ String(w.stroke_count ?? ""),
54
+ String(w.calories_total ?? ""),
55
+ String(w.drag_factor ?? ""),
56
+ w.heart_rate?.average ? String(w.heart_rate.average) : "",
57
+ w.heart_rate?.min ? String(w.heart_rate.min) : "",
58
+ w.heart_rate?.max ? String(w.heart_rate.max) : "",
59
+ w.workout_type ?? "",
60
+ w.type ?? "",
61
+ escapeCSV(w.comments ?? ""),
62
+ ];
63
+ console.log(row.join(","));
64
+ }
65
+ }
66
+
67
+ function exportJSON(workouts: Workout[]): void {
68
+ console.log(JSON.stringify(workouts, null, 2));
69
+ }
70
+
71
+ function exportJSONL(workouts: Workout[]): void {
72
+ for (const w of workouts) {
73
+ console.log(JSON.stringify(w));
74
+ }
75
+ }
76
+
77
+ export function registerExport(program: Command): void {
78
+ program
79
+ .command("export")
80
+ .description("Export workouts to CSV or JSON")
81
+ .option("-f, --format <fmt>", "output format: csv, json, or jsonl", "csv")
82
+ .option("--from <date>", "filter workouts from date (YYYY-MM-DD)")
83
+ .option("--to <date>", "filter workouts to date (YYYY-MM-DD)")
84
+ .action(async (opts: { format: string; from?: string; to?: string }) => {
85
+ let workouts = await readWorkouts();
86
+ if (workouts.length === 0) {
87
+ console.error("No workouts found. Run `c2 sync` first.");
88
+ process.exit(1);
89
+ }
90
+
91
+ workouts = filterByDate(workouts, opts.from ?? "", opts.to ?? "");
92
+ if (workouts.length === 0) {
93
+ console.error("No workouts match the specified date range.");
94
+ process.exit(1);
95
+ }
96
+
97
+ // Sort oldest first for export
98
+ workouts.sort((a, b) => a.date.localeCompare(b.date));
99
+
100
+ switch (opts.format) {
101
+ case "csv":
102
+ exportCSV(workouts);
103
+ break;
104
+ case "json":
105
+ exportJSON(workouts);
106
+ break;
107
+ case "jsonl":
108
+ exportJSONL(workouts);
109
+ break;
110
+ default:
111
+ console.error(`Unsupported format "${opts.format}": must be csv, json, or jsonl`);
112
+ process.exit(1);
113
+ }
114
+ });
115
+ }
@@ -0,0 +1,31 @@
1
+ import type { Command } from "commander";
2
+ import { loadConfig } from "../config.ts";
3
+ import { formatWorkoutLine } from "../display.ts";
4
+ import { readWorkouts } from "../storage.ts";
5
+
6
+ export function registerLog(program: Command): void {
7
+ program
8
+ .command("log")
9
+ .description("Show recent workouts")
10
+ .option("-n, --count <n>", "number of workouts to display", "10")
11
+ .action(async (opts: { count: string }) => {
12
+ const cfg = await loadConfig();
13
+ const workouts = await readWorkouts();
14
+ if (workouts.length === 0) {
15
+ console.log("No workouts found. Run `c2 sync` first.");
16
+ return;
17
+ }
18
+
19
+ workouts.sort((a, b) => b.date.localeCompare(a.date));
20
+
21
+ const count = parseInt(opts.count, 10);
22
+ if (Number.isNaN(count) || count < 1) {
23
+ console.error("Error: --count must be a positive integer.");
24
+ process.exit(1);
25
+ }
26
+ const n = Math.min(count, workouts.length);
27
+ for (const w of workouts.slice(0, n)) {
28
+ console.log(formatWorkoutLine(w, cfg.display.date_format));
29
+ }
30
+ });
31
+ }