matrix-workouts-core 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.
Files changed (77) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/MATRIX_API.md +245 -0
  4. package/README.md +123 -0
  5. package/dist/api/client.d.ts +63 -0
  6. package/dist/api/client.d.ts.map +1 -0
  7. package/dist/api/client.js +112 -0
  8. package/dist/api/client.js.map +1 -0
  9. package/dist/api/credentials.d.ts +24 -0
  10. package/dist/api/credentials.d.ts.map +1 -0
  11. package/dist/api/credentials.js +32 -0
  12. package/dist/api/credentials.js.map +1 -0
  13. package/dist/api/index.d.ts +4 -0
  14. package/dist/api/index.d.ts.map +1 -0
  15. package/dist/api/index.js +4 -0
  16. package/dist/api/index.js.map +1 -0
  17. package/dist/api/login.d.ts +43 -0
  18. package/dist/api/login.d.ts.map +1 -0
  19. package/dist/api/login.js +97 -0
  20. package/dist/api/login.js.map +1 -0
  21. package/dist/export/document.d.ts +108 -0
  22. package/dist/export/document.d.ts.map +1 -0
  23. package/dist/export/document.js +97 -0
  24. package/dist/export/document.js.map +1 -0
  25. package/dist/export/index.d.ts +2 -0
  26. package/dist/export/index.d.ts.map +1 -0
  27. package/dist/export/index.js +2 -0
  28. package/dist/export/index.js.map +1 -0
  29. package/dist/index.d.ts +16 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +16 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/node/bin/history.d.ts +3 -0
  34. package/dist/node/bin/history.d.ts.map +1 -0
  35. package/dist/node/bin/history.js +80 -0
  36. package/dist/node/bin/history.js.map +1 -0
  37. package/dist/node/history.d.ts +60 -0
  38. package/dist/node/history.d.ts.map +1 -0
  39. package/dist/node/history.js +126 -0
  40. package/dist/node/history.js.map +1 -0
  41. package/dist/node/index.d.ts +2 -0
  42. package/dist/node/index.d.ts.map +1 -0
  43. package/dist/node/index.js +2 -0
  44. package/dist/node/index.js.map +1 -0
  45. package/dist/parse/controlSignature.d.ts +38 -0
  46. package/dist/parse/controlSignature.d.ts.map +1 -0
  47. package/dist/parse/controlSignature.js +42 -0
  48. package/dist/parse/controlSignature.js.map +1 -0
  49. package/dist/parse/heartRate.d.ts +78 -0
  50. package/dist/parse/heartRate.d.ts.map +1 -0
  51. package/dist/parse/heartRate.js +57 -0
  52. package/dist/parse/heartRate.js.map +1 -0
  53. package/dist/parse/index.d.ts +30 -0
  54. package/dist/parse/index.d.ts.map +1 -0
  55. package/dist/parse/index.js +33 -0
  56. package/dist/parse/index.js.map +1 -0
  57. package/dist/parse/machine.d.ts +42 -0
  58. package/dist/parse/machine.d.ts.map +1 -0
  59. package/dist/parse/machine.js +65 -0
  60. package/dist/parse/machine.js.map +1 -0
  61. package/dist/parse/persist.d.ts +13 -0
  62. package/dist/parse/persist.d.ts.map +1 -0
  63. package/dist/parse/persist.js +40 -0
  64. package/dist/parse/persist.js.map +1 -0
  65. package/dist/parse/program.d.ts +58 -0
  66. package/dist/parse/program.d.ts.map +1 -0
  67. package/dist/parse/program.js +51 -0
  68. package/dist/parse/program.js.map +1 -0
  69. package/dist/parse/types.d.ts +127 -0
  70. package/dist/parse/types.d.ts.map +1 -0
  71. package/dist/parse/types.js +5 -0
  72. package/dist/parse/types.js.map +1 -0
  73. package/dist/parse/workout.d.ts +22 -0
  74. package/dist/parse/workout.d.ts.map +1 -0
  75. package/dist/parse/workout.js +130 -0
  76. package/dist/parse/workout.js.map +1 -0
  77. package/package.json +68 -0
