trainerroad-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +87 -0
- package/package.json +52 -0
- package/src/cli.mjs +677 -0
- package/src/commands/annotations.mjs +53 -0
- package/src/commands/auth.mjs +28 -0
- package/src/commands/discovery.mjs +165 -0
- package/src/commands/events.mjs +52 -0
- package/src/commands/ftp.mjs +210 -0
- package/src/commands/levels.mjs +58 -0
- package/src/commands/plan.mjs +81 -0
- package/src/commands/power.mjs +111 -0
- package/src/commands/timeline.mjs +74 -0
- package/src/commands/weight-history.mjs +51 -0
- package/src/commands/workouts.mjs +436 -0
- package/src/lib/agent-filters.mjs +287 -0
- package/src/lib/command-manifest.mjs +328 -0
- package/src/lib/planning-normalizers.mjs +193 -0
- package/src/trainerroad-client.mjs +465 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export async function commandPowerRanking(flags, deps) {
|
|
2
|
+
const { resolveQueryContext, requirePrivateContext, isJsonMode, writeOutput } = deps;
|
|
3
|
+
const context = await resolveQueryContext(flags);
|
|
4
|
+
requirePrivateContext(context, "power-ranking");
|
|
5
|
+
|
|
6
|
+
const records = await context.client.getPowerRanking(
|
|
7
|
+
context.memberInfo.memberId,
|
|
8
|
+
context.memberInfo.username,
|
|
9
|
+
);
|
|
10
|
+
const payload = {
|
|
11
|
+
mode: "private",
|
|
12
|
+
generatedAt: new Date().toISOString(),
|
|
13
|
+
command: "power-ranking",
|
|
14
|
+
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
15
|
+
count: records.length,
|
|
16
|
+
records,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
if (!isJsonMode(flags)) {
|
|
20
|
+
await writeOutput(payload, flags, (value) => {
|
|
21
|
+
const lines = [`Power ranking entries: ${value.count}`];
|
|
22
|
+
for (const item of value.records) {
|
|
23
|
+
const watts = item?.wattsRanking?.value ?? null;
|
|
24
|
+
const wattsPct = item?.wattsRanking?.percentile ?? null;
|
|
25
|
+
const wkg = item?.wattsPerKgRanking?.value ?? null;
|
|
26
|
+
const wkgPct = item?.wattsPerKgRanking?.percentile ?? null;
|
|
27
|
+
lines.push(
|
|
28
|
+
`- ${item.duration}s | watts=${watts ?? "n/a"} (pct=${wattsPct ?? "n/a"}) | w/kg=${wkg ?? "n/a"} (pct=${wkgPct ?? "n/a"})`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function commandPowerRecords(flags, deps) {
|
|
39
|
+
const {
|
|
40
|
+
resolveQueryContext,
|
|
41
|
+
requirePrivateContext,
|
|
42
|
+
normalizeDateOnlyInput,
|
|
43
|
+
isoDateShift,
|
|
44
|
+
requirePositiveInteger,
|
|
45
|
+
toBoolean,
|
|
46
|
+
compactPersonalRecord,
|
|
47
|
+
isJsonMode,
|
|
48
|
+
writeOutput,
|
|
49
|
+
toIsoDate,
|
|
50
|
+
} = deps;
|
|
51
|
+
const context = await resolveQueryContext(flags);
|
|
52
|
+
requirePrivateContext(context, "power-records");
|
|
53
|
+
|
|
54
|
+
const startDate = normalizeDateOnlyInput(flags["start-date"], "2013-05-10");
|
|
55
|
+
const endDate = normalizeDateOnlyInput(flags["end-date"], isoDateShift(0));
|
|
56
|
+
const rowType = requirePositiveInteger(flags["row-type"], 101);
|
|
57
|
+
const indoorOnly = toBoolean(flags["indoor-only"], false);
|
|
58
|
+
const slot = requirePositiveInteger(flags.slot, 1);
|
|
59
|
+
const limit = requirePositiveInteger(flags.limit, 25);
|
|
60
|
+
const full = toBoolean(flags.full, false);
|
|
61
|
+
|
|
62
|
+
const raw = await context.client.getPersonalRecordsForDateRange(
|
|
63
|
+
context.memberInfo.memberId,
|
|
64
|
+
context.memberInfo.username,
|
|
65
|
+
{ startDate, endDate, rowType, indoorOnly, slot },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const allRecords = Array.isArray(raw?.results?.[0]?.personalRecords)
|
|
69
|
+
? raw.results[0].personalRecords
|
|
70
|
+
: [];
|
|
71
|
+
const rankedByWatts = [...allRecords].sort((a, b) => (b?.Watts ?? 0) - (a?.Watts ?? 0));
|
|
72
|
+
const selectedRaw = full ? allRecords : rankedByWatts.slice(0, limit);
|
|
73
|
+
const records = full ? selectedRaw : selectedRaw.map((item) => compactPersonalRecord(item));
|
|
74
|
+
|
|
75
|
+
const payload = {
|
|
76
|
+
mode: "private",
|
|
77
|
+
generatedAt: new Date().toISOString(),
|
|
78
|
+
command: "power-records",
|
|
79
|
+
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
80
|
+
query: { startDate, endDate, rowType, indoorOnly, slot, limit, full },
|
|
81
|
+
totalRecords: allRecords.length,
|
|
82
|
+
count: records.length,
|
|
83
|
+
records,
|
|
84
|
+
results: full ? raw?.results ?? [] : undefined,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (!isJsonMode(flags)) {
|
|
88
|
+
await writeOutput(payload, flags, (value) => {
|
|
89
|
+
const lines = [
|
|
90
|
+
`Power records: returned ${value.count} of ${value.totalRecords}`,
|
|
91
|
+
`Range: ${value.query.startDate}..${value.query.endDate} | rowType=${value.query.rowType} | indoorOnly=${value.query.indoorOnly}`,
|
|
92
|
+
];
|
|
93
|
+
for (const item of value.records) {
|
|
94
|
+
const workoutDate = item?.workoutDate ?? item?.WorkoutDate ?? null;
|
|
95
|
+
const seconds = item?.seconds ?? item?.Seconds ?? null;
|
|
96
|
+
const watts = item?.watts ?? item?.Watts ?? null;
|
|
97
|
+
const name = item?.workoutRecordName ?? item?.WorkoutRecordName ?? "(unknown)";
|
|
98
|
+
const dateLabel = workoutDate ? toIsoDate(workoutDate) : "(unknown-date)";
|
|
99
|
+
lines.push(
|
|
100
|
+
`- ${dateLabel} ${seconds}s ${watts}W | ride=${name}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (!value.query.full) {
|
|
104
|
+
lines.push("Tip: add --full --json for complete personal-record payload.");
|
|
105
|
+
}
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
111
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
function summarizePrivateTimeline(memberInfo, timeline) {
|
|
2
|
+
return {
|
|
3
|
+
mode: "private",
|
|
4
|
+
generatedAt: new Date().toISOString(),
|
|
5
|
+
member: { memberId: memberInfo.memberId, username: memberInfo.username },
|
|
6
|
+
counts: {
|
|
7
|
+
activities: timeline.activities.length,
|
|
8
|
+
plannedActivities: timeline.plannedActivities.length,
|
|
9
|
+
events: timeline.events.length,
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function summarizePublicTimeline(targetUsername, publicDays, today) {
|
|
15
|
+
return {
|
|
16
|
+
mode: "public",
|
|
17
|
+
generatedAt: new Date().toISOString(),
|
|
18
|
+
member: { username: targetUsername },
|
|
19
|
+
counts: {
|
|
20
|
+
days: publicDays.length,
|
|
21
|
+
rideDays: publicDays.filter((d) => d.hasRides || d.tss > 0).length,
|
|
22
|
+
futurePlannedDays: publicDays.filter((d) => d.date >= today && d.plannedTssTotal > 0).length,
|
|
23
|
+
},
|
|
24
|
+
limitations: [
|
|
25
|
+
"Public mode does not expose detailed workout records.",
|
|
26
|
+
"Use authenticated private mode for full workout detail.",
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function commandTimeline(flags, deps) {
|
|
32
|
+
const { resolveQueryContext, isJsonMode, writeOutput, sortByDateAsc, isoDateShift } = deps;
|
|
33
|
+
const context = await resolveQueryContext(flags);
|
|
34
|
+
if (context.mode === "private") {
|
|
35
|
+
const payload = {
|
|
36
|
+
...summarizePrivateTimeline(context.memberInfo, context.timeline),
|
|
37
|
+
timeline: flags.full ? context.timeline : undefined,
|
|
38
|
+
};
|
|
39
|
+
if (!isJsonMode(flags) && !flags.full) {
|
|
40
|
+
await writeOutput(payload, flags, (value) => {
|
|
41
|
+
return [
|
|
42
|
+
`Mode: ${value.mode}`,
|
|
43
|
+
`User: ${value.member.username} (${value.member.memberId})`,
|
|
44
|
+
`Activities: ${value.counts.activities}`,
|
|
45
|
+
`Planned: ${value.counts.plannedActivities}`,
|
|
46
|
+
`Events: ${value.counts.events}`,
|
|
47
|
+
"Tip: add --json for machine output or --full --json for full payload.",
|
|
48
|
+
].join("\n");
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const payload = {
|
|
57
|
+
...summarizePublicTimeline(context.targetUsername, context.publicDays, isoDateShift(0)),
|
|
58
|
+
days: flags.full ? sortByDateAsc(context.publicDays) : undefined,
|
|
59
|
+
};
|
|
60
|
+
if (!isJsonMode(flags) && !flags.full) {
|
|
61
|
+
await writeOutput(payload, flags, (value) => {
|
|
62
|
+
return [
|
|
63
|
+
`Mode: ${value.mode}`,
|
|
64
|
+
`Profile: ${value.member.username}`,
|
|
65
|
+
`Total days: ${value.counts.days}`,
|
|
66
|
+
`Ride days: ${value.counts.rideDays}`,
|
|
67
|
+
`Future planned days: ${value.counts.futurePlannedDays}`,
|
|
68
|
+
`Limitations: ${value.limitations.join(" ")}`,
|
|
69
|
+
].join("\n");
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await writeOutput(payload, { ...flags, json: !flags.jsonl });
|
|
74
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { compactWeightRecord } from "../lib/planning-normalizers.mjs";
|
|
2
|
+
|
|
3
|
+
export async function commandWeightHistory(flags, deps) {
|
|
4
|
+
const {
|
|
5
|
+
resolveQueryContext,
|
|
6
|
+
requirePrivateContext,
|
|
7
|
+
applyAgentRecordFilters,
|
|
8
|
+
toRecordsOnlyPayload,
|
|
9
|
+
isJsonMode,
|
|
10
|
+
writeOutput,
|
|
11
|
+
hasAgentRecordTransforms,
|
|
12
|
+
} = deps;
|
|
13
|
+
|
|
14
|
+
const context = await resolveQueryContext(flags);
|
|
15
|
+
requirePrivateContext(context, "weight-history");
|
|
16
|
+
|
|
17
|
+
const raw = await context.client.getWeightHistory(
|
|
18
|
+
context.memberInfo.memberId,
|
|
19
|
+
context.memberInfo.username,
|
|
20
|
+
);
|
|
21
|
+
const records = (Array.isArray(raw) ? raw : []).map((record) => compactWeightRecord(record));
|
|
22
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(records, flags);
|
|
23
|
+
|
|
24
|
+
const payload = {
|
|
25
|
+
mode: "private",
|
|
26
|
+
generatedAt: new Date().toISOString(),
|
|
27
|
+
command: "weight-history",
|
|
28
|
+
filters: filterSummary,
|
|
29
|
+
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
30
|
+
count: filteredRecords.length,
|
|
31
|
+
records: filteredRecords,
|
|
32
|
+
};
|
|
33
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
34
|
+
|
|
35
|
+
if (!isJsonMode(flags)) {
|
|
36
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
37
|
+
const lines = [`Weight history (${value.count})`];
|
|
38
|
+
if (hasAgentRecordTransforms(flags) || flags["records-only"]) {
|
|
39
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
40
|
+
if (value.filters) lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
for (const record of value.records) {
|
|
44
|
+
lines.push(`- ${record.dateOnly ?? "(unknown-date)"} ${record.value ?? "n/a"} (units=${record.units ?? "n/a"})`);
|
|
45
|
+
}
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
51
|
+
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
export async function commandFuture(flags, deps) {
|
|
2
|
+
const {
|
|
3
|
+
requireNumber,
|
|
4
|
+
normalizeDateOnlyInput,
|
|
5
|
+
isoDateShift,
|
|
6
|
+
resolveQueryContext,
|
|
7
|
+
filterFuturePlanned,
|
|
8
|
+
applyAgentRecordFilters,
|
|
9
|
+
toRecordsOnlyPayload,
|
|
10
|
+
isJsonMode,
|
|
11
|
+
writeOutput,
|
|
12
|
+
hasAgentRecordTransforms,
|
|
13
|
+
toIsoDateFromPlanned,
|
|
14
|
+
sortByDateAsc,
|
|
15
|
+
} = deps;
|
|
16
|
+
|
|
17
|
+
const days = requireNumber(flags.days, 60);
|
|
18
|
+
const fromDate = normalizeDateOnlyInput(flags.from, isoDateShift(0));
|
|
19
|
+
const toDate = normalizeDateOnlyInput(flags.to, isoDateShift(days));
|
|
20
|
+
if (toDate < fromDate) {
|
|
21
|
+
throw new Error(`Invalid range: --to (${toDate}) is before --from (${fromDate}).`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const context = await resolveQueryContext(flags);
|
|
25
|
+
if (context.mode === "private") {
|
|
26
|
+
const subset = filterFuturePlanned(context.timeline.plannedActivities, fromDate, toDate);
|
|
27
|
+
let records = subset;
|
|
28
|
+
if (flags.details) {
|
|
29
|
+
records = await context.client.getPlannedActivitiesByIds(
|
|
30
|
+
context.memberInfo.memberId,
|
|
31
|
+
context.memberInfo.username,
|
|
32
|
+
subset.map((item) => item.id),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(records, flags);
|
|
36
|
+
|
|
37
|
+
const payload = {
|
|
38
|
+
mode: "private",
|
|
39
|
+
generatedAt: new Date().toISOString(),
|
|
40
|
+
command: "future",
|
|
41
|
+
query: { fromDate, toDate, days, details: Boolean(flags.details) },
|
|
42
|
+
filters: filterSummary,
|
|
43
|
+
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
44
|
+
count: filteredRecords.length,
|
|
45
|
+
records: filteredRecords,
|
|
46
|
+
};
|
|
47
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
48
|
+
|
|
49
|
+
if (!isJsonMode(flags)) {
|
|
50
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
51
|
+
const lines = [`Future workouts (${value.count}) ${value.query.fromDate}..${value.query.toDate}`];
|
|
52
|
+
if (hasAgentRecordTransforms(flags)) {
|
|
53
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
54
|
+
lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
|
57
|
+
for (const item of value.records) {
|
|
58
|
+
if (flags.details) {
|
|
59
|
+
lines.push(
|
|
60
|
+
`- ${toIsoDateFromPlanned(item)} ${item.name || "(untitled)"} | id=${item.id} | tss=${item.tss} | duration=${item.durationInSeconds}s`,
|
|
61
|
+
);
|
|
62
|
+
} else {
|
|
63
|
+
lines.push(`- ${toIsoDateFromPlanned(item)} id=${item.id} type=${item.type} tss=${item.tss}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const records = sortByDateAsc(
|
|
75
|
+
context.publicDays.filter(
|
|
76
|
+
(day) => day.date >= fromDate && day.date <= toDate && day.plannedTssTotal > 0,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(records, flags);
|
|
80
|
+
const payload = {
|
|
81
|
+
mode: "public",
|
|
82
|
+
generatedAt: new Date().toISOString(),
|
|
83
|
+
command: "future",
|
|
84
|
+
query: { fromDate, toDate, days, details: Boolean(flags.details) },
|
|
85
|
+
filters: filterSummary,
|
|
86
|
+
member: { username: context.targetUsername },
|
|
87
|
+
count: filteredRecords.length,
|
|
88
|
+
records: filteredRecords,
|
|
89
|
+
limitations: [
|
|
90
|
+
"Public mode returns day-level planned TSS only.",
|
|
91
|
+
"Detailed workout names/durations are unavailable in public mode.",
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
95
|
+
|
|
96
|
+
if (!isJsonMode(flags)) {
|
|
97
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
98
|
+
const lines = [
|
|
99
|
+
`Future plan signal (${value.count}) ${value.query.fromDate}..${value.query.toDate} [public mode]`,
|
|
100
|
+
];
|
|
101
|
+
if (hasAgentRecordTransforms(flags)) {
|
|
102
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
103
|
+
lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
104
|
+
lines.push(`Limitations: ${value.limitations.join(" ")}`);
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
for (const day of value.records) {
|
|
108
|
+
lines.push(
|
|
109
|
+
`- ${day.date} plannedTss=${day.plannedTssTotal} (TR=${day.plannedTssTrainerRoad}, other=${day.plannedTssOther})`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
lines.push(`Limitations: ${value.limitations.join(" ")}`);
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function commandPast(flags, deps) {
|
|
121
|
+
const {
|
|
122
|
+
requireNumber,
|
|
123
|
+
normalizeDateOnlyInput,
|
|
124
|
+
isoDateShift,
|
|
125
|
+
resolveQueryContext,
|
|
126
|
+
filterPastActivities,
|
|
127
|
+
applyAgentRecordFilters,
|
|
128
|
+
toRecordsOnlyPayload,
|
|
129
|
+
isJsonMode,
|
|
130
|
+
writeOutput,
|
|
131
|
+
hasAgentRecordTransforms,
|
|
132
|
+
sortByDateDesc,
|
|
133
|
+
} = deps;
|
|
134
|
+
|
|
135
|
+
const days = requireNumber(flags.days, 60);
|
|
136
|
+
const limit = requireNumber(flags.limit, 30);
|
|
137
|
+
const fromDate = normalizeDateOnlyInput(flags.from, isoDateShift(-days));
|
|
138
|
+
const toDate = normalizeDateOnlyInput(flags.to, isoDateShift(0));
|
|
139
|
+
if (toDate < fromDate) {
|
|
140
|
+
throw new Error(`Invalid range: --to (${toDate}) is before --from (${fromDate}).`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const context = await resolveQueryContext(flags);
|
|
144
|
+
if (context.mode === "private") {
|
|
145
|
+
const filtered = filterPastActivities(context.timeline.activities, fromDate, toDate).slice(0, limit);
|
|
146
|
+
if (!flags.details) {
|
|
147
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(filtered, flags);
|
|
148
|
+
const payload = {
|
|
149
|
+
mode: "private",
|
|
150
|
+
generatedAt: new Date().toISOString(),
|
|
151
|
+
command: "past",
|
|
152
|
+
query: { fromDate, toDate, days, limit, details: false },
|
|
153
|
+
filters: filterSummary,
|
|
154
|
+
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
155
|
+
count: filteredRecords.length,
|
|
156
|
+
records: filteredRecords,
|
|
157
|
+
};
|
|
158
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
159
|
+
if (!isJsonMode(flags)) {
|
|
160
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
161
|
+
const lines = [`Past workouts (${value.count}) ${value.query.fromDate}..${value.query.toDate}`];
|
|
162
|
+
if (hasAgentRecordTransforms(flags)) {
|
|
163
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
164
|
+
lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
167
|
+
for (const item of value.records) {
|
|
168
|
+
lines.push(`- ${item.started} id=${item.id} type=${item.type} tss=${item.tss}`);
|
|
169
|
+
}
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ids = filtered.map((item) => item.id);
|
|
179
|
+
const details = await context.client.getActivitiesByIds(
|
|
180
|
+
context.memberInfo.memberId,
|
|
181
|
+
context.memberInfo.username,
|
|
182
|
+
ids,
|
|
183
|
+
);
|
|
184
|
+
const personalRecords = await context.client.getPersonalRecordsByActivityIds(
|
|
185
|
+
context.memberInfo.memberId,
|
|
186
|
+
context.memberInfo.username,
|
|
187
|
+
ids,
|
|
188
|
+
);
|
|
189
|
+
const detailRecords = details.map((item) => ({
|
|
190
|
+
...item,
|
|
191
|
+
personalRecordCount: Array.isArray(personalRecords[item.id]) ? personalRecords[item.id].length : 0,
|
|
192
|
+
}));
|
|
193
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(detailRecords, flags);
|
|
194
|
+
const payload = {
|
|
195
|
+
mode: "private",
|
|
196
|
+
generatedAt: new Date().toISOString(),
|
|
197
|
+
command: "past",
|
|
198
|
+
query: { fromDate, toDate, days, limit, details: true },
|
|
199
|
+
filters: filterSummary,
|
|
200
|
+
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
201
|
+
count: filteredRecords.length,
|
|
202
|
+
records: filteredRecords,
|
|
203
|
+
personalRecords,
|
|
204
|
+
};
|
|
205
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
206
|
+
|
|
207
|
+
if (!isJsonMode(flags)) {
|
|
208
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
209
|
+
const lines = [
|
|
210
|
+
`Past workouts detailed (${value.count}) ${value.query.fromDate}..${value.query.toDate}`,
|
|
211
|
+
];
|
|
212
|
+
if (hasAgentRecordTransforms(flags)) {
|
|
213
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
214
|
+
lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
for (const item of value.records) {
|
|
218
|
+
lines.push(
|
|
219
|
+
`- ${new Date(item.started).toISOString()} ${item.name} | id=${item.id} | tss=${item.tss} | duration=${item.durationInSeconds}s | prs=${item.personalRecordCount}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const records = sortByDateDesc(
|
|
231
|
+
context.publicDays.filter(
|
|
232
|
+
(day) => day.date >= fromDate && day.date <= toDate && (day.hasRides || day.tss > 0),
|
|
233
|
+
),
|
|
234
|
+
).slice(0, limit);
|
|
235
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(records, flags);
|
|
236
|
+
const payload = {
|
|
237
|
+
mode: "public",
|
|
238
|
+
generatedAt: new Date().toISOString(),
|
|
239
|
+
command: "past",
|
|
240
|
+
query: { fromDate, toDate, days, limit, details: Boolean(flags.details) },
|
|
241
|
+
filters: filterSummary,
|
|
242
|
+
member: { username: context.targetUsername },
|
|
243
|
+
count: filteredRecords.length,
|
|
244
|
+
records: filteredRecords,
|
|
245
|
+
limitations: [
|
|
246
|
+
"Public mode returns day-level historical load signals only.",
|
|
247
|
+
"Detailed completed workout records are unavailable in public mode.",
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
251
|
+
|
|
252
|
+
if (!isJsonMode(flags)) {
|
|
253
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
254
|
+
const lines = [
|
|
255
|
+
`Past load signal (${value.count}) ${value.query.fromDate}..${value.query.toDate} [public mode]`,
|
|
256
|
+
];
|
|
257
|
+
if (hasAgentRecordTransforms(flags)) {
|
|
258
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
259
|
+
lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
260
|
+
lines.push(`Limitations: ${value.limitations.join(" ")}`);
|
|
261
|
+
return lines.join("\n");
|
|
262
|
+
}
|
|
263
|
+
for (const day of value.records) {
|
|
264
|
+
lines.push(
|
|
265
|
+
`- ${day.date} tss=${day.tss} (TR=${day.tssTrainerRoad}, other=${day.tssOther}) hasRides=${day.hasRides}`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
lines.push(`Limitations: ${value.limitations.join(" ")}`);
|
|
269
|
+
return lines.join("\n");
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function commandToday(flags, deps) {
|
|
277
|
+
const {
|
|
278
|
+
normalizeDateOnlyInput,
|
|
279
|
+
isoDateShift,
|
|
280
|
+
resolveQueryContext,
|
|
281
|
+
filterFuturePlanned,
|
|
282
|
+
filterPastActivities,
|
|
283
|
+
applyAgentRecordFilters,
|
|
284
|
+
toRecordsOnlyPayload,
|
|
285
|
+
isJsonMode,
|
|
286
|
+
writeOutput,
|
|
287
|
+
hasAgentRecordTransforms,
|
|
288
|
+
toIsoDateFromPlanned,
|
|
289
|
+
} = deps;
|
|
290
|
+
|
|
291
|
+
const today = normalizeDateOnlyInput(flags.date, isoDateShift(0));
|
|
292
|
+
const context = await resolveQueryContext(flags);
|
|
293
|
+
|
|
294
|
+
if (context.mode === "private") {
|
|
295
|
+
const plannedToday = filterFuturePlanned(context.timeline.plannedActivities, today, today);
|
|
296
|
+
const activitiesToday = filterPastActivities(context.timeline.activities, today, today);
|
|
297
|
+
let plannedRecords = plannedToday;
|
|
298
|
+
let activityRecords = activitiesToday;
|
|
299
|
+
let personalRecords = {};
|
|
300
|
+
|
|
301
|
+
if (flags.details) {
|
|
302
|
+
plannedRecords = await context.client.getPlannedActivitiesByIds(
|
|
303
|
+
context.memberInfo.memberId,
|
|
304
|
+
context.memberInfo.username,
|
|
305
|
+
plannedToday.map((item) => item.id),
|
|
306
|
+
);
|
|
307
|
+
activityRecords = await context.client.getActivitiesByIds(
|
|
308
|
+
context.memberInfo.memberId,
|
|
309
|
+
context.memberInfo.username,
|
|
310
|
+
activitiesToday.map((item) => item.id),
|
|
311
|
+
);
|
|
312
|
+
personalRecords = await context.client.getPersonalRecordsByActivityIds(
|
|
313
|
+
context.memberInfo.memberId,
|
|
314
|
+
context.memberInfo.username,
|
|
315
|
+
activitiesToday.map((item) => item.id),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const records = [
|
|
320
|
+
...plannedRecords.map((item) => ({ recordType: "planned", ...item })),
|
|
321
|
+
...activityRecords.map((item) => ({
|
|
322
|
+
recordType: "completed",
|
|
323
|
+
...item,
|
|
324
|
+
personalRecordCount: Array.isArray(personalRecords[item.id]) ? personalRecords[item.id].length : 0,
|
|
325
|
+
})),
|
|
326
|
+
];
|
|
327
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(records, flags);
|
|
328
|
+
|
|
329
|
+
const payload = {
|
|
330
|
+
mode: "private",
|
|
331
|
+
generatedAt: new Date().toISOString(),
|
|
332
|
+
command: "today",
|
|
333
|
+
query: { date: today, details: Boolean(flags.details) },
|
|
334
|
+
filters: filterSummary,
|
|
335
|
+
member: { memberId: context.memberInfo.memberId, username: context.memberInfo.username },
|
|
336
|
+
counts: { planned: plannedRecords.length, completed: activityRecords.length },
|
|
337
|
+
planned: plannedRecords,
|
|
338
|
+
completed: activityRecords,
|
|
339
|
+
personalRecords,
|
|
340
|
+
count: filteredRecords.length,
|
|
341
|
+
records: filteredRecords,
|
|
342
|
+
};
|
|
343
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
344
|
+
|
|
345
|
+
if (!isJsonMode(flags)) {
|
|
346
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
347
|
+
const lines = [`Today (${value.query?.date ?? today})`];
|
|
348
|
+
if (value.counts) {
|
|
349
|
+
lines.push(`Planned: ${value.counts.planned}`);
|
|
350
|
+
lines.push(`Completed: ${value.counts.completed}`);
|
|
351
|
+
}
|
|
352
|
+
if (
|
|
353
|
+
hasAgentRecordTransforms(flags) ||
|
|
354
|
+
flags["records-only"] ||
|
|
355
|
+
!Array.isArray(value.planned) ||
|
|
356
|
+
!Array.isArray(value.completed)
|
|
357
|
+
) {
|
|
358
|
+
lines.push(`Filtered records: ${value.count}`);
|
|
359
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
360
|
+
if (value.filters) {
|
|
361
|
+
lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
362
|
+
}
|
|
363
|
+
return lines.join("\n");
|
|
364
|
+
}
|
|
365
|
+
for (const item of value.planned) {
|
|
366
|
+
if (flags.details) {
|
|
367
|
+
lines.push(`- planned ${toIsoDateFromPlanned(item)} ${item.name || "(untitled)"} | tss=${item.tss}`);
|
|
368
|
+
} else {
|
|
369
|
+
lines.push(`- planned id=${item.id} type=${item.type} tss=${item.tss}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const item of value.completed) {
|
|
373
|
+
if (flags.details) {
|
|
374
|
+
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}`);
|
|
376
|
+
} else {
|
|
377
|
+
lines.push(`- completed ${item.started} id=${item.id} type=${item.type} tss=${item.tss}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return lines.join("\n");
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const day = context.publicDays.find((item) => item.date === today) ?? null;
|
|
389
|
+
const records = day ? [day] : [];
|
|
390
|
+
const { records: filteredRecords, filterSummary } = applyAgentRecordFilters(records, flags);
|
|
391
|
+
const payload = {
|
|
392
|
+
mode: "public",
|
|
393
|
+
generatedAt: new Date().toISOString(),
|
|
394
|
+
command: "today",
|
|
395
|
+
query: { date: today, details: Boolean(flags.details) },
|
|
396
|
+
filters: filterSummary,
|
|
397
|
+
member: { username: context.targetUsername },
|
|
398
|
+
counts: { days: day ? 1 : 0 },
|
|
399
|
+
day,
|
|
400
|
+
count: filteredRecords.length,
|
|
401
|
+
records: filteredRecords,
|
|
402
|
+
limitations: [
|
|
403
|
+
"Public mode provides day-level load/plan signal only.",
|
|
404
|
+
"No workout-level detail without authentication.",
|
|
405
|
+
],
|
|
406
|
+
};
|
|
407
|
+
const outputPayload = flags["records-only"] ? toRecordsOnlyPayload(payload) : payload;
|
|
408
|
+
|
|
409
|
+
if (!isJsonMode(flags)) {
|
|
410
|
+
await writeOutput(outputPayload, flags, (value) => {
|
|
411
|
+
const dayCandidate =
|
|
412
|
+
value.day ?? (Array.isArray(value.records) && value.records.length === 1 ? value.records[0] : null);
|
|
413
|
+
if (!dayCandidate && !hasAgentRecordTransforms(flags) && !flags["records-only"]) {
|
|
414
|
+
return `Today (${value.query?.date ?? today}) [public mode]\n- No day-level record returned.`;
|
|
415
|
+
}
|
|
416
|
+
if (hasAgentRecordTransforms(flags)) {
|
|
417
|
+
const lines = [`Today (${value.query?.date ?? today}) [public mode]`, `Filtered records: ${value.count}`];
|
|
418
|
+
for (const item of value.records) lines.push(`- ${JSON.stringify(item)}`);
|
|
419
|
+
if (value.filters) {
|
|
420
|
+
lines.push(`Filter output: ${value.filters.outputCount}/${value.filters.inputCount}`);
|
|
421
|
+
}
|
|
422
|
+
lines.push(`Limitations: ${value.limitations.join(" ")}`);
|
|
423
|
+
return lines.join("\n");
|
|
424
|
+
}
|
|
425
|
+
return [
|
|
426
|
+
`Today (${value.query?.date ?? today}) [public mode]`,
|
|
427
|
+
`- tss=${dayCandidate.tss} (TR=${dayCandidate.tssTrainerRoad}, other=${dayCandidate.tssOther})`,
|
|
428
|
+
`- plannedTss=${dayCandidate.plannedTssTotal} (TR=${dayCandidate.plannedTssTrainerRoad}, other=${dayCandidate.plannedTssOther})`,
|
|
429
|
+
`- hasRides=${dayCandidate.hasRides}`,
|
|
430
|
+
`Limitations: ${value.limitations.join(" ")}`,
|
|
431
|
+
].join("\n");
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await writeOutput(outputPayload, { ...flags, json: !flags.jsonl });
|
|
436
|
+
}
|