trainerroad-cli 0.1.0 → 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.
- package/README.md +19 -10
- package/package.json +1 -1
- package/src/cli.mjs +71 -12
- package/src/commands/discovery.mjs +7 -0
- package/src/commands/workouts.mjs +41 -9
- package/src/lib/agent-filters.mjs +9 -2
- package/src/lib/command-manifest.mjs +3 -3
- package/src/lib/planning-normalizers.mjs +9 -2
- package/src/lib/timezone.mjs +187 -0
- package/src/trainerroad-client.mjs +7 -7
package/README.md
CHANGED
|
@@ -16,26 +16,33 @@ CLI to fetch TrainerRoad data for your account, including:
|
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
19
|
-
###
|
|
19
|
+
### Run without install (npx)
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
npx --yes
|
|
22
|
+
npx --yes trainerroad-cli help
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
###
|
|
25
|
+
### Global install
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
npm install
|
|
31
|
-
npm exec --yes trainerroad-cli -- help
|
|
28
|
+
npm install -g trainerroad-cli
|
|
29
|
+
trainerroad-cli help
|
|
32
30
|
```
|
|
33
31
|
|
|
34
|
-
###
|
|
32
|
+
### Local project install
|
|
35
33
|
|
|
36
34
|
```bash
|
|
37
|
-
npm install
|
|
38
|
-
trainerroad-cli help
|
|
35
|
+
npm install trainerroad-cli
|
|
36
|
+
npx trainerroad-cli help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Local development (from source)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git clone https://github.com/quinnsprouse/trainerroad-cli.git
|
|
43
|
+
cd trainerroad-cli
|
|
44
|
+
npm install
|
|
45
|
+
npm run help
|
|
39
46
|
```
|
|
40
47
|
|
|
41
48
|
## Quickstart
|
|
@@ -56,6 +63,7 @@ trainerroad-cli past --days 30 --json
|
|
|
56
63
|
trainerroad-cli plan --view current --json
|
|
57
64
|
trainerroad-cli levels --json
|
|
58
65
|
trainerroad-cli ftp --json
|
|
66
|
+
trainerroad-cli today --tz America/New_York --json
|
|
59
67
|
```
|
|
60
68
|
|
|
61
69
|
3. Discover all commands
|
|
@@ -80,6 +88,7 @@ Use `--target <username>` and/or `--public` for public mode queries.
|
|
|
80
88
|
- `--jsonl`: one record per line
|
|
81
89
|
- `--fields a,b,c`: project record fields
|
|
82
90
|
- `--records-only`: lighter record payloads
|
|
91
|
+
- `--tz <IANA timezone>`: localize day boundaries/timestamps (defaults to `TR_TIMEZONE` or system timezone)
|
|
83
92
|
|
|
84
93
|
## Security
|
|
85
94
|
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -33,6 +33,16 @@ import { commandPowerRanking, commandPowerRecords } from "./commands/power.mjs";
|
|
|
33
33
|
import { commandTimeline } from "./commands/timeline.mjs";
|
|
34
34
|
import { commandWeightHistory } from "./commands/weight-history.mjs";
|
|
35
35
|
import { commandFuture, commandPast, commandToday } from "./commands/workouts.mjs";
|
|
36
|
+
import {
|
|
37
|
+
formatDateTimeInTimeZone,
|
|
38
|
+
isoDateShiftInTimeZone,
|
|
39
|
+
normalizeTimeZone,
|
|
40
|
+
parseApiDateTime,
|
|
41
|
+
summarizeActivityTimeWindow,
|
|
42
|
+
toDateOnlyInTimeZone,
|
|
43
|
+
} from "./lib/timezone.mjs";
|
|
44
|
+
|
|
45
|
+
let ACTIVE_TIME_ZONE = normalizeTimeZone();
|
|
36
46
|
|
|
37
47
|
function printGlobalHelp() {
|
|
38
48
|
console.log("trainerroad-cli (unofficial)");
|
|
@@ -53,6 +63,7 @@ function printGlobalHelp() {
|
|
|
53
63
|
console.log("Examples:");
|
|
54
64
|
console.log(" node src/cli.mjs login --username quinnsprouse --password-stdin");
|
|
55
65
|
console.log(" node src/cli.mjs future --days 30 --details --json");
|
|
66
|
+
console.log(" node src/cli.mjs today --tz America/New_York --json");
|
|
56
67
|
console.log(" node src/cli.mjs future --from 2026-03-01 --to 2026-03-31 --min-tss 60 --fields id,title,tss --jsonl");
|
|
57
68
|
console.log(" node src/cli.mjs timeline --target quinnsprouse --public --json");
|
|
58
69
|
console.log(" node src/cli.mjs ftp --target quinnsprouse --public --json");
|
|
@@ -179,6 +190,7 @@ function printCommandHelp(command, flags = {}) {
|
|
|
179
190
|
command,
|
|
180
191
|
summary: def.summary,
|
|
181
192
|
usage: def.usage,
|
|
193
|
+
timezoneOption: "--tz <IANA timezone> (defaults to TR_TIMEZONE or system timezone)",
|
|
182
194
|
supportsAgentFilters: FILTERABLE_COMMANDS.has(command),
|
|
183
195
|
agentFilterOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_FILTER_OPTIONS : [],
|
|
184
196
|
agentOutputOptions: FILTERABLE_COMMANDS.has(command) ? AGENT_OUTPUT_OPTIONS : [],
|
|
@@ -194,6 +206,9 @@ function printCommandHelp(command, flags = {}) {
|
|
|
194
206
|
console.log("");
|
|
195
207
|
console.log("Usage:");
|
|
196
208
|
for (const line of def.usage) console.log(` ${line}`);
|
|
209
|
+
console.log("");
|
|
210
|
+
console.log("Timezone:");
|
|
211
|
+
console.log(" --tz <IANA timezone> Override local-day bucketing (defaults: TR_TIMEZONE or system timezone).");
|
|
197
212
|
if (FILTERABLE_COMMANDS.has(command)) {
|
|
198
213
|
console.log("");
|
|
199
214
|
console.log("Agent filters:");
|
|
@@ -234,9 +249,7 @@ function parseArgs(argv) {
|
|
|
234
249
|
}
|
|
235
250
|
|
|
236
251
|
function isoDateShift(days) {
|
|
237
|
-
|
|
238
|
-
date.setUTCDate(date.getUTCDate() + days);
|
|
239
|
-
return date.toISOString().slice(0, 10);
|
|
252
|
+
return isoDateShiftInTimeZone(days, ACTIVE_TIME_ZONE);
|
|
240
253
|
}
|
|
241
254
|
|
|
242
255
|
function toIsoDateFromPlanned(item) {
|
|
@@ -244,8 +257,26 @@ function toIsoDateFromPlanned(item) {
|
|
|
244
257
|
}
|
|
245
258
|
|
|
246
259
|
function toIsoDate(value) {
|
|
247
|
-
if (typeof value === "string" && value.length >= 10
|
|
248
|
-
|
|
260
|
+
if (typeof value === "string" && value.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
261
|
+
return value.slice(0, 10);
|
|
262
|
+
}
|
|
263
|
+
return (
|
|
264
|
+
toDateOnlyInTimeZone(value, ACTIVE_TIME_ZONE, { assumeUtcForOffsetlessDateTime: true }) ??
|
|
265
|
+
new Date(value).toISOString().slice(0, 10)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formatDateTime(value) {
|
|
270
|
+
return (
|
|
271
|
+
formatDateTimeInTimeZone(value, ACTIVE_TIME_ZONE, { assumeUtcForOffsetlessDateTime: true }) ??
|
|
272
|
+
String(value ?? "")
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function summarizeActivityTime(started, durationInSeconds) {
|
|
277
|
+
return summarizeActivityTimeWindow(started, durationInSeconds, ACTIVE_TIME_ZONE, {
|
|
278
|
+
assumeUtcForOffsetlessDateTime: true,
|
|
279
|
+
});
|
|
249
280
|
}
|
|
250
281
|
|
|
251
282
|
function normalizeDateOnlyInput(value, fallback) {
|
|
@@ -292,8 +323,10 @@ function normalizeFtpHistory(raw) {
|
|
|
292
323
|
const valueRaw = item?.value ?? item?.Value ?? null;
|
|
293
324
|
const value = Number(valueRaw);
|
|
294
325
|
if (!dateRaw || !Number.isFinite(value)) return null;
|
|
326
|
+
const parsedDate = parseApiDateTime(dateRaw, { assumeUtcForOffsetlessDateTime: true });
|
|
327
|
+
if (!parsedDate) return null;
|
|
295
328
|
return {
|
|
296
|
-
date:
|
|
329
|
+
date: parsedDate.toISOString(),
|
|
297
330
|
dateOnly: toIsoDate(dateRaw),
|
|
298
331
|
value,
|
|
299
332
|
};
|
|
@@ -328,9 +361,11 @@ function normalizeFitnessThresholds(raw) {
|
|
|
328
361
|
const valueRaw = item?.value ?? item?.Value ?? null;
|
|
329
362
|
const value = Number(valueRaw);
|
|
330
363
|
if (!dateRaw || !Number.isFinite(value)) return null;
|
|
364
|
+
const parsedDate = parseApiDateTime(dateRaw, { assumeUtcForOffsetlessDateTime: true });
|
|
365
|
+
if (!parsedDate) return null;
|
|
331
366
|
return {
|
|
332
367
|
id: item?.id ?? item?.Id ?? null,
|
|
333
|
-
date:
|
|
368
|
+
date: parsedDate.toISOString(),
|
|
334
369
|
dateOnly: toIsoDate(dateRaw),
|
|
335
370
|
value,
|
|
336
371
|
isApplied: Boolean(item?.isApplied ?? item?.IsApplied),
|
|
@@ -397,9 +432,21 @@ async function readPasswordFromStdin() {
|
|
|
397
432
|
return Buffer.concat(chunks).toString("utf8").trim();
|
|
398
433
|
}
|
|
399
434
|
|
|
435
|
+
function withTimeZoneMeta(payload) {
|
|
436
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
|
437
|
+
if (payload.timeZone != null) return payload;
|
|
438
|
+
return { ...payload, timeZone: ACTIVE_TIME_ZONE };
|
|
439
|
+
}
|
|
440
|
+
|
|
400
441
|
async function writeOutput(payload, flags, textRenderer = null) {
|
|
442
|
+
const payloadWithTimeZone = withTimeZoneMeta(payload);
|
|
443
|
+
|
|
401
444
|
if (flags.jsonl) {
|
|
402
|
-
const records = Array.isArray(
|
|
445
|
+
const records = Array.isArray(payloadWithTimeZone?.records)
|
|
446
|
+
? payloadWithTimeZone.records
|
|
447
|
+
: Array.isArray(payloadWithTimeZone)
|
|
448
|
+
? payloadWithTimeZone
|
|
449
|
+
: [];
|
|
403
450
|
const content = records.map((item) => JSON.stringify(item)).join("\n");
|
|
404
451
|
if (flags.output) {
|
|
405
452
|
await fs.writeFile(flags.output, `${content}${content ? "\n" : ""}`, "utf8");
|
|
@@ -410,9 +457,11 @@ async function writeOutput(payload, flags, textRenderer = null) {
|
|
|
410
457
|
return;
|
|
411
458
|
}
|
|
412
459
|
|
|
413
|
-
if (flags.json || typeof
|
|
460
|
+
if (flags.json || typeof payloadWithTimeZone !== "string") {
|
|
414
461
|
const content =
|
|
415
|
-
typeof
|
|
462
|
+
typeof payloadWithTimeZone === "string"
|
|
463
|
+
? payloadWithTimeZone
|
|
464
|
+
: `${JSON.stringify(payloadWithTimeZone, null, 2)}\n`;
|
|
416
465
|
if (flags.output) {
|
|
417
466
|
await fs.writeFile(flags.output, content, "utf8");
|
|
418
467
|
console.log(`Wrote JSON to ${flags.output}`);
|
|
@@ -422,7 +471,7 @@ async function writeOutput(payload, flags, textRenderer = null) {
|
|
|
422
471
|
return;
|
|
423
472
|
}
|
|
424
473
|
|
|
425
|
-
const text = textRenderer ? textRenderer(
|
|
474
|
+
const text = textRenderer ? textRenderer(payloadWithTimeZone) : String(payloadWithTimeZone);
|
|
426
475
|
if (flags.output) {
|
|
427
476
|
await fs.writeFile(flags.output, `${text}\n`, "utf8");
|
|
428
477
|
console.log(`Wrote text to ${flags.output}`);
|
|
@@ -532,6 +581,9 @@ async function main() {
|
|
|
532
581
|
process.exit(1);
|
|
533
582
|
}
|
|
534
583
|
|
|
584
|
+
ACTIVE_TIME_ZONE = normalizeTimeZone(flags.tz ?? null);
|
|
585
|
+
process.env.TR_TIMEZONE = ACTIVE_TIME_ZONE;
|
|
586
|
+
|
|
535
587
|
if (command === "help") {
|
|
536
588
|
if (positionals[0]) {
|
|
537
589
|
process.exit(printCommandHelp(positionals[0], flags));
|
|
@@ -550,6 +602,7 @@ async function main() {
|
|
|
550
602
|
}
|
|
551
603
|
|
|
552
604
|
const commandDeps = {
|
|
605
|
+
timeZone: ACTIVE_TIME_ZONE,
|
|
553
606
|
resolveQueryContext,
|
|
554
607
|
requirePrivateContext,
|
|
555
608
|
applyAgentRecordFilters,
|
|
@@ -562,11 +615,14 @@ async function main() {
|
|
|
562
615
|
normalizeDateOnlyInput,
|
|
563
616
|
isoDateShift,
|
|
564
617
|
filterFuturePlanned,
|
|
565
|
-
filterPastActivities,
|
|
618
|
+
filterPastActivities: (activities, fromDateIso, toDateIso) =>
|
|
619
|
+
filterPastActivities(activities, fromDateIso, toDateIso, ACTIVE_TIME_ZONE),
|
|
566
620
|
sortByDateAsc,
|
|
567
621
|
sortByDateDesc,
|
|
568
622
|
toIsoDateFromPlanned,
|
|
569
623
|
toIsoDate,
|
|
624
|
+
formatDateTime,
|
|
625
|
+
summarizeActivityTime,
|
|
570
626
|
withClient,
|
|
571
627
|
readPasswordFromStdin,
|
|
572
628
|
normalizeFtpHistory,
|
|
@@ -667,6 +723,9 @@ main().catch((error) => {
|
|
|
667
723
|
if (message.includes('Invalid date "')) {
|
|
668
724
|
console.error("Tip: expected date format is YYYY-MM-DD");
|
|
669
725
|
}
|
|
726
|
+
if (message.includes('Invalid timezone "')) {
|
|
727
|
+
console.error("Tip: use an IANA timezone like America/New_York.");
|
|
728
|
+
}
|
|
670
729
|
console.error('Run "trainerroad-cli help" or "trainerroad-cli help <command>" for usage.');
|
|
671
730
|
|
|
672
731
|
if (process.env.TR_CLI_DEBUG === "1" && error?.stack) {
|
|
@@ -30,6 +30,7 @@ export function buildDiscoveryPayload(level = 1, commandFilter = null) {
|
|
|
30
30
|
"node src/cli.mjs capabilities --json",
|
|
31
31
|
"node src/cli.mjs whoami --json",
|
|
32
32
|
"node src/cli.mjs future --days 30 --json",
|
|
33
|
+
"node src/cli.mjs today --tz America/New_York --json",
|
|
33
34
|
"node src/cli.mjs help future --json",
|
|
34
35
|
],
|
|
35
36
|
commandCount: commandEntries.length,
|
|
@@ -152,6 +153,11 @@ export async function commandCapabilities(flags, deps) {
|
|
|
152
153
|
outputOptions: AGENT_OUTPUT_OPTIONS,
|
|
153
154
|
},
|
|
154
155
|
outputModes: ["text", "json", "jsonl"],
|
|
156
|
+
timezone: {
|
|
157
|
+
flag: "--tz <IANA timezone>",
|
|
158
|
+
environment: "TR_TIMEZONE",
|
|
159
|
+
example: "America/New_York",
|
|
160
|
+
},
|
|
155
161
|
};
|
|
156
162
|
|
|
157
163
|
await writeOutput(payload, flags, () => {
|
|
@@ -160,6 +166,7 @@ export async function commandCapabilities(flags, deps) {
|
|
|
160
166
|
"- Private mode (authenticated): full timeline + workout details.",
|
|
161
167
|
"- Public mode (unauthenticated): day-level TSS/ride/planned signals + FTP history.",
|
|
162
168
|
"- Commands support both via automatic mode selection and --target/--public flags.",
|
|
169
|
+
"- Timezone-aware date bucketing: use --tz or TR_TIMEZONE for local-day accuracy.",
|
|
163
170
|
].join("\n");
|
|
164
171
|
});
|
|
165
172
|
}
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
function addLocalTimeSummary(record, summarizeActivityTime) {
|
|
2
|
+
if (!record || typeof record !== "object" || typeof summarizeActivityTime !== "function") {
|
|
3
|
+
return record;
|
|
4
|
+
}
|
|
5
|
+
const summary = summarizeActivityTime(record.started, record.durationInSeconds);
|
|
6
|
+
if (!summary) return record;
|
|
7
|
+
return { ...record, ...summary };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function addLocalTimeSummaryList(records, summarizeActivityTime) {
|
|
11
|
+
const rows = Array.isArray(records) ? records : [];
|
|
12
|
+
return rows.map((record) => addLocalTimeSummary(record, summarizeActivityTime));
|
|
13
|
+
}
|
|
14
|
+
|
|
1
15
|
export async function commandFuture(flags, deps) {
|
|
2
16
|
const {
|
|
3
17
|
requireNumber,
|
|
@@ -130,6 +144,7 @@ export async function commandPast(flags, deps) {
|
|
|
130
144
|
writeOutput,
|
|
131
145
|
hasAgentRecordTransforms,
|
|
132
146
|
sortByDateDesc,
|
|
147
|
+
summarizeActivityTime,
|
|
133
148
|
} = deps;
|
|
134
149
|
|
|
135
150
|
const days = requireNumber(flags.days, 60);
|
|
@@ -144,7 +159,8 @@ export async function commandPast(flags, deps) {
|
|
|
144
159
|
if (context.mode === "private") {
|
|
145
160
|
const filtered = filterPastActivities(context.timeline.activities, fromDate, toDate).slice(0, limit);
|
|
146
161
|
if (!flags.details) {
|
|
147
|
-
const
|
|
162
|
+
const recordsWithLocalTime = addLocalTimeSummaryList(filtered, summarizeActivityTime);
|
|
163
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(recordsWithLocalTime, flags);
|
|
148
164
|
const payload = {
|
|
149
165
|
mode: "private",
|
|
150
166
|
generatedAt: new Date().toISOString(),
|
|
@@ -165,7 +181,10 @@ export async function commandPast(flags, deps) {
|
|
|
165
181
|
return lines.join("\n");
|
|
166
182
|
}
|
|
167
183
|
for (const item of value.records) {
|
|
168
|
-
|
|
184
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
185
|
+
lines.push(
|
|
186
|
+
`- ${item.startedAtLocal ?? item.started} id=${item.id} type=${item.type} tss=${item.tss}${overnightLabel}`,
|
|
187
|
+
);
|
|
169
188
|
}
|
|
170
189
|
return lines.join("\n");
|
|
171
190
|
});
|
|
@@ -190,7 +209,11 @@ export async function commandPast(flags, deps) {
|
|
|
190
209
|
...item,
|
|
191
210
|
personalRecordCount: Array.isArray(personalRecords[item.id]) ? personalRecords[item.id].length : 0,
|
|
192
211
|
}));
|
|
193
|
-
const
|
|
212
|
+
const detailRecordsWithLocalTime = addLocalTimeSummaryList(detailRecords, summarizeActivityTime);
|
|
213
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(
|
|
214
|
+
detailRecordsWithLocalTime,
|
|
215
|
+
flags,
|
|
216
|
+
);
|
|
194
217
|
const payload = {
|
|
195
218
|
mode: "private",
|
|
196
219
|
generatedAt: new Date().toISOString(),
|
|
@@ -215,8 +238,9 @@ export async function commandPast(flags, deps) {
|
|
|
215
238
|
return lines.join("\n");
|
|
216
239
|
}
|
|
217
240
|
for (const item of value.records) {
|
|
241
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
218
242
|
lines.push(
|
|
219
|
-
`- ${
|
|
243
|
+
`- ${item.startedAtLocal ?? item.started} ${item.name} | id=${item.id} | tss=${item.tss} | duration=${item.durationInSeconds}s | prs=${item.personalRecordCount}${overnightLabel}`,
|
|
220
244
|
);
|
|
221
245
|
}
|
|
222
246
|
return lines.join("\n");
|
|
@@ -286,6 +310,7 @@ export async function commandToday(flags, deps) {
|
|
|
286
310
|
writeOutput,
|
|
287
311
|
hasAgentRecordTransforms,
|
|
288
312
|
toIsoDateFromPlanned,
|
|
313
|
+
summarizeActivityTime,
|
|
289
314
|
} = deps;
|
|
290
315
|
|
|
291
316
|
const today = normalizeDateOnlyInput(flags.date, isoDateShift(0));
|
|
@@ -316,9 +341,10 @@ export async function commandToday(flags, deps) {
|
|
|
316
341
|
);
|
|
317
342
|
}
|
|
318
343
|
|
|
344
|
+
const completedWithLocalTime = addLocalTimeSummaryList(activityRecords, summarizeActivityTime);
|
|
319
345
|
const records = [
|
|
320
346
|
...plannedRecords.map((item) => ({ recordType: "planned", ...item })),
|
|
321
|
-
...
|
|
347
|
+
...completedWithLocalTime.map((item) => ({
|
|
322
348
|
recordType: "completed",
|
|
323
349
|
...item,
|
|
324
350
|
personalRecordCount: Array.isArray(personalRecords[item.id]) ? personalRecords[item.id].length : 0,
|
|
@@ -333,9 +359,9 @@ export async function commandToday(flags, deps) {
|
|
|
333
359
|
query: { date: today, details: Boolean(flags.details) },
|
|
334
360
|
filters: filterSummary,
|
|
335
361
|
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
336
|
-
counts: { planned: plannedRecords.length, completed:
|
|
362
|
+
counts: { planned: plannedRecords.length, completed: completedWithLocalTime.length },
|
|
337
363
|
planned: plannedRecords,
|
|
338
|
-
completed:
|
|
364
|
+
completed: completedWithLocalTime,
|
|
339
365
|
personalRecords,
|
|
340
366
|
count: filteredRecords.length,
|
|
341
367
|
records: filteredRecords,
|
|
@@ -372,9 +398,15 @@ export async function commandToday(flags, deps) {
|
|
|
372
398
|
for (const item of value.completed) {
|
|
373
399
|
if (flags.details) {
|
|
374
400
|
const prs = Array.isArray(value.personalRecords[item.id]) ? value.personalRecords[item.id].length : 0;
|
|
375
|
-
|
|
401
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
402
|
+
lines.push(
|
|
403
|
+
`- completed ${item.startedAtLocal ?? item.started} ${item.name} | tss=${item.tss} | prs=${prs}${overnightLabel}`,
|
|
404
|
+
);
|
|
376
405
|
} else {
|
|
377
|
-
|
|
406
|
+
const overnightLabel = item.crossesMidnightLocal ? " | overnight=true" : "";
|
|
407
|
+
lines.push(
|
|
408
|
+
`- completed ${item.startedAtLocal ?? item.started} id=${item.id} type=${item.type} tss=${item.tss}${overnightLabel}`,
|
|
409
|
+
);
|
|
378
410
|
}
|
|
379
411
|
}
|
|
380
412
|
return lines.join("\n");
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import { normalizeTimeZone, toDateOnlyInTimeZone } from "./timezone.mjs";
|
|
2
|
+
|
|
1
3
|
function toIsoDate(value) {
|
|
2
|
-
if (typeof value === "string" && value.length >= 10
|
|
3
|
-
|
|
4
|
+
if (typeof value === "string" && value.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
5
|
+
return value.slice(0, 10);
|
|
6
|
+
}
|
|
7
|
+
return (
|
|
8
|
+
toDateOnlyInTimeZone(value, normalizeTimeZone(), { assumeUtcForOffsetlessDateTime: true }) ??
|
|
9
|
+
new Date(value).toISOString().slice(0, 10)
|
|
10
|
+
);
|
|
4
11
|
}
|
|
5
12
|
|
|
6
13
|
function toIsoDateFromPlannedRecord(record) {
|
|
@@ -115,7 +115,8 @@ export const PROJECT_NOTICE = "Unofficial tool. Not affiliated with or endorsed
|
|
|
115
115
|
|
|
116
116
|
export const GLOBAL_NOTES = [
|
|
117
117
|
PROJECT_NOTICE,
|
|
118
|
-
"Environment: TR_USERNAME, TR_PASSWORD, TR_SESSION_FILE",
|
|
118
|
+
"Environment: TR_USERNAME, TR_PASSWORD, TR_SESSION_FILE, TR_TIMEZONE",
|
|
119
|
+
"Timezone: --tz <IANA timezone> (for example America/New_York).",
|
|
119
120
|
"Output modes: default JSON, --json, --jsonl, --output <path>",
|
|
120
121
|
"Session file default: .trainerroad/session.json",
|
|
121
122
|
"Private mode: authenticated cookie session + full workout endpoints.",
|
|
@@ -161,8 +162,7 @@ function trimFlagPrefix(flag) {
|
|
|
161
162
|
|
|
162
163
|
function mergeFlagGroups(...groups) {
|
|
163
164
|
return new Set(
|
|
164
|
-
groups
|
|
165
|
-
.flat()
|
|
165
|
+
[...groups.flat(), "tz"]
|
|
166
166
|
.map((flag) => trimFlagPrefix(flag))
|
|
167
167
|
.filter(Boolean),
|
|
168
168
|
);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeTimeZone, toDateOnlyInTimeZone } from "./timezone.mjs";
|
|
2
|
+
|
|
1
3
|
const PROGRESSION_ZONE_META = {
|
|
2
4
|
33: { zoneKey: "endurance", zoneLabel: "Endurance", sortOrder: 1 },
|
|
3
5
|
16: { zoneKey: "tempo", zoneLabel: "Tempo", sortOrder: 2 },
|
|
@@ -20,8 +22,13 @@ function toIsoDateFromPlanned(item) {
|
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export function toIsoDate(value) {
|
|
23
|
-
if (typeof value === "string" && value.length >= 10
|
|
24
|
-
|
|
25
|
+
if (typeof value === "string" && value.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
26
|
+
return value.slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
return (
|
|
29
|
+
toDateOnlyInTimeZone(value, normalizeTimeZone(), { assumeUtcForOffsetlessDateTime: true }) ??
|
|
30
|
+
new Date(value).toISOString().slice(0, 10)
|
|
31
|
+
);
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
function toIsoDateFromCalendarDate(dateValue) {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
2
|
+
const DATE_TIME_WITH_OFFSET_PATTERN = /(Z|[+\-]\d{2}:\d{2})$/i;
|
|
3
|
+
|
|
4
|
+
function isFiniteDate(date) {
|
|
5
|
+
return date instanceof Date && Number.isFinite(date.getTime());
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function pickPart(parts, type) {
|
|
9
|
+
const found = parts.find((part) => part.type === type);
|
|
10
|
+
return found ? found.value : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toDateTimeParts(date, timeZone) {
|
|
14
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
15
|
+
timeZone,
|
|
16
|
+
year: "numeric",
|
|
17
|
+
month: "2-digit",
|
|
18
|
+
day: "2-digit",
|
|
19
|
+
hour: "2-digit",
|
|
20
|
+
minute: "2-digit",
|
|
21
|
+
second: "2-digit",
|
|
22
|
+
hour12: false,
|
|
23
|
+
timeZoneName: "shortOffset",
|
|
24
|
+
});
|
|
25
|
+
return formatter.formatToParts(date);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toDateOnlyParts(date, timeZone) {
|
|
29
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
30
|
+
timeZone,
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "2-digit",
|
|
33
|
+
day: "2-digit",
|
|
34
|
+
});
|
|
35
|
+
return formatter.formatToParts(date);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeTimeZone(value = null, fallback = null) {
|
|
39
|
+
const fromArg = typeof value === "string" ? value.trim() : "";
|
|
40
|
+
const fromEnv = typeof process.env.TR_TIMEZONE === "string" ? process.env.TR_TIMEZONE.trim() : "";
|
|
41
|
+
const fromIntl = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
42
|
+
const candidate = fromArg || fromEnv || fallback || fromIntl || "UTC";
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
new Intl.DateTimeFormat("en-US", { timeZone: candidate });
|
|
46
|
+
return candidate;
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`Invalid timezone "${candidate}". Use an IANA timezone like "America/New_York".`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseApiDateTime(value, { assumeUtcForOffsetlessDateTime = true } = {}) {
|
|
53
|
+
if (value == null) return null;
|
|
54
|
+
|
|
55
|
+
if (value instanceof Date) {
|
|
56
|
+
return isFiniteDate(value) ? new Date(value.getTime()) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof value === "number") {
|
|
60
|
+
const parsed = new Date(value);
|
|
61
|
+
return isFiniteDate(parsed) ? parsed : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof value !== "string") return null;
|
|
65
|
+
const raw = value.trim();
|
|
66
|
+
if (!raw) return null;
|
|
67
|
+
|
|
68
|
+
if (DATE_ONLY_PATTERN.test(raw)) {
|
|
69
|
+
const parsedDateOnly = new Date(`${raw}T00:00:00Z`);
|
|
70
|
+
return isFiniteDate(parsedDateOnly) ? parsedDateOnly : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let normalized = raw;
|
|
74
|
+
if (
|
|
75
|
+
assumeUtcForOffsetlessDateTime &&
|
|
76
|
+
/^\d{4}-\d{2}-\d{2}T/.test(raw) &&
|
|
77
|
+
!DATE_TIME_WITH_OFFSET_PATTERN.test(raw)
|
|
78
|
+
) {
|
|
79
|
+
normalized = `${raw}Z`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const parsed = new Date(normalized);
|
|
83
|
+
return isFiniteDate(parsed) ? parsed : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toDateOnlyInTimeZone(
|
|
87
|
+
value,
|
|
88
|
+
timeZone,
|
|
89
|
+
{ assumeUtcForOffsetlessDateTime = true } = {},
|
|
90
|
+
) {
|
|
91
|
+
if (typeof value === "string" && DATE_ONLY_PATTERN.test(value.trim())) {
|
|
92
|
+
return value.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parsed = parseApiDateTime(value, { assumeUtcForOffsetlessDateTime });
|
|
96
|
+
if (!parsed) return null;
|
|
97
|
+
|
|
98
|
+
const parts = toDateOnlyParts(parsed, normalizeTimeZone(timeZone));
|
|
99
|
+
const year = pickPart(parts, "year");
|
|
100
|
+
const month = pickPart(parts, "month");
|
|
101
|
+
const day = pickPart(parts, "day");
|
|
102
|
+
if (!year || !month || !day) return null;
|
|
103
|
+
return `${year}-${month}-${day}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function formatDateTimeInTimeZone(
|
|
107
|
+
value,
|
|
108
|
+
timeZone,
|
|
109
|
+
{ assumeUtcForOffsetlessDateTime = true } = {},
|
|
110
|
+
) {
|
|
111
|
+
const parsed = parseApiDateTime(value, { assumeUtcForOffsetlessDateTime });
|
|
112
|
+
if (!parsed) return null;
|
|
113
|
+
|
|
114
|
+
const parts = toDateTimeParts(parsed, normalizeTimeZone(timeZone));
|
|
115
|
+
const year = pickPart(parts, "year");
|
|
116
|
+
const month = pickPart(parts, "month");
|
|
117
|
+
const day = pickPart(parts, "day");
|
|
118
|
+
const hour = pickPart(parts, "hour");
|
|
119
|
+
const minute = pickPart(parts, "minute");
|
|
120
|
+
const second = pickPart(parts, "second");
|
|
121
|
+
const timeZoneName = pickPart(parts, "timeZoneName");
|
|
122
|
+
if (!year || !month || !day || !hour || !minute || !second) return null;
|
|
123
|
+
|
|
124
|
+
const suffix = timeZoneName ? ` ${timeZoneName}` : "";
|
|
125
|
+
return `${year}-${month}-${day}T${hour}:${minute}:${second}${suffix}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function shiftDateOnly(dateOnly, days) {
|
|
129
|
+
const parsed = parseApiDateTime(dateOnly, { assumeUtcForOffsetlessDateTime: false });
|
|
130
|
+
if (!parsed) return null;
|
|
131
|
+
parsed.setUTCDate(parsed.getUTCDate() + Number(days));
|
|
132
|
+
return parsed.toISOString().slice(0, 10);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function dateOnlyNowInTimeZone(timeZone) {
|
|
136
|
+
return toDateOnlyInTimeZone(new Date(), normalizeTimeZone(timeZone), {
|
|
137
|
+
assumeUtcForOffsetlessDateTime: false,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isoDateShiftInTimeZone(days, timeZone) {
|
|
142
|
+
const today = dateOnlyNowInTimeZone(timeZone);
|
|
143
|
+
return shiftDateOnly(today, days);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function summarizeActivityTimeWindow(
|
|
147
|
+
started,
|
|
148
|
+
durationInSeconds,
|
|
149
|
+
timeZone,
|
|
150
|
+
{ assumeUtcForOffsetlessDateTime = true } = {},
|
|
151
|
+
) {
|
|
152
|
+
const startDate = parseApiDateTime(started, { assumeUtcForOffsetlessDateTime });
|
|
153
|
+
if (!startDate) return null;
|
|
154
|
+
|
|
155
|
+
const duration =
|
|
156
|
+
durationInSeconds != null && Number.isFinite(Number(durationInSeconds))
|
|
157
|
+
? Number(durationInSeconds)
|
|
158
|
+
: null;
|
|
159
|
+
const endDate = duration != null ? new Date(startDate.getTime() + duration * 1000) : null;
|
|
160
|
+
const normalizedTimeZone = normalizeTimeZone(timeZone);
|
|
161
|
+
|
|
162
|
+
const startedDateOnly = toDateOnlyInTimeZone(startDate, normalizedTimeZone, {
|
|
163
|
+
assumeUtcForOffsetlessDateTime: false,
|
|
164
|
+
});
|
|
165
|
+
const endedDateOnly = endDate
|
|
166
|
+
? toDateOnlyInTimeZone(endDate, normalizedTimeZone, {
|
|
167
|
+
assumeUtcForOffsetlessDateTime: false,
|
|
168
|
+
})
|
|
169
|
+
: null;
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
startedAtUtc: startDate.toISOString(),
|
|
173
|
+
startedAtLocal: formatDateTimeInTimeZone(startDate, normalizedTimeZone, {
|
|
174
|
+
assumeUtcForOffsetlessDateTime: false,
|
|
175
|
+
}),
|
|
176
|
+
endedAtUtc: endDate ? endDate.toISOString() : null,
|
|
177
|
+
endedAtLocal: endDate
|
|
178
|
+
? formatDateTimeInTimeZone(endDate, normalizedTimeZone, {
|
|
179
|
+
assumeUtcForOffsetlessDateTime: false,
|
|
180
|
+
})
|
|
181
|
+
: null,
|
|
182
|
+
localDate: startedDateOnly,
|
|
183
|
+
endLocalDate: endedDateOnly,
|
|
184
|
+
crossesMidnightLocal:
|
|
185
|
+
Boolean(startedDateOnly) && Boolean(endedDateOnly) && startedDateOnly !== endedDateOnly,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeTimeZone, toDateOnlyInTimeZone } from "./lib/timezone.mjs";
|
|
3
4
|
|
|
4
5
|
const BASE_URL = "https://www.trainerroad.com";
|
|
5
6
|
const APP_URL = `${BASE_URL}/app`;
|
|
@@ -11,11 +12,6 @@ function ensureLeadingSlash(value) {
|
|
|
11
12
|
return value;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
function toIsoDateOnly(value) {
|
|
15
|
-
if (typeof value === "string" && value.length >= 10) return value.slice(0, 10);
|
|
16
|
-
return new Date(value).toISOString().slice(0, 10);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
15
|
function plannedDateToIso(item) {
|
|
20
16
|
const year = String(item.date?.year ?? "").padStart(4, "0");
|
|
21
17
|
const month = String(item.date?.month ?? "").padStart(2, "0");
|
|
@@ -38,10 +34,14 @@ export function filterFuturePlanned(plannedActivities, fromDateIso, toDateIso =
|
|
|
38
34
|
});
|
|
39
35
|
}
|
|
40
36
|
|
|
41
|
-
export function filterPastActivities(activities, fromDateIso = null, toDateIso = null) {
|
|
37
|
+
export function filterPastActivities(activities, fromDateIso = null, toDateIso = null, timeZone = null) {
|
|
38
|
+
const resolvedTimeZone = normalizeTimeZone(timeZone);
|
|
42
39
|
return activities
|
|
43
40
|
.filter((item) => {
|
|
44
|
-
const startedDate =
|
|
41
|
+
const startedDate = toDateOnlyInTimeZone(item.started, resolvedTimeZone, {
|
|
42
|
+
assumeUtcForOffsetlessDateTime: true,
|
|
43
|
+
});
|
|
44
|
+
if (!startedDate) return false;
|
|
45
45
|
if (fromDateIso && startedDate < fromDateIso) return false;
|
|
46
46
|
if (toDateIso && startedDate > toDateIso) return false;
|
|
47
47
|
return true;
|