technogym-js 0.0.0-stage → 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/AGENTS.md +83 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +147 -2
- package/dist/index.cjs +1016 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +606 -0
- package/dist/index.d.ts +606 -0
- package/dist/index.js +961 -0
- package/dist/index.js.map +1 -0
- package/package.json +93 -4
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
APP_ID: () => APP_ID,
|
|
24
|
+
APP_NAME: () => APP_NAME,
|
|
25
|
+
CORE_URL: () => CORE_URL,
|
|
26
|
+
CUMULATIVE_CHANNELS: () => CUMULATIVE_CHANNELS,
|
|
27
|
+
FileSessionStore: () => FileSessionStore,
|
|
28
|
+
MemorySessionStore: () => MemorySessionStore,
|
|
29
|
+
SERVICES_URL: () => SERVICES_URL,
|
|
30
|
+
SETPOINT_CHANNELS: () => SETPOINT_CHANNELS,
|
|
31
|
+
Technogym: () => Technogym,
|
|
32
|
+
TechnogymApiError: () => TechnogymApiError,
|
|
33
|
+
TechnogymAuthError: () => TechnogymAuthError,
|
|
34
|
+
TechnogymClient: () => TechnogymClient,
|
|
35
|
+
TechnogymConnectionError: () => TechnogymConnectionError,
|
|
36
|
+
TechnogymError: () => TechnogymError,
|
|
37
|
+
TechnogymHttpError: () => TechnogymHttpError,
|
|
38
|
+
TechnogymNotFoundError: () => TechnogymNotFoundError,
|
|
39
|
+
TechnogymRateLimitError: () => TechnogymRateLimitError,
|
|
40
|
+
channelStats: () => channelStats,
|
|
41
|
+
downsample: () => downsample,
|
|
42
|
+
isAuthErrorEntry: () => isAuthErrorEntry,
|
|
43
|
+
parseExercise: () => parseExercise,
|
|
44
|
+
parseExerciseAnalytics: () => parseExerciseAnalytics,
|
|
45
|
+
parseHrSession: () => parseHrSession,
|
|
46
|
+
parseProperties: () => parseProperties,
|
|
47
|
+
parseSessionSummary: () => parseSessionSummary,
|
|
48
|
+
parseUser: () => parseUser,
|
|
49
|
+
parseWorkoutSession: () => parseWorkoutSession,
|
|
50
|
+
segmentAnalytics: () => segmentAnalytics,
|
|
51
|
+
windowSamples: () => windowSamples
|
|
52
|
+
});
|
|
53
|
+
module.exports = __toCommonJS(index_exports);
|
|
54
|
+
|
|
55
|
+
// src/errors.ts
|
|
56
|
+
var TechnogymError = class extends Error {
|
|
57
|
+
/** `options.cause` is the underlying error, if any. */
|
|
58
|
+
constructor(message, options) {
|
|
59
|
+
super(message, options);
|
|
60
|
+
this.name = new.target.name;
|
|
61
|
+
Error.captureStackTrace?.(this, new.target);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var TechnogymAuthError = class extends TechnogymError {
|
|
65
|
+
};
|
|
66
|
+
var TechnogymConnectionError = class extends TechnogymError {
|
|
67
|
+
};
|
|
68
|
+
var TechnogymRateLimitError = class extends TechnogymError {
|
|
69
|
+
/** Thrown by the transport on a 429. */
|
|
70
|
+
constructor(message, retryAfter, options) {
|
|
71
|
+
super(message, options);
|
|
72
|
+
this.retryAfter = retryAfter;
|
|
73
|
+
}
|
|
74
|
+
retryAfter;
|
|
75
|
+
};
|
|
76
|
+
var TechnogymHttpError = class extends TechnogymError {
|
|
77
|
+
/** Thrown by the transport for a non-2xx response that no other error class covers. */
|
|
78
|
+
constructor(message, status, url, body, options) {
|
|
79
|
+
super(message, options);
|
|
80
|
+
this.status = status;
|
|
81
|
+
this.url = url;
|
|
82
|
+
this.body = body;
|
|
83
|
+
}
|
|
84
|
+
status;
|
|
85
|
+
url;
|
|
86
|
+
body;
|
|
87
|
+
};
|
|
88
|
+
var TechnogymApiError = class extends TechnogymError {
|
|
89
|
+
/** Thrown when a 200 response's envelope carries errors. */
|
|
90
|
+
constructor(message, action, errors, options) {
|
|
91
|
+
super(message, options);
|
|
92
|
+
this.action = action;
|
|
93
|
+
this.errors = errors;
|
|
94
|
+
}
|
|
95
|
+
action;
|
|
96
|
+
errors;
|
|
97
|
+
};
|
|
98
|
+
var TechnogymNotFoundError = class extends TechnogymError {
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/auth/constants.ts
|
|
102
|
+
var CORE_URL = "https://core.mywellness.com";
|
|
103
|
+
var SERVICES_URL = "https://services.mywellness.com";
|
|
104
|
+
var APP_ID = "EC1D38D7-D359-48D0-A60C-D8C0B8FB9DF9";
|
|
105
|
+
var APP_NAME = "enduserweb";
|
|
106
|
+
var APP_VERSION = "1.0";
|
|
107
|
+
var LOGIN_PATH = "/v2/enduser/authentication/login";
|
|
108
|
+
var USER_AGENT = "technogym-js (+https://github.com/DynamicsNinja/technogym-js)";
|
|
109
|
+
function appHeaders() {
|
|
110
|
+
return {
|
|
111
|
+
accept: "application/json",
|
|
112
|
+
"user-agent": USER_AGENT,
|
|
113
|
+
"x-mwapps-appid": APP_ID,
|
|
114
|
+
"x-mwapps-client": APP_NAME,
|
|
115
|
+
"x-mwapps-clientversion": `${APP_VERSION},${APP_NAME}`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/auth/session-store.ts
|
|
120
|
+
var import_promises = require("fs/promises");
|
|
121
|
+
var import_node_path = require("path");
|
|
122
|
+
var MemorySessionStore = class {
|
|
123
|
+
#session = null;
|
|
124
|
+
async load() {
|
|
125
|
+
return this.#session;
|
|
126
|
+
}
|
|
127
|
+
async save(session) {
|
|
128
|
+
this.#session = session;
|
|
129
|
+
}
|
|
130
|
+
async clear() {
|
|
131
|
+
this.#session = null;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
var SESSION_FILE = "session.json";
|
|
135
|
+
var FileSessionStore = class {
|
|
136
|
+
constructor(dir) {
|
|
137
|
+
this.dir = dir;
|
|
138
|
+
}
|
|
139
|
+
dir;
|
|
140
|
+
async load() {
|
|
141
|
+
try {
|
|
142
|
+
return parseSession(JSON.parse(await (0, import_promises.readFile)((0, import_node_path.join)(this.dir, SESSION_FILE), "utf8")));
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async save(session) {
|
|
148
|
+
await (0, import_promises.mkdir)(this.dir, { recursive: true });
|
|
149
|
+
const tmp = (0, import_node_path.join)(this.dir, `${SESSION_FILE}.${process.pid}-${Math.random().toString(36).slice(2)}.tmp`);
|
|
150
|
+
try {
|
|
151
|
+
await (0, import_promises.writeFile)(tmp, JSON.stringify(session, null, 2), { mode: 384 });
|
|
152
|
+
await (0, import_promises.rename)(tmp, (0, import_node_path.join)(this.dir, SESSION_FILE));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
await (0, import_promises.rm)(tmp, { force: true });
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async clear() {
|
|
159
|
+
await (0, import_promises.rm)((0, import_node_path.join)(this.dir, SESSION_FILE), { force: true });
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
function parseSession(value) {
|
|
163
|
+
if (!isRecord(value) || typeof value["token"] !== "string" || !value["token"]) return null;
|
|
164
|
+
const user = value["user"];
|
|
165
|
+
if (!isRecord(user) || typeof user["userId"] !== "string" || !user["userId"]) return null;
|
|
166
|
+
if (!Array.isArray(user["facilities"])) return null;
|
|
167
|
+
const facilities = [];
|
|
168
|
+
for (const f of user["facilities"]) {
|
|
169
|
+
if (!isRecord(f) || typeof f["url"] !== "string") return null;
|
|
170
|
+
const str = (v) => typeof v === "string" ? v : "";
|
|
171
|
+
facilities.push({ id: str(f["id"]), url: f["url"], name: str(f["name"]) });
|
|
172
|
+
}
|
|
173
|
+
const text2 = (key) => typeof user[key] === "string" ? user[key] : "";
|
|
174
|
+
return {
|
|
175
|
+
token: value["token"],
|
|
176
|
+
user: {
|
|
177
|
+
userId: user["userId"],
|
|
178
|
+
firstName: text2("firstName"),
|
|
179
|
+
lastName: text2("lastName"),
|
|
180
|
+
nickName: text2("nickName"),
|
|
181
|
+
measurementSystem: text2("measurementSystem"),
|
|
182
|
+
culture: text2("culture"),
|
|
183
|
+
facilities
|
|
184
|
+
},
|
|
185
|
+
savedAt: typeof value["savedAt"] === "number" ? value["savedAt"] : 0
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function isRecord(value) {
|
|
189
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/http/fetcher.ts
|
|
193
|
+
var RETRY_STATUSES = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
|
|
194
|
+
var IDEMPOTENT_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
195
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
196
|
+
var Fetcher = class {
|
|
197
|
+
#timeoutMs;
|
|
198
|
+
#retries;
|
|
199
|
+
#backoffMs;
|
|
200
|
+
#fetch;
|
|
201
|
+
constructor(options = {}) {
|
|
202
|
+
this.#timeoutMs = options.timeoutMs ?? 3e4;
|
|
203
|
+
this.#retries = options.retries ?? 3;
|
|
204
|
+
this.#backoffMs = options.backoffMs ?? 500;
|
|
205
|
+
this.#fetch = options.fetchImpl ?? globalThis.fetch;
|
|
206
|
+
}
|
|
207
|
+
async request(url, options = {}) {
|
|
208
|
+
const safeUrl = redactUrl(url);
|
|
209
|
+
const headers = new Headers(options.headers ?? {});
|
|
210
|
+
let body;
|
|
211
|
+
if (options.json !== void 0) {
|
|
212
|
+
body = JSON.stringify(options.json);
|
|
213
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
214
|
+
}
|
|
215
|
+
const method = options.method ?? "GET";
|
|
216
|
+
const canRetry = IDEMPOTENT_METHODS.has(method.toUpperCase()) || options.retry === true;
|
|
217
|
+
const timeoutMs = options.timeoutMs ?? this.#timeoutMs;
|
|
218
|
+
let lastError;
|
|
219
|
+
for (let attempt = 0; attempt <= this.#retries; attempt++) {
|
|
220
|
+
if (attempt > 0) await sleep(this.#backoffMs * 2 ** (attempt - 1));
|
|
221
|
+
let res;
|
|
222
|
+
try {
|
|
223
|
+
res = await this.#fetch(url, {
|
|
224
|
+
method,
|
|
225
|
+
headers,
|
|
226
|
+
body,
|
|
227
|
+
redirect: "follow",
|
|
228
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
229
|
+
});
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
lastError = cause;
|
|
232
|
+
if (canRetry && attempt < this.#retries) continue;
|
|
233
|
+
throw new TechnogymConnectionError(
|
|
234
|
+
`Request to ${safeUrl} failed after ${attempt + 1} attempt${attempt === 0 ? "" : "s"}`,
|
|
235
|
+
{ cause: lastError }
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (res.ok) return res;
|
|
239
|
+
if (RETRY_STATUSES.has(res.status) && canRetry && attempt < this.#retries) continue;
|
|
240
|
+
throw await toError(res, safeUrl);
|
|
241
|
+
}
|
|
242
|
+
throw new TechnogymConnectionError(`Request to ${safeUrl} failed after ${this.#retries + 1} attempts`, {
|
|
243
|
+
cause: lastError
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
async function toError(res, safeUrl) {
|
|
248
|
+
const body = await res.text().catch(() => "");
|
|
249
|
+
if (res.status === 429) {
|
|
250
|
+
const raw = res.headers.get("retry-after");
|
|
251
|
+
const retryAfter = raw && !Number.isNaN(Number(raw)) ? Number(raw) : void 0;
|
|
252
|
+
return new TechnogymRateLimitError(`Rate limited by mywellness at ${safeUrl}`, retryAfter);
|
|
253
|
+
}
|
|
254
|
+
if (res.status === 401 || res.status === 403) {
|
|
255
|
+
return new TechnogymAuthError(`Not authorized for ${safeUrl} (${res.status})`);
|
|
256
|
+
}
|
|
257
|
+
return new TechnogymHttpError(`Request to ${safeUrl} failed with ${res.status}`, res.status, safeUrl, body);
|
|
258
|
+
}
|
|
259
|
+
function redactUrl(url) {
|
|
260
|
+
try {
|
|
261
|
+
const u = new URL(url);
|
|
262
|
+
return `${u.origin}${u.pathname}`;
|
|
263
|
+
} catch {
|
|
264
|
+
return url;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/util/dates.ts
|
|
269
|
+
var ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
270
|
+
function toIsoDate(value) {
|
|
271
|
+
if (value instanceof Date) {
|
|
272
|
+
if (Number.isNaN(value.getTime())) throw new TechnogymError("Invalid Date");
|
|
273
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
274
|
+
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`;
|
|
275
|
+
}
|
|
276
|
+
const m = ISO_DATE.exec(value);
|
|
277
|
+
if (!m) throw new TechnogymError(`Invalid date "${value}"; expected YYYY-MM-DD`);
|
|
278
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
279
|
+
if (d.toISOString().slice(0, 10) !== value) throw new TechnogymError(`Invalid date "${value}"`);
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
function toPartitionKey(value) {
|
|
283
|
+
return toIsoDate(value).replaceAll("-", "");
|
|
284
|
+
}
|
|
285
|
+
function fromPartitionKey(value) {
|
|
286
|
+
const s = typeof value === "string" || typeof value === "number" ? String(value) : "";
|
|
287
|
+
return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s;
|
|
288
|
+
}
|
|
289
|
+
function addDays(date, days) {
|
|
290
|
+
const [y, m, d] = date.split("-").map(Number);
|
|
291
|
+
const t = new Date(Date.UTC(y, m - 1, d + days));
|
|
292
|
+
return t.toISOString().slice(0, 10);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/parse.ts
|
|
296
|
+
function rec(value) {
|
|
297
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
298
|
+
}
|
|
299
|
+
function list(value) {
|
|
300
|
+
return Array.isArray(value) ? value.map(rec) : [];
|
|
301
|
+
}
|
|
302
|
+
function text(value) {
|
|
303
|
+
if (typeof value === "string") return value;
|
|
304
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
305
|
+
return typeof value === "object" && value !== null ? JSON.stringify(value) : "";
|
|
306
|
+
}
|
|
307
|
+
function firstText(...values) {
|
|
308
|
+
for (const v of values) if (v != null && v !== "") return text(v);
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
function intOrNull(value) {
|
|
312
|
+
if (value == null || value === "") return null;
|
|
313
|
+
const n = Number(value);
|
|
314
|
+
return Number.isFinite(n) ? Math.trunc(n) : null;
|
|
315
|
+
}
|
|
316
|
+
function numOrNull(value) {
|
|
317
|
+
if (value == null || value === "") return null;
|
|
318
|
+
const n = Number(value);
|
|
319
|
+
return Number.isFinite(n) ? n : null;
|
|
320
|
+
}
|
|
321
|
+
function parseUser(data) {
|
|
322
|
+
const d = rec(data);
|
|
323
|
+
const uc = rec(d["userContext"]);
|
|
324
|
+
if (!uc["id"]) return null;
|
|
325
|
+
const facilities = list(d["facilities"]).map((f) => ({
|
|
326
|
+
id: text(f["id"]),
|
|
327
|
+
url: text(f["url"]),
|
|
328
|
+
name: text(f["name"])
|
|
329
|
+
}));
|
|
330
|
+
return {
|
|
331
|
+
userId: text(uc["id"]),
|
|
332
|
+
firstName: text(uc["firstName"]),
|
|
333
|
+
lastName: text(uc["lastName"]),
|
|
334
|
+
nickName: text(uc["nickName"]),
|
|
335
|
+
measurementSystem: text(uc["measurementSystem"]),
|
|
336
|
+
culture: text(uc["defaultCulture"]),
|
|
337
|
+
facilities
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function parseSessionSummary(item) {
|
|
341
|
+
const i = rec(item);
|
|
342
|
+
const pad = (v) => String(intOrNull(v) ?? 0).padStart(2, "0");
|
|
343
|
+
return {
|
|
344
|
+
sessionId: text(i["idCr"]),
|
|
345
|
+
date: fromPartitionKey(i["partitionDate"]),
|
|
346
|
+
time: `${pad(i["hour"])}:${pad(i["minute"])}`,
|
|
347
|
+
name: text(i["name"]),
|
|
348
|
+
type: text(i["type"]),
|
|
349
|
+
durationMin: intOrNull(i["totalDurationInMinutes"] ?? i["duration"]),
|
|
350
|
+
moves: intOrNull(i["moves"]),
|
|
351
|
+
calories: intOrNull(i["calories"]),
|
|
352
|
+
exercisesDone: intOrNull(i["numberOfExerciseDoneWithGroups"]),
|
|
353
|
+
exercisesPlanned: intOrNull(i["numberOfExerciseToDoWithGroups"]),
|
|
354
|
+
userWorkoutSessionId: text(i["userWorkoutSessionId"]),
|
|
355
|
+
facilityId: text(i["facilityId"]),
|
|
356
|
+
workoutEffectiveness: intOrNull(i["workoutEffectiveness"]),
|
|
357
|
+
workoutCompliance: intOrNull(i["workoutCompliance"])
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function parseProperties(rows) {
|
|
361
|
+
const out = {};
|
|
362
|
+
for (const p of list(rows)) {
|
|
363
|
+
const key = text(p["physicalProperty"] ?? p["property"]);
|
|
364
|
+
if (!key) continue;
|
|
365
|
+
if ("rawValue" in p) {
|
|
366
|
+
out[key] = { value: p["rawValue"], displayValue: p["rawValue"], unit: text(p["uM"]), formatted: p["value"] };
|
|
367
|
+
} else {
|
|
368
|
+
out[key] = {
|
|
369
|
+
value: p["value"],
|
|
370
|
+
displayValue: "displayValue" in p ? p["displayValue"] : p["value"],
|
|
371
|
+
unit: text(p["unitOfMeasureShortString"]),
|
|
372
|
+
formatted: p["formattedValue"]
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return out;
|
|
377
|
+
}
|
|
378
|
+
function stepToSet(setNumber, properties) {
|
|
379
|
+
const num = (key) => numOrNull(properties[key]?.value);
|
|
380
|
+
return {
|
|
381
|
+
setNumber,
|
|
382
|
+
reps: num("IsoReps") ?? num("Reps"),
|
|
383
|
+
weightKg: num("IsoWeight") ?? num("Weight"),
|
|
384
|
+
restSec: num("RestTime"),
|
|
385
|
+
durationSec: num("Duration"),
|
|
386
|
+
properties
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function parseExercise(activity) {
|
|
390
|
+
const act = rec(activity);
|
|
391
|
+
const pa = rec(act["performedPhysicalActivity"]);
|
|
392
|
+
const pdata = rec(pa["data"]);
|
|
393
|
+
const prescribed = rec(act["displayPrescibedPhysicalActivity"]);
|
|
394
|
+
const workloadRanges = {};
|
|
395
|
+
for (const c of list(pdata["constraints"])) {
|
|
396
|
+
const prop = text(c["physicalProperty"]);
|
|
397
|
+
if (!prop) continue;
|
|
398
|
+
const range = rec(c["discreteRange"]);
|
|
399
|
+
workloadRanges[prop] = {
|
|
400
|
+
min: range["min"],
|
|
401
|
+
max: range["max"],
|
|
402
|
+
step: range["step"],
|
|
403
|
+
readOnly: Boolean(c["isReadOnly"]),
|
|
404
|
+
visibility: c["visibility"]
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const extData = {};
|
|
408
|
+
for (const [k, v] of Object.entries(rec(pa["extData"]))) {
|
|
409
|
+
if (v != null && v !== "") extData[k.startsWith("mwc_") ? k.slice(4) : k] = v;
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
activityId: text(pa["id"]),
|
|
413
|
+
analyticsId: firstText(act["analyticsId"], pa["analiticsId"]),
|
|
414
|
+
// sic
|
|
415
|
+
name: text(act["physicalActivityName"]),
|
|
416
|
+
machine: text(act["equipmentName"]),
|
|
417
|
+
equipmentType: text(act["equipmentType"]),
|
|
418
|
+
type: text(act["physicalActivityType"]),
|
|
419
|
+
isCardio: Boolean(act["isCardio"]),
|
|
420
|
+
status: text(act["status"]),
|
|
421
|
+
doneOn: text(pa["doneOn"]),
|
|
422
|
+
position: intOrNull(act["position"]),
|
|
423
|
+
duration: firstText(act["displayDurationDoneShort"], act["displayDurationShort"]),
|
|
424
|
+
calories: text(act["displayCaloriesDoneShort"]),
|
|
425
|
+
moves: text(act["displayMoveDoneShort"]),
|
|
426
|
+
muscles: list(act["muscles"]).map((m) => firstText(m["muscleName"], m["muscleType"])),
|
|
427
|
+
summary: parseProperties(pdata["data"]),
|
|
428
|
+
sets: list(pdata["steps"]).map((step, i) => stepToSet(i + 1, parseProperties(step["data"]))),
|
|
429
|
+
prescribedSets: list(prescribed["steps"]).map((step, i) => stepToSet(i + 1, parseProperties(step["properties"]))),
|
|
430
|
+
shortName: firstText(act["physicalActivityShortName"], pa["shortName"]),
|
|
431
|
+
activityCode: intOrNull(act["physicalActivityCode"]),
|
|
432
|
+
activityTypeId: text(act["physicalActivityId"]),
|
|
433
|
+
equipmentCode: intOrNull(act["equipmentCode"]),
|
|
434
|
+
targetType: text(act["targetType"]),
|
|
435
|
+
doneAs: text(pa["doneAs"]),
|
|
436
|
+
manuallyDone: Boolean(pa["manuallyDone"]),
|
|
437
|
+
hasAdjustments: Boolean(act["hasAdjustments"]),
|
|
438
|
+
donePartially: Boolean(act["hasBeenDonePartially"]),
|
|
439
|
+
progress: intOrNull(pa["progress"] ?? act["progress"]),
|
|
440
|
+
estimated: {
|
|
441
|
+
duration: act["estimatedDuration"],
|
|
442
|
+
calories: act["estimatedCalories"],
|
|
443
|
+
moves: act["estimatedMove"]
|
|
444
|
+
},
|
|
445
|
+
workloadRanges,
|
|
446
|
+
extData
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function parseWorkoutSession(data, sessionId) {
|
|
450
|
+
const d = rec(data);
|
|
451
|
+
const startedOn = text(d["startedOn"]);
|
|
452
|
+
const done = parseProperties(d["doneSummary"]);
|
|
453
|
+
const muscleScores = {};
|
|
454
|
+
for (const m of list(d["muscleScores"])) {
|
|
455
|
+
muscleScores[firstText(m["muscleName"], m["muscleType"])] = intOrNull(m["score"]) ?? 0;
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
sessionId,
|
|
459
|
+
date: startedOn.slice(0, 10),
|
|
460
|
+
startedOn,
|
|
461
|
+
closedOn: text(d["closedOn"]),
|
|
462
|
+
name: firstText(d["displayName"], d["name"]),
|
|
463
|
+
type: text(d["sessionType"]),
|
|
464
|
+
status: text(d["workoutSessionStatus"]),
|
|
465
|
+
duration: text(d["displayDurationDoneShort"]),
|
|
466
|
+
durationPlanned: text(d["displayDurationShort"]),
|
|
467
|
+
calories: firstText(done["Calories"]?.formatted, d["displayCaloriesDoneShort"]),
|
|
468
|
+
moves: firstText(done["Move"]?.formatted, d["displayMoveDoneShort"]),
|
|
469
|
+
efficacy: intOrNull(d["workoutEfficacy"]),
|
|
470
|
+
efficacyQuality: text(d["workoutEfficacyQuality"]),
|
|
471
|
+
efficacyDescription: text(d["workoutEfficacyDescription"]),
|
|
472
|
+
muscleScores,
|
|
473
|
+
exercises: list(d["physicalActivities"]).filter((a) => rec(a["performedPhysicalActivity"])["id"]).map(parseExercise),
|
|
474
|
+
fromTgCoach: Boolean(d["isFromTgCoach"]),
|
|
475
|
+
facilityId: text(d["startedInFacilityId"]),
|
|
476
|
+
userWorkoutSessionId: text(d["userWorkoutSessionId"]),
|
|
477
|
+
clientApplication: text(rec(d["extData"])["mwc_client_application"]),
|
|
478
|
+
doneSummary: done
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function parseZones(rows) {
|
|
482
|
+
return list(rows).map((z) => ({
|
|
483
|
+
name: text(z["name"]),
|
|
484
|
+
lowerBound: numOrNull(z["lowerBound"]),
|
|
485
|
+
upperBound: numOrNull(z["upperBound"]),
|
|
486
|
+
secondsInZone: numOrNull(z["secondsInZone"]) ?? 0
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
function parseExerciseAnalytics(data, analyticsId) {
|
|
490
|
+
const d = rec(data);
|
|
491
|
+
const an = rec(d["analitics"] ?? d["analytics"]);
|
|
492
|
+
const channelByIndex = /* @__PURE__ */ new Map();
|
|
493
|
+
for (const ch of list(an["descriptor"])) {
|
|
494
|
+
const index = intOrNull(ch["i"]);
|
|
495
|
+
if (index === null) continue;
|
|
496
|
+
channelByIndex.set(index, text(rec(ch["pr"])["name"]));
|
|
497
|
+
}
|
|
498
|
+
const channels = [...channelByIndex.entries()].sort(([a], [b]) => a - b).map(([, name]) => name);
|
|
499
|
+
const samples = [];
|
|
500
|
+
for (const raw of list(an["samples"])) {
|
|
501
|
+
const vs = Array.isArray(raw["vs"]) ? raw["vs"] : [];
|
|
502
|
+
if (vs.length === 0) continue;
|
|
503
|
+
const row = { t: intOrNull(raw["t"]) ?? 0 };
|
|
504
|
+
for (const [index, name] of channelByIndex) row[name] = numOrNull(vs[index]);
|
|
505
|
+
samples.push(row);
|
|
506
|
+
}
|
|
507
|
+
const hr = list(an["hr"]).filter((h) => "hr" in h).map((h) => ({ t: intOrNull(h["t"]) ?? 0, hr: intOrNull(h["hr"]) ?? 0 }));
|
|
508
|
+
const laps = list(an["laps"]).map((lap) => ({
|
|
509
|
+
n: intOrNull(lap["n"]),
|
|
510
|
+
lapDistanceM: numOrNull(lap["lapDistance"]),
|
|
511
|
+
lapTimeSec: numOrNull(lap["lapTime"]),
|
|
512
|
+
totalDistanceM: numOrNull(lap["totalDistance"]),
|
|
513
|
+
totalTimeSec: numOrNull(lap["totalTime"])
|
|
514
|
+
}));
|
|
515
|
+
return {
|
|
516
|
+
analyticsId,
|
|
517
|
+
name: text(d["name"]),
|
|
518
|
+
equipmentType: text(d["equipmentType"]),
|
|
519
|
+
channels,
|
|
520
|
+
samples,
|
|
521
|
+
hr,
|
|
522
|
+
hrZones: parseZones(an["hrZones"]),
|
|
523
|
+
summary: parseProperties(d["data"]),
|
|
524
|
+
date: text(d["date"]),
|
|
525
|
+
target: text(d["target"]),
|
|
526
|
+
activityTypeId: text(d["physicalActivityId"]),
|
|
527
|
+
activityName: text(d["physicalActivityName"]),
|
|
528
|
+
laps
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
function parseHrSession(data, sessionId, date) {
|
|
532
|
+
const d = rec(data);
|
|
533
|
+
return {
|
|
534
|
+
sessionId,
|
|
535
|
+
date,
|
|
536
|
+
summary: parseProperties(d["properties"]),
|
|
537
|
+
hrSamples: list(d["hrSamples"]).map((h) => ({ timestamp: text(h["timestamp"]), hr: intOrNull(h["hr"]) ?? 0 })),
|
|
538
|
+
hrZones: parseZones(d["hrZones"])
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/client.ts
|
|
543
|
+
var AUTH_ERROR_DETAILS = /* @__PURE__ */ new Set([
|
|
544
|
+
"TokenNotValid",
|
|
545
|
+
"InvalidToken",
|
|
546
|
+
"TokenExpired",
|
|
547
|
+
"NotAuthenticated",
|
|
548
|
+
"Unauthorized",
|
|
549
|
+
"UserNotLogged"
|
|
550
|
+
]);
|
|
551
|
+
function isAuthErrorEntry(errors) {
|
|
552
|
+
return errors.some((e) => {
|
|
553
|
+
const details = String(e.details ?? e.message ?? "");
|
|
554
|
+
return AUTH_ERROR_DETAILS.has(details) || details.toLowerCase().includes("token");
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
function errorsText(errors) {
|
|
558
|
+
return errors.map((e) => String(e.errorMessage ?? e.message ?? e.details ?? JSON.stringify(e))).join("; ");
|
|
559
|
+
}
|
|
560
|
+
var TechnogymClient = class {
|
|
561
|
+
/** Where this client loads and saves its session. */
|
|
562
|
+
sessionStore;
|
|
563
|
+
#fetcher;
|
|
564
|
+
#facility;
|
|
565
|
+
#credentials;
|
|
566
|
+
#session = null;
|
|
567
|
+
#signingIn = null;
|
|
568
|
+
/** Creates a client that is not signed in yet. */
|
|
569
|
+
constructor(options = {}) {
|
|
570
|
+
this.sessionStore = options.sessionStore ?? new MemorySessionStore();
|
|
571
|
+
this.#credentials = options.credentials;
|
|
572
|
+
this.#facility = options.facility;
|
|
573
|
+
this.#fetcher = new Fetcher({
|
|
574
|
+
timeoutMs: options.timeoutMs,
|
|
575
|
+
retries: options.retries,
|
|
576
|
+
backoffMs: options.backoffMs,
|
|
577
|
+
fetchImpl: options.fetchImpl
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Signs in with email and password and saves the session to the store. The credentials are
|
|
582
|
+
* kept in memory so a rejected token can be renewed without asking again. Throws
|
|
583
|
+
* `TechnogymAuthError` when mywellness refuses them.
|
|
584
|
+
*/
|
|
585
|
+
async login(email, password) {
|
|
586
|
+
this.#credentials = { email, password };
|
|
587
|
+
return this.#signIn();
|
|
588
|
+
}
|
|
589
|
+
/** Loads the saved session. Returns false when the store holds none. */
|
|
590
|
+
async loadSession() {
|
|
591
|
+
const session = await this.sessionStore.load();
|
|
592
|
+
if (!session) return false;
|
|
593
|
+
this.#session = session;
|
|
594
|
+
return true;
|
|
595
|
+
}
|
|
596
|
+
/** Uses this session from now on. Does not save it to the store. */
|
|
597
|
+
setSession(session) {
|
|
598
|
+
this.#session = session;
|
|
599
|
+
}
|
|
600
|
+
/** The session currently held in memory, or `null` before a login or `loadSession()`. */
|
|
601
|
+
getSession() {
|
|
602
|
+
return this.#session;
|
|
603
|
+
}
|
|
604
|
+
/** Forgets the session and the in-memory credentials, and clears the store. */
|
|
605
|
+
async logout() {
|
|
606
|
+
this.#session = null;
|
|
607
|
+
this.#credentials = void 0;
|
|
608
|
+
await this.sessionStore.clear();
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* The signed-in account. Signs in first when the client was given credentials and holds no
|
|
612
|
+
* session yet; otherwise throws `TechnogymAuthError`.
|
|
613
|
+
*/
|
|
614
|
+
async user() {
|
|
615
|
+
return (await this.#requireSession()).user;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The facility training paths are sent to (see `TechnogymClientOptions.facility`), or `null` for
|
|
619
|
+
* an account with no facility. Live on 2026-10-31, an account whose login listed no facilities
|
|
620
|
+
* got the same answers with no facility prefix as with any prefix, so paths go out unprefixed.
|
|
621
|
+
*/
|
|
622
|
+
facilityOf(user) {
|
|
623
|
+
if (!this.#facility) return user.facilities[0] ?? null;
|
|
624
|
+
const facility = user.facilities.find((f) => f.id === this.#facility || f.url === this.#facility);
|
|
625
|
+
if (!facility) throw new TechnogymError(`The account has no facility "${this.#facility}"`);
|
|
626
|
+
return facility;
|
|
627
|
+
}
|
|
628
|
+
async #requireSession() {
|
|
629
|
+
if (this.#session) return this.#session;
|
|
630
|
+
if (this.#credentials) return this.#signIn();
|
|
631
|
+
throw new TechnogymAuthError("Not signed in: call login() or loadSession() first");
|
|
632
|
+
}
|
|
633
|
+
/** Concurrent sign-ins collapse into one login request. */
|
|
634
|
+
#signIn() {
|
|
635
|
+
const credentials = this.#credentials;
|
|
636
|
+
if (!credentials) return Promise.reject(new TechnogymAuthError("No credentials to sign in with"));
|
|
637
|
+
this.#signingIn ??= (async () => {
|
|
638
|
+
try {
|
|
639
|
+
const session = await this.#loginRequest(credentials);
|
|
640
|
+
this.#session = session;
|
|
641
|
+
await this.sessionStore.save(session);
|
|
642
|
+
return session;
|
|
643
|
+
} finally {
|
|
644
|
+
this.#signingIn = null;
|
|
645
|
+
}
|
|
646
|
+
})();
|
|
647
|
+
return this.#signingIn;
|
|
648
|
+
}
|
|
649
|
+
async #loginRequest({ email, password }) {
|
|
650
|
+
let body;
|
|
651
|
+
try {
|
|
652
|
+
const res = await this.#fetcher.request(`${CORE_URL}${LOGIN_PATH}`, {
|
|
653
|
+
method: "POST",
|
|
654
|
+
json: { username: email, password, keepMeLoggedIn: true },
|
|
655
|
+
headers: appHeaders()
|
|
656
|
+
});
|
|
657
|
+
body = await parseEnvelope(res);
|
|
658
|
+
} catch (cause) {
|
|
659
|
+
if (cause instanceof TechnogymAuthError) {
|
|
660
|
+
throw new TechnogymAuthError(`Login failed: ${cause.message}`, { cause });
|
|
661
|
+
}
|
|
662
|
+
throw cause;
|
|
663
|
+
}
|
|
664
|
+
if (body.errors?.length) throw new TechnogymAuthError(`Login failed: ${errorsText(body.errors)}`);
|
|
665
|
+
const data = body.data ?? body;
|
|
666
|
+
const token = data["token"];
|
|
667
|
+
const user = parseUser(data);
|
|
668
|
+
if (typeof token !== "string" || !token || !user) {
|
|
669
|
+
throw new TechnogymAuthError(`Login failed: unexpected response (result=${JSON.stringify(data["result"])})`);
|
|
670
|
+
}
|
|
671
|
+
return { token, user, savedAt: Date.now() };
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* POSTs `body` to a training endpoint and returns the envelope's `data` (or the whole body when
|
|
675
|
+
* it has none). `path` is relative to the facility, e.g. `/Training/User/{userId}/MyMovergy`;
|
|
676
|
+
* `{userId}` is replaced with the signed-in user's id. For endpoints `Technogym` has no method for.
|
|
677
|
+
*
|
|
678
|
+
* A rejected session is renewed once, when the client has credentials, and the call repeated.
|
|
679
|
+
*/
|
|
680
|
+
async services(path, body = {}, options = {}) {
|
|
681
|
+
const session = await this.#requireSession();
|
|
682
|
+
try {
|
|
683
|
+
return await this.#servicesOnce(session, path, body, options);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (!(error instanceof TechnogymAuthError) || !this.#credentials) throw error;
|
|
686
|
+
const renewed = this.#session && this.#session.token !== session.token ? this.#session : await this.#signIn();
|
|
687
|
+
return this.#servicesOnce(renewed, path, body, options);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async #servicesOnce(session, path, body, options) {
|
|
691
|
+
const facility = this.facilityOf(session.user);
|
|
692
|
+
const resolved = path.replaceAll("{userId}", encodeURIComponent(session.user.userId));
|
|
693
|
+
const headers = new Headers(appHeaders());
|
|
694
|
+
headers.set("authorization", `Bearer ${session.token}`);
|
|
695
|
+
const prefix = facility?.url ? `/${encodeURIComponent(facility.url)}` : "";
|
|
696
|
+
const res = await this.#fetcher.request(`${SERVICES_URL}${prefix}${resolved}`, {
|
|
697
|
+
method: "POST",
|
|
698
|
+
json: body,
|
|
699
|
+
headers,
|
|
700
|
+
timeoutMs: options.timeoutMs,
|
|
701
|
+
retry: options.retry ?? true
|
|
702
|
+
});
|
|
703
|
+
const envelope = await parseEnvelope(res);
|
|
704
|
+
const action = resolved.split("/").pop() ?? resolved;
|
|
705
|
+
if (envelope.errors?.length) {
|
|
706
|
+
if (isAuthErrorEntry(envelope.errors)) {
|
|
707
|
+
throw new TechnogymAuthError(`${action}: mywellness rejected the session (${errorsText(envelope.errors)})`);
|
|
708
|
+
}
|
|
709
|
+
throw new TechnogymApiError(`${action}: ${errorsText(envelope.errors)}`, action, envelope.errors);
|
|
710
|
+
}
|
|
711
|
+
await this.#adoptRotatedToken(session, envelope.token);
|
|
712
|
+
return "data" in envelope ? envelope.data : envelope;
|
|
713
|
+
}
|
|
714
|
+
/** Some responses carry a fresh `token`; keep it so the session outlives the old one. */
|
|
715
|
+
async #adoptRotatedToken(session, token) {
|
|
716
|
+
if (typeof token !== "string" || !token || token === session.token) return;
|
|
717
|
+
if (this.#session !== session) return;
|
|
718
|
+
this.#session = { ...session, token, savedAt: Date.now() };
|
|
719
|
+
await this.sessionStore.save(this.#session);
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
async function parseEnvelope(res) {
|
|
723
|
+
const text2 = await res.text();
|
|
724
|
+
if (text2.length === 0) return {};
|
|
725
|
+
let parsed;
|
|
726
|
+
try {
|
|
727
|
+
parsed = JSON.parse(text2);
|
|
728
|
+
} catch (cause) {
|
|
729
|
+
throw new TechnogymError("mywellness returned a non-JSON response", { cause });
|
|
730
|
+
}
|
|
731
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : { data: parsed };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/analysis.ts
|
|
735
|
+
var CUMULATIVE_CHANNELS = /* @__PURE__ */ new Set(["HDistance", "Distance", "VDistance", "Calories", "Move"]);
|
|
736
|
+
var SETPOINT_CHANNELS = ["Speed", "Grade", "Level", "Resistance", "Incline", "Rpm_Target"];
|
|
737
|
+
var MIN_INTERVAL_SEC = 10;
|
|
738
|
+
var round = (n, digits) => Math.round(n * 10 ** digits) / 10 ** digits;
|
|
739
|
+
function numbers(samples, channel) {
|
|
740
|
+
const out = [];
|
|
741
|
+
for (const row of samples) {
|
|
742
|
+
const v = row[channel];
|
|
743
|
+
if (typeof v === "number") out.push(v);
|
|
744
|
+
}
|
|
745
|
+
return out;
|
|
746
|
+
}
|
|
747
|
+
function channelStats(samples, channels) {
|
|
748
|
+
const stats = {};
|
|
749
|
+
for (const ch of channels) {
|
|
750
|
+
const values = numbers(samples, ch);
|
|
751
|
+
if (values.length === 0) continue;
|
|
752
|
+
const entry = { max: Math.max(...values), samples: values.length };
|
|
753
|
+
if (CUMULATIVE_CHANNELS.has(ch)) {
|
|
754
|
+
entry.final = values[values.length - 1];
|
|
755
|
+
} else {
|
|
756
|
+
const nonzero = values.filter((v) => v > 0);
|
|
757
|
+
entry.avg = nonzero.length ? round(nonzero.reduce((a, b) => a + b, 0) / nonzero.length, 1) : 0;
|
|
758
|
+
entry.minNonzero = nonzero.length ? Math.min(...nonzero) : 0;
|
|
759
|
+
}
|
|
760
|
+
stats[ch] = entry;
|
|
761
|
+
}
|
|
762
|
+
return stats;
|
|
763
|
+
}
|
|
764
|
+
function workloadBounds(samples, channels) {
|
|
765
|
+
const keys = SETPOINT_CHANNELS.filter((c) => channels.includes(c));
|
|
766
|
+
if (keys.length === 0) return [];
|
|
767
|
+
const sig = (row) => keys.map((k) => round(row[k] ?? 0, 1));
|
|
768
|
+
const same = (a, b) => a.every((v, i) => v === b[i]);
|
|
769
|
+
const blocks = [];
|
|
770
|
+
for (const row of samples) {
|
|
771
|
+
const s = sig(row);
|
|
772
|
+
const last = blocks[blocks.length - 1];
|
|
773
|
+
if (last && same(last.sig, s)) last.end = row.t;
|
|
774
|
+
else blocks.push({ sig: s, start: row.t, end: row.t });
|
|
775
|
+
}
|
|
776
|
+
const merged = [];
|
|
777
|
+
let carryStart = null;
|
|
778
|
+
for (const b of blocks) {
|
|
779
|
+
if (b.end - b.start + 1 < MIN_INTERVAL_SEC) {
|
|
780
|
+
carryStart ??= b.start;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
merged.push({ sig: b.sig, start: carryStart ?? b.start, end: b.end });
|
|
784
|
+
carryStart = null;
|
|
785
|
+
}
|
|
786
|
+
if (carryStart !== null) {
|
|
787
|
+
const lastMerged = merged[merged.length - 1];
|
|
788
|
+
const lastBlock = blocks[blocks.length - 1];
|
|
789
|
+
if (lastMerged) lastMerged.end = lastBlock.end;
|
|
790
|
+
else merged.push({ sig: blocks[0].sig, start: blocks[0].start, end: lastBlock.end });
|
|
791
|
+
}
|
|
792
|
+
if (merged.length < 2) return [];
|
|
793
|
+
return merged.map((m, i) => [
|
|
794
|
+
m.start - 1,
|
|
795
|
+
m.end,
|
|
796
|
+
{ interval: i + 1, setpoint: Object.fromEntries(keys.map((k, j) => [k, m.sig[j]])) }
|
|
797
|
+
]);
|
|
798
|
+
}
|
|
799
|
+
function segmentAnalytics(samples, channels, laps, options = {}) {
|
|
800
|
+
const segmentBy = options.segmentBy ?? "auto";
|
|
801
|
+
if (samples.length === 0) return { mode: segmentBy === "auto" ? "fixed" : segmentBy, segments: [] };
|
|
802
|
+
let mode = "fixed";
|
|
803
|
+
let bounds = [];
|
|
804
|
+
if (segmentBy === "auto" || segmentBy === "workload") {
|
|
805
|
+
bounds = workloadBounds(samples, channels);
|
|
806
|
+
if (bounds.length) mode = "workload";
|
|
807
|
+
}
|
|
808
|
+
if (bounds.length === 0 && (segmentBy === "auto" || segmentBy === "laps") && laps.length) {
|
|
809
|
+
let prev = 0;
|
|
810
|
+
for (const lap of laps) {
|
|
811
|
+
if (lap.totalTimeSec == null) continue;
|
|
812
|
+
bounds.push([prev, lap.totalTimeSec, { lap: lap.n, lapDistanceM: lap.lapDistanceM }]);
|
|
813
|
+
prev = lap.totalTimeSec;
|
|
814
|
+
}
|
|
815
|
+
if (bounds.length) mode = "laps";
|
|
816
|
+
}
|
|
817
|
+
if (bounds.length === 0) {
|
|
818
|
+
mode = "fixed";
|
|
819
|
+
const step = Math.max(10, Math.trunc(options.segmentSeconds ?? 300) || 300);
|
|
820
|
+
const lastT = Math.max(...samples.map((r) => r.t));
|
|
821
|
+
for (let s = 0; s < lastT; s += step) bounds.push([s, Math.min(s + step, lastT), {}]);
|
|
822
|
+
}
|
|
823
|
+
const segments = [];
|
|
824
|
+
for (const [start, end, meta] of bounds) {
|
|
825
|
+
const rows = samples.filter((r) => start < r.t && r.t <= end);
|
|
826
|
+
if (rows.length === 0) continue;
|
|
827
|
+
const stats = channelStats(rows, channels);
|
|
828
|
+
for (const ch of Object.keys(stats)) {
|
|
829
|
+
if (!CUMULATIVE_CHANNELS.has(ch)) continue;
|
|
830
|
+
const vals = numbers(rows, ch);
|
|
831
|
+
stats[ch] = { delta: round(vals[vals.length - 1] - vals[0], 2), end: vals[vals.length - 1] };
|
|
832
|
+
}
|
|
833
|
+
const segment = { startSec: start, endSec: end, durationSec: end - start, ...meta, channels: stats };
|
|
834
|
+
const speed = stats["Speed"]?.avg;
|
|
835
|
+
if (speed) segment.paceMinPerKm = round(60 / speed, 2);
|
|
836
|
+
segments.push(segment);
|
|
837
|
+
}
|
|
838
|
+
return { mode, segments };
|
|
839
|
+
}
|
|
840
|
+
function windowSamples(rows, fromSec = 0, toSec = 0) {
|
|
841
|
+
const lo = Math.max(0, fromSec);
|
|
842
|
+
return rows.filter((r) => r.t >= lo && (toSec <= 0 || r.t <= toSec));
|
|
843
|
+
}
|
|
844
|
+
function downsample(rows, step) {
|
|
845
|
+
const n = Math.max(1, Math.trunc(step) || 1);
|
|
846
|
+
return rows.filter((_, i) => i % n === 0);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// src/technogym.ts
|
|
850
|
+
var HISTORY_PATH = "/Training/User/{userId}/ActivityHistory";
|
|
851
|
+
var SESSION_PATH = "/Training/User/{userId}/GetPerformedWorkoutSessionByIdCr";
|
|
852
|
+
var HR_SESSION_PATH = "/Training/User/{userId}/GetHrSession";
|
|
853
|
+
var MOVERGY_PATH = "/Training/User/{userId}/MyMovergy";
|
|
854
|
+
var analyticsPath = (analyticsId) => `/Training/CardioLog/${encodeURIComponent(analyticsId)}/Details`;
|
|
855
|
+
var Technogym = class {
|
|
856
|
+
/** Wraps a client. It signs in on first use when it was given credentials. */
|
|
857
|
+
constructor(client) {
|
|
858
|
+
this.client = client;
|
|
859
|
+
}
|
|
860
|
+
client;
|
|
861
|
+
/** The signed-in account: user id, name, culture and facilities. No HTTP call once signed in. */
|
|
862
|
+
getUser() {
|
|
863
|
+
return this.client.user();
|
|
864
|
+
}
|
|
865
|
+
/** The account's current Movergy score, or `null` when mywellness has none. */
|
|
866
|
+
async getMovergy() {
|
|
867
|
+
const data = await this.client.services(MOVERGY_PATH);
|
|
868
|
+
return typeof data?.movergy === "number" ? data.movergy : null;
|
|
869
|
+
}
|
|
870
|
+
/** The untouched `ActivityHistory` payload for a date range (bounds in either order). */
|
|
871
|
+
getActivityHistoryRaw(from, to, options = {}) {
|
|
872
|
+
let start = toIsoDate(from);
|
|
873
|
+
let end = toIsoDate(to);
|
|
874
|
+
if (start > end) [start, end] = [end, start];
|
|
875
|
+
const body = { startDay: toPartitionKey(start), endDay: toPartitionKey(end) };
|
|
876
|
+
if (options.onlyWorkouts ?? true) body["justThisType"] = "WorkoutSession";
|
|
877
|
+
return this.client.services(HISTORY_PATH, body);
|
|
878
|
+
}
|
|
879
|
+
/** Sessions between two dates (inclusive, either order), newest first. An overview only. */
|
|
880
|
+
async listSessions(from, to, options = {}) {
|
|
881
|
+
const data = await this.getActivityHistoryRaw(from, to, options);
|
|
882
|
+
const items = Array.isArray(data?.["items"]) ? data["items"] : [];
|
|
883
|
+
return items.map(parseSessionSummary).sort((a, b) => (b.date + b.time).localeCompare(a.date + a.time));
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* The date of a session, found by scanning the last `lookbackDays` (default 365) of history
|
|
887
|
+
* across every activity type. Throws `TechnogymNotFoundError` when it is not there.
|
|
888
|
+
*/
|
|
889
|
+
async findSessionDate(sessionId, lookbackDays = 365) {
|
|
890
|
+
const today = toIsoDate(/* @__PURE__ */ new Date());
|
|
891
|
+
const sessions = await this.listSessions(addDays(today, -lookbackDays), today, { onlyWorkouts: false });
|
|
892
|
+
const match = sessions.find((s) => s.sessionId === String(sessionId));
|
|
893
|
+
if (!match) throw new TechnogymNotFoundError(`Session ${sessionId} not found in the last ${lookbackDays} days`);
|
|
894
|
+
return match.date;
|
|
895
|
+
}
|
|
896
|
+
/** The untouched `GetPerformedWorkoutSessionByIdCr` payload. Pass `date`; see `getWorkoutSession`. */
|
|
897
|
+
async getWorkoutSessionRaw(sessionId, date) {
|
|
898
|
+
const day = await this.#resolveDate(sessionId, date);
|
|
899
|
+
const data = await this.client.services(SESSION_PATH, {
|
|
900
|
+
idCr: idCr(sessionId),
|
|
901
|
+
partitionDate: toPartitionKey(day)
|
|
902
|
+
});
|
|
903
|
+
if (!data?.["id"]) throw new TechnogymNotFoundError(`Session ${sessionId} on ${day} not found`);
|
|
904
|
+
return data;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Every exercise of a session with machine, sets (performed and prescribed), the machine's full
|
|
908
|
+
* summary and workload ranges, plus efficacy, muscle scores and totals. Summaries are averages
|
|
909
|
+
* and maxima only: for splits and time series, call `getExerciseAnalytics` with an exercise's
|
|
910
|
+
* `analyticsId`. `date` is strongly recommended; without it the last year is scanned for it.
|
|
911
|
+
*/
|
|
912
|
+
async getWorkoutSession(sessionId, date) {
|
|
913
|
+
return parseWorkoutSession(await this.getWorkoutSessionRaw(sessionId, date), String(sessionId));
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Every workout session of the last `days` days (1-365, default 14), fully loaded, newest first.
|
|
917
|
+
* One request for the list plus one per session.
|
|
918
|
+
*/
|
|
919
|
+
async getRecentWorkouts(days = 14) {
|
|
920
|
+
const n = Math.max(1, Math.min(Math.trunc(days) || 14, 365));
|
|
921
|
+
const today = toIsoDate(/* @__PURE__ */ new Date());
|
|
922
|
+
const items = await this.listSessions(addDays(today, -n), today);
|
|
923
|
+
const sessions = [];
|
|
924
|
+
for (const item of items) sessions.push(await this.getWorkoutSession(item.sessionId, item.date));
|
|
925
|
+
return sessions;
|
|
926
|
+
}
|
|
927
|
+
/** The untouched `CardioLog/{analyticsId}/Details` payload. */
|
|
928
|
+
async getExerciseAnalyticsRaw(analyticsId) {
|
|
929
|
+
if (!analyticsId) throw new TechnogymError("analyticsId is required");
|
|
930
|
+
const data = await this.client.services(analyticsPath(analyticsId));
|
|
931
|
+
if (!data || Object.keys(data).length === 0) throw new TechnogymNotFoundError(`No analytics for ${analyticsId}`);
|
|
932
|
+
return data;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Per-second analytics of one exercise: the machine's channels (Skillrun: Speed, Grade,
|
|
936
|
+
* HDistance, RunningCadence, RunningPower; bikes: Power, Rpm...), heart rate, HR zones, laps and
|
|
937
|
+
* summary. Strength machines have no time series; use the sets of `getWorkoutSession`.
|
|
938
|
+
*/
|
|
939
|
+
async getExerciseAnalytics(analyticsId) {
|
|
940
|
+
return parseExerciseAnalytics(await this.getExerciseAnalyticsRaw(analyticsId), analyticsId);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* `getExerciseAnalytics` plus per-channel stats and segments: by default one per interval
|
|
944
|
+
* detected from setpoint changes (speed / grade / level), else per console lap, else fixed
|
|
945
|
+
* windows. See `segmentAnalytics` to run the same analysis on samples you already have.
|
|
946
|
+
*/
|
|
947
|
+
async analyzeExercise(analyticsId, options = {}) {
|
|
948
|
+
const analytics = await this.getExerciseAnalytics(analyticsId);
|
|
949
|
+
const { mode, segments } = segmentAnalytics(analytics.samples, analytics.channels, analytics.laps, options);
|
|
950
|
+
return {
|
|
951
|
+
...analytics,
|
|
952
|
+
channelStats: channelStats(analytics.samples, analytics.channels),
|
|
953
|
+
segmentMode: mode,
|
|
954
|
+
segments
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/** The untouched `GetHrSession` payload. */
|
|
958
|
+
async getSessionHeartRateRaw(sessionId, date) {
|
|
959
|
+
const day = await this.#resolveDate(sessionId, date);
|
|
960
|
+
const data = await this.client.services(HR_SESSION_PATH, {
|
|
961
|
+
idCr: idCr(sessionId),
|
|
962
|
+
// A NUMBER here, unlike the string partitionDate of GetPerformedWorkoutSessionByIdCr.
|
|
963
|
+
partitionDate: Number(toPartitionKey(day))
|
|
964
|
+
});
|
|
965
|
+
if (!data || Object.keys(data).length === 0) {
|
|
966
|
+
throw new TechnogymNotFoundError(`No heart-rate data for session ${sessionId}`);
|
|
967
|
+
}
|
|
968
|
+
return data;
|
|
969
|
+
}
|
|
970
|
+
/** The whole-session heart-rate trace: timestamped samples, HR zones and avg/min/max HR. */
|
|
971
|
+
async getSessionHeartRate(sessionId, date) {
|
|
972
|
+
const day = await this.#resolveDate(sessionId, date);
|
|
973
|
+
return parseHrSession(await this.getSessionHeartRateRaw(sessionId, day), String(sessionId), day);
|
|
974
|
+
}
|
|
975
|
+
async #resolveDate(sessionId, date) {
|
|
976
|
+
return date === void 0 ? this.findSessionDate(sessionId) : toIsoDate(date);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
function idCr(sessionId) {
|
|
980
|
+
const n = Number(sessionId);
|
|
981
|
+
if (!Number.isSafeInteger(n)) throw new TechnogymError(`sessionId must be an integer, got "${sessionId}"`);
|
|
982
|
+
return n;
|
|
983
|
+
}
|
|
984
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
985
|
+
0 && (module.exports = {
|
|
986
|
+
APP_ID,
|
|
987
|
+
APP_NAME,
|
|
988
|
+
CORE_URL,
|
|
989
|
+
CUMULATIVE_CHANNELS,
|
|
990
|
+
FileSessionStore,
|
|
991
|
+
MemorySessionStore,
|
|
992
|
+
SERVICES_URL,
|
|
993
|
+
SETPOINT_CHANNELS,
|
|
994
|
+
Technogym,
|
|
995
|
+
TechnogymApiError,
|
|
996
|
+
TechnogymAuthError,
|
|
997
|
+
TechnogymClient,
|
|
998
|
+
TechnogymConnectionError,
|
|
999
|
+
TechnogymError,
|
|
1000
|
+
TechnogymHttpError,
|
|
1001
|
+
TechnogymNotFoundError,
|
|
1002
|
+
TechnogymRateLimitError,
|
|
1003
|
+
channelStats,
|
|
1004
|
+
downsample,
|
|
1005
|
+
isAuthErrorEntry,
|
|
1006
|
+
parseExercise,
|
|
1007
|
+
parseExerciseAnalytics,
|
|
1008
|
+
parseHrSession,
|
|
1009
|
+
parseProperties,
|
|
1010
|
+
parseSessionSummary,
|
|
1011
|
+
parseUser,
|
|
1012
|
+
parseWorkoutSession,
|
|
1013
|
+
segmentAnalytics,
|
|
1014
|
+
windowSamples
|
|
1015
|
+
});
|
|
1016
|
+
//# sourceMappingURL=index.cjs.map
|