incremnt 0.1.0
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/README.md +66 -0
- package/package.json +17 -0
- package/src/auth.js +319 -0
- package/src/contract.js +40 -0
- package/src/format.js +72 -0
- package/src/index.js +6 -0
- package/src/lib.js +341 -0
- package/src/local.js +59 -0
- package/src/queries.js +335 -0
- package/src/remote.js +161 -0
- package/src/service-url.js +7 -0
- package/src/state.js +129 -0
- package/src/sync-service.js +1165 -0
- package/src/transport.js +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# incremnt
|
|
2
|
+
|
|
3
|
+
Command-line tool for querying your [incremnt](https://incremnt.app) strength training data.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g incremnt
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Hosted sync (recommended)
|
|
14
|
+
|
|
15
|
+
After connecting with Apple in the iOS app (Settings > Workout Sync), your workouts sync automatically. To access them from the CLI:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
incremnt login
|
|
19
|
+
incremnt sessions list --limit 5
|
|
20
|
+
incremnt records
|
|
21
|
+
incremnt records --pretty
|
|
22
|
+
incremnt programs current
|
|
23
|
+
incremnt exercises history --name "Bench Press"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Local snapshot
|
|
27
|
+
|
|
28
|
+
If you prefer to work offline, export a snapshot from the app and point the CLI at it:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
incremnt sessions list --input ~/Downloads/export.onemore.json --limit 5
|
|
32
|
+
incremnt records --input ~/Downloads/export.onemore.json --pretty
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPSHOT`, then common local paths, then the most recent `.onemore.json` in `~/Downloads`.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Description |
|
|
40
|
+
|---------|-------------|
|
|
41
|
+
| `sessions list` | Recent sessions with duration and exercise count |
|
|
42
|
+
| `sessions show --id <id>` | Details for a single session |
|
|
43
|
+
| `programs current` | Active program state |
|
|
44
|
+
| `programs list` | All programs |
|
|
45
|
+
| `exercises history --name <name>` | Set-by-set history for an exercise |
|
|
46
|
+
| `records` | Personal records (best e1RM per exercise) |
|
|
47
|
+
| `login` | Authenticate with the hosted sync service |
|
|
48
|
+
| `logout` | Clear stored session |
|
|
49
|
+
| `status` | Show current mode, auth state, and config paths |
|
|
50
|
+
| `contract` | Machine-readable command surface for scripts |
|
|
51
|
+
|
|
52
|
+
## Flags
|
|
53
|
+
|
|
54
|
+
| Flag | Description |
|
|
55
|
+
|------|-------------|
|
|
56
|
+
| `--pretty` | Human-readable formatted output (default is JSON) |
|
|
57
|
+
| `--input <path>` | Path to a local `.onemore.json` snapshot |
|
|
58
|
+
| `--limit <n>` | Limit number of results (for `sessions list`) |
|
|
59
|
+
|
|
60
|
+
## Exercise matching
|
|
61
|
+
|
|
62
|
+
`exercises history --name "Bench Press"` uses canonical synonym matching, so it finds `Barbell Bench Press` without pulling in incline, machine, or dumbbell variants.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "incremnt",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line tool for querying your incremnt strength training data",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"incremnt": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test",
|
|
15
|
+
"dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { contractVersion } from './contract.js';
|
|
3
|
+
import { writeSessionState } from './state.js';
|
|
4
|
+
import { readSnapshot } from './local.js';
|
|
5
|
+
import { resolveServiceUrl } from './service-url.js';
|
|
6
|
+
|
|
7
|
+
export async function importSessionFile(sessionFilePath) {
|
|
8
|
+
const raw = await fs.readFile(sessionFilePath, 'utf8');
|
|
9
|
+
const session = JSON.parse(raw);
|
|
10
|
+
return writeSessionState(session);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function bootstrapSessionFromSnapshot(snapshotPath) {
|
|
14
|
+
await readSnapshot(snapshotPath);
|
|
15
|
+
|
|
16
|
+
return writeSessionState({
|
|
17
|
+
version: 1,
|
|
18
|
+
mode: 'remote',
|
|
19
|
+
account: {
|
|
20
|
+
id: 'bootstrap-user',
|
|
21
|
+
email: null
|
|
22
|
+
},
|
|
23
|
+
auth: {
|
|
24
|
+
accessToken: 'bootstrap-token',
|
|
25
|
+
refreshToken: null,
|
|
26
|
+
expiresAt: '2999-01-01T00:00:00Z'
|
|
27
|
+
},
|
|
28
|
+
transport: {
|
|
29
|
+
fixturePath: snapshotPath
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function bootstrapSessionFromRemoteBaseUrl(baseUrl, token, account = null) {
|
|
35
|
+
const issuedSession = await issueRemoteSession(baseUrl, token);
|
|
36
|
+
const remoteContract = await fetchRemoteContract(baseUrl, issuedSession.session.accessToken);
|
|
37
|
+
|
|
38
|
+
return writeSessionState({
|
|
39
|
+
version: 1,
|
|
40
|
+
mode: 'remote',
|
|
41
|
+
account: account ?? issuedSession.account ?? {
|
|
42
|
+
id: 'remote-user',
|
|
43
|
+
email: null
|
|
44
|
+
},
|
|
45
|
+
auth: {
|
|
46
|
+
accessToken: issuedSession.session.accessToken,
|
|
47
|
+
refreshToken: null,
|
|
48
|
+
expiresAt: issuedSession.session.expiresAt
|
|
49
|
+
},
|
|
50
|
+
sync: {
|
|
51
|
+
verifiedAt: new Date().toISOString()
|
|
52
|
+
},
|
|
53
|
+
transport: {
|
|
54
|
+
baseUrl,
|
|
55
|
+
contractVersion: remoteContract.contractVersion,
|
|
56
|
+
capabilities: remoteContract.capabilities ?? null
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function bootstrapSessionFromRemoteBaseUrlWithDeviceFlow(baseUrl, {
|
|
62
|
+
authConfig = null,
|
|
63
|
+
onChallenge = null,
|
|
64
|
+
timeoutMs = 60 * 1000
|
|
65
|
+
} = {}) {
|
|
66
|
+
const challenge = await startDeviceLogin(baseUrl);
|
|
67
|
+
if (onChallenge) {
|
|
68
|
+
await onChallenge(challenge, authConfig);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const deadline = Date.now() + timeoutMs;
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
const result = await pollDeviceLogin(baseUrl, challenge.deviceCode);
|
|
74
|
+
if (result.status === 'approved') {
|
|
75
|
+
const remoteContract = await fetchRemoteContract(baseUrl, result.session.accessToken);
|
|
76
|
+
return writeSessionState({
|
|
77
|
+
version: 1,
|
|
78
|
+
mode: 'remote',
|
|
79
|
+
account: result.account ?? {
|
|
80
|
+
id: 'remote-user',
|
|
81
|
+
email: null
|
|
82
|
+
},
|
|
83
|
+
auth: {
|
|
84
|
+
accessToken: result.session.accessToken,
|
|
85
|
+
refreshToken: null,
|
|
86
|
+
expiresAt: result.session.expiresAt
|
|
87
|
+
},
|
|
88
|
+
sync: {
|
|
89
|
+
verifiedAt: new Date().toISOString()
|
|
90
|
+
},
|
|
91
|
+
transport: {
|
|
92
|
+
baseUrl,
|
|
93
|
+
contractVersion: remoteContract.contractVersion,
|
|
94
|
+
capabilities: remoteContract.capabilities ?? null
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (result.status !== 'pending') {
|
|
100
|
+
const error = new Error(result.message ?? 'Unable to complete device login.');
|
|
101
|
+
error.code = result.code ?? 'REMOTE_AUTH_ERROR';
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
await delay((result.intervalSeconds ?? challenge.intervalSeconds ?? 1) * 1000);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
throw new Error('Timed out waiting for device login approval.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function bootstrapSessionFromRemoteBaseUrlWithEmail(baseUrl, email, userId = null) {
|
|
112
|
+
const devLogin = await issueDevLogin(baseUrl, email, userId);
|
|
113
|
+
return bootstrapSessionFromRemoteBaseUrl(baseUrl, devLogin.token, devLogin.account);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function fetchRemoteAuthConfig(baseUrl) {
|
|
117
|
+
let response;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const url = resolveServiceUrl(baseUrl, '/auth/config');
|
|
121
|
+
response = await fetch(url);
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (response.status === 404) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
const payload = await response.json().catch(() => ({ error: null }));
|
|
132
|
+
const error = new Error(payload.error ?? 'Unable to fetch incremnt sync auth config.');
|
|
133
|
+
error.code = 'REMOTE_AUTH_ERROR';
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return response.json();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function startDeviceLogin(baseUrl) {
|
|
141
|
+
let response;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const url = resolveServiceUrl(baseUrl, '/auth/device/start');
|
|
145
|
+
response = await fetch(url, {
|
|
146
|
+
method: 'POST'
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
const error = new Error('Unable to reach incremnt sync service.');
|
|
150
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const payload = await response.json().catch(() => ({ error: null }));
|
|
156
|
+
const error = new Error(payload.error ?? 'Unable to start device login.');
|
|
157
|
+
error.code = 'REMOTE_AUTH_ERROR';
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return response.json();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function pollDeviceLogin(baseUrl, deviceCode) {
|
|
165
|
+
let response;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const url = resolveServiceUrl(baseUrl, '/auth/device/poll');
|
|
169
|
+
response = await fetch(url, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: {
|
|
172
|
+
'content-type': 'application/json'
|
|
173
|
+
},
|
|
174
|
+
body: JSON.stringify({ deviceCode })
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
const error = new Error('Unable to reach incremnt sync service.');
|
|
178
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
179
|
+
throw error;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (response.status === 202) {
|
|
183
|
+
const payload = await response.json();
|
|
184
|
+
return {
|
|
185
|
+
status: 'pending',
|
|
186
|
+
intervalSeconds: payload.intervalSeconds ?? 1
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (response.status === 410) {
|
|
191
|
+
return {
|
|
192
|
+
status: 'failed',
|
|
193
|
+
code: 'REMOTE_AUTH_EXPIRED',
|
|
194
|
+
message: 'Device login expired. Run incremnt login again.'
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
const payload = await response.json().catch(() => ({ error: null }));
|
|
200
|
+
return {
|
|
201
|
+
status: 'failed',
|
|
202
|
+
code: 'REMOTE_AUTH_ERROR',
|
|
203
|
+
message: payload.error ?? 'Unable to complete device login.'
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const payload = await response.json();
|
|
208
|
+
return {
|
|
209
|
+
status: 'approved',
|
|
210
|
+
session: payload.session,
|
|
211
|
+
account: payload.account
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function issueRemoteSession(baseUrl, token) {
|
|
216
|
+
let response;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const url = resolveServiceUrl(baseUrl, '/auth/session');
|
|
220
|
+
response = await fetch(url, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: {
|
|
223
|
+
Authorization: `Bearer ${token}`
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
} catch {
|
|
227
|
+
const error = new Error('Unable to reach incremnt sync service.');
|
|
228
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (response.status === 401 || response.status === 403) {
|
|
233
|
+
const error = new Error('Authentication failed. Check your token and run incremnt login again.');
|
|
234
|
+
error.code = 'REMOTE_AUTH_ERROR';
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!response.ok) {
|
|
239
|
+
const payload = await response.json().catch(() => ({ error: null }));
|
|
240
|
+
const error = new Error(payload.error ?? 'Unable to issue a remote session.');
|
|
241
|
+
error.code = 'REMOTE_AUTH_ERROR';
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return response.json();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function fetchRemoteContract(baseUrl, token) {
|
|
249
|
+
let response;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const url = resolveServiceUrl(baseUrl, '/cli/contract');
|
|
253
|
+
response = await fetch(url, {
|
|
254
|
+
headers: {
|
|
255
|
+
Authorization: `Bearer ${token}`
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
} catch {
|
|
259
|
+
const error = new Error('Unable to reach incremnt sync service.');
|
|
260
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (response.status === 401 || response.status === 403) {
|
|
265
|
+
const error = new Error('Authentication failed. Check your token and run incremnt login again.');
|
|
266
|
+
error.code = 'REMOTE_AUTH_ERROR';
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!response.ok) {
|
|
271
|
+
const error = new Error('Unable to reach incremnt sync service.');
|
|
272
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const payload = await response.json();
|
|
277
|
+
if (payload.contractVersion !== contractVersion) {
|
|
278
|
+
const error = new Error(`Remote incremnt sync service is incompatible with this CLI. Expected contract v${contractVersion}, got v${payload.contractVersion}.`);
|
|
279
|
+
error.code = 'REMOTE_CONTRACT_MISMATCH';
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return payload;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function issueDevLogin(baseUrl, email, userId) {
|
|
287
|
+
let response;
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
const url = resolveServiceUrl(baseUrl, '/auth/dev-login');
|
|
291
|
+
response = await fetch(url, {
|
|
292
|
+
method: 'POST',
|
|
293
|
+
headers: {
|
|
294
|
+
'content-type': 'application/json'
|
|
295
|
+
},
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
email,
|
|
298
|
+
userId
|
|
299
|
+
})
|
|
300
|
+
});
|
|
301
|
+
} catch {
|
|
302
|
+
const error = new Error('Unable to reach incremnt sync service.');
|
|
303
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
const payload = await response.json().catch(() => ({ error: null }));
|
|
309
|
+
const error = new Error(payload.error ?? 'Unable to issue a dev login token.');
|
|
310
|
+
error.code = 'REMOTE_AUTH_ERROR';
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return response.json();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function delay(ms) {
|
|
318
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
319
|
+
}
|
package/src/contract.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const contractVersion = 1;
|
|
2
|
+
|
|
3
|
+
export const capabilities = {
|
|
4
|
+
readOnly: true,
|
|
5
|
+
localSnapshots: true,
|
|
6
|
+
remoteReads: true,
|
|
7
|
+
remoteAuthShell: true,
|
|
8
|
+
remoteBootstrap: true
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const officialCommands = [
|
|
12
|
+
'sessions list',
|
|
13
|
+
'sessions show --id <session-id>',
|
|
14
|
+
'sessions compare --session-id <session-id>',
|
|
15
|
+
'sessions explain --session-id <session-id>',
|
|
16
|
+
'programs list',
|
|
17
|
+
'programs current',
|
|
18
|
+
'exercises history --name <exercise-name>',
|
|
19
|
+
'records',
|
|
20
|
+
'status',
|
|
21
|
+
'contract',
|
|
22
|
+
'login',
|
|
23
|
+
'login --base-url <base-url>',
|
|
24
|
+
'login --snapshot <snapshot-file>',
|
|
25
|
+
'login --base-url <base-url> --token <token>',
|
|
26
|
+
'login --base-url <base-url> --email <email>',
|
|
27
|
+
'login --session-file <session-file>',
|
|
28
|
+
'logout'
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export const readCommands = new Set([
|
|
32
|
+
'session-insights',
|
|
33
|
+
'session-show',
|
|
34
|
+
'exercise-history',
|
|
35
|
+
'records',
|
|
36
|
+
'program-list',
|
|
37
|
+
'program-summary',
|
|
38
|
+
'planned-vs-actual',
|
|
39
|
+
'why-did-this-change'
|
|
40
|
+
]);
|
package/src/format.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
2
|
+
const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
3
|
+
|
|
4
|
+
function formatShortDate(dateString) {
|
|
5
|
+
const date = new Date(dateString);
|
|
6
|
+
if (Number.isNaN(date.getTime())) {
|
|
7
|
+
return dateString;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return `${date.getDate()} ${shortMonths[date.getMonth()]}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatDayAndDate(dateString) {
|
|
14
|
+
const date = new Date(dateString);
|
|
15
|
+
if (Number.isNaN(date.getTime())) {
|
|
16
|
+
return dateString;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return `${shortDays[date.getDay()]} ${date.getDate()} ${shortMonths[date.getMonth()]}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatRecords(payload) {
|
|
23
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
24
|
+
return 'No records found.';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const maxNameLength = Math.max(...payload.map((record) => record.exerciseName.length));
|
|
28
|
+
|
|
29
|
+
return payload.map((record) => {
|
|
30
|
+
const name = record.exerciseName.padEnd(maxNameLength);
|
|
31
|
+
const date = formatShortDate(record.sessionDate);
|
|
32
|
+
const isBodyweight = Number(record.weight) === 0;
|
|
33
|
+
|
|
34
|
+
if (isBodyweight) {
|
|
35
|
+
const reps = `${record.reps} reps`.padStart(12);
|
|
36
|
+
return `${name} ${reps} BW \u00b7 ${date}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const weight = `${Number(record.weight).toFixed(1)} kg`.padStart(12);
|
|
40
|
+
return `${name} ${weight} e1RM \u00b7 ${date}`;
|
|
41
|
+
}).join('\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatSessionInsights(payload) {
|
|
45
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
46
|
+
return 'No sessions found.';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return payload.map((session) => {
|
|
50
|
+
const date = formatDayAndDate(session.sessionDate);
|
|
51
|
+
const dayName = session.dayName ?? 'Workout';
|
|
52
|
+
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}`;
|
|
57
|
+
|
|
58
|
+
return `${date} ${dayName}${suffix}`;
|
|
59
|
+
}).join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatPretty(command, payload) {
|
|
63
|
+
if (command === 'records') {
|
|
64
|
+
return formatRecords(payload);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (command === 'session-insights') {
|
|
68
|
+
return formatSessionInsights(payload);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|