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.
@@ -0,0 +1,175 @@
1
+ import { queryWorkoutLibrary } from "./workout-library.mjs";
2
+
3
+ async function requirePrivateMember(flags, deps) {
4
+ const { withClient } = deps;
5
+ const client = await withClient(flags);
6
+ try {
7
+ const memberInfo = await client.getMemberInfo();
8
+ return { client, memberInfo };
9
+ } catch {
10
+ throw new Error(
11
+ "workout-recommend requires private authenticated mode. Login first with trainerroad-cli login.",
12
+ );
13
+ }
14
+ }
15
+
16
+ function numericOrNull(value, deps) {
17
+ if (value == null) return null;
18
+ return deps.requireNumber(value, null);
19
+ }
20
+
21
+ function deriveTarget(explicit, min, max) {
22
+ if (explicit != null) return explicit;
23
+ if (min != null && max != null) return (min + max) / 2;
24
+ return null;
25
+ }
26
+
27
+ function compareValues(left, right) {
28
+ if (left === right) return 0;
29
+ if (left == null) return 1;
30
+ if (right == null) return -1;
31
+ if (typeof left === "string" || typeof right === "string") {
32
+ return String(left).localeCompare(String(right));
33
+ }
34
+ return Number(left) - Number(right);
35
+ }
36
+
37
+ function scoreWorkout(item, targets) {
38
+ let score = 0;
39
+ const reasons = [];
40
+
41
+ if (targets.level != null && item.progressionLevel != null) {
42
+ const delta = Math.abs(item.progressionLevel - targets.level);
43
+ score += delta * 10;
44
+ reasons.push({ metric: "level", target: targets.level, actual: item.progressionLevel, delta });
45
+ }
46
+
47
+ if (targets.duration != null && item.durationMinutes != null) {
48
+ const delta = Math.abs(item.durationMinutes - targets.duration);
49
+ score += delta / 5;
50
+ reasons.push({ metric: "duration", target: targets.duration, actual: item.durationMinutes, delta });
51
+ }
52
+
53
+ if (targets.tss != null && item.tss != null) {
54
+ const delta = Math.abs(item.tss - targets.tss);
55
+ score += delta / 10;
56
+ reasons.push({ metric: "tss", target: targets.tss, actual: item.tss, delta });
57
+ }
58
+
59
+ if (targets.searchText) {
60
+ const text = String(item.workoutName ?? "").toLowerCase();
61
+ const query = String(targets.searchText).toLowerCase();
62
+ if (text === query) score -= 2;
63
+ else if (text.includes(query)) score -= 1;
64
+ }
65
+
66
+ if (item.hasInstructions) score -= 0.15;
67
+ if (item.isOutside === false) score -= 0.05;
68
+
69
+ return { score, reasons };
70
+ }
71
+
72
+ export async function commandWorkoutRecommend(flags, deps) {
73
+ const { isJsonMode, requirePositiveInteger, requireNumber, toBoolean, writeOutput } = deps;
74
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
75
+
76
+ const count = requirePositiveInteger(flags.count, 5);
77
+ const candidateLimit = requirePositiveInteger(flags["candidate-limit"], 100);
78
+ const minDuration = numericOrNull(flags["min-duration"], deps);
79
+ const maxDuration = numericOrNull(flags["max-duration"], deps);
80
+ const minTss = numericOrNull(flags["min-tss"], deps);
81
+ const maxTss = numericOrNull(flags["max-tss"], deps);
82
+ const minLevel = numericOrNull(flags["min-level"], deps);
83
+ const maxLevel = numericOrNull(flags["max-level"], deps);
84
+ const targetDuration = deriveTarget(numericOrNull(flags["target-duration"], deps), minDuration, maxDuration);
85
+ const targetTss = deriveTarget(numericOrNull(flags["target-tss"], deps), minTss, maxTss);
86
+ const targetLevel = deriveTarget(numericOrNull(flags["target-level"], deps), minLevel, maxLevel);
87
+ const searchText = String(flags.search ?? "").trim();
88
+
89
+ const library = await queryWorkoutLibrary(client, memberInfo, {
90
+ searchText,
91
+ zone: flags.zone,
92
+ zoneId: flags["zone-id"],
93
+ profile: flags.profile,
94
+ profileId: flags["profile-id"],
95
+ outside: flags.outside == null ? null : toBoolean(flags.outside, false),
96
+ hasInstructions:
97
+ flags["has-instructions"] == null ? null : toBoolean(flags["has-instructions"], false),
98
+ minDuration,
99
+ maxDuration,
100
+ minTss,
101
+ maxTss,
102
+ minLevel,
103
+ maxLevel,
104
+ sort: String(flags.sort ?? "level"),
105
+ limit: candidateLimit,
106
+ pageSize: requirePositiveInteger(flags["page-size"], 50),
107
+ });
108
+
109
+ const targets = {
110
+ duration: targetDuration,
111
+ tss: targetTss,
112
+ level: targetLevel,
113
+ searchText,
114
+ };
115
+
116
+ const recommendations = library.records
117
+ .map((item) => {
118
+ const ranking = scoreWorkout(item, targets);
119
+ return { ...item, recommendationScore: ranking.score, rationale: ranking.reasons };
120
+ })
121
+ .sort(
122
+ (a, b) =>
123
+ compareValues(a.recommendationScore, b.recommendationScore) ||
124
+ compareValues(a.progressionLevel, b.progressionLevel) ||
125
+ compareValues(a.durationMinutes, b.durationMinutes) ||
126
+ compareValues(a.workoutName, b.workoutName),
127
+ )
128
+ .slice(0, count);
129
+
130
+ const payload = {
131
+ generatedAt: new Date().toISOString(),
132
+ command: "workout-recommend",
133
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
134
+ query: {
135
+ searchText,
136
+ zone: flags.zone ?? null,
137
+ zoneId: flags["zone-id"] ?? null,
138
+ profile: flags.profile ?? null,
139
+ profileId: flags["profile-id"] ?? null,
140
+ outside: flags.outside ?? null,
141
+ hasInstructions: flags["has-instructions"] ?? null,
142
+ minDuration,
143
+ maxDuration,
144
+ minTss,
145
+ maxTss,
146
+ minLevel,
147
+ maxLevel,
148
+ targetDuration,
149
+ targetTss,
150
+ targetLevel,
151
+ count,
152
+ candidateLimit,
153
+ },
154
+ fetch: library.fetch,
155
+ recommendationCount: recommendations.length,
156
+ records: recommendations,
157
+ };
158
+
159
+ if (!isJsonMode(flags)) {
160
+ await writeOutput(payload, flags, (value) => {
161
+ const lines = [
162
+ `Workout recommendations (${value.recommendationCount}) candidates=${value.fetch.fetchedCount}/${value.fetch.serverTotalCount ?? "?"}`,
163
+ ];
164
+ for (const item of value.records) {
165
+ lines.push(
166
+ `- ${item.workoutName ?? "(untitled)"} | workoutId=${item.workoutId} | score=${item.recommendationScore.toFixed(2)} | level=${item.progressionLevel ?? "?"} | duration=${item.durationMinutes ?? "?"}m | tss=${item.tss ?? "?"}`,
167
+ );
168
+ }
169
+ return lines.join("\n");
170
+ });
171
+ return;
172
+ }
173
+
174
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
175
+ }
@@ -0,0 +1,326 @@
1
+ function stripHtml(value) {
2
+ return String(value ?? "")
3
+ .replace(/<[^>]+>/g, " ")
4
+ .replace(/&nbsp;/gi, " ")
5
+ .replace(/&amp;/gi, "&")
6
+ .replace(/&quot;/gi, '"')
7
+ .replace(/&#39;/gi, "'")
8
+ .replace(/\s+/g, " ")
9
+ .trim();
10
+ }
11
+
12
+ function summarizeWorkout(workout) {
13
+ if (!workout || typeof workout !== "object") return null;
14
+ return {
15
+ workoutId: workout.id ?? null,
16
+ workoutName: workout.workoutName ?? workout.name ?? null,
17
+ zoneId: workout.progressionId ?? workout.progression?.id ?? null,
18
+ zoneName: workout.progression?.text ?? null,
19
+ profileId: workout.profileId ?? null,
20
+ profileName: workout.profileName ?? null,
21
+ durationMinutes: workout.duration ?? null,
22
+ tss: workout.tss ?? null,
23
+ intensityFactor: workout.intensityFactor ?? null,
24
+ averageFtpPercent: workout.averageFtpPercent ?? null,
25
+ progressionLevel: workout.progressionLevel ?? null,
26
+ workoutDifficultyRating: workout.workoutDifficultyRating ?? null,
27
+ workoutTypeId: workout.workoutTypeId ?? null,
28
+ workoutLabelId: workout.workoutLabelId ?? null,
29
+ isOutside: workout.isOutside ?? null,
30
+ hasInstructions: workout.hasInstructions ?? null,
31
+ firstPublishDate: workout.firstPublishDate ?? null,
32
+ indoorAlternativeId: workout.indoorAlternativeId ?? null,
33
+ energyKj: workout.kj ?? null,
34
+ goal: stripHtml(workout.goalDescription),
35
+ description: stripHtml(workout.workoutDescription),
36
+ };
37
+ }
38
+
39
+ function summarizePlannedActivity(activity) {
40
+ if (!activity || typeof activity !== "object") return null;
41
+ const workout = activity.workout ?? null;
42
+ const date = activity.date
43
+ ? `${String(activity.date.year).padStart(4, "0")}-${String(activity.date.month).padStart(2, "0")}-${String(activity.date.day).padStart(2, "0")}`
44
+ : null;
45
+ return {
46
+ plannedActivityId: activity.id ?? null,
47
+ date,
48
+ timeOfDay: activity.timeOfDay ?? null,
49
+ workoutId: workout?.id ?? null,
50
+ workoutName: workout?.name ?? activity.name ?? null,
51
+ isOutside: workout?.isOutside ?? null,
52
+ durationMinutes:
53
+ workout?.duration ??
54
+ (Number.isFinite(Number(activity.durationInSeconds))
55
+ ? Math.round(Number(activity.durationInSeconds) / 60)
56
+ : null),
57
+ tss: activity.tss ?? workout?.tss ?? null,
58
+ recommendationReason: activity.recommendationReason ?? null,
59
+ modified: activity.modified ?? null,
60
+ };
61
+ }
62
+
63
+ function summarizeLevels(levelsPayload) {
64
+ return {
65
+ workoutLevels: Array.isArray(levelsPayload?.workoutLevels) ? levelsPayload.workoutLevels : [],
66
+ athleteLevels:
67
+ levelsPayload && typeof levelsPayload.athleteLevels === "object" ? levelsPayload.athleteLevels : {},
68
+ };
69
+ }
70
+
71
+ function summarizeChart(chartData, pointLimit = 200) {
72
+ const rawPoints = Array.isArray(chartData?.courseData) ? chartData.courseData : [];
73
+ const pointsUseMilliseconds =
74
+ rawPoints.length > 1 && Number(rawPoints[1]?.seconds) >= 1000 && Number(rawPoints[1]?.seconds) % 1000 === 0;
75
+ const points = rawPoints.map((point) => ({
76
+ ...point,
77
+ seconds:
78
+ pointsUseMilliseconds && Number.isFinite(Number(point?.seconds))
79
+ ? Number(point.seconds) / 1000
80
+ : point?.seconds ?? null,
81
+ }));
82
+ let minPercent = null;
83
+ let maxPercent = null;
84
+ let durationSeconds = 0;
85
+ for (const point of points) {
86
+ const ftpPercent = Number(point?.ftpPercent);
87
+ if (Number.isFinite(ftpPercent)) {
88
+ minPercent = minPercent == null ? ftpPercent : Math.min(minPercent, ftpPercent);
89
+ maxPercent = maxPercent == null ? ftpPercent : Math.max(maxPercent, ftpPercent);
90
+ }
91
+ const seconds = Number(point?.seconds);
92
+ if (Number.isFinite(seconds)) durationSeconds = Math.max(durationSeconds, seconds);
93
+ }
94
+ return {
95
+ pointCount: points.length,
96
+ durationSeconds,
97
+ minFtpPercent: minPercent,
98
+ maxFtpPercent: maxPercent,
99
+ points: points.slice(0, pointLimit),
100
+ };
101
+ }
102
+
103
+ function requireFlag(flags, name) {
104
+ const value = flags[name];
105
+ if (value === undefined || value === null || value === "") {
106
+ throw new Error(`Missing required flag --${name}.`);
107
+ }
108
+ return value;
109
+ }
110
+
111
+ async function requirePrivateMember(flags, deps) {
112
+ const { withClient } = deps;
113
+ const client = await withClient(flags);
114
+ try {
115
+ const memberInfo = await client.getMemberInfo();
116
+ return { client, memberInfo };
117
+ } catch {
118
+ throw new Error(
119
+ "This command requires private authenticated mode. Login first with trainerroad-cli login.",
120
+ );
121
+ }
122
+ }
123
+
124
+ async function sleep(ms) {
125
+ await new Promise((resolve) => setTimeout(resolve, ms));
126
+ }
127
+
128
+ async function findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId) {
129
+ const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
130
+ const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
131
+ .filter((item) => {
132
+ const date = item?.date;
133
+ const asIso = date
134
+ ? `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`
135
+ : null;
136
+ return asIso === dateIso;
137
+ })
138
+ .map((item) => item.id)
139
+ .filter(Boolean);
140
+
141
+ if (candidateIds.length === 0) return null;
142
+ const details = await client.getPlannedActivitiesByIds(
143
+ memberInfo.memberId,
144
+ memberInfo.username,
145
+ candidateIds,
146
+ );
147
+ const matches = details.filter((item) => Number(item?.workout?.id) === Number(workoutId));
148
+ if (matches.length === 0) return null;
149
+ return matches.sort((a, b) => String(b.modified ?? "").localeCompare(String(a.modified ?? "")))[0];
150
+ }
151
+
152
+ async function listPlannedWorkoutsOnDate(client, memberInfo, dateIso) {
153
+ const timeline = await client.getTimeline(memberInfo.memberId, memberInfo.username);
154
+ const candidateIds = (Array.isArray(timeline?.plannedActivities) ? timeline.plannedActivities : [])
155
+ .filter((item) => {
156
+ const date = item?.date;
157
+ const asIso = date
158
+ ? `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`
159
+ : null;
160
+ return asIso === dateIso;
161
+ })
162
+ .map((item) => item.id)
163
+ .filter(Boolean);
164
+
165
+ if (candidateIds.length === 0) return [];
166
+ return client.getPlannedActivitiesByIds(memberInfo.memberId, memberInfo.username, candidateIds);
167
+ }
168
+
169
+ export async function commandWorkoutDetails(flags, deps) {
170
+ const { isJsonMode, requirePositiveInteger, toBoolean, writeOutput } = deps;
171
+ const workoutId = Number(requireFlag(flags, "id"));
172
+ if (!Number.isFinite(workoutId)) {
173
+ throw new Error(`Invalid --id "${flags.id}". Expected a numeric workout ID.`);
174
+ }
175
+
176
+ const includeChart = toBoolean(flags["include-chart"], false);
177
+ const chartPointLimit = requirePositiveInteger(flags["chart-point-limit"], 200);
178
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
179
+
180
+ const [byIdRows, summaryPayload, levelsPayload, chartData] = await Promise.all([
181
+ client.getWorkoutsByIds([workoutId], memberInfo.username),
182
+ client.getWorkoutSummary(workoutId, memberInfo.username),
183
+ client.getWorkoutLevels(workoutId, memberInfo.username),
184
+ includeChart ? client.getWorkoutChartData(workoutId, memberInfo.username) : Promise.resolve(null),
185
+ ]);
186
+
187
+ const byIdWorkout = Array.isArray(byIdRows) && byIdRows.length > 0 ? byIdRows[0] : null;
188
+ const summaryWorkout = summaryPayload?.summary ?? null;
189
+ const workout = summarizeWorkout(summaryWorkout ?? byIdWorkout);
190
+ const payload = {
191
+ generatedAt: new Date().toISOString(),
192
+ command: "workout-details",
193
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
194
+ query: { workoutId, includeChart, chartPointLimit },
195
+ workout,
196
+ levels: summarizeLevels(levelsPayload),
197
+ chart: includeChart ? summarizeChart(chartData, chartPointLimit) : undefined,
198
+ };
199
+
200
+ if (!isJsonMode(flags)) {
201
+ await writeOutput(payload, flags, (value) => {
202
+ const lines = [
203
+ `${value.workout?.workoutName ?? "Workout"} | workoutId=${value.query.workoutId} | zone=${value.workout?.zoneName ?? value.workout?.zoneId ?? "?"} | profile=${value.workout?.profileName ?? value.workout?.profileId ?? "?"}`,
204
+ `duration=${value.workout?.durationMinutes ?? "?"}m | tss=${value.workout?.tss ?? "?"} | if=${value.workout?.intensityFactor ?? "?"} | level=${value.workout?.progressionLevel ?? "?"} | outside=${value.workout?.isOutside}`,
205
+ ];
206
+ if (value.chart) {
207
+ lines.push(
208
+ `chart: points=${value.chart.pointCount} duration=${value.chart.durationSeconds}s ftp%=${value.chart.minFtpPercent ?? "?"}-${value.chart.maxFtpPercent ?? "?"}`,
209
+ );
210
+ }
211
+ return lines.join("\n");
212
+ });
213
+ return;
214
+ }
215
+
216
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
217
+ }
218
+
219
+ export async function commandAddWorkout(flags, deps) {
220
+ const { isJsonMode, toBoolean, writeOutput } = deps;
221
+ const workoutId = Number(requireFlag(flags, "workout-id"));
222
+ const dateIso = String(requireFlag(flags, "date"));
223
+ if (!Number.isFinite(workoutId)) {
224
+ throw new Error(`Invalid --workout-id "${flags["workout-id"]}". Expected a numeric workout ID.`);
225
+ }
226
+
227
+ const outside = toBoolean(flags.outside, false);
228
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
229
+ const workoutRows = await client.getWorkoutsByIds([workoutId], memberInfo.username);
230
+ const workout = summarizeWorkout(Array.isArray(workoutRows) ? workoutRows[0] : null);
231
+ if (!workout) {
232
+ throw new Error(`Workout ${workoutId} was not found in the library.`);
233
+ }
234
+
235
+ const attempts = await client.tryAddWorkoutToCalendar(workoutId, dateIso, {
236
+ outside,
237
+ usernameForReferer: memberInfo.username,
238
+ });
239
+
240
+ let created = null;
241
+ for (let attempt = 0; attempt < 4; attempt += 1) {
242
+ created = await findPlannedWorkoutOnDate(client, memberInfo, dateIso, workoutId);
243
+ if (created) break;
244
+ await sleep(750);
245
+ }
246
+
247
+ if (!created) {
248
+ throw new Error(
249
+ `TrainerRoad did not expose a confirmed added workout for ${dateIso}. Attempts: ${JSON.stringify(attempts)}`,
250
+ );
251
+ }
252
+
253
+ const payload = {
254
+ generatedAt: new Date().toISOString(),
255
+ command: "add-workout",
256
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
257
+ query: { workoutId, date: dateIso, outside },
258
+ workout,
259
+ created: summarizePlannedActivity(created),
260
+ attempts,
261
+ warnings: attempts.some((item) => !item.ok)
262
+ ? [
263
+ "TrainerRoad returned one or more non-2xx responses during add-workout, but the workout was observed on the calendar after reconciliation.",
264
+ ]
265
+ : [],
266
+ };
267
+
268
+ if (!isJsonMode(flags)) {
269
+ await writeOutput(payload, flags, (value) => {
270
+ const warning = value.warnings.length > 0 ? " | warning=server-status-mismatch" : "";
271
+ return `Added ${value.workout?.workoutName ?? "workout"} to ${value.query.date} | plannedActivityId=${value.created?.plannedActivityId}${warning}`;
272
+ });
273
+ return;
274
+ }
275
+
276
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
277
+ }
278
+
279
+ export async function commandCopyWorkout(flags, deps) {
280
+ const { isJsonMode, writeOutput } = deps;
281
+ const sourcePlannedActivityId = String(requireFlag(flags, "id"));
282
+ const targetDate = String(requireFlag(flags, "date"));
283
+ const { client, memberInfo } = await requirePrivateMember(flags, deps);
284
+ const source = await client.getPlannedActivity(sourcePlannedActivityId, memberInfo.username);
285
+ const beforeTarget = await listPlannedWorkoutsOnDate(client, memberInfo, targetDate);
286
+ const beforeIds = new Set(beforeTarget.map((item) => item.id));
287
+
288
+ const mutation = await client.copyPlannedActivity(sourcePlannedActivityId, targetDate, memberInfo.username);
289
+
290
+ let created = null;
291
+ for (let attempt = 0; attempt < 5; attempt += 1) {
292
+ const afterTarget = await listPlannedWorkoutsOnDate(client, memberInfo, targetDate);
293
+ created = afterTarget.find(
294
+ (item) =>
295
+ !beforeIds.has(item.id) &&
296
+ Number(item?.workout?.id) === Number(source?.workout?.id),
297
+ );
298
+ if (created) break;
299
+ await sleep(500);
300
+ }
301
+
302
+ if (!created) {
303
+ throw new Error(
304
+ `Copy completed but no new planned workout was found on ${targetDate} for source ${sourcePlannedActivityId}.`,
305
+ );
306
+ }
307
+
308
+ const payload = {
309
+ generatedAt: new Date().toISOString(),
310
+ command: "copy-workout",
311
+ member: { memberId: memberInfo.memberId, username: memberInfo.username },
312
+ query: { sourcePlannedActivityId, date: targetDate },
313
+ source: summarizePlannedActivity(source),
314
+ created: summarizePlannedActivity(created),
315
+ mutation,
316
+ };
317
+
318
+ if (!isJsonMode(flags)) {
319
+ await writeOutput(payload, flags, (value) => {
320
+ return `Copied ${value.source?.workoutName ?? "workout"} to ${value.query.date} | plannedActivityId=${value.created?.plannedActivityId}`;
321
+ });
322
+ return;
323
+ }
324
+
325
+ await writeOutput(payload, { ...flags, json: !flags.jsonl });
326
+ }
@@ -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) {