incremnt 0.1.0 → 0.1.2

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.
Files changed (3) hide show
  1. package/package.json +4 -1
  2. package/src/format.js +330 -29
  3. package/src/lib.js +17 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,5 +13,8 @@
13
13
  "scripts": {
14
14
  "test": "node --test",
15
15
  "dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
16
+ },
17
+ "dependencies": {
18
+ "chalk": "^5.6.2"
16
19
  }
17
20
  }
package/src/format.js CHANGED
@@ -1,44 +1,72 @@
1
+ import chalk from 'chalk';
2
+
1
3
  const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
2
4
  const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
3
5
 
4
- function formatShortDate(dateString) {
6
+ // --- Shared helpers ---
7
+
8
+ function parseDate(dateString) {
5
9
  const date = new Date(dateString);
6
- if (Number.isNaN(date.getTime())) {
7
- return dateString;
8
- }
10
+ return Number.isNaN(date.getTime()) ? null : date;
11
+ }
9
12
 
10
- return `${date.getDate()} ${shortMonths[date.getMonth()]}`;
13
+ function formatShortDate(dateString) {
14
+ const date = parseDate(dateString);
15
+ return date ? `${date.getDate()} ${shortMonths[date.getMonth()]}` : dateString;
11
16
  }
12
17
 
13
18
  function formatDayAndDate(dateString) {
14
- const date = new Date(dateString);
15
- if (Number.isNaN(date.getTime())) {
16
- return dateString;
17
- }
19
+ const date = parseDate(dateString);
20
+ return date
21
+ ? `${shortDays[date.getDay()]} ${date.getDate()} ${shortMonths[date.getMonth()]}`
22
+ : dateString;
23
+ }
24
+
25
+ function header(text) {
26
+ return chalk.bold(` ${text}`);
27
+ }
28
+
29
+ function dimDot() {
30
+ return chalk.dim(' \u00b7 ');
31
+ }
32
+
33
+ function formatWeight(weight) {
34
+ return chalk.bold.cyan(`${Number(weight).toFixed(1)} kg`);
35
+ }
18
36
 
19
- return `${shortDays[date.getDay()]} ${date.getDate()} ${shortMonths[date.getMonth()]}`;
37
+ function formatDuration(seconds) {
38
+ return `${Math.round(seconds / 60)} min`;
20
39
  }
21
40
 
41
+ function keyValue(key, value, keyWidth = 12) {
42
+ return ` ${chalk.dim(key.padEnd(keyWidth))} ${chalk.bold(value)}`;
43
+ }
44
+
45
+ // --- Formatters ---
46
+
22
47
  function formatRecords(payload) {
23
48
  if (!Array.isArray(payload) || payload.length === 0) {
24
49
  return 'No records found.';
25
50
  }
26
51
 
27
52
  const maxNameLength = Math.max(...payload.map((record) => record.exerciseName.length));
53
+ const lines = [header('PERSONAL RECORDS'), ''];
28
54
 
29
- return payload.map((record) => {
55
+ for (const record of payload) {
30
56
  const name = record.exerciseName.padEnd(maxNameLength);
31
57
  const date = formatShortDate(record.sessionDate);
32
58
  const isBodyweight = Number(record.weight) === 0;
33
59
 
34
60
  if (isBodyweight) {
35
61
  const reps = `${record.reps} reps`.padStart(12);
36
- return `${name} ${reps} BW \u00b7 ${date}`;
62
+ lines.push(` ${name} ${chalk.bold.cyan(reps)} ${chalk.dim('BW')}${dimDot()}${chalk.dim(date)}`);
63
+ } else {
64
+ const weight = `${Number(record.weight).toFixed(1)} kg`.padStart(12);
65
+ lines.push(` ${name} ${chalk.bold.cyan(weight)} ${chalk.dim('e1RM')}${dimDot()}${chalk.dim(date)}`);
37
66
  }
67
+ }
38
68
 
39
- const weight = `${Number(record.weight).toFixed(1)} kg`.padStart(12);
40
- return `${name} ${weight} e1RM \u00b7 ${date}`;
41
- }).join('\n');
69
+ return lines.join('\n');
42
70
  }
43
71
 
44
72
  function formatSessionInsights(payload) {
@@ -46,27 +74,300 @@ function formatSessionInsights(payload) {
46
74
  return 'No sessions found.';
47
75
  }
48
76
 
49
- return payload.map((session) => {
50
- const date = formatDayAndDate(session.sessionDate);
77
+ const lines = [header('RECENT SESSIONS'), ''];
78
+ const maxDateLength = Math.max(...payload.map((session) => formatDayAndDate(session.sessionDate).length));
79
+
80
+ for (const session of payload) {
81
+ const date = formatDayAndDate(session.sessionDate).padEnd(maxDateLength);
51
82
  const dayName = session.dayName ?? 'Workout';
52
83
  const exercises = `${session.exerciseCount ?? '?'} exercises`;
53
- const duration = session.durationSeconds
54
- ? `${Math.round(session.durationSeconds / 60)} min`
55
- : '';
56
- const suffix = duration ? ` \u00b7 ${exercises} \u00b7 ${duration}` : ` \u00b7 ${exercises}`;
84
+ const duration = session.durationSeconds ? formatDuration(session.durationSeconds) : '';
85
+ const suffix = duration
86
+ ? `${dimDot()}${chalk.dim(exercises)}${dimDot()}${chalk.dim(duration)}`
87
+ : `${dimDot()}${chalk.dim(exercises)}`;
88
+
89
+ lines.push(` ${chalk.bold(date)} ${dayName}${suffix}`);
90
+ }
57
91
 
58
- return `${date} ${dayName}${suffix}`;
59
- }).join('\n');
92
+ return lines.join('\n');
60
93
  }
61
94
 
62
- export function formatPretty(command, payload) {
63
- if (command === 'records') {
64
- return formatRecords(payload);
95
+ function formatSessionShow(payload) {
96
+ if (!payload) {
97
+ return 'Session not found.';
98
+ }
99
+
100
+ const date = formatDayAndDate(payload.sessionDate);
101
+ const lines = [` ${chalk.bold('SESSION')}${dimDot()}${date}`, ''];
102
+
103
+ if (payload.programName) {
104
+ lines.push(keyValue('Program', payload.programName));
105
+ }
106
+
107
+ if (payload.dayName) {
108
+ const dayDetail = payload.programDayIndex != null
109
+ ? `${payload.dayName} (Day ${payload.programDayIndex + 1})`
110
+ : payload.dayName;
111
+ lines.push(keyValue('Day', dayDetail));
112
+ }
113
+
114
+ if (payload.historicalContext?.programWeekNumber) {
115
+ lines.push(keyValue('Week', String(payload.historicalContext.programWeekNumber)));
65
116
  }
66
117
 
67
- if (command === 'session-insights') {
68
- return formatSessionInsights(payload);
118
+ if (payload.totalVolume) {
119
+ lines.push(keyValue('Volume', `${Number(payload.totalVolume).toLocaleString()} kg`));
69
120
  }
70
121
 
71
- return null;
122
+ if (payload.durationSeconds) {
123
+ lines.push(keyValue('Duration', formatDuration(payload.durationSeconds)));
124
+ }
125
+
126
+ if (payload.effortScore != null) {
127
+ lines.push(keyValue('Effort', String(payload.effortScore)));
128
+ }
129
+
130
+ if (payload.averageHeartRate != null || payload.maxHeartRate != null) {
131
+ const parts = [];
132
+ if (payload.averageHeartRate != null) {
133
+ parts.push(`${payload.averageHeartRate} avg`);
134
+ }
135
+
136
+ if (payload.maxHeartRate != null) {
137
+ parts.push(`${payload.maxHeartRate} max`);
138
+ }
139
+
140
+ lines.push(keyValue('HR', parts.join(chalk.dim(' \u00b7 '))));
141
+ }
142
+
143
+ if (payload.activeCalories != null) {
144
+ lines.push(keyValue('Calories', `${payload.activeCalories} kcal`));
145
+ }
146
+
147
+ return lines.join('\n');
148
+ }
149
+
150
+ function formatExerciseHistory(payload) {
151
+ if (!Array.isArray(payload) || payload.length === 0) {
152
+ return 'No history found.';
153
+ }
154
+
155
+ const exerciseName = payload[0]?.exerciseName ?? 'Exercise';
156
+ const lines = [` ${chalk.bold(exerciseName.toUpperCase())}${dimDot()}History`, ''];
157
+ const maxDateLength = Math.max(...payload.map((entry) => formatShortDate(entry.sessionDate).length));
158
+
159
+ for (const entry of payload) {
160
+ const date = formatShortDate(entry.sessionDate).padEnd(maxDateLength);
161
+ const weight = entry.weight > 0 ? `${formatWeight(entry.weight)} \u00d7 ${entry.reps}` : `${chalk.bold.cyan(`${entry.reps} reps`)}`;
162
+ const rir = entry.rir != null ? ` ${chalk.dim(`RIR ${entry.rir}`)}` : '';
163
+ lines.push(` ${chalk.dim(date)} ${weight}${rir}`);
164
+ }
165
+
166
+ return lines.join('\n');
167
+ }
168
+
169
+ function formatProgramSummary(payload) {
170
+ if (!payload) {
171
+ return 'No active program.';
172
+ }
173
+
174
+ const lines = [header('CURRENT PROGRAM'), ''];
175
+
176
+ lines.push(keyValue('Name', payload.programName));
177
+ lines.push(keyValue('Week', String(payload.currentWeek)));
178
+
179
+ if (payload.currentDayTitle) {
180
+ const nextDay = payload.currentDayIndex != null
181
+ ? `${payload.currentDayTitle} (Day ${payload.currentDayIndex + 1})`
182
+ : payload.currentDayTitle;
183
+ lines.push(keyValue('Next', nextDay));
184
+ }
185
+
186
+ if (payload.trainingWeekdays?.length > 0) {
187
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
188
+ const schedule = payload.trainingWeekdays.map((d) => dayNames[d] ?? d).join(', ');
189
+ lines.push(keyValue('Schedule', schedule));
190
+ }
191
+
192
+ lines.push(keyValue('Cycles', `${payload.completedCyclesCount} completed`));
193
+
194
+ return lines.join('\n');
195
+ }
196
+
197
+ function formatProgramList(payload) {
198
+ if (!Array.isArray(payload) || payload.length === 0) {
199
+ return 'No programs found.';
200
+ }
201
+
202
+ const lines = [header('PROGRAMS'), ''];
203
+
204
+ for (const program of payload) {
205
+ const dot = program.isActive ? chalk.green('\u25cf') : chalk.dim('\u25cb');
206
+ const name = program.isActive ? chalk.bold(program.programName) : program.programName;
207
+ const week = `Week ${program.currentWeek}`;
208
+ const day = program.currentDayTitle ? `Day ${program.currentDayIndex + 1} (${program.currentDayTitle})` : `Day ${program.currentDayIndex + 1}`;
209
+ lines.push(` ${dot} ${name} ${chalk.dim(`${week}${' \u00b7 '}${day}`)}`);
210
+ }
211
+
212
+ return lines.join('\n');
213
+ }
214
+
215
+ function formatPlannedVsActual(payload) {
216
+ if (!payload) {
217
+ return 'No comparison data found.';
218
+ }
219
+
220
+ const date = formatShortDate(payload.sessionDate);
221
+ const dayTitle = payload.dayTitle ?? 'Workout';
222
+ const lines = [` ${chalk.bold('PLANNED vs ACTUAL')}${dimDot()}${dayTitle} (${date})`, ''];
223
+
224
+ for (const exercise of payload.exercises ?? []) {
225
+ lines.push(` ${chalk.bold(exercise.exerciseName)}`);
226
+
227
+ if (exercise.plannedSets.length > 0) {
228
+ const weights = [...new Set(exercise.plannedSets.map((s) => s.weight))];
229
+ const reps = [...new Set(exercise.plannedSets.map((s) => s.reps))];
230
+ const setCount = exercise.plannedSets.length;
231
+ const weightStr = weights.length === 1 ? `${weights[0]} kg` : `${Math.min(...weights)}-${Math.max(...weights)} kg`;
232
+ const repStr = reps.length === 1 ? `${setCount}\u00d7${reps[0]}` : `${setCount}\u00d7${reps.join('/')}`;
233
+ const rirStr = exercise.plannedRir != null ? ` ${chalk.dim(`RIR ${exercise.plannedRir}`)}` : '';
234
+ lines.push(` ${chalk.dim('Planned')} ${repStr} @ ${weightStr}${rirStr}`);
235
+ }
236
+
237
+ if (exercise.actualSets.length > 0) {
238
+ const repsList = exercise.actualSets.map((s) => s.reps).join(', ');
239
+ const weights = [...new Set(exercise.actualSets.map((s) => s.weight))];
240
+ const weightStr = weights.length === 1 ? `${weights[0]} kg` : `${Math.min(...weights)}-${Math.max(...weights)} kg`;
241
+ const rirStr = exercise.actualRir != null ? ` ${chalk.dim(`RIR ${exercise.actualRir}`)}` : '';
242
+ lines.push(` ${chalk.dim('Actual')} ${repsList} @ ${weightStr}${rirStr}`);
243
+ }
244
+
245
+ lines.push('');
246
+ }
247
+
248
+ // Remove trailing blank line
249
+ if (lines.at(-1) === '') {
250
+ lines.pop();
251
+ }
252
+
253
+ return lines.join('\n');
254
+ }
255
+
256
+ function formatWhyDidThisChange(payload) {
257
+ if (!payload) {
258
+ return 'No session context found.';
259
+ }
260
+
261
+ const date = formatShortDate(payload.sessionDate);
262
+ const lines = [` ${chalk.bold('SESSION CONTEXT')}${dimDot()}${date}`, ''];
263
+
264
+ if (payload.programWeekNumber != null) {
265
+ lines.push(keyValue('Week', String(payload.programWeekNumber)));
266
+ }
267
+
268
+ if (payload.sessionIntent) {
269
+ lines.push(keyValue('Intent', payload.sessionIntent));
270
+ }
271
+
272
+ if (payload.programProgressionType) {
273
+ lines.push(keyValue('Progression', payload.programProgressionType));
274
+ }
275
+
276
+ if (payload.effortScore != null) {
277
+ lines.push(keyValue('Effort', String(payload.effortScore)));
278
+ }
279
+
280
+ if (payload.averageHeartRate != null || payload.maxHeartRate != null) {
281
+ const parts = [];
282
+ if (payload.averageHeartRate != null) {
283
+ parts.push(`${payload.averageHeartRate} avg`);
284
+ }
285
+
286
+ if (payload.maxHeartRate != null) {
287
+ parts.push(`${payload.maxHeartRate} max`);
288
+ }
289
+
290
+ lines.push(keyValue('HR', parts.join(chalk.dim(' \u00b7 '))));
291
+ }
292
+
293
+ if (payload.latestAdaptationSummary) {
294
+ lines.push(keyValue('Adaptation', payload.latestAdaptationSummary));
295
+ }
296
+
297
+ const recommendations = payload.recommendations ?? {};
298
+ const recEntries = Object.entries(recommendations);
299
+
300
+ if (recEntries.length > 0) {
301
+ lines.push('');
302
+ lines.push(` ${chalk.bold('Recommendations')}`);
303
+
304
+ for (const [exerciseName, rec] of recEntries) {
305
+ const description = rec.kind === 'increaseWeight'
306
+ ? `Increase weight by ${rec.amount} ${rec.unit ?? 'kg'}`
307
+ : rec.kind === 'maintainWeight'
308
+ ? 'Maintain current weight'
309
+ : rec.kind ?? 'Adjust';
310
+ lines.push(` ${exerciseName.padEnd(20)} ${chalk.dim(description)}`);
311
+ }
312
+ }
313
+
314
+ return lines.join('\n');
315
+ }
316
+
317
+ // --- Main export ---
318
+
319
+ export function formatHelp() {
320
+ const cmd = (name, desc) => ` ${chalk.bold(name.padEnd(38))} ${chalk.dim(desc)}`;
321
+ const lines = [
322
+ '',
323
+ header('INCREMNT') + dimDot() + chalk.dim('strength tracking CLI'),
324
+ '',
325
+ header('USAGE'),
326
+ ` incremnt <command> [options]`,
327
+ '',
328
+ header('COMMANDS'),
329
+ cmd('sessions list', 'List recent sessions'),
330
+ cmd('sessions show --id <id>', 'Show session details'),
331
+ cmd('sessions compare --id <id>', 'Compare planned vs actual'),
332
+ cmd('sessions explain --id <id>', 'Explain session context'),
333
+ cmd('programs list', 'List all programs'),
334
+ cmd('programs current', 'Show active program'),
335
+ cmd('exercises history --name <name>', 'Exercise history'),
336
+ cmd('records', 'Personal records'),
337
+ '',
338
+ header('AUTH'),
339
+ cmd('login --base-url <url>', 'Sign in (device flow)'),
340
+ cmd('login --base-url <url> --token <t>', 'Sign in with token'),
341
+ cmd('login --snapshot <file>', 'Use local snapshot'),
342
+ cmd('login --session-file <file>', 'Import session file'),
343
+ cmd('logout', 'Clear session'),
344
+ '',
345
+ header('OTHER'),
346
+ cmd('status', 'Connection & auth info'),
347
+ cmd('contract', 'API contract info'),
348
+ cmd('--json', 'Force JSON output'),
349
+ cmd('--help', 'Show this help'),
350
+ '',
351
+ header('ALIASES'),
352
+ ` ${chalk.dim('insights')} ${chalk.dim('→')} sessions list${dimDot()}${chalk.dim('prs')} ${chalk.dim('→')} records`,
353
+ ` ${chalk.dim('history')} ${chalk.dim('→')} exercises history${dimDot()}${chalk.dim('program')} ${chalk.dim('→')} programs current`,
354
+ ''
355
+ ];
356
+
357
+ return lines.join('\n');
358
+ }
359
+
360
+ export function formatPretty(command, payload) {
361
+ const formatter = {
362
+ records: formatRecords,
363
+ 'session-insights': formatSessionInsights,
364
+ 'session-show': formatSessionShow,
365
+ 'exercise-history': formatExerciseHistory,
366
+ 'program-summary': formatProgramSummary,
367
+ 'program-list': formatProgramList,
368
+ 'planned-vs-actual': formatPlannedVsActual,
369
+ 'why-did-this-change': formatWhyDidThisChange
370
+ }[command];
371
+
372
+ return formatter ? formatter(payload) : null;
72
373
  }
package/src/lib.js CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from './auth.js';
12
12
  import { clearSessionState, isSessionExpired, readSessionState, resolveConfigDir } from './state.js';
13
13
  import { createTransport } from './transport.js';
14
- import { formatPretty } from './format.js';
14
+ import { formatPretty, formatHelp } from './format.js';
15
15
 
16
16
  function parseArgs(argv) {
17
17
  const commandTokens = [];
@@ -68,9 +68,9 @@ export async function runCli(argv, stdout, stderr) {
68
68
  explain: 'why-did-this-change'
69
69
  })[command] ?? command;
70
70
 
71
- if (!command) {
72
- stderr.write('Usage: incremnt <sessions list|sessions show|sessions compare|sessions explain|programs list|programs current|exercises history|records|status|contract|login|logout> [options]\n');
73
- return 1;
71
+ if (!command || options.help) {
72
+ stdout.write(`${formatHelp()}\n`);
73
+ return 0;
74
74
  }
75
75
 
76
76
  const sessionState = await readSessionState();
@@ -160,7 +160,11 @@ export async function runCli(argv, stdout, stderr) {
160
160
  } else if (providers.length === 1 && auth?.providers?.google?.configured) {
161
161
  verificationUrl = `${baseUrlNormalized}/auth/google/start?userCode=${userCodeParam}`;
162
162
  } else {
163
- verificationUrl = `${baseUrlNormalized}${challenge.verificationUri ?? '/auth/device/approve'}?userCode=${userCodeParam}`;
163
+ const path = challenge.verificationUri ?? '/auth/device/approve';
164
+ if (!/^\/[a-zA-Z0-9\-._~/]+$/.test(path)) {
165
+ throw new Error(`Server returned an invalid verificationUri: ${path}`);
166
+ }
167
+ verificationUrl = `${baseUrlNormalized}${path}?userCode=${userCodeParam}`;
164
168
  }
165
169
 
166
170
  stderr.write('Signing in...\n');
@@ -266,8 +270,14 @@ export async function runCli(argv, stdout, stderr) {
266
270
 
267
271
  try {
268
272
  const payload = await transport.executeReadCommand(normalizedCommand, options);
269
- const pretty = options.pretty ? formatPretty(normalizedCommand, payload) : null;
270
- stdout.write(pretty ? `${pretty}\n` : `${JSON.stringify(payload, null, 2)}\n`);
273
+ const wantJson = options.json || !(stdout.isTTY ?? false);
274
+ if (wantJson) {
275
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
276
+ } else {
277
+ const pretty = formatPretty(normalizedCommand, payload);
278
+ stdout.write(`${pretty ?? JSON.stringify(payload, null, 2)}\n`);
279
+ }
280
+
271
281
  return 0;
272
282
  } catch (error) {
273
283
  stderr.write(`${error.message}\n`);