@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,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Workout } from "./models.ts";
3
+ import { calendarDay, pace500m, pace500mSeconds, parsedDate } from "./models.ts";
4
+
5
+ function makeWorkout(overrides: Partial<Workout> = {}): Workout {
6
+ return {
7
+ id: 1,
8
+ user_id: 1,
9
+ date: "2026-03-07 09:21:00",
10
+ distance: 5500,
11
+ type: "rower",
12
+ time: 19122,
13
+ time_formatted: "31:52.2",
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ describe("parsedDate", () => {
19
+ test("parses workout date string", () => {
20
+ const w = makeWorkout({ date: "2026-03-07 09:21:00" });
21
+ const d = parsedDate(w);
22
+ expect(d.getFullYear()).toBe(2026);
23
+ expect(d.getMonth()).toBe(2); // March = 2 (0-indexed)
24
+ expect(d.getDate()).toBe(7);
25
+ expect(d.getHours()).toBe(9);
26
+ expect(d.getMinutes()).toBe(21);
27
+ });
28
+ });
29
+
30
+ describe("calendarDay", () => {
31
+ test("extracts date portion", () => {
32
+ const w = makeWorkout({ date: "2026-03-07 09:21:00" });
33
+ expect(calendarDay(w)).toBe("2026-03-07");
34
+ });
35
+ });
36
+
37
+ describe("pace500mSeconds", () => {
38
+ test("computes pace for normal workout", () => {
39
+ const w = makeWorkout({ distance: 5500, time: 19122 });
40
+ const pace = pace500mSeconds(w);
41
+ // 19122 / 10 * 500 / 5500 = 173.836...
42
+ expect(pace).toBeCloseTo(173.836, 2);
43
+ });
44
+
45
+ test("returns 0 for zero distance", () => {
46
+ const w = makeWorkout({ distance: 0 });
47
+ expect(pace500mSeconds(w)).toBe(0);
48
+ });
49
+
50
+ test("returns 0 for zero time", () => {
51
+ const w = makeWorkout({ time: 0 });
52
+ expect(pace500mSeconds(w)).toBe(0);
53
+ });
54
+ });
55
+
56
+ describe("pace500m", () => {
57
+ test("formats pace as M:SS.S", () => {
58
+ const w = makeWorkout({ distance: 5500, time: 19122 });
59
+ expect(pace500m(w)).toBe("2:53.8");
60
+ });
61
+
62
+ test("returns dash for zero values", () => {
63
+ const w = makeWorkout({ distance: 0, time: 0 });
64
+ expect(pace500m(w)).toBe("-");
65
+ });
66
+
67
+ test("formats 3+ minute pace correctly", () => {
68
+ const w = makeWorkout({ distance: 1000, time: 3706 });
69
+ // 3706 / 10 * 500 / 1000 = 185.3
70
+ expect(pace500m(w)).toBe("3:05.3");
71
+ });
72
+ });
package/src/models.ts ADDED
@@ -0,0 +1,93 @@
1
+ export interface HeartRate {
2
+ average?: number;
3
+ min?: number;
4
+ max?: number;
5
+ }
6
+
7
+ /** A single result from the Concept2 Logbook API. Time is in tenths of a second. */
8
+ export interface Workout {
9
+ id: number;
10
+ user_id: number;
11
+ date: string; // "2026-03-02 17:41:00"
12
+ timezone?: string;
13
+ distance: number;
14
+ type: string; // "rower"
15
+ time: number; // tenths of a second
16
+ time_formatted: string;
17
+ workout_type?: string;
18
+ source?: string;
19
+ weight_class?: string;
20
+ stroke_rate?: number;
21
+ stroke_count?: number;
22
+ calories_total?: number;
23
+ drag_factor?: number;
24
+ heart_rate?: HeartRate;
25
+ stroke_data?: boolean;
26
+ comments?: string;
27
+ }
28
+
29
+ export interface StrokeData {
30
+ t?: number; // cumulative time
31
+ d?: number; // cumulative distance
32
+ p?: number; // pace
33
+ spm?: number; // strokes per minute
34
+ hr?: number; // heart rate
35
+ }
36
+
37
+ export interface UserProfile {
38
+ id: number;
39
+ username: string;
40
+ first_name?: string;
41
+ last_name?: string;
42
+ email?: string;
43
+ }
44
+
45
+ export interface UserResponse {
46
+ data: UserProfile;
47
+ }
48
+
49
+ export interface Pagination {
50
+ total: number;
51
+ count: number;
52
+ per_page: number;
53
+ current_page: number;
54
+ total_pages: number;
55
+ }
56
+
57
+ export interface ResultsMeta {
58
+ pagination?: Pagination;
59
+ }
60
+
61
+ export interface ResultsResponse {
62
+ data: Workout[];
63
+ meta?: ResultsMeta;
64
+ }
65
+
66
+ /** API wraps stroke data in {"data": [...]} */
67
+ export interface StrokeDataResponse {
68
+ data: StrokeData[];
69
+ }
70
+
71
+ const TENTHS_PER_SECOND = 10;
72
+ const PACE_DISTANCE = 500;
73
+
74
+ export function parsedDate(w: Workout): Date {
75
+ return new Date(w.date.replace(" ", "T"));
76
+ }
77
+
78
+ export function calendarDay(w: Workout): string {
79
+ return w.date.slice(0, 10);
80
+ }
81
+
82
+ export function pace500mSeconds(w: Workout): number {
83
+ if (w.distance === 0 || w.time === 0) return 0;
84
+ return (w.time / TENTHS_PER_SECOND) * (PACE_DISTANCE / w.distance);
85
+ }
86
+
87
+ export function pace500m(w: Workout): string {
88
+ const secs = pace500mSeconds(w);
89
+ if (secs === 0) return "-";
90
+ const mins = Math.floor(secs / 60);
91
+ const rem = secs - mins * 60;
92
+ return `${mins}:${rem.toFixed(1).padStart(4, "0")}`;
93
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Workout } from "./models.ts";
3
+ import { groupIntoSessions, sessionCount } from "./sessions.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("groupIntoSessions", () => {
18
+ test("groups 3 workouts on same day into 1 session", () => {
19
+ const workouts = [
20
+ makeWorkout(1, "2026-03-07 09:21:00", 1000),
21
+ makeWorkout(2, "2026-03-07 09:45:00", 2500),
22
+ makeWorkout(3, "2026-03-07 09:53:00", 1000),
23
+ ];
24
+ const sessions = groupIntoSessions(workouts);
25
+ expect(sessions).toHaveLength(1);
26
+ expect(sessions[0]!.date).toBe("2026-03-07");
27
+ expect(sessions[0]!.totalDistance).toBe(4500);
28
+ expect(sessions[0]!.workouts).toHaveLength(3);
29
+ });
30
+
31
+ test("keeps different days as separate sessions", () => {
32
+ const workouts = [
33
+ makeWorkout(1, "2026-03-05 10:00:00", 5000),
34
+ makeWorkout(2, "2026-03-07 09:00:00", 5000),
35
+ ];
36
+ const sessions = groupIntoSessions(workouts);
37
+ expect(sessions).toHaveLength(2);
38
+ expect(sessions[0]!.date).toBe("2026-03-05");
39
+ expect(sessions[1]!.date).toBe("2026-03-07");
40
+ });
41
+
42
+ test("sorts sessions by date", () => {
43
+ const workouts = [
44
+ makeWorkout(2, "2026-03-07 09:00:00", 5000),
45
+ makeWorkout(1, "2026-03-05 10:00:00", 5000),
46
+ ];
47
+ const sessions = groupIntoSessions(workouts);
48
+ expect(sessions[0]!.date).toBe("2026-03-05");
49
+ expect(sessions[1]!.date).toBe("2026-03-07");
50
+ });
51
+
52
+ test("single workout produces single session", () => {
53
+ const workouts = [makeWorkout(1, "2026-03-07 09:00:00", 5500)];
54
+ const sessions = groupIntoSessions(workouts);
55
+ expect(sessions).toHaveLength(1);
56
+ expect(sessions[0]!.totalDistance).toBe(5500);
57
+ expect(sessions[0]!.workouts).toHaveLength(1);
58
+ });
59
+
60
+ test("empty input returns empty array", () => {
61
+ expect(groupIntoSessions([])).toEqual([]);
62
+ });
63
+ });
64
+
65
+ describe("sessionCount", () => {
66
+ test("counts unique calendar days", () => {
67
+ const workouts = [
68
+ makeWorkout(1, "2026-03-07 09:21:00", 1000),
69
+ makeWorkout(2, "2026-03-07 09:45:00", 2500),
70
+ makeWorkout(3, "2026-03-07 09:53:00", 1000),
71
+ makeWorkout(4, "2026-03-05 14:00:00", 5000),
72
+ ];
73
+ expect(sessionCount(workouts)).toBe(2);
74
+ });
75
+
76
+ test("returns 0 for empty list", () => {
77
+ expect(sessionCount([])).toBe(0);
78
+ });
79
+ });
@@ -0,0 +1,38 @@
1
+ import type { Workout } from "./models.ts";
2
+ import { calendarDay } from "./models.ts";
3
+
4
+ export interface Session {
5
+ date: string; // "2026-03-07"
6
+ workouts: Workout[];
7
+ totalDistance: number;
8
+ totalTime: number; // tenths of seconds
9
+ }
10
+
11
+ export function groupIntoSessions(workouts: Workout[]): Session[] {
12
+ const byDay = new Map<string, Workout[]>();
13
+
14
+ for (const w of workouts) {
15
+ const day = calendarDay(w);
16
+ const existing = byDay.get(day);
17
+ if (existing) {
18
+ existing.push(w);
19
+ } else {
20
+ byDay.set(day, [w]);
21
+ }
22
+ }
23
+
24
+ return Array.from(byDay.entries())
25
+ .map(([date, ws]) => ({
26
+ date,
27
+ workouts: ws.sort((a, b) => a.date.localeCompare(b.date)),
28
+ totalDistance: ws.reduce((sum, w) => sum + w.distance, 0),
29
+ totalTime: ws.reduce((sum, w) => sum + w.time, 0),
30
+ }))
31
+ .sort((a, b) => a.date.localeCompare(b.date));
32
+ }
33
+
34
+ /** Count unique calendar days (sessions) in a list of workouts. */
35
+ export function sessionCount(workouts: Workout[]): number {
36
+ const days = new Set(workouts.map(calendarDay));
37
+ return days.size;
38
+ }
@@ -0,0 +1,161 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Config } from "./config.ts";
3
+ import { defaultConfig } from "./config.ts";
4
+ import type { Workout } from "./models.ts";
5
+ import { buildWeekSummaries, computeGoalProgress, mondayOf, workoutsInRange } from "./stats.ts";
6
+
7
+ function makeWorkout(id: number, date: string, distance: number, time?: number): Workout {
8
+ return {
9
+ id,
10
+ user_id: 1,
11
+ date,
12
+ distance,
13
+ type: "rower",
14
+ time: time ?? Math.round(distance * 3.5),
15
+ time_formatted: "0:00.0",
16
+ };
17
+ }
18
+
19
+ function makeGoalConfig(overrides: Partial<Config["goal"]> = {}): Config {
20
+ const cfg = defaultConfig();
21
+ cfg.goal = {
22
+ ...cfg.goal,
23
+ start_date: "2026-01-01",
24
+ end_date: "2026-12-31",
25
+ target_meters: 1_000_000,
26
+ ...overrides,
27
+ };
28
+ return cfg;
29
+ }
30
+
31
+ describe("mondayOf", () => {
32
+ test("monday returns same date", () => {
33
+ const d = new Date(2026, 2, 2); // Mon Mar 2
34
+ const m = mondayOf(d);
35
+ expect(m.getDay()).toBe(1); // Monday
36
+ expect(m.getDate()).toBe(2);
37
+ });
38
+
39
+ test("wednesday returns previous monday", () => {
40
+ const d = new Date(2026, 2, 4); // Wed Mar 4
41
+ const m = mondayOf(d);
42
+ expect(m.getDay()).toBe(1);
43
+ expect(m.getDate()).toBe(2);
44
+ });
45
+
46
+ test("sunday returns previous monday", () => {
47
+ const d = new Date(2026, 2, 8); // Sun Mar 8
48
+ const m = mondayOf(d);
49
+ expect(m.getDay()).toBe(1);
50
+ expect(m.getDate()).toBe(2);
51
+ });
52
+
53
+ test("handles month boundary", () => {
54
+ const d = new Date(2026, 2, 1); // Mar 1
55
+ const m = mondayOf(d);
56
+ expect(m.getDay()).toBe(1);
57
+ expect(m.getTime()).toBeLessThanOrEqual(d.getTime());
58
+ });
59
+ });
60
+
61
+ describe("workoutsInRange", () => {
62
+ test("filters workouts within date range", () => {
63
+ const workouts = [
64
+ makeWorkout(1, "2026-01-15 10:00:00", 5000),
65
+ makeWorkout(2, "2026-02-15 10:00:00", 5000),
66
+ makeWorkout(3, "2026-03-15 10:00:00", 5000),
67
+ ];
68
+ const from = new Date(2026, 1, 1); // Feb 1
69
+ const to = new Date(2026, 2, 1); // Mar 1
70
+ const result = workoutsInRange(workouts, from, to);
71
+ expect(result).toHaveLength(1);
72
+ expect(result[0]!.id).toBe(2);
73
+ });
74
+
75
+ test("returns empty for no matches", () => {
76
+ const workouts = [makeWorkout(1, "2026-06-15 10:00:00", 5000)];
77
+ const from = new Date(2026, 0, 1);
78
+ const to = new Date(2026, 1, 1);
79
+ expect(workoutsInRange(workouts, from, to)).toHaveLength(0);
80
+ });
81
+ });
82
+
83
+ describe("buildWeekSummaries", () => {
84
+ test("buckets workouts into correct weeks", () => {
85
+ const now = new Date(2026, 2, 7); // Sat Mar 7
86
+ const workouts = [
87
+ makeWorkout(1, "2026-03-02 10:00:00", 5000), // Mon of current week
88
+ makeWorkout(2, "2026-02-23 10:00:00", 6000), // Previous week
89
+ ];
90
+ const summaries = buildWeekSummaries(workouts, now, 2);
91
+ expect(summaries).toHaveLength(2);
92
+ expect(summaries[0]!.meters).toBe(6000);
93
+ expect(summaries[1]!.meters).toBe(5000);
94
+ });
95
+
96
+ test("counts sessions as unique days", () => {
97
+ const now = new Date(2026, 2, 7);
98
+ const workouts = [
99
+ makeWorkout(1, "2026-03-02 09:00:00", 1000),
100
+ makeWorkout(2, "2026-03-02 10:00:00", 2000), // same day
101
+ makeWorkout(3, "2026-03-04 10:00:00", 3000), // different day
102
+ ];
103
+ const summaries = buildWeekSummaries(workouts, now, 1);
104
+ expect(summaries[0]!.meters).toBe(6000);
105
+ expect(summaries[0]!.sessions).toBe(2); // 2 unique days
106
+ });
107
+
108
+ test("returns empty summaries for no workouts", () => {
109
+ const now = new Date(2026, 2, 7);
110
+ const summaries = buildWeekSummaries([], now, 4);
111
+ expect(summaries).toHaveLength(4);
112
+ expect(summaries.every((s) => s.meters === 0)).toBe(true);
113
+ });
114
+ });
115
+
116
+ describe("computeGoalProgress", () => {
117
+ test("computes progress for mid-season", () => {
118
+ const cfg = makeGoalConfig();
119
+ const workouts = [
120
+ makeWorkout(1, "2026-03-01 10:00:00", 100_000),
121
+ makeWorkout(2, "2026-02-01 10:00:00", 100_000),
122
+ ];
123
+ const now = new Date(2026, 2, 7); // Mar 7
124
+ const goal = computeGoalProgress(workouts, cfg, now);
125
+ expect(goal.totalMeters).toBe(200_000);
126
+ expect(goal.target).toBe(1_000_000);
127
+ expect(goal.progress).toBeCloseTo(0.2, 2);
128
+ expect(goal.remainingMeters).toBe(800_000);
129
+ expect(goal.weeksElapsed).toBeGreaterThan(0);
130
+ });
131
+
132
+ test("clamps remainingMeters to 0 when goal exceeded", () => {
133
+ const cfg = makeGoalConfig({ target_meters: 100_000 });
134
+ const workouts = [makeWorkout(1, "2026-03-01 10:00:00", 150_000)];
135
+ const now = new Date(2026, 2, 7);
136
+ const goal = computeGoalProgress(workouts, cfg, now);
137
+ expect(goal.remainingMeters).toBe(0);
138
+ expect(goal.progress).toBeGreaterThan(1);
139
+ });
140
+
141
+ test("excludes workouts outside goal date range", () => {
142
+ const cfg = makeGoalConfig();
143
+ const workouts = [
144
+ makeWorkout(1, "2025-12-01 10:00:00", 50_000), // before start
145
+ makeWorkout(2, "2026-03-01 10:00:00", 100_000), // in range
146
+ makeWorkout(3, "2027-02-01 10:00:00", 50_000), // after end
147
+ ];
148
+ const now = new Date(2026, 2, 7);
149
+ const goal = computeGoalProgress(workouts, cfg, now);
150
+ expect(goal.totalMeters).toBe(100_000);
151
+ });
152
+
153
+ test("before start date: weeksElapsed is 0", () => {
154
+ const cfg = makeGoalConfig({ start_date: "2026-06-01" });
155
+ const workouts: Workout[] = [];
156
+ const now = new Date(2026, 2, 7); // before start
157
+ const goal = computeGoalProgress(workouts, cfg, now);
158
+ expect(goal.weeksElapsed).toBe(0);
159
+ expect(goal.currentAvgPace).toBe(0);
160
+ });
161
+ });
package/src/stats.ts ADDED
@@ -0,0 +1,150 @@
1
+ import type { Config } from "./config.ts";
2
+ import { parseGoalDate } from "./config.ts";
3
+ import type { Workout } from "./models.ts";
4
+ import { calendarDay, pace500mSeconds, parsedDate } from "./models.ts";
5
+
6
+ export interface WeekSummary {
7
+ weekStart: Date;
8
+ meters: number;
9
+ sessions: number;
10
+ paceSum: number;
11
+ paceCount: number;
12
+ spmSum: number;
13
+ spmCount: number;
14
+ hrSum: number;
15
+ hrCount: number;
16
+ }
17
+
18
+ export interface GoalProgress {
19
+ target: number;
20
+ totalMeters: number;
21
+ progress: number; // ratio 0–1
22
+ weeksElapsed: number;
23
+ totalWeeks: number;
24
+ remainingMeters: number;
25
+ remainingWeeks: number;
26
+ requiredPace: number; // meters/week needed going forward
27
+ currentAvgPace: number; // meters/week so far
28
+ onPace: boolean;
29
+ }
30
+
31
+ export function mondayOf(t: Date): Date {
32
+ const d = new Date(t.getFullYear(), t.getMonth(), t.getDate());
33
+ const offset = (d.getDay() + 6) % 7;
34
+ d.setDate(d.getDate() - offset);
35
+ return d;
36
+ }
37
+
38
+ export function workoutsInRange(workouts: Workout[], from: Date, to: Date): Workout[] {
39
+ return workouts.filter((w) => {
40
+ const t = parsedDate(w);
41
+ return t >= from && t < to;
42
+ });
43
+ }
44
+
45
+ export function buildWeekSummaries(workouts: Workout[], now: Date, weeks: number): WeekSummary[] {
46
+ const thisMonday = mondayOf(now);
47
+ const cutoff = new Date(thisMonday);
48
+ cutoff.setDate(cutoff.getDate() - (weeks - 1) * 7);
49
+
50
+ const summaries: WeekSummary[] = [];
51
+ for (let i = 0; i < weeks; i++) {
52
+ const ws = new Date(thisMonday);
53
+ ws.setDate(ws.getDate() - (weeks - 1 - i) * 7);
54
+ summaries.push({
55
+ weekStart: ws,
56
+ meters: 0,
57
+ sessions: 0,
58
+ paceSum: 0,
59
+ paceCount: 0,
60
+ spmSum: 0,
61
+ spmCount: 0,
62
+ hrSum: 0,
63
+ hrCount: 0,
64
+ });
65
+ }
66
+
67
+ const daysByWeek = new Map<number, Set<string>>();
68
+
69
+ for (const w of workouts) {
70
+ const t = new Date(w.date.replace(" ", "T"));
71
+ if (t < cutoff || t > now) continue;
72
+
73
+ const monday = mondayOf(t);
74
+ const idx = Math.floor((monday.getTime() - cutoff.getTime()) / (1000 * 60 * 60 * 24 * 7));
75
+ if (idx < 0 || idx >= weeks) continue;
76
+
77
+ const ws = summaries[idx]!;
78
+ ws.meters += w.distance;
79
+
80
+ if (!daysByWeek.has(idx)) daysByWeek.set(idx, new Set());
81
+ daysByWeek.get(idx)!.add(calendarDay(w));
82
+
83
+ const pace = pace500mSeconds(w);
84
+ if (pace > 0) {
85
+ ws.paceSum += pace;
86
+ ws.paceCount++;
87
+ }
88
+ if (w.stroke_rate && w.stroke_rate > 0) {
89
+ ws.spmSum += w.stroke_rate;
90
+ ws.spmCount++;
91
+ }
92
+ if (w.heart_rate?.average && w.heart_rate.average > 0) {
93
+ ws.hrSum += w.heart_rate.average;
94
+ ws.hrCount++;
95
+ }
96
+ }
97
+
98
+ for (const [idx, days] of daysByWeek) {
99
+ summaries[idx]!.sessions = days.size;
100
+ }
101
+
102
+ return summaries;
103
+ }
104
+
105
+ export function computeGoalProgress(workouts: Workout[], cfg: Config, now?: Date): GoalProgress {
106
+ const target = cfg.goal.target_meters;
107
+ const start = parseGoalDate(cfg.goal.start_date);
108
+ const end = parseGoalDate(cfg.goal.end_date);
109
+ const today = now ?? new Date();
110
+
111
+ let totalMeters = 0;
112
+ for (const w of workouts) {
113
+ const t = parsedDate(w);
114
+ if (t >= start && t <= end) {
115
+ totalMeters += w.distance;
116
+ }
117
+ }
118
+
119
+ const progress = totalMeters / target;
120
+ const totalDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24);
121
+ const totalWeeks = Math.ceil(totalDays / 7);
122
+
123
+ let weeksElapsed = 0;
124
+ if (today > start) {
125
+ weeksElapsed = Math.floor((today.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 7));
126
+ }
127
+
128
+ let remainingMeters = target - totalMeters;
129
+ if (remainingMeters < 0) remainingMeters = 0;
130
+ let remainingWeeks = totalWeeks - weeksElapsed;
131
+ if (remainingWeeks < 1) remainingWeeks = 1;
132
+ const requiredPace = Math.floor(remainingMeters / remainingWeeks);
133
+
134
+ const currentAvgPace = weeksElapsed > 0 ? Math.floor(totalMeters / weeksElapsed) : 0;
135
+ const targetWeekly = target / totalWeeks;
136
+ const onPace = currentAvgPace >= targetWeekly;
137
+
138
+ return {
139
+ target,
140
+ totalMeters,
141
+ progress,
142
+ weeksElapsed,
143
+ totalWeeks,
144
+ remainingMeters,
145
+ remainingWeeks,
146
+ requiredPace,
147
+ currentAvgPace,
148
+ onPace,
149
+ };
150
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { appendFile, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { dataDir } from "./config.ts";
4
+ import type { StrokeData, Workout } from "./models.ts";
5
+
6
+ function workoutsPath(): string {
7
+ return join(dataDir(), "workouts.jsonl");
8
+ }
9
+
10
+ function strokesPath(workoutId: number): string {
11
+ return join(dataDir(), "strokes", `${workoutId}.jsonl`);
12
+ }
13
+
14
+ export async function readWorkouts(): Promise<Workout[]> {
15
+ try {
16
+ const text = await readFile(workoutsPath(), "utf-8");
17
+ return text
18
+ .split("\n")
19
+ .filter((line) => line.trim() !== "")
20
+ .map((line) => JSON.parse(line) as Workout);
21
+ } catch (err: unknown) {
22
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ export async function appendWorkouts(newWorkouts: Workout[]): Promise<number> {
28
+ const existing = await readWorkouts();
29
+ const seen = new Set(existing.map((w) => w.id));
30
+
31
+ const toWrite = newWorkouts.filter((w) => !seen.has(w.id));
32
+ if (toWrite.length === 0) return 0;
33
+
34
+ const lines = `${toWrite.map((w) => JSON.stringify(w)).join("\n")}\n`;
35
+ await appendFile(workoutsPath(), lines, "utf-8");
36
+ return toWrite.length;
37
+ }
38
+
39
+ export async function workoutCount(): Promise<number> {
40
+ try {
41
+ const text = await readFile(workoutsPath(), "utf-8");
42
+ return text.split("\n").filter((line) => line.trim() !== "").length;
43
+ } catch (err: unknown) {
44
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0;
45
+ throw err;
46
+ }
47
+ }
48
+
49
+ export async function hasStrokeData(workoutId: number): Promise<boolean> {
50
+ try {
51
+ await stat(strokesPath(workoutId));
52
+ return true;
53
+ } catch (err: unknown) {
54
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
55
+ throw err;
56
+ }
57
+ }
58
+
59
+ export async function writeStrokeData(workoutId: number, strokes: StrokeData[]): Promise<void> {
60
+ const lines = `${strokes.map((s) => JSON.stringify(s)).join("\n")}\n`;
61
+ await writeFile(strokesPath(workoutId), lines, "utf-8");
62
+ }
63
+
64
+ export async function readStrokeData(workoutId: number): Promise<StrokeData[]> {
65
+ try {
66
+ const text = await readFile(strokesPath(workoutId), "utf-8");
67
+ return text
68
+ .split("\n")
69
+ .filter((line) => line.trim() !== "")
70
+ .map((line) => JSON.parse(line) as StrokeData);
71
+ } catch (err: unknown) {
72
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
73
+ throw err;
74
+ }
75
+ }