@@ -0,0 +1,60 @@
1
+ import { type Credentials, type FetchLike, type HistoryResult } from "../api/index.js";
2
+ /**
3
+ * `FetchLike` over the real thing.
4
+ *
5
+ * The extension does not use this — inside the browser the request goes via the
6
+ * service worker, which supplies its own adapter. Keeping the shape this narrow is
7
+ * what lets `api/` be tested with no network and no globals at all.
8
+ */
9
+ export declare const httpFetch: FetchLike;
10
+ /**
11
+ * Read `.env` without taking on a dependency for it. Deliberately minimal:
12
+ * `KEY=value`, `#` comments, blank lines, optional surrounding quotes. A real `.env`
13
+ * parser handles multi-line values and interpolation; two numbers need neither.
14
+ *
15
+ * The real environment wins, so `MATRIX_XID=... <command>` works without editing the
16
+ * file.
17
+ */
18
+ export declare function loadEnvFile(path: string): void;
19
+ /**
20
+ * The raw response, kept verbatim, for the same reason the export carries
21
+ * `source.record`: this API is undocumented and its shape has already changed once.
22
+ * A normalized-only download would quietly become the smaller of the two records.
23
+ */
24
+ export declare function fetchRawHistory(credentials: Credentials): Promise<unknown>;
25
+ export interface DownloadOptions {
26
+ /** Directory for the raw dump and, with `split`, the per-ride documents. */
27
+ outDir: string;
28
+ /** Also write one export document per ride, as the extension's button does. */
29
+ split?: boolean;
30
+ /** Overrides the timestamped default filename. Used for a cache with one slot. */
31
+ rawFilename?: string;
32
+ }
33
+ export interface DownloadResult extends HistoryResult {
34
+ /** Where the verbatim response was written. */
35
+ rawPath: string;
36
+ /** The verbatim response, so a caller need not read the file back. */
37
+ raw: unknown;
38
+ /** Paths of the per-ride documents, empty unless `split`. */
39
+ exportPaths: string[];
40
+ }
41
+ /**
42
+ * Sign-in is the caller's job; this downloads and writes.
43
+ *
44
+ * Both requests go out together because they are the same record twice — the
45
+ * verbatim response for the archive, the normalized one for anything that has to
46
+ * read it — and serializing them would double the wait for no gain.
47
+ *
48
+ * Files are written `0600`. They are one person's heart rate and there is no reason
49
+ * for the rest of the machine to read them.
50
+ */
51
+ export declare function downloadHistory(credentials: Credentials, options: DownloadOptions): Promise<DownloadResult>;
52
+ /**
53
+ * Read a raw dump back into the normalized model.
54
+ *
55
+ * Goes through `parseHistoryResponse`, so a file read from disk and a response read
56
+ * off the wire are the same data handled the same way — including which records are
57
+ * skipped and whether the paging says something is missing.
58
+ */
59
+ export declare function readRawHistoryFile(path: string): HistoryResult;
60
+ //# sourceMappingURL=history.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history.d.ts","sourceRoot":"","sources":["../../src/node/history.ts"],"names":[],"mappings":"AAeA,OAAO,EAKL,KAAK,WAAW,EAChB,KAAK,SAAS,EACd,KAAK,aAAa,EACnB,MAAM,iBAAiB,CAAC;AAIzB;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,EAAE,SAWvB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAc9C;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,CAQhF;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,kFAAkF;IAClF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAe,SAAQ,aAAa;IACnD,+CAA+C;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,GAAG,EAAE,OAAO,CAAC;IACb,6DAA6D;IAC7D,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,WAAW,EAAE,WAAW,EACxB,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC,cAAc,CAAC,CA8BzB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,aAAa,CAE9D"}
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The Node half of the client: real `fetch`, real files.
3
+ *
4
+ * Everything above this file is platform-free by construction — `api/` takes a
5
+ * `FetchLike` rather than calling `fetch`, `parse/` takes a `ReadableStorage` rather
6
+ * than touching `localStorage`. This is where that abstinence is paid off exactly
7
+ * once, for every consumer outside the browser: the standalone CLI in `bin/`, and
8
+ * the MCP server in its own repo.
9
+ *
10
+ * PRIVACY: credentials pass through and are never written to the output or logged.
11
+ * The downloaded files are the rider's heart rate — callers choose where they land.
12
+ */
13
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+ import { ApiError, fetchWorkoutHistory, parseHistoryResponse, workoutsUrl, } from "../api/index.js";
16
+ import { exportFilename, workoutExport } from "../export/index.js";
17
+ import { toWorkout } from "../parse/index.js";
18
+ /**
19
+ * `FetchLike` over the real thing.
20
+ *
21
+ * The extension does not use this — inside the browser the request goes via the
22
+ * service worker, which supplies its own adapter. Keeping the shape this narrow is
23
+ * what lets `api/` be tested with no network and no globals at all.
24
+ */
25
+ export const httpFetch = async (url, init) => {
26
+ const response = await fetch(url, {
27
+ method: init.method ?? "GET",
28
+ headers: init.headers,
29
+ ...(init.body === undefined ? {} : { body: init.body }),
30
+ });
31
+ return {
32
+ ok: response.ok,
33
+ status: response.status,
34
+ json: () => response.json(),
35
+ };
36
+ };
37
+ /**
38
+ * Read `.env` without taking on a dependency for it. Deliberately minimal:
39
+ * `KEY=value`, `#` comments, blank lines, optional surrounding quotes. A real `.env`
40
+ * parser handles multi-line values and interpolation; two numbers need neither.
41
+ *
42
+ * The real environment wins, so `MATRIX_XID=... <command>` works without editing the
43
+ * file.
44
+ */
45
+ export function loadEnvFile(path) {
46
+ if (!existsSync(path))
47
+ return;
48
+ for (const line of readFileSync(path, "utf8").split("\n")) {
49
+ const trimmed = line.trim();
50
+ if (!trimmed || trimmed.startsWith("#"))
51
+ continue;
52
+ const eq = trimmed.indexOf("=");
53
+ if (eq < 1)
54
+ continue;
55
+ const key = trimmed.slice(0, eq).trim();
56
+ if (process.env[key] !== undefined)
57
+ continue;
58
+ process.env[key] = trimmed
59
+ .slice(eq + 1)
60
+ .trim()
61
+ .replace(/^(["'])(.*)\1$/, "$2");
62
+ }
63
+ }
64
+ /**
65
+ * The raw response, kept verbatim, for the same reason the export carries
66
+ * `source.record`: this API is undocumented and its shape has already changed once.
67
+ * A normalized-only download would quietly become the smaller of the two records.
68
+ */
69
+ export async function fetchRawHistory(credentials) {
70
+ const response = await fetch(workoutsUrl(credentials.exerciserId), {
71
+ headers: { Authorization: `Bearer ${credentials.token}`, Accept: "application/json" },
72
+ });
73
+ if (!response.ok) {
74
+ throw new ApiError(`History request failed (HTTP ${response.status}).`, response.status);
75
+ }
76
+ return response.json();
77
+ }
78
+ /**
79
+ * Sign-in is the caller's job; this downloads and writes.
80
+ *
81
+ * Both requests go out together because they are the same record twice — the
82
+ * verbatim response for the archive, the normalized one for anything that has to
83
+ * read it — and serializing them would double the wait for no gain.
84
+ *
85
+ * Files are written `0600`. They are one person's heart rate and there is no reason
86
+ * for the rest of the machine to read them.
87
+ */
88
+ export async function downloadHistory(credentials, options) {
89
+ const [raw, parsed] = await Promise.all([
90
+ fetchRawHistory(credentials),
91
+ fetchWorkoutHistory(credentials, httpFetch),
92
+ ]);
93
+ mkdirSync(options.outDir, { recursive: true });
94
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
95
+ const rawPath = resolve(options.outDir, options.rawFilename ?? `raw-history-${stamp}.json`);
96
+ writeFileSync(rawPath, `${JSON.stringify(raw, null, 2)}\n`, { mode: 0o600 });
97
+ const exportPaths = [];
98
+ if (options.split) {
99
+ // The verbatim response already holds every record; these are the normalized
100
+ // documents, one per ride, in the format the extension's export button writes.
101
+ const records = raw.workouts ?? [];
102
+ for (const record of records) {
103
+ try {
104
+ const workout = toWorkout(record);
105
+ const path = resolve(options.outDir, exportFilename(workout));
106
+ writeFileSync(path, `${JSON.stringify(workoutExport(workout), null, 2)}\n`, { mode: 0o600 });
107
+ exportPaths.push(path);
108
+ }
109
+ catch {
110
+ // Already counted in `skipped`; one bad record is not worth the run.
111
+ }
112
+ }
113
+ }
114
+ return { ...parsed, raw, rawPath, exportPaths };
115
+ }
116
+ /**
117
+ * Read a raw dump back into the normalized model.
118
+ *
119
+ * Goes through `parseHistoryResponse`, so a file read from disk and a response read
120
+ * off the wire are the same data handled the same way — including which records are
121
+ * skipped and whether the paging says something is missing.
122
+ */
123
+ export function readRawHistoryFile(path) {
124
+ return parseHistoryResponse(JSON.parse(readFileSync(path, "utf8")));
125
+ }
126
+ //# sourceMappingURL=history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history.js","sourceRoot":"","sources":["../../src/node/history.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export * from "./history.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC"}
@@ -0,0 +1,2 @@
1
+ export * from "./history.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC"}
@@ -0,0 +1,38 @@
1
+ import type { Sample } from "./types.js";
2
+ /**
3
+ * How a ride's load was actually driven, derived from the samples rather than from
4
+ * the program id. This matters because `programType` is only partly decoded, and
5
+ * because the right visualization follows the control mode, not the number.
6
+ */
7
+ export type ControlMode =
8
+ /** Resistance held flat while power moves: constant-power / ERG, e.g. a ramp test. */
9
+ "power_controlled"
10
+ /** Large, frequent resistance swings: discrete hard/easy blocks, e.g. Sprint 8. */
11
+ | "interval_blocks"
12
+ /** Not separable from the telemetry alone. */
13
+ | "unclassified";
14
+ export interface ControlSignature {
15
+ mode: ControlMode;
16
+ /** Distinct resistance levels used. */
17
+ levels: number;
18
+ minLevel: number;
19
+ maxLevel: number;
20
+ /** Number of samples where resistance differs from the previous sample. */
21
+ changes: number;
22
+ /** Changes per 100 samples. */
23
+ changeRate: number;
24
+ /** Mean magnitude of a resistance change. ~1 means single-step nudging. */
25
+ meanStep: number;
26
+ }
27
+ /**
28
+ * NOTE ON WHAT THIS DELIBERATELY DOES NOT DO.
29
+ *
30
+ * A closed-loop heart-rate program nudges resistance by a single level at a time
31
+ * (mean step ~1.0-1.4 across 24 observed rides), which looks tempting to detect.
32
+ * It is not reliably separable: rider-controlled rides land in the same band, and
33
+ * both overlap on change rate too. So this classifier names only the two modes the
34
+ * telemetry genuinely isolates and returns "unclassified" otherwise, exposing the
35
+ * raw metrics so a caller can decide with more context than the series alone.
36
+ */
37
+ export declare function controlSignature(samples: readonly Sample[]): ControlSignature;
38
+ //# sourceMappingURL=controlSignature.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controlSignature.d.ts","sourceRoot":"","sources":["../../src/parse/controlSignature.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAEzC;;;;GAIG;AACH,MAAM,MAAM,WAAW;AACrB,sFAAsF;AACpF,kBAAkB;AACpB,mFAAmF;GACjF,iBAAiB;AACnB,8CAA8C;GAC5C,cAAc,CAAC;AAEnB,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,WAAW,CAAC;IAClB,uCAAuC;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,OAAO,EAAE,MAAM,CAAC;IAChB,+BAA+B;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,gBAAgB,CAgC7E"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * NOTE ON WHAT THIS DELIBERATELY DOES NOT DO.
3
+ *
4
+ * A closed-loop heart-rate program nudges resistance by a single level at a time
5
+ * (mean step ~1.0-1.4 across 24 observed rides), which looks tempting to detect.
6
+ * It is not reliably separable: rider-controlled rides land in the same band, and
7
+ * both overlap on change rate too. So this classifier names only the two modes the
8
+ * telemetry genuinely isolates and returns "unclassified" otherwise, exposing the
9
+ * raw metrics so a caller can decide with more context than the series alone.
10
+ */
11
+ export function controlSignature(samples) {
12
+ const levels = samples.map((s) => s.resistanceLevel);
13
+ const power = samples.map((s) => s.powerWatts);
14
+ const distinct = new Set(levels);
15
+ const steps = [];
16
+ for (let i = 1; i < levels.length; i += 1) {
17
+ const delta = Math.abs(levels[i] - levels[i - 1]);
18
+ if (delta > 0)
19
+ steps.push(delta);
20
+ }
21
+ const changes = steps.length;
22
+ const meanStep = changes ? steps.reduce((a, b) => a + b, 0) / changes : 0;
23
+ const changeRate = samples.length ? (changes / samples.length) * 100 : 0;
24
+ const powerSpread = power.length ? Math.max(...power) - Math.min(...power) : 0;
25
+ let mode = "unclassified";
26
+ if (distinct.size === 1 && powerSpread > 50) {
27
+ mode = "power_controlled";
28
+ }
29
+ else if (meanStep >= 4 && changeRate >= 20) {
30
+ mode = "interval_blocks";
31
+ }
32
+ return {
33
+ mode,
34
+ levels: distinct.size,
35
+ minLevel: levels.length ? Math.min(...levels) : 0,
36
+ maxLevel: levels.length ? Math.max(...levels) : 0,
37
+ changes,
38
+ changeRate,
39
+ meanStep,
40
+ };
41
+ }
42
+ //# sourceMappingURL=controlSignature.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"controlSignature.js","sourceRoot":"","sources":["../../src/parse/controlSignature.ts"],"names":[],"mappings":"AA6BA;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAAC,OAA0B;IACzD,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC;IACrD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;IAC/C,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;IAEjC,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1C,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAE,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,CAAE,CAAC,CAAC;QACpD,IAAI,KAAK,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACnC,CAAC;IACD,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC;IAC7B,MAAM,QAAQ,GAAG,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;IAC1E,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IAEzE,MAAM,WAAW,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAE/E,IAAI,IAAI,GAAgB,cAAc,CAAC;IACvC,IAAI,QAAQ,CAAC,IAAI,KAAK,CAAC,IAAI,WAAW,GAAG,EAAE,EAAE,CAAC;QAC5C,IAAI,GAAG,kBAAkB,CAAC;IAC5B,CAAC;SAAM,IAAI,QAAQ,IAAI,CAAC,IAAI,UAAU,IAAI,EAAE,EAAE,CAAC;QAC7C,IAAI,GAAG,iBAAiB,CAAC;IAC3B,CAAC;IAED,OAAO;QACL,IAAI;QACJ,MAAM,EAAE,QAAQ,CAAC,IAAI;QACrB,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QACjD,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QACjD,OAAO;QACP,UAAU;QACV,QAAQ;KACT,CAAC;AACJ,CAAC"}
@@ -0,0 +1,78 @@
1
+ import type { Sample } from "./types.js";
2
+ /**
3
+ * Chest-strap dropouts.
4
+ *
5
+ * The console records whatever the strap reports, including nothing. Dropouts show
6
+ * up as implausibly low values (0, 14, 15, 30, 43...) sitting between physiologically
7
+ * normal neighbours. Two independent signals catch them:
8
+ *
9
+ * - an absolute floor: a working strap on someone mid-cardio does not read 15 bpm;
10
+ * - a rate-of-change limit: heart rate cannot fall 90 bpm in ten seconds.
11
+ *
12
+ * The rate check compares against the last *accepted* value, so a run of consecutive
13
+ * bad samples cannot drag the reference down with it.
14
+ *
15
+ * THE REFERENCE GOES STALE, AND THE CHECK HAS TO ACCOUNT FOR IT. The last accepted
16
+ * sample may be two minutes back, and over two minutes a heart rate legitimately
17
+ * moves much further than it can in ten seconds. Comparing a recovered sample
18
+ * against a stale reference rejects it for being too far from a value that is no
19
+ * longer relevant — which keeps the reference stale, and rejects the next one too.
20
+ * That cascade ate a third of one real ride's samples and stretched short gaps into
21
+ * long ones. So the allowance widens with the gap, at a rate a heart rate can
22
+ * actually drift.
23
+ *
24
+ * THE WIDENING IS ASYMMETRIC, because dropouts are not. A strap losing contact reads
25
+ * *low* — every bad value observed across the fixtures is below the true rate (0, 14,
26
+ * 15, 30, 43, and softer ones in the 80s and 90s during a 140 bpm ride). So widening
27
+ * the window equally in both directions lets the softer glitches in, and once the
28
+ * reference anchors on an 86 the genuine 140s that follow are all rejected. Measured
29
+ * on one fixture, symmetric widening made things *worse*: 83 rejected to 89.
30
+ *
31
+ * So upward moves get the full drift allowance immediately, while downward moves get
32
+ * none at all until the strap has been out long enough for a real fall to have
33
+ * happened — and only then at a slower rate. Without that grace window a two-sample
34
+ * gap buys just enough slack for a 121 to follow a 147, and the reference anchors on
35
+ * it and rejects the genuine 147s behind it; that single sample cost seven real ones
36
+ * on the fixture it was found in.
37
+ *
38
+ * The three constants are physiological in kind and empirical in value: they are
39
+ * tuned against the fixtures, and the fixtures are what should be re-run if they
40
+ * ever change. `npm test` asserts the outcome on every one.
41
+ */
42
+ export interface HeartRateQualityOptions {
43
+ /** Values at or below this are dropouts regardless of context. Default 60. */
44
+ floorBpm?: number;
45
+ /** Largest plausible change between consecutive samples. Default 25 bpm per 10s. */
46
+ maxDeltaBpm?: number;
47
+ /**
48
+ * How fast a heart rate can plausibly *rise*, bpm per second, used to widen the
49
+ * rate check across a gap. Default 0.5 — 30 bpm per minute, at the top of what a
50
+ * hard interval start produces, so the check stays generous rather than clever.
51
+ */
52
+ driftBpmPerSecond?: number;
53
+ /**
54
+ * The same allowance for a *fall*. Much smaller, because a low reading is what a
55
+ * failing strap produces. Default 0.15 — still enough that a real recovery across
56
+ * a five-minute gap (60 bpm down) is accepted.
57
+ */
58
+ fallBpmPerSecond?: number;
59
+ /**
60
+ * A fall gets no extra allowance at all until the gap exceeds this. Below it, a
61
+ * sharp drop is the strap failing, not the rider easing off. Default 60 s.
62
+ */
63
+ fallGraceSeconds?: number;
64
+ }
65
+ /** Per-sample validity mask, parallel to `samples`. */
66
+ export declare function flagHeartRateDropouts(samples: readonly Sample[], options?: HeartRateQualityOptions): boolean[];
67
+ export interface HeartRateStats {
68
+ minBpm: number | null;
69
+ maxBpm: number | null;
70
+ meanBpm: number | null;
71
+ /** Count of samples accepted as real. */
72
+ validCount: number;
73
+ /** Count of samples rejected as dropouts. */
74
+ dropoutCount: number;
75
+ }
76
+ /** Summary over the samples that survive dropout filtering. */
77
+ export declare function heartRateStats(samples: readonly Sample[], options?: HeartRateQualityOptions): HeartRateStats;
78
+ //# sourceMappingURL=heartRate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"heartRate.d.ts","sourceRoot":"","sources":["../../src/parse/heartRate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAGzC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,MAAM,WAAW,uBAAuB;IACtC,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAUD,uDAAuD;AACvD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,GAAE,uBAA4B,GACpC,OAAO,EAAE,CA+BX;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,yCAAyC;IACzC,UAAU,EAAE,MAAM,CAAC;IACnB,6CAA6C;IAC7C,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,+DAA+D;AAC/D,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,GAAE,uBAA4B,GACpC,cAAc,CAehB"}
@@ -0,0 +1,57 @@
1
+ import { SAMPLE_INTERVAL_SECONDS } from "./workout.js";
2
+ const DEFAULTS = {
3
+ floorBpm: 60,
4
+ maxDeltaBpm: 25,
5
+ driftBpmPerSecond: 0.5,
6
+ fallBpmPerSecond: 0.15,
7
+ fallGraceSeconds: 60,
8
+ };
9
+ /** Per-sample validity mask, parallel to `samples`. */
10
+ export function flagHeartRateDropouts(samples, options = {}) {
11
+ const floor = options.floorBpm ?? DEFAULTS.floorBpm;
12
+ const maxDelta = options.maxDeltaBpm ?? DEFAULTS.maxDeltaBpm;
13
+ const rise = options.driftBpmPerSecond ?? DEFAULTS.driftBpmPerSecond;
14
+ const fall = options.fallBpmPerSecond ?? DEFAULTS.fallBpmPerSecond;
15
+ const fallGrace = options.fallGraceSeconds ?? DEFAULTS.fallGraceSeconds;
16
+ const valid = [];
17
+ let reference = null;
18
+ let referenceSeconds = 0;
19
+ for (const sample of samples) {
20
+ const bpm = sample.heartRateBpm;
21
+ let ok = bpm > floor;
22
+ if (ok && reference !== null) {
23
+ // Never tighter than the consecutive-sample allowance, however short the gap.
24
+ const gap = Math.max(SAMPLE_INTERVAL_SECONDS, sample.elapsedSeconds - referenceSeconds);
25
+ const change = bpm - reference;
26
+ const widening = change >= 0
27
+ ? rise * (gap - SAMPLE_INTERVAL_SECONDS)
28
+ : fall * Math.max(0, gap - fallGrace);
29
+ if (Math.abs(change) > maxDelta + widening)
30
+ ok = false;
31
+ }
32
+ valid.push(ok);
33
+ if (ok) {
34
+ reference = bpm;
35
+ referenceSeconds = sample.elapsedSeconds;
36
+ }
37
+ }
38
+ return valid;
39
+ }
40
+ /** Summary over the samples that survive dropout filtering. */
41
+ export function heartRateStats(samples, options = {}) {
42
+ const valid = flagHeartRateDropouts(samples, options);
43
+ const good = samples.filter((_, i) => valid[i]).map((s) => s.heartRateBpm);
44
+ const dropoutCount = samples.length - good.length;
45
+ if (good.length === 0) {
46
+ return { minBpm: null, maxBpm: null, meanBpm: null, validCount: 0, dropoutCount };
47
+ }
48
+ const sum = good.reduce((a, b) => a + b, 0);
49
+ return {
50
+ minBpm: Math.min(...good),
51
+ maxBpm: Math.max(...good),
52
+ meanBpm: sum / good.length,
53
+ validCount: good.length,
54
+ dropoutCount,
55
+ };
56
+ }
57
+ //# sourceMappingURL=heartRate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"heartRate.js","sourceRoot":"","sources":["../../src/parse/heartRate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ export * from "./types.js";
2
+ export * from "./program.js";
3
+ export * from "./persist.js";
4
+ export * from "./machine.js";
5
+ export * from "./workout.js";
6
+ export * from "./heartRate.js";
7
+ export * from "./controlSignature.js";
8
+ import type { Workout } from "./types.js";
9
+ /** Minimal surface we need from localStorage — keeps this testable without a DOM. */
10
+ export interface ReadableStorage {
11
+ getItem(key: string): string | null;
12
+ }
13
+ /**
14
+ * Read every workout the site has cached in this browser, newest first.
15
+ *
16
+ * Note this is only what the app happens to hold — typically the current week.
17
+ * Deeper history lives behind the HTTP API (see AGENTS.md).
18
+ */
19
+ export declare function loadCachedWorkouts(storage: ReadableStorage): Workout[];
20
+ /**
21
+ * Find one workout by either of the two ids a record carries.
22
+ *
23
+ * `routeId` is tried first because the callers that matter hold a URL segment, and
24
+ * the site's links are built from the record's `id`. The fallback to `workoutId`
25
+ * keeps every other caller — fixtures, exports, tests — working, and the two id
26
+ * spaces do not collide: across one account's 45 records, all 45 `workoutId`s and
27
+ * all 45 `id`s were distinct and no value appeared in both roles.
28
+ */
29
+ export declare function findWorkout(workouts: readonly Workout[], id: string): Workout | null;
30
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/parse/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,uBAAuB,CAAC;AAItC,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,qFAAqF;AACrF,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACrC;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,EAAE,CAItE;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAEpF"}
@@ -0,0 +1,33 @@
1
+ export * from "./types.js";
2
+ export * from "./program.js";
3
+ export * from "./persist.js";
4
+ export * from "./machine.js";
5
+ export * from "./workout.js";
6
+ export * from "./heartRate.js";
7
+ export * from "./controlSignature.js";
8
+ import { extractRawWorkouts, PERSIST_KEY } from "./persist.js";
9
+ import { toWorkout } from "./workout.js";
10
+ /**
11
+ * Read every workout the site has cached in this browser, newest first.
12
+ *
13
+ * Note this is only what the app happens to hold — typically the current week.
14
+ * Deeper history lives behind the HTTP API (see AGENTS.md).
15
+ */
16
+ export function loadCachedWorkouts(storage) {
17
+ return extractRawWorkouts(storage.getItem(PERSIST_KEY))
18
+ .map((raw) => toWorkout(raw))
19
+ .sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime());
20
+ }
21
+ /**
22
+ * Find one workout by either of the two ids a record carries.
23
+ *
24
+ * `routeId` is tried first because the callers that matter hold a URL segment, and
25
+ * the site's links are built from the record's `id`. The fallback to `workoutId`
26
+ * keeps every other caller — fixtures, exports, tests — working, and the two id
27
+ * spaces do not collide: across one account's 45 records, all 45 `workoutId`s and
28
+ * all 45 `id`s were distinct and no value appeared in both roles.
29
+ */
30
+ export function findWorkout(workouts, id) {
31
+ return workouts.find((w) => w.routeId === id) ?? workouts.find((w) => w.id === id) ?? null;
32
+ }
33
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/parse/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,uBAAuB,CAAC;AAEtC,OAAO,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC/D,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAQzC;;;;;GAKG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAwB;IACzD,OAAO,kBAAkB,CAAC,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;SACpD,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,SAAS,CAAC,GAA8B,CAAC,CAAC;SACvD,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC;AACnE,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,WAAW,CAAC,QAA4B,EAAE,EAAU;IAClE,OAAO,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,EAAE,CAAC,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,IAAI,IAAI,CAAC;AAC7F,CAAC"}
@@ -0,0 +1,42 @@
1
+ import type { MachineType } from "./types.js";
2
+ /**
3
+ * What this extension is for, and what it declines to take over.
4
+ *
5
+ * The project's scope is the indoor bike (see AGENTS.md). That has always been true
6
+ * of what it was *designed* against; this module is what makes it true of what it
7
+ * actually renders. A treadmill or rower record populates different fields
8
+ * (`totalSteps`, `incline`, `totalStrokes`, `peakSpm`) and would get a dashboard
9
+ * built around power, resistance and cadence — three channels those machines may not
10
+ * report at all. Better to leave the stock page alone than to draw a confident set of
11
+ * panels about a ride we have never seen one of.
12
+ *
13
+ * BOTH bike types are in scope, not just the upright one. The account this was built
14
+ * against contains a recumbent ride (24 Jul, `raw-6a6368cb…`), and it is a committed
15
+ * fixture — narrowing to `upright_bike` would switch off a real ride that renders
16
+ * correctly today.
17
+ */
18
+ export declare const BIKE_MACHINE_TYPES: readonly MachineType[];
19
+ /**
20
+ * Whether to render our dashboard for this machine.
21
+ *
22
+ * `"unknown"` passes, and that is deliberate: it is `toWorkout`'s own sentinel for a
23
+ * record that carried no `machineType` at all, which is not knowing rather than
24
+ * knowing it is out of scope. The parse layer's standing rule is to stay tolerant of
25
+ * an undocumented upstream shape, and a record missing one field still has a full
26
+ * interval series worth drawing.
27
+ */
28
+ export declare function isSupportedMachine(machineType: MachineType): boolean;
29
+ /**
30
+ * The machine type of one cached workout, without parsing its samples.
31
+ *
32
+ * Returns `null` for "cannot tell" — no blob, unreadable blob, workout not cached,
33
+ * or no `machineType` on it. Callers must treat that as *not* grounds for hiding
34
+ * anything: most of the user's history is outside the cached week, and refusing a
35
+ * ride because it is old would be far worse than showing a pill we might not need.
36
+ *
37
+ * This exists so route handling can decide without going through `toWorkout`, which
38
+ * maps every interval into a `Sample`. The costly half of parsing stays where the
39
+ * design put it: on open, not on navigation.
40
+ */
41
+ export declare function cachedMachineType(persistRoot: string | null | undefined, workoutId: string): MachineType | null;
42
+ //# sourceMappingURL=machine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../src/parse/machine.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,kBAAkB,EAAE,SAAS,WAAW,EAAuC,CAAC;AAE7F;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAEpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACtC,SAAS,EAAE,MAAM,GAChB,WAAW,GAAG,IAAI,CAuBpB"}
@@ -0,0 +1,65 @@
1
+ import { extractRawWorkouts } from "./persist.js";
2
+ /**
3
+ * What this extension is for, and what it declines to take over.
4
+ *
5
+ * The project's scope is the indoor bike (see AGENTS.md). That has always been true
6
+ * of what it was *designed* against; this module is what makes it true of what it
7
+ * actually renders. A treadmill or rower record populates different fields
8
+ * (`totalSteps`, `incline`, `totalStrokes`, `peakSpm`) and would get a dashboard
9
+ * built around power, resistance and cadence — three channels those machines may not
10
+ * report at all. Better to leave the stock page alone than to draw a confident set of
11
+ * panels about a ride we have never seen one of.
12
+ *
13
+ * BOTH bike types are in scope, not just the upright one. The account this was built
14
+ * against contains a recumbent ride (24 Jul, `raw-6a6368cb…`), and it is a committed
15
+ * fixture — narrowing to `upright_bike` would switch off a real ride that renders
16
+ * correctly today.
17
+ */
18
+ export const BIKE_MACHINE_TYPES = ["upright_bike", "recumbent_bike"];
19
+ /**
20
+ * Whether to render our dashboard for this machine.
21
+ *
22
+ * `"unknown"` passes, and that is deliberate: it is `toWorkout`'s own sentinel for a
23
+ * record that carried no `machineType` at all, which is not knowing rather than
24
+ * knowing it is out of scope. The parse layer's standing rule is to stay tolerant of
25
+ * an undocumented upstream shape, and a record missing one field still has a full
26
+ * interval series worth drawing.
27
+ */
28
+ export function isSupportedMachine(machineType) {
29
+ return machineType === "unknown" || BIKE_MACHINE_TYPES.includes(machineType);
30
+ }
31
+ /**
32
+ * The machine type of one cached workout, without parsing its samples.
33
+ *
34
+ * Returns `null` for "cannot tell" — no blob, unreadable blob, workout not cached,
35
+ * or no `machineType` on it. Callers must treat that as *not* grounds for hiding
36
+ * anything: most of the user's history is outside the cached week, and refusing a
37
+ * ride because it is old would be far worse than showing a pill we might not need.
38
+ *
39
+ * This exists so route handling can decide without going through `toWorkout`, which
40
+ * maps every interval into a `Sample`. The costly half of parsing stays where the
41
+ * design put it: on open, not on navigation.
42
+ */
43
+ export function cachedMachineType(persistRoot, workoutId) {
44
+ let records;
45
+ try {
46
+ records = extractRawWorkouts(persistRoot);
47
+ }
48
+ catch {
49
+ // A blob we cannot read is not a machine type we know. Opening the view will
50
+ // surface the real parse error; this is not the place to report it.
51
+ return null;
52
+ }
53
+ // Matched against both identifiers, in both key styles: `workoutId` names the
54
+ // ride but the URL this is called with carries the record's `id`, and the two
55
+ // differ on every ride before 13 Aug 2026. See `Workout.routeId`.
56
+ const record = records.find((w) => {
57
+ const raw = w;
58
+ return (raw["id"] === workoutId || raw["workoutId"] === workoutId || raw["workout_id"] === workoutId);
59
+ });
60
+ if (!record)
61
+ return null;
62
+ const type = record["machineType"] ?? record["machine_type"];
63
+ return typeof type === "string" && type !== "" ? type : null;
64
+ }
65
+ //# sourceMappingURL=machine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machine.js","sourceRoot":"","sources":["../../src/parse/machine.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGlD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAA2B,CAAC,cAAc,EAAE,gBAAgB,CAAC,CAAC;AAE7F;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,WAAwB;IACzD,OAAO,WAAW,KAAK,SAAS,IAAI,kBAAkB,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AAC/E,CAAC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,iBAAiB,CAC/B,WAAsC,EACtC,SAAiB;IAEjB,IAAI,OAAO,CAAC;IACZ,IAAI,CAAC;QACH,OAAO,GAAG,kBAAkB,CAAC,WAAW,CAAC,CAAC;IAC5C,CAAC;IAAC,MAAM,CAAC;QACP,6EAA6E;QAC7E,oEAAoE;QACpE,OAAO,IAAI,CAAC;IACd,CAAC;IAED,8EAA8E;IAC9E,8EAA8E;IAC9E,kEAAkE;IAClE,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE;QAChC,MAAM,GAAG,GAAG,CAA4B,CAAC;QACzC,OAAO,CACL,GAAG,CAAC,IAAI,CAAC,KAAK,SAAS,IAAI,GAAG,CAAC,WAAW,CAAC,KAAK,SAAS,IAAI,GAAG,CAAC,YAAY,CAAC,KAAK,SAAS,CAC7F,CAAC;IACJ,CAAC,CAAwC,CAAC;IAC1C,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAC;IAEzB,MAAM,IAAI,GAAG,MAAM,CAAC,aAAa,CAAC,IAAI,MAAM,CAAC,cAAc,CAAC,CAAC;IAC7D,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AAC/D,CAAC"}