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 CHANGED
@@ -16,26 +16,33 @@ CLI to fetch TrainerRoad data for your account, including:
16
16
 
17
17
  ## Install
18
18
 
19
- ### From GitHub
19
+ ### Run without install (npx)
20
20
 
21
21
  ```bash
22
- npx --yes github:quinnsprouse/trainerroad-cli help
22
+ npx --yes trainerroad-cli help
23
23
  ```
24
24
 
25
- ### Local development
25
+ ### Global install
26
26
 
27
27
  ```bash
28
- git clone https://github.com/quinnsprouse/trainerroad-cli.git
29
- cd trainerroad-cli
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
- ### Global
32
+ ### Local project install
35
33
 
36
34
  ```bash
37
- npm install -g github:quinnsprouse/trainerroad-cli
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trainerroad-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Unofficial CLI for authenticating with TrainerRoad and querying timeline/workout data",
5
5
  "main": "src/cli.mjs",
6
6
  "bin": {
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
- const date = new Date();
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) return value.slice(0, 10);
248
- return new Date(value).toISOString().slice(0, 10);
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: new Date(dateRaw).toISOString(),
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: new Date(dateRaw).toISOString(),
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(payload?.records) ? payload.records : Array.isArray(payload) ? payload : [];
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 payload !== "string") {
460
+ if (flags.json || typeof payloadWithTimeZone !== "string") {
414
461
  const content =
415
- typeof payload === "string" ? payload : `${JSON.stringify(payload, null, 2)}\n`;
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(payload) : String(payload);
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 { records: filteredRecords, filterSummary } = applyAgentRecordFilters(filtered, flags);
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
- lines.push(`- ${item.started} id=${item.id} type=${item.type} tss=${item.tss}`);
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 { records: filteredRecords, filterSummary } = applyAgentRecordFilters(detailRecords, flags);
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
- `- ${new Date(item.started).toISOString()} ${item.name} | id=${item.id} | tss=${item.tss} | duration=${item.durationInSeconds}s | prs=${item.personalRecordCount}`,
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
- ...activityRecords.map((item) => ({
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: activityRecords.length },
362
+ counts: { planned: plannedRecords.length, completed: completedWithLocalTime.length },
337
363
  planned: plannedRecords,
338
- completed: activityRecords,
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
- lines.push(`- completed ${new Date(item.started).toISOString()} ${item.name} | tss=${item.tss} | prs=${prs}`);
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
- lines.push(`- completed ${item.started} id=${item.id} type=${item.type} tss=${item.tss}`);
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) return value.slice(0, 10);
3
- return new Date(value).toISOString().slice(0, 10);
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) return value.slice(0, 10);
24
- return new Date(value).toISOString().slice(0, 10);
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 = toIsoDateOnly(item.started);
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;