trainerroad-cli 0.1.0 → 0.2.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 +60 -11
- package/package.json +11 -1
- package/src/cli.mjs +119 -12
- package/src/commands/discovery.mjs +7 -0
- package/src/commands/train-now.mjs +142 -0
- package/src/commands/workout-library.mjs +430 -0
- package/src/commands/workout-mutations.mjs +230 -0
- package/src/commands/workout-recommend.mjs +175 -0
- package/src/commands/workout-tools.mjs +326 -0
- package/src/commands/workouts.mjs +41 -9
- package/src/lib/agent-filters.mjs +9 -2
- package/src/lib/command-manifest.mjs +181 -3
- package/src/lib/planning-normalizers.mjs +9 -2
- package/src/lib/timezone.mjs +187 -0
- package/src/trainerroad-client.mjs +299 -5
|
@@ -37,6 +37,12 @@ export const COMMANDS = {
|
|
|
37
37
|
"node src/cli.mjs timeline --target quinnsprouse --public --json",
|
|
38
38
|
],
|
|
39
39
|
},
|
|
40
|
+
"train-now": {
|
|
41
|
+
summary: "Fetch TrainerRoad AI suggested workouts (TrainNow) for a target duration (private mode).",
|
|
42
|
+
usage: [
|
|
43
|
+
"node src/cli.mjs train-now [--duration <minutes>] [--num-suggestions <n>] [--category climbing|endurance|attacking] [--json|--jsonl]",
|
|
44
|
+
],
|
|
45
|
+
},
|
|
40
46
|
events: {
|
|
41
47
|
summary: "Show calendar events/races from timeline (private mode).",
|
|
42
48
|
usage: [
|
|
@@ -105,6 +111,60 @@ export const COMMANDS = {
|
|
|
105
111
|
"node src/cli.mjs power-records [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--row-type 100|101] [--indoor-only true|false] [--limit <n>] [--full] [--json|--jsonl]",
|
|
106
112
|
],
|
|
107
113
|
},
|
|
114
|
+
"workout-library": {
|
|
115
|
+
summary: "Search the TrainerRoad workout library with agent-friendly filters (private mode).",
|
|
116
|
+
usage: [
|
|
117
|
+
'node src/cli.mjs workout-library [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--limit <n>] [--page-size <n>] [--json|--jsonl]',
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
"workout-recommend": {
|
|
121
|
+
summary: "Recommend library workouts by ranking candidates against target duration/level/TSS (private mode).",
|
|
122
|
+
usage: [
|
|
123
|
+
'node src/cli.mjs workout-recommend [--search <text>] [--zone <name>|--zone-id <id>] [--profile <name>|--profile-id <id>] [--outside true|false] [--has-instructions true|false] [--min-duration <minutes>] [--max-duration <minutes>] [--min-tss <n>] [--max-tss <n>] [--min-level <n>] [--max-level <n>] [--target-duration <minutes>] [--target-tss <n>] [--target-level <n>] [--count <n>] [--candidate-limit <n>] [--page-size <n>] [--sort level|level-desc|duration|duration-desc|tss|tss-desc|name|name-desc] [--json|--jsonl]',
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
"workout-details": {
|
|
127
|
+
summary: "Fetch detailed workout-library metadata for a workout ID (private mode).",
|
|
128
|
+
usage: [
|
|
129
|
+
"node src/cli.mjs workout-details --id <workout-id> [--include-chart true|false] [--chart-point-limit <n>] [--json|--jsonl]",
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
"add-workout": {
|
|
133
|
+
summary: "Add a library workout to the calendar on a target date (private mode; reconciles flaky API responses).",
|
|
134
|
+
usage: [
|
|
135
|
+
"node src/cli.mjs add-workout --workout-id <workout-id> --date YYYY-MM-DD [--outside true|false] [--json|--jsonl]",
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
"copy-workout": {
|
|
139
|
+
summary: "Copy an existing planned workout to another date (private mode).",
|
|
140
|
+
usage: [
|
|
141
|
+
"node src/cli.mjs copy-workout --id <planned-activity-id> --date YYYY-MM-DD [--json|--jsonl]",
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
"workout-alternates": {
|
|
145
|
+
summary: "List alternate workout options for a planned workout (private mode).",
|
|
146
|
+
usage: [
|
|
147
|
+
"node src/cli.mjs workout-alternates --id <planned-activity-id> [--category similar|easier|harder|longer|shorter] [--json|--jsonl]",
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
"move-workout": {
|
|
151
|
+
summary: "Move a planned workout to a different date (private mode).",
|
|
152
|
+
usage: [
|
|
153
|
+
"node src/cli.mjs move-workout --id <planned-activity-id> --to YYYY-MM-DD [--json|--jsonl]",
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
"replace-workout": {
|
|
157
|
+
summary: "Replace a planned workout with a specific alternate workout ID (private mode).",
|
|
158
|
+
usage: [
|
|
159
|
+
"node src/cli.mjs replace-workout --id <planned-activity-id> --alternate-id <workout-id> [--update-duration true|false] [--json|--jsonl]",
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
"switch-workout": {
|
|
163
|
+
summary: "Switch a planned workout between inside and outside variants (private mode).",
|
|
164
|
+
usage: [
|
|
165
|
+
"node src/cli.mjs switch-workout --id <planned-activity-id> --mode inside|outside [--json|--jsonl]",
|
|
166
|
+
],
|
|
167
|
+
},
|
|
108
168
|
logout: {
|
|
109
169
|
summary: "Clear local persisted session.",
|
|
110
170
|
usage: ["node src/cli.mjs logout"],
|
|
@@ -115,7 +175,8 @@ export const PROJECT_NOTICE = "Unofficial tool. Not affiliated with or endorsed
|
|
|
115
175
|
|
|
116
176
|
export const GLOBAL_NOTES = [
|
|
117
177
|
PROJECT_NOTICE,
|
|
118
|
-
"Environment: TR_USERNAME, TR_PASSWORD, TR_SESSION_FILE",
|
|
178
|
+
"Environment: TR_USERNAME, TR_PASSWORD, TR_SESSION_FILE, TR_TIMEZONE",
|
|
179
|
+
"Timezone: --tz <IANA timezone> (for example America/New_York).",
|
|
119
180
|
"Output modes: default JSON, --json, --jsonl, --output <path>",
|
|
120
181
|
"Session file default: .trainerroad/session.json",
|
|
121
182
|
"Private mode: authenticated cookie session + full workout endpoints.",
|
|
@@ -161,8 +222,7 @@ function trimFlagPrefix(flag) {
|
|
|
161
222
|
|
|
162
223
|
function mergeFlagGroups(...groups) {
|
|
163
224
|
return new Set(
|
|
164
|
-
groups
|
|
165
|
-
.flat()
|
|
225
|
+
[...groups.flat(), "tz"]
|
|
166
226
|
.map((flag) => trimFlagPrefix(flag))
|
|
167
227
|
.filter(Boolean),
|
|
168
228
|
);
|
|
@@ -212,6 +272,14 @@ export const COMMAND_FLAG_ALLOWLIST = {
|
|
|
212
272
|
SHARED_FLAGS.publicProfile,
|
|
213
273
|
["full"],
|
|
214
274
|
),
|
|
275
|
+
"train-now": mergeFlagGroups(
|
|
276
|
+
SHARED_FLAGS.help,
|
|
277
|
+
SHARED_FLAGS.output,
|
|
278
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
279
|
+
SHARED_FLAGS.session,
|
|
280
|
+
SHARED_FLAGS.credentials,
|
|
281
|
+
["duration", "num-suggestions", "category"],
|
|
282
|
+
),
|
|
215
283
|
events: mergeFlagGroups(
|
|
216
284
|
SHARED_FLAGS.help,
|
|
217
285
|
SHARED_FLAGS.output,
|
|
@@ -324,5 +392,115 @@ export const COMMAND_FLAG_ALLOWLIST = {
|
|
|
324
392
|
SHARED_FLAGS.credentials,
|
|
325
393
|
["start-date", "end-date", "row-type", "indoor-only", "slot", "limit", "full"],
|
|
326
394
|
),
|
|
395
|
+
"workout-library": mergeFlagGroups(
|
|
396
|
+
SHARED_FLAGS.help,
|
|
397
|
+
SHARED_FLAGS.output,
|
|
398
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
399
|
+
SHARED_FLAGS.session,
|
|
400
|
+
SHARED_FLAGS.credentials,
|
|
401
|
+
[
|
|
402
|
+
"search",
|
|
403
|
+
"zone",
|
|
404
|
+
"zone-id",
|
|
405
|
+
"profile",
|
|
406
|
+
"profile-id",
|
|
407
|
+
"outside",
|
|
408
|
+
"has-instructions",
|
|
409
|
+
"min-duration",
|
|
410
|
+
"max-duration",
|
|
411
|
+
"min-tss",
|
|
412
|
+
"max-tss",
|
|
413
|
+
"min-level",
|
|
414
|
+
"max-level",
|
|
415
|
+
"sort",
|
|
416
|
+
"limit",
|
|
417
|
+
"page-size",
|
|
418
|
+
],
|
|
419
|
+
),
|
|
420
|
+
"workout-recommend": mergeFlagGroups(
|
|
421
|
+
SHARED_FLAGS.help,
|
|
422
|
+
SHARED_FLAGS.output,
|
|
423
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
424
|
+
SHARED_FLAGS.session,
|
|
425
|
+
SHARED_FLAGS.credentials,
|
|
426
|
+
[
|
|
427
|
+
"search",
|
|
428
|
+
"zone",
|
|
429
|
+
"zone-id",
|
|
430
|
+
"profile",
|
|
431
|
+
"profile-id",
|
|
432
|
+
"outside",
|
|
433
|
+
"has-instructions",
|
|
434
|
+
"min-duration",
|
|
435
|
+
"max-duration",
|
|
436
|
+
"min-tss",
|
|
437
|
+
"max-tss",
|
|
438
|
+
"min-level",
|
|
439
|
+
"max-level",
|
|
440
|
+
"target-duration",
|
|
441
|
+
"target-tss",
|
|
442
|
+
"target-level",
|
|
443
|
+
"count",
|
|
444
|
+
"candidate-limit",
|
|
445
|
+
"page-size",
|
|
446
|
+
"sort",
|
|
447
|
+
],
|
|
448
|
+
),
|
|
449
|
+
"workout-details": mergeFlagGroups(
|
|
450
|
+
SHARED_FLAGS.help,
|
|
451
|
+
SHARED_FLAGS.output,
|
|
452
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
453
|
+
SHARED_FLAGS.session,
|
|
454
|
+
SHARED_FLAGS.credentials,
|
|
455
|
+
["id", "include-chart", "chart-point-limit"],
|
|
456
|
+
),
|
|
457
|
+
"add-workout": mergeFlagGroups(
|
|
458
|
+
SHARED_FLAGS.help,
|
|
459
|
+
SHARED_FLAGS.output,
|
|
460
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
461
|
+
SHARED_FLAGS.session,
|
|
462
|
+
SHARED_FLAGS.credentials,
|
|
463
|
+
["workout-id", "date", "outside"],
|
|
464
|
+
),
|
|
465
|
+
"copy-workout": mergeFlagGroups(
|
|
466
|
+
SHARED_FLAGS.help,
|
|
467
|
+
SHARED_FLAGS.output,
|
|
468
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
469
|
+
SHARED_FLAGS.session,
|
|
470
|
+
SHARED_FLAGS.credentials,
|
|
471
|
+
["id", "date"],
|
|
472
|
+
),
|
|
473
|
+
"workout-alternates": mergeFlagGroups(
|
|
474
|
+
SHARED_FLAGS.help,
|
|
475
|
+
SHARED_FLAGS.output,
|
|
476
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
477
|
+
SHARED_FLAGS.session,
|
|
478
|
+
SHARED_FLAGS.credentials,
|
|
479
|
+
["id", "category"],
|
|
480
|
+
),
|
|
481
|
+
"move-workout": mergeFlagGroups(
|
|
482
|
+
SHARED_FLAGS.help,
|
|
483
|
+
SHARED_FLAGS.output,
|
|
484
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
485
|
+
SHARED_FLAGS.session,
|
|
486
|
+
SHARED_FLAGS.credentials,
|
|
487
|
+
["id", "to"],
|
|
488
|
+
),
|
|
489
|
+
"replace-workout": mergeFlagGroups(
|
|
490
|
+
SHARED_FLAGS.help,
|
|
491
|
+
SHARED_FLAGS.output,
|
|
492
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
493
|
+
SHARED_FLAGS.session,
|
|
494
|
+
SHARED_FLAGS.credentials,
|
|
495
|
+
["id", "alternate-id", "update-duration"],
|
|
496
|
+
),
|
|
497
|
+
"switch-workout": mergeFlagGroups(
|
|
498
|
+
SHARED_FLAGS.help,
|
|
499
|
+
SHARED_FLAGS.output,
|
|
500
|
+
SHARED_FLAGS.jsonAndJsonl,
|
|
501
|
+
SHARED_FLAGS.session,
|
|
502
|
+
SHARED_FLAGS.credentials,
|
|
503
|
+
["id", "mode"],
|
|
504
|
+
),
|
|
327
505
|
logout: mergeFlagGroups(SHARED_FLAGS.help, SHARED_FLAGS.output, SHARED_FLAGS.session),
|
|
328
506
|
};
|
|
@@ -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
|
+
}
|