@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.
@@ -0,0 +1,94 @@
1
+ import { join } from "node:path";
2
+ import type { Command } from "commander";
3
+ import { C2Client } from "../api/client.ts";
4
+ import {
5
+ type Config,
6
+ configDir,
7
+ defaultConfig,
8
+ ensureDirs,
9
+ loadConfig,
10
+ parseGoalDate,
11
+ saveConfig,
12
+ } from "../config.ts";
13
+ import { formatMeters } from "../display.ts";
14
+
15
+ function maskToken(token: string): string {
16
+ if (token.length <= 4) return token;
17
+ return "·".repeat(token.length - 4) + token.slice(-4);
18
+ }
19
+
20
+ function promptValue(label: string, current: string, mask = false): string {
21
+ const display = current ? ` [${mask ? maskToken(current) : current}]` : "";
22
+ const input = prompt(`${label}${display}:`);
23
+ return (input ?? "").trim() || current;
24
+ }
25
+
26
+ export function registerSetup(program: Command): void {
27
+ program
28
+ .command("setup")
29
+ .description("Configure token, goal, and preferences")
30
+ .action(async () => {
31
+ let cfg: Config;
32
+ try {
33
+ cfg = await loadConfig();
34
+ } catch (err) {
35
+ console.error(`Warning: could not load existing config: ${(err as Error).message}`);
36
+ console.error("Starting from defaults.");
37
+ cfg = defaultConfig();
38
+ }
39
+
40
+ console.log("Concept2 CLI Setup\n");
41
+
42
+ // Token
43
+ const token = promptValue("API token (from log.concept2.com)", cfg.api.token, true);
44
+ cfg.api.token = token;
45
+
46
+ // Goal target
47
+ const targetDisplay = formatMeters(cfg.goal.target_meters);
48
+ const targetInput = promptValue("Goal target meters", targetDisplay);
49
+ const parsed = parseInt(targetInput.replace(/,/g, ""), 10);
50
+ if (!Number.isNaN(parsed) && parsed > 0) {
51
+ cfg.goal.target_meters = parsed;
52
+ }
53
+
54
+ // Goal dates
55
+ const startInput = promptValue("Goal start date (YYYY-MM-DD)", cfg.goal.start_date);
56
+ try {
57
+ parseGoalDate(startInput);
58
+ cfg.goal.start_date = startInput;
59
+ } catch {
60
+ console.log(`Invalid date "${startInput}", keeping previous value.`);
61
+ }
62
+
63
+ const endInput = promptValue("Goal end date (YYYY-MM-DD)", cfg.goal.end_date);
64
+ try {
65
+ parseGoalDate(endInput);
66
+ cfg.goal.end_date = endInput;
67
+ } catch {
68
+ console.log(`Invalid date "${endInput}", keeping previous value.`);
69
+ }
70
+
71
+ // Save
72
+ await ensureDirs();
73
+ await saveConfig(cfg);
74
+ console.log(`\nConfig written to ${join(configDir(), "config.json")}`);
75
+
76
+ if (!cfg.goal.start_date || !cfg.goal.end_date) {
77
+ console.log(
78
+ "\nNote: Goal dates not set. Commands like `c2 status` require start/end dates.",
79
+ );
80
+ }
81
+
82
+ // Verify token
83
+ if (cfg.api.token) {
84
+ console.log("Verifying token...");
85
+ try {
86
+ const client = C2Client.fromConfig(cfg);
87
+ const user = await client.getUser();
88
+ console.log(`Authenticated as: ${user.username} (ID: ${user.id})`);
89
+ } catch (err) {
90
+ console.error(`Warning: could not verify token: ${(err as Error).message}`);
91
+ }
92
+ }
93
+ });
94
+ }
@@ -0,0 +1,57 @@
1
+ import type { Command } from "commander";
2
+ import { loadConfig } from "../config.ts";
3
+ import { formatMeters, formatMetersPerWeek, formatPercent } from "../display.ts";
4
+ import { calendarDay } from "../models.ts";
5
+ import { computeGoalProgress, mondayOf, workoutsInRange } from "../stats.ts";
6
+ import { readWorkouts } from "../storage.ts";
7
+
8
+ export function registerStatus(program: Command): void {
9
+ program
10
+ .command("status")
11
+ .description("Show progress toward your distance goal")
12
+ .action(async () => {
13
+ const cfg = await loadConfig();
14
+ if (!cfg.goal.start_date || !cfg.goal.end_date) {
15
+ console.error("Goal dates not configured. Run `c2 setup` to set start and end dates.");
16
+ process.exit(1);
17
+ }
18
+ const workouts = await readWorkouts();
19
+ if (workouts.length === 0) {
20
+ console.log("No workouts found. Run `c2 sync` first.");
21
+ return;
22
+ }
23
+ const goal = computeGoalProgress(workouts, cfg);
24
+
25
+ console.log(`Goal: ${formatMeters(goal.target)}m`);
26
+ console.log(`Season start: ${cfg.goal.start_date}`);
27
+ console.log(
28
+ `Progress: ${formatMeters(goal.totalMeters)} / ${formatMeters(goal.target)} (${formatPercent(goal.progress)})`,
29
+ );
30
+ console.log(`Weeks elapsed: ${goal.weeksElapsed} / ${goal.totalWeeks}`);
31
+ console.log(`Required pace: ${formatMetersPerWeek(goal.requiredPace)}`);
32
+ console.log();
33
+
34
+ const today = new Date();
35
+ 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)`);
49
+ }
50
+ console.log();
51
+
52
+ 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}`);
55
+ }
56
+ });
57
+ }
@@ -0,0 +1,68 @@
1
+ import type { Command } from "commander";
2
+ import { C2Client } from "../api/client.ts";
3
+ import { ensureDirs, loadConfig, saveConfig } from "../config.ts";
4
+ import type { Workout } from "../models.ts";
5
+ import { appendWorkouts, hasStrokeData, workoutCount, writeStrokeData } from "../storage.ts";
6
+
7
+ async function syncStrokes(client: C2Client, workouts: Workout[]): Promise<number> {
8
+ let count = 0;
9
+ let failures = 0;
10
+ for (const w of workouts) {
11
+ if (!w.stroke_data || (await hasStrokeData(w.id))) continue;
12
+ try {
13
+ const strokes = await client.getStrokes(w.id);
14
+ if (strokes.length > 0) {
15
+ await writeStrokeData(w.id, strokes);
16
+ count++;
17
+ }
18
+ } catch (err) {
19
+ failures++;
20
+ console.error(
21
+ `Warning: failed to fetch strokes for workout ${w.id}: ${(err as Error).message}`,
22
+ );
23
+ if (failures >= 3) {
24
+ console.error("Too many failures, skipping remaining stroke data.");
25
+ break;
26
+ }
27
+ }
28
+ }
29
+ return count;
30
+ }
31
+
32
+ export function registerSync(program: Command): void {
33
+ program
34
+ .command("sync")
35
+ .description("Pull new workouts from the API")
36
+ .action(async () => {
37
+ const cfg = await loadConfig();
38
+ if (!cfg.api.token) {
39
+ console.error("No API token configured. Run `c2 setup` first.");
40
+ process.exit(1);
41
+ }
42
+ await ensureDirs();
43
+
44
+ const client = C2Client.fromConfig(cfg);
45
+ const from = cfg.sync.last_sync ?? "";
46
+
47
+ if (from) {
48
+ console.log(`Syncing workouts since ${from}...`);
49
+ } else {
50
+ console.log("First sync \u2014 pulling all workouts...");
51
+ }
52
+
53
+ const workouts = await client.getAllResults(from, "");
54
+ const written = await appendWorkouts(workouts);
55
+ console.log(`Fetched ${workouts.length} workouts, ${written} new.`);
56
+
57
+ const strokeCount = await syncStrokes(client, workouts);
58
+ if (strokeCount > 0) {
59
+ console.log(`Fetched stroke data for ${strokeCount} workouts.`);
60
+ }
61
+
62
+ cfg.sync.last_sync = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
63
+ await saveConfig(cfg);
64
+
65
+ const total = await workoutCount();
66
+ console.log(`Total workouts: ${total}`);
67
+ });
68
+ }
@@ -0,0 +1,112 @@
1
+ import type { Command } from "commander";
2
+ import { formatMeters, paceArrow, sparkBar, trendArrow } from "../display.ts";
3
+ import { buildWeekSummaries, type WeekSummary } from "../stats.ts";
4
+ import { readWorkouts } from "../storage.ts";
5
+
6
+ function fmtDate(d: Date): string {
7
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
8
+ const dd = String(d.getDate()).padStart(2, "0");
9
+ return `${mm}/${dd}`;
10
+ }
11
+
12
+ function maxMeters(summaries: WeekSummary[]): number {
13
+ let max = 0;
14
+ for (const ws of summaries) {
15
+ if (ws.meters > max) max = ws.meters;
16
+ }
17
+ return max;
18
+ }
19
+
20
+ function printVolumeTrend(summaries: WeekSummary[]): void {
21
+ console.log("Volume (meters/week):");
22
+ let prevMeters = 0;
23
+ for (const ws of summaries) {
24
+ const arrow = trendArrow(prevMeters, ws.meters);
25
+ const bar = sparkBar(ws.meters, maxMeters(summaries));
26
+ console.log(
27
+ ` ${fmtDate(ws.weekStart)} ${arrow} ${formatMeters(ws.meters).padStart(7)} ${bar} (${ws.sessions} sessions)`,
28
+ );
29
+ prevMeters = ws.meters;
30
+ }
31
+ }
32
+
33
+ function printPaceTrend(summaries: WeekSummary[]): void {
34
+ console.log("Avg Pace (/500m):");
35
+ let prevPace = 0;
36
+ for (const ws of summaries) {
37
+ if (ws.paceCount === 0) {
38
+ console.log(` ${fmtDate(ws.weekStart)} -`);
39
+ continue;
40
+ }
41
+ const avgPace = ws.paceSum / ws.paceCount;
42
+ const arrow = paceArrow(prevPace, avgPace);
43
+ const mins = Math.floor(avgPace / 60);
44
+ const secs = avgPace - mins * 60;
45
+ console.log(` ${fmtDate(ws.weekStart)} ${arrow} ${mins}:${secs.toFixed(1).padStart(4, "0")}`);
46
+ prevPace = avgPace;
47
+ }
48
+ }
49
+
50
+ function printSPMTrend(summaries: WeekSummary[]): void {
51
+ console.log("Avg Stroke Rate (spm):");
52
+ let prevSPM = 0;
53
+ for (const ws of summaries) {
54
+ if (ws.spmCount === 0) {
55
+ console.log(` ${fmtDate(ws.weekStart)} -`);
56
+ continue;
57
+ }
58
+ const avg = ws.spmSum / ws.spmCount;
59
+ const arrow = trendArrow(prevSPM, avg);
60
+ console.log(` ${fmtDate(ws.weekStart)} ${arrow} ${avg.toFixed(1).padStart(4)}`);
61
+ prevSPM = avg;
62
+ }
63
+ }
64
+
65
+ function printHRTrend(summaries: WeekSummary[]): void {
66
+ console.log("Avg Heart Rate (bpm):");
67
+ let hasAny = false;
68
+ let prevHR = 0;
69
+ for (const ws of summaries) {
70
+ if (ws.hrCount === 0) {
71
+ console.log(` ${fmtDate(ws.weekStart)} -`);
72
+ continue;
73
+ }
74
+ hasAny = true;
75
+ const avg = ws.hrSum / ws.hrCount;
76
+ const arrow = trendArrow(prevHR, avg);
77
+ console.log(` ${fmtDate(ws.weekStart)} ${arrow} ${avg.toFixed(1).padStart(5)}`);
78
+ prevHR = avg;
79
+ }
80
+ if (!hasAny) {
81
+ console.log(" No heart rate data available.");
82
+ }
83
+ }
84
+
85
+ export function registerTrend(program: Command): void {
86
+ program
87
+ .command("trend")
88
+ .description("Show training trends over time")
89
+ .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
+ }
96
+
97
+ const weeks = parseInt(opts.weeks, 10);
98
+ if (Number.isNaN(weeks) || weeks < 1) {
99
+ console.error("Error: --weeks must be a positive integer.");
100
+ process.exit(1);
101
+ }
102
+ const summaries = buildWeekSummaries(workouts, new Date(), weeks);
103
+
104
+ printVolumeTrend(summaries);
105
+ console.log();
106
+ printPaceTrend(summaries);
107
+ console.log();
108
+ printSPMTrend(summaries);
109
+ console.log();
110
+ printHRTrend(summaries);
111
+ });
112
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defaultConfig, parseGoalDate } from "./config.ts";
3
+
4
+ describe("parseGoalDate", () => {
5
+ test("parses valid date string", () => {
6
+ const d = parseGoalDate("2026-03-07");
7
+ expect(d.getFullYear()).toBe(2026);
8
+ expect(d.getMonth()).toBe(2); // March = 2
9
+ expect(d.getDate()).toBe(7);
10
+ });
11
+
12
+ test("throws on empty string", () => {
13
+ expect(() => parseGoalDate("")).toThrow("Invalid date");
14
+ });
15
+
16
+ test("throws on malformed string", () => {
17
+ expect(() => parseGoalDate("not-a-date")).toThrow("Invalid date");
18
+ });
19
+ });
20
+
21
+ describe("defaultConfig", () => {
22
+ test("returns expected defaults", () => {
23
+ const cfg = defaultConfig();
24
+ expect(cfg.api.base_url).toBe("https://log.concept2.com");
25
+ expect(cfg.api.token).toBe("");
26
+ expect(cfg.sync.machine_type).toBe("rower");
27
+ expect(cfg.goal.target_meters).toBe(1_000_000);
28
+ expect(cfg.goal.start_date).toBe("");
29
+ expect(cfg.goal.end_date).toBe("");
30
+ expect(cfg.display.date_format).toBe("%m/%d");
31
+ });
32
+ });
package/src/config.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface Config {
6
+ api: { base_url: string; token: string };
7
+ sync: { last_sync?: string; machine_type: string };
8
+ goal: { target_meters: number; start_date: string; end_date: string };
9
+ display: { date_format: string };
10
+ }
11
+
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
+ export function configDir(): string {
22
+ return join(homedir(), ".config", "c2cli");
23
+ }
24
+
25
+ export function dataDir(): string {
26
+ return join(configDir(), "data");
27
+ }
28
+
29
+ export async function ensureDirs(): Promise<void> {
30
+ await mkdir(join(dataDir(), "strokes"), { recursive: true });
31
+ }
32
+
33
+ export async function loadConfig(): Promise<Config> {
34
+ const path = join(configDir(), "config.json");
35
+ const defaults = defaultConfig();
36
+ try {
37
+ const text = await readFile(path, "utf-8");
38
+ const parsed = JSON.parse(text) as Partial<Config>;
39
+ return {
40
+ api: { ...defaults.api, ...parsed.api },
41
+ sync: { ...defaults.sync, ...parsed.sync },
42
+ goal: { ...defaults.goal, ...parsed.goal },
43
+ display: { ...defaults.display, ...parsed.display },
44
+ };
45
+ } catch (err: unknown) {
46
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
47
+ return defaults;
48
+ }
49
+ throw err;
50
+ }
51
+ }
52
+
53
+ export async function saveConfig(cfg: Config): Promise<void> {
54
+ const path = join(configDir(), "config.json");
55
+ await mkdir(configDir(), { recursive: true });
56
+ const text = JSON.stringify(cfg, null, 2);
57
+ await writeFile(path, `${text}\n`, "utf-8");
58
+ }
59
+
60
+ export function parseGoalDate(s: string): Date {
61
+ const d = new Date(`${s}T00:00:00`);
62
+ if (Number.isNaN(d.getTime())) throw new Error(`Invalid date: ${s}`);
63
+ return d;
64
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ formatMeters,
4
+ formatMetersPerWeek,
5
+ formatPercent,
6
+ paceArrow,
7
+ sparkBar,
8
+ trendArrow,
9
+ } from "./display.ts";
10
+
11
+ describe("formatMeters", () => {
12
+ test.each([
13
+ [0, "0"],
14
+ [500, "500"],
15
+ [1000, "1,000"],
16
+ [12345, "12,345"],
17
+ [1000000, "1,000,000"],
18
+ ])("formatMeters(%i) = %s", (input, expected) => {
19
+ expect(formatMeters(input)).toBe(expected);
20
+ });
21
+ });
22
+
23
+ describe("formatPercent", () => {
24
+ test.each([
25
+ [0, "0.0%"],
26
+ [0.5, "50.0%"],
27
+ [1.0, "100.0%"],
28
+ [0.1234, "12.3%"],
29
+ [0.131, "13.1%"],
30
+ ])("formatPercent(%f) = %s", (input, expected) => {
31
+ expect(formatPercent(input)).toBe(expected);
32
+ });
33
+ });
34
+
35
+ describe("formatMetersPerWeek", () => {
36
+ test("formats with unit", () => {
37
+ expect(formatMetersPerWeek(20212)).toBe("20,212m/week");
38
+ });
39
+ });
40
+
41
+ describe("sparkBar", () => {
42
+ test("returns empty string when max is 0", () => {
43
+ expect(sparkBar(100, 0)).toBe("");
44
+ });
45
+
46
+ test("returns full bar for max value", () => {
47
+ const bar = sparkBar(100, 100);
48
+ expect(bar).toBe("\u2588".repeat(20));
49
+ });
50
+
51
+ test("returns half bar for 50%", () => {
52
+ const bar = sparkBar(50, 100);
53
+ expect(bar.length).toBe(20);
54
+ expect(bar).toBe("\u2588".repeat(10) + "\u2591".repeat(10));
55
+ });
56
+ });
57
+
58
+ describe("trendArrow", () => {
59
+ test("returns space when prev is 0", () => {
60
+ expect(trendArrow(0, 100)).toBe(" ");
61
+ });
62
+
63
+ test("returns up arrow for increase", () => {
64
+ expect(trendArrow(100, 110)).toBe("\u2191");
65
+ });
66
+
67
+ test("returns down arrow for decrease", () => {
68
+ expect(trendArrow(100, 90)).toBe("\u2193");
69
+ });
70
+
71
+ test("returns right arrow for stable", () => {
72
+ expect(trendArrow(100, 101)).toBe("\u2192");
73
+ });
74
+ });
75
+
76
+ describe("paceArrow", () => {
77
+ test("reversed: lower pace shows up arrow (improvement)", () => {
78
+ expect(paceArrow(180, 170)).toBe("\u2191");
79
+ });
80
+
81
+ test("reversed: higher pace shows down arrow", () => {
82
+ expect(paceArrow(170, 180)).toBe("\u2193");
83
+ });
84
+ });
package/src/display.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { Workout } from "./models.ts";
2
+ import { pace500m, parsedDate } from "./models.ts";
3
+
4
+ export function formatMeters(m: number): string {
5
+ return m.toLocaleString("en-US");
6
+ }
7
+
8
+ export function formatPercent(ratio: number): string {
9
+ return `${(ratio * 100).toFixed(1)}%`;
10
+ }
11
+
12
+ export function formatMetersPerWeek(m: number): string {
13
+ return `${formatMeters(m)}m/week`;
14
+ }
15
+
16
+ export function formatDate(d: Date, fmt: string): string {
17
+ // Go-style reference time: "01/02" means month/day
18
+ if (fmt === "01/02" || fmt === "%m/%d") {
19
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
20
+ const dd = String(d.getDate()).padStart(2, "0");
21
+ return `${mm}/${dd}`;
22
+ }
23
+ if (fmt === "%Y-%m-%d") {
24
+ const y = d.getFullYear();
25
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
26
+ const dd = String(d.getDate()).padStart(2, "0");
27
+ return `${y}-${mm}-${dd}`;
28
+ }
29
+ // Fallback: MM/DD
30
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
31
+ const dd = String(d.getDate()).padStart(2, "0");
32
+ return `${mm}/${dd}`;
33
+ }
34
+
35
+ export function formatWorkoutLine(w: Workout, dateFormat: string): string {
36
+ const d = parsedDate(w);
37
+ const dateStr = formatDate(d, dateFormat);
38
+ const distance = `${formatMeters(w.distance)}m`;
39
+ const pace = pace500m(w);
40
+ const spm = w.stroke_rate ? `${w.stroke_rate}spm` : "-";
41
+ const hr = w.heart_rate?.average && w.heart_rate.average > 0 ? `${w.heart_rate.average}bpm` : "-";
42
+ const df = w.drag_factor ? `${w.drag_factor}df` : "-";
43
+
44
+ return `${dateStr} ${distance.padStart(7)} ${w.time_formatted.padStart(8)} ${pace.padStart(7)}/500m ${spm.padStart(5)} ${hr.padStart(6)} ${df.padStart(4)}`;
45
+ }
46
+
47
+ export function sparkBar(value: number, max: number): string {
48
+ if (max === 0) return "";
49
+ const BAR_WIDTH = 20;
50
+ const filled = Math.round((value / max) * BAR_WIDTH);
51
+ return "\u2588".repeat(filled) + "\u2591".repeat(BAR_WIDTH - filled);
52
+ }
53
+
54
+ const TREND_THRESHOLD = 0.02;
55
+
56
+ export function trendArrow(prev: number, curr: number): string {
57
+ if (prev === 0) return " ";
58
+ const diff = (curr - prev) / prev;
59
+ if (diff > TREND_THRESHOLD) return "\u2191";
60
+ if (diff < -TREND_THRESHOLD) return "\u2193";
61
+ return "\u2192";
62
+ }
63
+
64
+ export function paceArrow(prev: number, curr: number): string {
65
+ if (prev === 0) return " ";
66
+ return trendArrow(curr, prev); // reversed: lower pace is better
67
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { escapeCSV, filterByDate } from "./commands/export.ts";
3
+ import type { Workout } from "./models.ts";
4
+
5
+ function makeWorkout(id: number, date: string, distance: number): Workout {
6
+ return {
7
+ id,
8
+ user_id: 1,
9
+ date,
10
+ distance,
11
+ type: "rower",
12
+ time: Math.round(distance * 3.5),
13
+ time_formatted: "0:00.0",
14
+ };
15
+ }
16
+
17
+ describe("escapeCSV", () => {
18
+ test("passes through plain string", () => {
19
+ expect(escapeCSV("hello")).toBe("hello");
20
+ });
21
+
22
+ test("quotes string with commas", () => {
23
+ expect(escapeCSV("a,b")).toBe('"a,b"');
24
+ });
25
+
26
+ test("escapes double quotes", () => {
27
+ expect(escapeCSV('say "hi"')).toBe('"say ""hi"""');
28
+ });
29
+
30
+ test("quotes string with newlines", () => {
31
+ expect(escapeCSV("line1\nline2")).toBe('"line1\nline2"');
32
+ });
33
+
34
+ test("handles empty string", () => {
35
+ expect(escapeCSV("")).toBe("");
36
+ });
37
+ });
38
+
39
+ describe("filterByDate", () => {
40
+ const workouts = [
41
+ makeWorkout(1, "2026-01-15 10:00:00", 5000),
42
+ makeWorkout(2, "2026-02-15 10:00:00", 5000),
43
+ makeWorkout(3, "2026-03-15 10:00:00", 5000),
44
+ ];
45
+
46
+ test("filters with both from and to", () => {
47
+ const result = filterByDate(workouts, "2026-02-01", "2026-02-28");
48
+ expect(result).toHaveLength(1);
49
+ expect(result[0]!.id).toBe(2);
50
+ });
51
+
52
+ test("filters with from only", () => {
53
+ const result = filterByDate(workouts, "2026-02-01", "");
54
+ expect(result).toHaveLength(2);
55
+ expect(result[0]!.id).toBe(2);
56
+ expect(result[1]!.id).toBe(3);
57
+ });
58
+
59
+ test("filters with to only", () => {
60
+ const result = filterByDate(workouts, "", "2026-02-28");
61
+ expect(result).toHaveLength(2);
62
+ expect(result[0]!.id).toBe(1);
63
+ expect(result[1]!.id).toBe(2);
64
+ });
65
+
66
+ test("returns all when no bounds", () => {
67
+ const result = filterByDate(workouts, "", "");
68
+ expect(result).toHaveLength(3);
69
+ });
70
+ });
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import pkg from "../package.json";
5
+ import { registerExport } from "./commands/export.ts";
6
+ import { registerLog } from "./commands/log.ts";
7
+ import { registerReport } from "./commands/report.ts";
8
+ import { registerSetup } from "./commands/setup.ts";
9
+ import { registerStatus } from "./commands/status.ts";
10
+ import { registerSync } from "./commands/sync.ts";
11
+ import { registerTrend } from "./commands/trend.ts";
12
+
13
+ const program = new Command()
14
+ .name("c2")
15
+ .description("Concept2 Logbook CLI")
16
+ .version(pkg.version, "-v, --version");
17
+
18
+ registerSetup(program);
19
+ registerSync(program);
20
+ registerLog(program);
21
+ registerStatus(program);
22
+ registerTrend(program);
23
+ registerExport(program);
24
+ registerReport(program);
25
+
26
+ program.parseAsync(process.argv).catch((err: Error) => {
27
+ console.error(`Error: ${err.message}`);
28
+ process.exit(1);
29
+ });