@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,709 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import type { Command } from "commander";
4
+ import { loadConfig } from "../config.ts";
5
+ import { formatMeters } from "../display.ts";
6
+ import type { Workout } from "../models.ts";
7
+ import { calendarDay, pace500m, pace500mSeconds } from "../models.ts";
8
+ import { sessionCount } from "../sessions.ts";
9
+ import {
10
+ buildWeekSummaries,
11
+ computeGoalProgress,
12
+ type GoalProgress,
13
+ type WeekSummary,
14
+ } from "../stats.ts";
15
+ import { readWorkouts } from "../storage.ts";
16
+
17
+ const MONTH_NAMES = [
18
+ "Jan",
19
+ "Feb",
20
+ "Mar",
21
+ "Apr",
22
+ "May",
23
+ "Jun",
24
+ "Jul",
25
+ "Aug",
26
+ "Sep",
27
+ "Oct",
28
+ "Nov",
29
+ "Dec",
30
+ ];
31
+
32
+ function shortDate(d: Date): string {
33
+ return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}`;
34
+ }
35
+
36
+ function fullDate(d: Date): string {
37
+ return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
38
+ }
39
+
40
+ function fmtPace(secs: number): string {
41
+ if (secs === 0) return "-";
42
+ const mins = Math.floor(secs / 60);
43
+ const rem = secs - mins * 60;
44
+ return `${mins}:${rem.toFixed(1).padStart(4, "0")}`;
45
+ }
46
+
47
+ function avgPaceForWorkouts(workouts: Workout[]): number {
48
+ let sum = 0;
49
+ let count = 0;
50
+ for (const w of workouts) {
51
+ const p = pace500mSeconds(w);
52
+ if (p > 0) {
53
+ sum += p;
54
+ count++;
55
+ }
56
+ }
57
+ return count > 0 ? sum / count : 0;
58
+ }
59
+
60
+ function avgHRForWorkouts(workouts: Workout[]): number {
61
+ let sum = 0;
62
+ let count = 0;
63
+ for (const w of workouts) {
64
+ if (w.heart_rate?.average && w.heart_rate.average > 0) {
65
+ sum += w.heart_rate.average;
66
+ count++;
67
+ }
68
+ }
69
+ return count > 0 ? Math.round(sum / count) : 0;
70
+ }
71
+
72
+ function esc(s: string): string {
73
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
74
+ }
75
+
76
+ function buildStatsCards(
77
+ goal: GoalProgress,
78
+ sessions: number,
79
+ avgPace: number,
80
+ avgHR: number,
81
+ ): string {
82
+ const paceClass = goal.onPace ? "green" : "red";
83
+ return `<div class="stats-grid">
84
+ <div class="stat-card">
85
+ <div class="label">Total Meters</div>
86
+ <div class="value">${formatMeters(goal.totalMeters)} <span class="unit">m</span></div>
87
+ </div>
88
+ <div class="stat-card">
89
+ <div class="label">Sessions</div>
90
+ <div class="value">${sessions}</div>
91
+ </div>
92
+ <div class="stat-card">
93
+ <div class="label">Avg Pace</div>
94
+ <div class="value">${fmtPace(avgPace)} <span class="unit">/500m</span></div>
95
+ </div>
96
+ <div class="stat-card">
97
+ <div class="label">Avg Heart Rate</div>
98
+ <div class="value">${avgHR > 0 ? avgHR : "-"} <span class="unit">bpm</span></div>
99
+ </div>
100
+ <div class="stat-card">
101
+ <div class="label">Current Weekly Avg</div>
102
+ <div class="value ${paceClass}">${formatMeters(goal.currentAvgPace)} <span class="unit">m/wk</span></div>
103
+ </div>
104
+ <div class="stat-card">
105
+ <div class="label">Required Weekly Pace</div>
106
+ <div class="value blue">${formatMeters(goal.requiredPace)} <span class="unit">m/wk</span></div>
107
+ </div>
108
+ </div>`;
109
+ }
110
+
111
+ function fmtShortNum(n: number): string {
112
+ if (n === 0) return "0";
113
+ if (n >= 1_000_000 && n % 1_000_000 === 0) return `${n / 1_000_000}M`;
114
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
115
+ if (n >= 1000) return `${Math.round(n / 1000)}K`;
116
+ return String(n);
117
+ }
118
+
119
+ function buildGoalProgress(goal: GoalProgress): string {
120
+ const pct = (goal.progress * 100).toFixed(1);
121
+ const onPacePct = ((goal.weeksElapsed / goal.totalWeeks) * 100).toFixed(1);
122
+ const diff = (goal.progress * 100 - parseFloat(onPacePct)).toFixed(1);
123
+ const diffLabel =
124
+ parseFloat(diff) >= 0
125
+ ? `${diff}% ahead of pace`
126
+ : `${Math.abs(parseFloat(diff)).toFixed(1)}% behind pace`;
127
+ const diffClass = parseFloat(diff) >= 0 ? "green" : "red";
128
+
129
+ const q = goal.target / 4;
130
+
131
+ return `<div class="section">
132
+ <h2>Goal Progress</h2>
133
+ <div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:4px;">
134
+ <span class="${diffClass}" style="font-weight:600;">${formatMeters(goal.totalMeters)}m &mdash; ${pct}%</span>
135
+ <span class="muted">${formatMeters(goal.target)}m</span>
136
+ </div>
137
+ <div class="progress-container">
138
+ <div class="progress-fill" style="width: ${pct}%;"></div>
139
+ <div class="progress-marker" style="left: ${onPacePct}%;">
140
+ <div class="progress-marker-label">On Pace (${onPacePct}%)</div>
141
+ </div>
142
+ </div>
143
+ <div class="progress-label-row">
144
+ <span>${fmtShortNum(0)}</span>
145
+ <span>${fmtShortNum(q)}</span>
146
+ <span>${fmtShortNum(q * 2)}</span>
147
+ <span>${fmtShortNum(q * 3)}</span>
148
+ <span>${fmtShortNum(goal.target)}</span>
149
+ </div>
150
+ <div style="margin-top: 12px; font-size: 13px;">
151
+ <span class="${diffClass}">&#9632;</span> Actual &nbsp;&nbsp;
152
+ <span class="green">|</span> On-pace target (week ${goal.weeksElapsed} of ${goal.totalWeeks})
153
+ &mdash; <span class="${diffClass}" style="font-weight:600;">${diffLabel}</span>
154
+ </div>
155
+ </div>`;
156
+ }
157
+
158
+ function buildWeeklyVolume(summaries: WeekSummary[], requiredPace: number): string {
159
+ const maxM = Math.max(...summaries.map((w) => w.meters), requiredPace * 1.25);
160
+ const scale = maxM > 0 ? maxM : 1;
161
+ const targetPct = ((requiredPace / scale) * 100).toFixed(1);
162
+ const lastIdx = summaries.length - 1;
163
+
164
+ const rows = summaries
165
+ .map((ws, i) => {
166
+ const pct = ((ws.meters / scale) * 100).toFixed(1);
167
+ const barClass = ws.meters >= requiredPace ? "on-pace" : "behind";
168
+ const isLast = i === lastIdx;
169
+ const labelStyle = isLast ? ' style="color:#c9d1d9; font-weight:600;"' : "";
170
+ const nowTag = isLast ? ' <span style="color:#58a6ff; font-size:10px;">(now)</span>' : "";
171
+ return ` <div class="week-row">
172
+ <div class="week-label"${labelStyle}>${shortDate(ws.weekStart)}</div>
173
+ <div class="week-bar-container">
174
+ <div class="week-bar ${barClass}" style="width: ${pct}%;"></div>
175
+ <div class="week-target-line" style="left: ${targetPct}%;"></div>
176
+ </div>
177
+ <div class="week-meta"><span class="meters">${formatMeters(ws.meters)}</span> m &middot; ${ws.sessions} sess${nowTag}</div>
178
+ </div>`;
179
+ })
180
+ .join("\n\n");
181
+
182
+ return `<div class="section">
183
+ <h2>Weekly Volume</h2>
184
+ <div class="target-legend">
185
+ <span class="target-legend-line"></span>
186
+ <span>Target: ${formatMeters(requiredPace)} m/wk</span>
187
+ </div>
188
+
189
+ ${rows}
190
+ </div>`;
191
+ }
192
+
193
+ function buildWeeklyTrends(summaries: WeekSummary[]): string {
194
+ // Find best volume week and best pace week for highlighting
195
+ let bestVolume = 0;
196
+ let bestPace = Infinity;
197
+ for (const ws of summaries) {
198
+ if (ws.meters > bestVolume) bestVolume = ws.meters;
199
+ if (ws.paceCount > 0) {
200
+ const avg = ws.paceSum / ws.paceCount;
201
+ if (avg < bestPace) bestPace = avg;
202
+ }
203
+ }
204
+
205
+ const rows = summaries
206
+ .map((ws) => {
207
+ const avgPace = ws.paceCount > 0 ? ws.paceSum / ws.paceCount : 0;
208
+ const avgSPM = ws.spmCount > 0 ? (ws.spmSum / ws.spmCount).toFixed(1) : "-";
209
+ const avgHR = ws.hrCount > 0 ? Math.round(ws.hrSum / ws.hrCount).toString() : "-";
210
+
211
+ const volStyle = ws.meters === bestVolume && ws.meters > 0 ? ' style="color:#3fb950;"' : "";
212
+ const paceStyle = avgPace === bestPace && avgPace > 0 ? ' style="color:#3fb950;"' : "";
213
+
214
+ return ` <tr>
215
+ <td>${shortDate(ws.weekStart)}</td>
216
+ <td class="r"${volStyle}>${formatMeters(ws.meters)}m</td>
217
+ <td class="r"${paceStyle}>${avgPace > 0 ? fmtPace(avgPace) : "-"}</td>
218
+ <td class="r">${esc(String(avgSPM))}</td>
219
+ <td class="r">${esc(String(avgHR))}</td>
220
+ </tr>`;
221
+ })
222
+ .join("\n");
223
+
224
+ // Pace trend summary
225
+ const firstPace = summaries.find((w) => w.paceCount > 0);
226
+ const lastPace = [...summaries].reverse().find((w) => w.paceCount > 0);
227
+ let trendNote = "";
228
+ if (firstPace && lastPace && firstPace !== lastPace) {
229
+ const fp = firstPace.paceSum / firstPace.paceCount;
230
+ const lp = lastPace.paceSum / lastPace.paceCount;
231
+ const diff = Math.abs(fp - lp);
232
+ const direction = lp < fp ? "faster" : "slower";
233
+ trendNote = `\n <div style="margin-top:12px; font-size:12px; color:#8b949e;">
234
+ Pace trending ${direction}: <span class="${lp < fp ? "green" : "red"}">${fmtPace(fp)} &rarr; ${fmtPace(lp)}</span> &mdash; ${Math.round(diff)} seconds ${direction === "faster" ? "improvement" : "decline"} over ${summaries.length} weeks
235
+ </div>`;
236
+ }
237
+
238
+ return `<div class="section">
239
+ <h2>Weekly Trends</h2>
240
+ <table>
241
+ <thead>
242
+ <tr>
243
+ <th>Week</th>
244
+ <th class="r">Volume</th>
245
+ <th class="r">Avg Pace /500m</th>
246
+ <th class="r">Avg SPM</th>
247
+ <th class="r">Avg HR</th>
248
+ </tr>
249
+ </thead>
250
+ <tbody>
251
+ ${rows}
252
+ </tbody>
253
+ </table>${trendNote}
254
+ </div>`;
255
+ }
256
+
257
+ function buildRecentWorkouts(workouts: Workout[], count: number): string {
258
+ const sorted = [...workouts].sort((a, b) => b.date.localeCompare(a.date));
259
+ const recent = sorted.slice(0, count).reverse();
260
+
261
+ // Detect same-day groups for session-aware formatting
262
+ const dayCounts = new Map<string, number>();
263
+ for (const w of recent) {
264
+ const day = calendarDay(w);
265
+ dayCounts.set(day, (dayCounts.get(day) || 0) + 1);
266
+ }
267
+
268
+ const rows = recent
269
+ .map((w) => {
270
+ const day = calendarDay(w);
271
+ const d = new Date(w.date.replace(" ", "T"));
272
+ const dateLabel = `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}`;
273
+ const pace = pace500m(w);
274
+ const paceS = pace500mSeconds(w);
275
+ const spm = w.stroke_rate ?? "-";
276
+ const hr = w.heart_rate?.average ?? "-";
277
+
278
+ // If multiple workouts on same day, annotate
279
+ const multiDay = (dayCounts.get(day) || 0) > 1;
280
+ if (!multiDay) {
281
+ return ` <tr>
282
+ <td>${esc(dateLabel)}</td>
283
+ <td class="r">${formatMeters(w.distance)}m</td>
284
+ <td class="r">${esc(pace)}</td>
285
+ <td class="r">${spm}</td>
286
+ <td class="r">${hr}</td>
287
+ </tr>`;
288
+ }
289
+
290
+ // Session-aware: short warmup/cooldown get muted style
291
+ const isShort = w.distance <= 1500;
292
+ const isHard = paceS > 0 && paceS < 160; // sub-2:40 is hard
293
+ let annotation = "";
294
+ let rowStyle = "";
295
+ let paceStyle = "";
296
+ let hrStyle = "";
297
+
298
+ if (isShort && !isHard) {
299
+ // Likely warmup or cooldown — check position
300
+ const dayWorkouts = recent
301
+ .filter((r) => calendarDay(r) === day)
302
+ .sort((a, b) => a.date.localeCompare(b.date));
303
+ const idx = dayWorkouts.indexOf(w);
304
+ if (idx === 0) annotation = "warmup";
305
+ else if (idx === dayWorkouts.length - 1) annotation = "cooldown";
306
+ else annotation = "warmup";
307
+ rowStyle = ' style="color:#8b949e;"';
308
+ } else if (isHard) {
309
+ annotation = "hard";
310
+ paceStyle = ' style="color:#3fb950;"';
311
+ if (typeof hr === "number" && hr >= 135) {
312
+ hrStyle = ' style="color:#f85149;"';
313
+ }
314
+ }
315
+
316
+ const dateCell = annotation
317
+ ? `${esc(dateLabel)} <span style="font-size:10px;${isHard ? " color:#3fb950;" : ""}">(${annotation})</span>`
318
+ : esc(dateLabel);
319
+
320
+ return ` <tr${rowStyle}>
321
+ <td>${dateCell}</td>
322
+ <td class="r">${formatMeters(w.distance)}m</td>
323
+ <td class="r"${paceStyle}>${esc(pace)}</td>
324
+ <td class="r">${spm}</td>
325
+ <td class="r"${hrStyle}>${hr}</td>
326
+ </tr>`;
327
+ })
328
+ .join("\n");
329
+
330
+ return `<div class="section">
331
+ <h2>Recent Workouts</h2>
332
+ <table>
333
+ <thead>
334
+ <tr>
335
+ <th>Date</th>
336
+ <th class="r">Distance</th>
337
+ <th class="r">Pace /500m</th>
338
+ <th class="r">SPM</th>
339
+ <th class="r">HR</th>
340
+ </tr>
341
+ </thead>
342
+ <tbody>
343
+ ${rows}
344
+ </tbody>
345
+ </table>
346
+ </div>`;
347
+ }
348
+
349
+ function buildProjection(goal: GoalProgress, workouts: Workout[]): string {
350
+ const projectedAtCurrent = goal.currentAvgPace * goal.remainingWeeks + goal.totalMeters;
351
+ const projectedPct = ((projectedAtCurrent / goal.target) * 100).toFixed(1);
352
+ const shortfall = goal.target - projectedAtCurrent;
353
+ const avgSessionDist =
354
+ workouts.length > 0
355
+ ? Math.round(workouts.reduce((s, w) => s + w.distance, 0) / workouts.length)
356
+ : 5000;
357
+ const sessionsPerWeek =
358
+ avgSessionDist > 0 ? (goal.requiredPace / avgSessionDist).toFixed(1) : "-";
359
+ const increaseNeeded =
360
+ goal.currentAvgPace > 0
361
+ ? (((goal.requiredPace - goal.currentAvgPace) / goal.currentAvgPace) * 100).toFixed(0)
362
+ : "-";
363
+
364
+ const currentClass = projectedAtCurrent >= goal.target ? "green" : "red";
365
+
366
+ return `<div class="section">
367
+ <h2>Year-End Projection</h2>
368
+ <div class="projection-grid">
369
+ <div class="projection-card">
370
+ <h3 class="${currentClass}">At Current Pace</h3>
371
+ <div class="big-num ${currentClass}">~${formatMeters(Math.round(projectedAtCurrent / 1000) * 1000)}m</div>
372
+ <div class="detail">
373
+ ${formatMeters(goal.currentAvgPace)} m/wk &times; ${goal.remainingWeeks} remaining + ${formatMeters(goal.totalMeters)}<br>
374
+ ${shortfall > 0 ? `${formatMeters(Math.round(shortfall))}m short of goal` : "On track to exceed goal"}<br>
375
+ <span class="${currentClass}" style="font-weight:600;">${projectedPct}% of target</span>
376
+ </div>
377
+ </div>
378
+ <div class="projection-card">
379
+ <h3 class="green">To Hit ${formatMeters(goal.target)}m</h3>
380
+ <div class="big-num green">${formatMeters(goal.requiredPace)} <span style="font-size:16px; font-weight:400;">m/wk</span></div>
381
+ <div class="detail">
382
+ ${formatMeters(goal.remainingMeters)}m remaining over ${goal.remainingWeeks} weeks<br>
383
+ ~${sessionsPerWeek} sessions of ${formatMeters(avgSessionDist)}m per week<br>
384
+ <span class="green" style="font-weight:600;">${Number(increaseNeeded) > 0 ? `+${increaseNeeded}% increase needed` : "Pace is sufficient"}</span>
385
+ </div>
386
+ </div>
387
+ </div>
388
+ <div style="margin-top: 16px; padding: 12px; background: #21262d; border-radius: 6px; font-size: 13px; line-height: 1.8;">
389
+ <strong style="color: #f0f6fc;">The path forward:</strong>
390
+ ${sessionsPerWeek} sessions/week at ${formatMeters(avgSessionDist)}m avg = ${formatMeters(Math.round(parseFloat(sessionsPerWeek as string) * avgSessionDist))}m/week.
391
+ </div>
392
+ </div>`;
393
+ }
394
+
395
+ function buildHTML(
396
+ goal: GoalProgress,
397
+ summaries: WeekSummary[],
398
+ allWorkouts: Workout[],
399
+ recentCount: number,
400
+ ): string {
401
+ const sessions = sessionCount(allWorkouts);
402
+ const avgPace = avgPaceForWorkouts(allWorkouts);
403
+ const avgHR = avgHRForWorkouts(allWorkouts);
404
+ const today = new Date();
405
+
406
+ return `<!DOCTYPE html>
407
+ <html lang="en">
408
+ <head>
409
+ <meta charset="UTF-8">
410
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
411
+ <title>Rowing Progress — ${today.getFullYear()}</title>
412
+ <style>
413
+ * { margin: 0; padding: 0; box-sizing: border-box; }
414
+ body {
415
+ background: #0d1117;
416
+ color: #c9d1d9;
417
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
418
+ line-height: 1.6;
419
+ padding: 24px;
420
+ max-width: 960px;
421
+ margin: 0 auto;
422
+ }
423
+ h1 { color: #f0f6fc; font-size: 28px; font-weight: 700; }
424
+ h2 { color: #f0f6fc; font-size: 20px; font-weight: 600; margin-bottom: 16px; }
425
+ .subtitle { color: #8b949e; font-size: 15px; margin-top: 4px; }
426
+ .date { color: #8b949e; font-size: 13px; margin-top: 2px; }
427
+ .muted { color: #8b949e; }
428
+ .green { color: #3fb950; }
429
+ .red { color: #f85149; }
430
+ .blue { color: #58a6ff; }
431
+
432
+ header { margin-bottom: 32px; }
433
+
434
+ .stats-grid {
435
+ display: grid;
436
+ grid-template-columns: repeat(3, 1fr);
437
+ gap: 12px;
438
+ margin-bottom: 32px;
439
+ }
440
+ .stat-card {
441
+ background: #161b22;
442
+ border: 1px solid #30363d;
443
+ border-radius: 8px;
444
+ padding: 16px;
445
+ }
446
+ .stat-card .label {
447
+ color: #8b949e;
448
+ font-size: 12px;
449
+ text-transform: uppercase;
450
+ letter-spacing: 0.5px;
451
+ margin-bottom: 4px;
452
+ }
453
+ .stat-card .value {
454
+ color: #f0f6fc;
455
+ font-size: 24px;
456
+ font-weight: 700;
457
+ }
458
+ .stat-card .unit {
459
+ color: #8b949e;
460
+ font-size: 13px;
461
+ font-weight: 400;
462
+ }
463
+
464
+ .section {
465
+ background: #161b22;
466
+ border: 1px solid #30363d;
467
+ border-radius: 8px;
468
+ padding: 20px;
469
+ margin-bottom: 24px;
470
+ }
471
+
472
+ .progress-container {
473
+ position: relative;
474
+ background: #21262d;
475
+ border-radius: 6px;
476
+ height: 32px;
477
+ margin: 16px 0 8px;
478
+ overflow: visible;
479
+ }
480
+ .progress-fill {
481
+ height: 100%;
482
+ border-radius: 6px;
483
+ background: #f85149;
484
+ position: relative;
485
+ z-index: 1;
486
+ min-width: 2px;
487
+ }
488
+ .progress-marker {
489
+ position: absolute;
490
+ top: -6px;
491
+ height: 44px;
492
+ width: 2px;
493
+ background: #3fb950;
494
+ z-index: 2;
495
+ }
496
+ .progress-marker-label {
497
+ position: absolute;
498
+ top: -22px;
499
+ transform: translateX(-50%);
500
+ font-size: 11px;
501
+ color: #3fb950;
502
+ white-space: nowrap;
503
+ font-weight: 600;
504
+ }
505
+ .progress-label-row {
506
+ display: flex;
507
+ justify-content: space-between;
508
+ font-size: 12px;
509
+ color: #8b949e;
510
+ margin-top: 4px;
511
+ }
512
+
513
+ .week-row {
514
+ display: flex;
515
+ align-items: center;
516
+ margin-bottom: 8px;
517
+ font-size: 13px;
518
+ }
519
+ .week-label {
520
+ width: 70px;
521
+ flex-shrink: 0;
522
+ color: #8b949e;
523
+ font-size: 12px;
524
+ text-align: right;
525
+ padding-right: 10px;
526
+ }
527
+ .week-bar-container {
528
+ flex: 1;
529
+ position: relative;
530
+ height: 24px;
531
+ background: #21262d;
532
+ border-radius: 4px;
533
+ overflow: visible;
534
+ }
535
+ .week-bar {
536
+ height: 100%;
537
+ border-radius: 4px;
538
+ min-width: 2px;
539
+ }
540
+ .week-bar.on-pace { background: #238636; }
541
+ .week-bar.behind { background: #8b2a2d; }
542
+ .week-meta {
543
+ width: 140px;
544
+ flex-shrink: 0;
545
+ text-align: right;
546
+ font-size: 12px;
547
+ color: #8b949e;
548
+ padding-left: 8px;
549
+ }
550
+ .week-meta .meters { color: #c9d1d9; font-weight: 500; }
551
+ .week-target-line {
552
+ position: absolute;
553
+ top: -2px;
554
+ height: 28px;
555
+ width: 0;
556
+ border-left: 2px dashed #58a6ff;
557
+ z-index: 2;
558
+ opacity: 0.7;
559
+ }
560
+
561
+ table {
562
+ width: 100%;
563
+ border-collapse: collapse;
564
+ font-size: 13px;
565
+ }
566
+ thead th {
567
+ text-align: left;
568
+ color: #8b949e;
569
+ font-weight: 600;
570
+ padding: 8px 10px;
571
+ border-bottom: 1px solid #30363d;
572
+ font-size: 12px;
573
+ text-transform: uppercase;
574
+ letter-spacing: 0.3px;
575
+ }
576
+ th.r, td.r { text-align: right; }
577
+ tbody td {
578
+ padding: 8px 10px;
579
+ border-bottom: 1px solid #21262d;
580
+ font-variant-numeric: tabular-nums;
581
+ }
582
+ tbody tr:last-child td { border-bottom: none; }
583
+ tbody tr:hover { background: #1c2128; }
584
+
585
+ .projection-grid {
586
+ display: grid;
587
+ grid-template-columns: 1fr 1fr;
588
+ gap: 16px;
589
+ }
590
+ .projection-card {
591
+ background: #21262d;
592
+ border-radius: 6px;
593
+ padding: 16px;
594
+ }
595
+ .projection-card h3 {
596
+ font-size: 14px;
597
+ font-weight: 600;
598
+ margin-bottom: 8px;
599
+ }
600
+ .projection-card .big-num {
601
+ font-size: 28px;
602
+ font-weight: 700;
603
+ margin-bottom: 4px;
604
+ }
605
+ .projection-card .detail {
606
+ font-size: 12px;
607
+ color: #8b949e;
608
+ line-height: 1.8;
609
+ }
610
+
611
+ .target-legend {
612
+ display: flex;
613
+ align-items: center;
614
+ gap: 6px;
615
+ font-size: 11px;
616
+ color: #8b949e;
617
+ margin-bottom: 12px;
618
+ justify-content: flex-end;
619
+ padding-right: 140px;
620
+ }
621
+ .target-legend-line {
622
+ width: 16px;
623
+ border-top: 2px dashed #58a6ff;
624
+ }
625
+
626
+ @media (max-width: 640px) {
627
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
628
+ .projection-grid { grid-template-columns: 1fr; }
629
+ body { padding: 16px; }
630
+ }
631
+ </style>
632
+ </head>
633
+ <body>
634
+
635
+ <header>
636
+ <h1>Rowing Progress</h1>
637
+ <div class="subtitle">${today.getFullYear()} Season &mdash; ${formatMeters(goal.target)}m Goal</div>
638
+ <div class="date">${fullDate(today)}</div>
639
+ </header>
640
+
641
+ ${buildStatsCards(goal, sessions, avgPace, avgHR)}
642
+
643
+ ${buildGoalProgress(goal)}
644
+
645
+ ${buildWeeklyVolume(summaries, goal.requiredPace)}
646
+
647
+ ${buildWeeklyTrends(summaries)}
648
+
649
+ ${buildRecentWorkouts(allWorkouts, recentCount)}
650
+
651
+ ${buildProjection(goal, allWorkouts)}
652
+
653
+ <div style="text-align: center; color: #484f58; font-size: 12px; margin-top: 32px; padding-bottom: 16px;">
654
+ Generated by c2cli &middot; Data from Concept2 Logbook &middot; ${fullDate(today)}
655
+ </div>
656
+
657
+ </body>
658
+ </html>`;
659
+ }
660
+
661
+ export function registerReport(program: Command): void {
662
+ program
663
+ .command("report")
664
+ .description("Generate HTML progress report")
665
+ .option("-o, --output <file>", "output file path", "report.html")
666
+ .option("-w, --weeks <n>", "weeks of history to show", "12")
667
+ .option("--open", "open report in default browser")
668
+ .action(async (opts: { output: string; weeks: string; open?: boolean }) => {
669
+ const cfg = await loadConfig();
670
+ if (!cfg.goal.start_date || !cfg.goal.end_date) {
671
+ console.error("Goal dates not configured. Run `c2 setup` to set start and end dates.");
672
+ process.exit(1);
673
+ }
674
+ const workouts = await readWorkouts();
675
+
676
+ if (workouts.length === 0) {
677
+ console.log("No workouts found. Run `c2 sync` first.");
678
+ return;
679
+ }
680
+
681
+ const goal = computeGoalProgress(workouts, cfg);
682
+ const weeks = parseInt(opts.weeks, 10);
683
+ if (Number.isNaN(weeks) || weeks < 1) {
684
+ console.error("Error: --weeks must be a positive integer.");
685
+ process.exit(1);
686
+ }
687
+ const summaries = buildWeekSummaries(workouts, new Date(), weeks);
688
+ const html = buildHTML(goal, summaries, workouts, 10);
689
+
690
+ const outPath = resolve(opts.output);
691
+ await writeFile(outPath, html, "utf-8");
692
+ console.log(`Report written to: ${outPath}`);
693
+
694
+ if (opts.open) {
695
+ const { spawn } = await import("node:child_process");
696
+ const cmd =
697
+ process.platform === "darwin"
698
+ ? "open"
699
+ : process.platform === "win32"
700
+ ? "start"
701
+ : "xdg-open";
702
+ const child = spawn(cmd, [outPath], { stdio: "ignore", detached: true });
703
+ child.on("error", (err) => {
704
+ console.error(`Could not open report: ${err.message}`);
705
+ });
706
+ child.unref();
707
+ }
708
+ });
709
+ }