forge-openclaw-plugin 0.2.68 → 0.2.70
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/dist/assets/{board-DFNV9VAZ.js → board-BfqxFNiQ.js} +1 -1
- package/dist/assets/index-BfLQnCNZ.js +91 -0
- package/dist/assets/index-DIapFz9v.css +1 -0
- package/dist/assets/{motion-CXdn34ih.js → motion-C0ALlgho.js} +1 -1
- package/dist/assets/{table-CEq3bTDv.js → table-WcMjnJll.js} +1 -1
- package/dist/assets/{ui-g7FaEglG.js → ui-B5I-3U91.js} +1 -1
- package/dist/assets/vendor-B-Lq_OG3.css +1 -0
- package/dist/assets/vendor-C56o26_3.js +2163 -0
- package/dist/index.html +8 -8
- package/dist/server/server/migrations/061_health_workout_raw_evidence.sql +95 -0
- package/dist/server/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
- package/dist/server/server/src/app.js +149 -21
- package/dist/server/server/src/health-workout-analytics.js +572 -0
- package/dist/server/server/src/health.js +612 -4
- package/dist/server/server/src/openapi.js +162 -0
- package/dist/server/server/src/psyche-types.js +59 -0
- package/dist/server/server/src/services/devrage.js +191 -0
- package/dist/server/src/lib/api.js +13 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/server/migrations/061_health_workout_raw_evidence.sql +95 -0
- package/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
- package/skills/forge-openclaw/SKILL.md +35 -6
- package/skills/forge-openclaw/entity_conversation_playbooks.md +179 -18
- package/dist/assets/index-B0PIKEnz.css +0 -1
- package/dist/assets/index-BofyMuFh.js +0 -90
- package/dist/assets/vendor-BcOHGipZ.js +0 -1341
- package/dist/assets/vendor-DT3pnAKJ.css +0 -1
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getDatabase } from "./db.js";
|
|
4
|
+
export const WORKOUT_ZONE_ORDER = [
|
|
5
|
+
"below_z1",
|
|
6
|
+
"zone_1",
|
|
7
|
+
"zone_2",
|
|
8
|
+
"zone_3",
|
|
9
|
+
"zone_4",
|
|
10
|
+
"zone_5"
|
|
11
|
+
];
|
|
12
|
+
const zoneLabels = {
|
|
13
|
+
below_z1: "Below Z1",
|
|
14
|
+
zone_1: "Zone 1",
|
|
15
|
+
zone_2: "Zone 2",
|
|
16
|
+
zone_3: "Zone 3",
|
|
17
|
+
zone_4: "Zone 4",
|
|
18
|
+
zone_5: "Zone 5"
|
|
19
|
+
};
|
|
20
|
+
const zoneHrrBounds = {
|
|
21
|
+
below_z1: [0, 0.5],
|
|
22
|
+
zone_1: [0.5, 0.6],
|
|
23
|
+
zone_2: [0.6, 0.7],
|
|
24
|
+
zone_3: [0.7, 0.8],
|
|
25
|
+
zone_4: [0.8, 0.9],
|
|
26
|
+
zone_5: [0.9, 1.2]
|
|
27
|
+
};
|
|
28
|
+
const scalarJsonSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]));
|
|
29
|
+
const customZoneSchema = z.object({
|
|
30
|
+
key: z.string().trim().min(1),
|
|
31
|
+
label: z.string().trim().min(1),
|
|
32
|
+
lowerBpm: z.number().nonnegative(),
|
|
33
|
+
upperBpm: z.number().nonnegative().nullable()
|
|
34
|
+
});
|
|
35
|
+
export const healthZoneProfilePatchSchema = z.object({
|
|
36
|
+
birthYear: z.number().int().min(1900).max(new Date().getFullYear()).nullable().optional(),
|
|
37
|
+
sexAtBirth: z.string().trim().max(40).nullable().optional(),
|
|
38
|
+
knownMaxHr: z.number().min(80).max(240).nullable().optional(),
|
|
39
|
+
thresholdHr: z.number().min(80).max(240).nullable().optional(),
|
|
40
|
+
restingHrOverride: z.number().min(30).max(120).nullable().optional(),
|
|
41
|
+
customZones: z.array(customZoneSchema).optional(),
|
|
42
|
+
metadata: scalarJsonSchema.optional()
|
|
43
|
+
});
|
|
44
|
+
export const workoutTimeSeriesSampleSchema = z.object({
|
|
45
|
+
sourceSampleUid: z.string().trim().min(1),
|
|
46
|
+
seriesIndex: z.number().int().nonnegative().default(0),
|
|
47
|
+
metricKey: z.string().trim().min(1),
|
|
48
|
+
label: z.string().trim().default(""),
|
|
49
|
+
category: z.string().trim().default(""),
|
|
50
|
+
unit: z.string().trim().default(""),
|
|
51
|
+
value: z.number(),
|
|
52
|
+
startedAt: z.string().datetime(),
|
|
53
|
+
endedAt: z.string().datetime(),
|
|
54
|
+
sourceDevice: z.string().trim().default(""),
|
|
55
|
+
sourceBundleIdentifier: z.string().trim().nullable().optional(),
|
|
56
|
+
sourceProductType: z.string().trim().nullable().optional(),
|
|
57
|
+
captureMethod: z.string().trim().default("associated_workout"),
|
|
58
|
+
qualityFlags: z.array(z.string().trim()).default([]),
|
|
59
|
+
metadata: scalarJsonSchema.default({}),
|
|
60
|
+
provenance: scalarJsonSchema.default({})
|
|
61
|
+
});
|
|
62
|
+
export const workoutRoutePointSchema = z.object({
|
|
63
|
+
sourceRouteUid: z.string().trim().min(1),
|
|
64
|
+
pointIndex: z.number().int().nonnegative(),
|
|
65
|
+
recordedAt: z.string().datetime(),
|
|
66
|
+
latitude: z.number().min(-90).max(90),
|
|
67
|
+
longitude: z.number().min(-180).max(180),
|
|
68
|
+
altitudeMeters: z.number().nullable().optional(),
|
|
69
|
+
horizontalAccuracyMeters: z.number().nullable().optional(),
|
|
70
|
+
verticalAccuracyMeters: z.number().nullable().optional(),
|
|
71
|
+
speedMps: z.number().nullable().optional(),
|
|
72
|
+
courseDegrees: z.number().nullable().optional(),
|
|
73
|
+
metadata: scalarJsonSchema.default({}),
|
|
74
|
+
provenance: scalarJsonSchema.default({})
|
|
75
|
+
});
|
|
76
|
+
export const workoutCaptureQualitySchema = z.object({
|
|
77
|
+
status: z
|
|
78
|
+
.enum([
|
|
79
|
+
"complete",
|
|
80
|
+
"partial",
|
|
81
|
+
"fallback_time_window_used",
|
|
82
|
+
"no_heart_rate",
|
|
83
|
+
"route_unavailable",
|
|
84
|
+
"series_expansion_failed",
|
|
85
|
+
"permission_missing",
|
|
86
|
+
"locked_device_deferred"
|
|
87
|
+
])
|
|
88
|
+
.default("partial"),
|
|
89
|
+
flags: z.array(z.string().trim()).default([]),
|
|
90
|
+
heartRateSamples: z.number().int().nonnegative().default(0),
|
|
91
|
+
routePoints: z.number().int().nonnegative().default(0),
|
|
92
|
+
associatedSampleQueryUsed: z.boolean().default(false),
|
|
93
|
+
fallbackTimeWindowUsed: z.boolean().default(false),
|
|
94
|
+
condensedSeriesExpanded: z.boolean().default(false)
|
|
95
|
+
});
|
|
96
|
+
function nowIso() {
|
|
97
|
+
return new Date().toISOString();
|
|
98
|
+
}
|
|
99
|
+
function id(prefix) {
|
|
100
|
+
return `${prefix}_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
|
|
101
|
+
}
|
|
102
|
+
function parseJson(value, fallback) {
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(value);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function dayKey(value) {
|
|
111
|
+
return value.slice(0, 10);
|
|
112
|
+
}
|
|
113
|
+
function getLatestVitalMetric(userId, metricKey, beforeDateKey) {
|
|
114
|
+
const rows = getDatabase()
|
|
115
|
+
.prepare(`SELECT metrics_json
|
|
116
|
+
FROM health_daily_summaries
|
|
117
|
+
WHERE user_id = ?
|
|
118
|
+
AND summary_type = 'vitals'
|
|
119
|
+
AND date_key <= ?
|
|
120
|
+
ORDER BY date_key DESC`)
|
|
121
|
+
.all(userId, beforeDateKey);
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
const metrics = parseJson(row.metrics_json, {});
|
|
124
|
+
const metric = metrics[metricKey];
|
|
125
|
+
const value = metric?.latest ?? metric?.average;
|
|
126
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function getObservedMaxHr(userId) {
|
|
133
|
+
const sampleMax = getDatabase()
|
|
134
|
+
.prepare(`SELECT MAX(value) AS value
|
|
135
|
+
FROM health_workout_time_series
|
|
136
|
+
WHERE user_id = ? AND metric_key = 'heart_rate'`)
|
|
137
|
+
.get(userId);
|
|
138
|
+
const workoutMax = getDatabase()
|
|
139
|
+
.prepare(`SELECT MAX(max_heart_rate) AS value
|
|
140
|
+
FROM health_workout_sessions
|
|
141
|
+
WHERE user_id = ?`)
|
|
142
|
+
.get(userId);
|
|
143
|
+
return Math.max(sampleMax?.value ?? 0, workoutMax?.value ?? 0) || null;
|
|
144
|
+
}
|
|
145
|
+
function ageEstimatedMaxHr(profile) {
|
|
146
|
+
if (!profile?.birth_year) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const age = new Date().getFullYear() - profile.birth_year;
|
|
150
|
+
if (age < 10 || age > 100) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
return 208 - 0.7 * age;
|
|
154
|
+
}
|
|
155
|
+
function resolveZoneProfile(userId, workoutStartedAt) {
|
|
156
|
+
const db = getDatabase();
|
|
157
|
+
const now = nowIso();
|
|
158
|
+
let profile = db
|
|
159
|
+
.prepare(`SELECT *
|
|
160
|
+
FROM health_zone_profiles
|
|
161
|
+
WHERE user_id = ? AND model_version = 'forge-hrr-v1'`)
|
|
162
|
+
.get(userId);
|
|
163
|
+
if (!profile) {
|
|
164
|
+
const profileId = id("hzp");
|
|
165
|
+
db.prepare(`INSERT INTO health_zone_profiles (
|
|
166
|
+
id, user_id, model_version, created_at, updated_at
|
|
167
|
+
) VALUES (?, ?, 'forge-hrr-v1', ?, ?)`).run(profileId, userId, now, now);
|
|
168
|
+
profile = db
|
|
169
|
+
.prepare(`SELECT * FROM health_zone_profiles WHERE id = ?`)
|
|
170
|
+
.get(profileId);
|
|
171
|
+
}
|
|
172
|
+
const restingHr = profile.resting_hr_override ??
|
|
173
|
+
getLatestVitalMetric(userId, "restingHeartRate", dayKey(workoutStartedAt)) ??
|
|
174
|
+
60;
|
|
175
|
+
const observedMax = getObservedMaxHr(userId);
|
|
176
|
+
const ageMax = ageEstimatedMaxHr(profile);
|
|
177
|
+
const maxHr = profile.known_max_hr ?? observedMax ?? ageMax ?? 190;
|
|
178
|
+
const customZones = parseJson(profile.custom_zones_json, []);
|
|
179
|
+
const thresholds = customZones.length > 0
|
|
180
|
+
? customZones.map((zone) => ({
|
|
181
|
+
key: zone.key,
|
|
182
|
+
label: zone.label,
|
|
183
|
+
lowerBpm: zone.lowerBpm,
|
|
184
|
+
upperBpm: zone.upperBpm
|
|
185
|
+
}))
|
|
186
|
+
: WORKOUT_ZONE_ORDER.map((zone) => {
|
|
187
|
+
const [lower, upper] = zoneHrrBounds[zone];
|
|
188
|
+
const reserve = maxHr - restingHr;
|
|
189
|
+
return {
|
|
190
|
+
key: zone,
|
|
191
|
+
label: zoneLabels[zone],
|
|
192
|
+
lowerBpm: Math.round(restingHr + lower * reserve),
|
|
193
|
+
upperBpm: zone === "zone_5" ? null : Math.round(restingHr + upper * reserve)
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
const confidence = profile.known_max_hr && restingHr
|
|
197
|
+
? "high"
|
|
198
|
+
: observedMax && restingHr
|
|
199
|
+
? "medium"
|
|
200
|
+
: "low";
|
|
201
|
+
db.prepare(`UPDATE health_zone_profiles
|
|
202
|
+
SET inferred_max_hr = ?, inferred_resting_hr = ?, confidence = ?,
|
|
203
|
+
thresholds_json = ?, updated_at = ?
|
|
204
|
+
WHERE id = ?`).run(maxHr, restingHr, confidence, JSON.stringify(thresholds), now, profile.id);
|
|
205
|
+
return {
|
|
206
|
+
...profile,
|
|
207
|
+
inferred_max_hr: maxHr,
|
|
208
|
+
inferred_resting_hr: restingHr,
|
|
209
|
+
confidence,
|
|
210
|
+
thresholds_json: JSON.stringify(thresholds)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function zoneForHr(hr, thresholds) {
|
|
214
|
+
const match = thresholds.find((zone) => {
|
|
215
|
+
return hr >= zone.lowerBpm && (zone.upperBpm == null || hr < zone.upperBpm);
|
|
216
|
+
});
|
|
217
|
+
return (match?.key ?? "zone_5");
|
|
218
|
+
}
|
|
219
|
+
function initializeZoneDurations(thresholds) {
|
|
220
|
+
return thresholds.map((zone) => ({
|
|
221
|
+
key: zone.key,
|
|
222
|
+
label: zone.label,
|
|
223
|
+
seconds: 0,
|
|
224
|
+
percentage: 0
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
function computeRouteSummary(points) {
|
|
228
|
+
if (points.length === 0) {
|
|
229
|
+
return {
|
|
230
|
+
hasRoute: false,
|
|
231
|
+
pointCount: 0,
|
|
232
|
+
bounds: null,
|
|
233
|
+
start: null,
|
|
234
|
+
end: null
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const latitudes = points.map((point) => point.latitude);
|
|
238
|
+
const longitudes = points.map((point) => point.longitude);
|
|
239
|
+
return {
|
|
240
|
+
hasRoute: true,
|
|
241
|
+
pointCount: points.length,
|
|
242
|
+
bounds: {
|
|
243
|
+
minLatitude: Math.min(...latitudes),
|
|
244
|
+
maxLatitude: Math.max(...latitudes),
|
|
245
|
+
minLongitude: Math.min(...longitudes),
|
|
246
|
+
maxLongitude: Math.max(...longitudes)
|
|
247
|
+
},
|
|
248
|
+
start: {
|
|
249
|
+
latitude: points[0].latitude,
|
|
250
|
+
longitude: points[0].longitude,
|
|
251
|
+
recordedAt: points[0].recorded_at
|
|
252
|
+
},
|
|
253
|
+
end: {
|
|
254
|
+
latitude: points[points.length - 1].latitude,
|
|
255
|
+
longitude: points[points.length - 1].longitude,
|
|
256
|
+
recordedAt: points[points.length - 1].recorded_at
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function computeAnalytics(workout, samples, routes) {
|
|
261
|
+
const profile = resolveZoneProfile(workout.user_id, workout.started_at);
|
|
262
|
+
const thresholds = parseJson(profile.thresholds_json, []);
|
|
263
|
+
const zoneDurations = initializeZoneDurations(thresholds);
|
|
264
|
+
const hrSamples = samples
|
|
265
|
+
.filter((sample) => sample.metric_key === "heart_rate")
|
|
266
|
+
.sort((left, right) => Date.parse(left.started_at) - Date.parse(right.started_at));
|
|
267
|
+
const workoutStart = Date.parse(workout.started_at);
|
|
268
|
+
const workoutEnd = Date.parse(workout.ended_at);
|
|
269
|
+
const durationSeconds = Math.max(0, (workoutEnd - workoutStart) / 1000);
|
|
270
|
+
let coveredSeconds = 0;
|
|
271
|
+
let weightedHr = 0;
|
|
272
|
+
let minHr = Number.POSITIVE_INFINITY;
|
|
273
|
+
let maxHr = 0;
|
|
274
|
+
if (hrSamples.length > 0 && thresholds.length > 0) {
|
|
275
|
+
for (let index = 0; index < hrSamples.length; index += 1) {
|
|
276
|
+
const sample = hrSamples[index];
|
|
277
|
+
const sampleStart = Math.max(Date.parse(sample.started_at), workoutStart);
|
|
278
|
+
const explicitEnd = Math.max(Date.parse(sample.ended_at), sampleStart);
|
|
279
|
+
const nextStart = index < hrSamples.length - 1
|
|
280
|
+
? Date.parse(hrSamples[index + 1].started_at)
|
|
281
|
+
: workoutEnd;
|
|
282
|
+
const sampleEnd = Math.min(workoutEnd, explicitEnd > sampleStart ? explicitEnd : nextStart);
|
|
283
|
+
const seconds = Math.max(0, (sampleEnd - sampleStart) / 1000);
|
|
284
|
+
if (seconds <= 0 || sample.value < 30 || sample.value > 240) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const zoneKey = zoneForHr(sample.value, thresholds);
|
|
288
|
+
const bucket = zoneDurations.find((zone) => zone.key === zoneKey);
|
|
289
|
+
if (bucket) {
|
|
290
|
+
bucket.seconds += seconds;
|
|
291
|
+
}
|
|
292
|
+
coveredSeconds += seconds;
|
|
293
|
+
weightedHr += sample.value * seconds;
|
|
294
|
+
minHr = Math.min(minHr, sample.value);
|
|
295
|
+
maxHr = Math.max(maxHr, sample.value);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else if (workout.average_heart_rate && thresholds.length > 0) {
|
|
299
|
+
const zoneKey = zoneForHr(workout.average_heart_rate, thresholds);
|
|
300
|
+
const bucket = zoneDurations.find((zone) => zone.key === zoneKey);
|
|
301
|
+
if (bucket) {
|
|
302
|
+
bucket.seconds = durationSeconds;
|
|
303
|
+
}
|
|
304
|
+
coveredSeconds = durationSeconds;
|
|
305
|
+
weightedHr = workout.average_heart_rate * durationSeconds;
|
|
306
|
+
minHr = workout.average_heart_rate;
|
|
307
|
+
maxHr = workout.max_heart_rate ?? workout.average_heart_rate;
|
|
308
|
+
}
|
|
309
|
+
for (const zone of zoneDurations) {
|
|
310
|
+
zone.seconds = Math.round(zone.seconds);
|
|
311
|
+
zone.percentage = coveredSeconds > 0 ? Number((zone.seconds / coveredSeconds).toFixed(4)) : 0;
|
|
312
|
+
}
|
|
313
|
+
const averageHr = coveredSeconds > 0 ? weightedHr / coveredSeconds : workout.average_heart_rate;
|
|
314
|
+
const restingHr = profile.inferred_resting_hr ?? 60;
|
|
315
|
+
const reserve = Math.max(1, (profile.inferred_max_hr ?? 190) - restingHr);
|
|
316
|
+
const intensity = averageHr != null ? Math.max(0, Math.min(1.3, (averageHr - restingHr) / reserve)) : null;
|
|
317
|
+
const trimp = intensity != null
|
|
318
|
+
? Number((durationSeconds / 60 * intensity * 1.67).toFixed(1))
|
|
319
|
+
: null;
|
|
320
|
+
const sampleCoverage = durationSeconds > 0 ? Math.min(1, coveredSeconds / durationSeconds) : 0;
|
|
321
|
+
const confidence = hrSamples.length >= 5 && sampleCoverage >= 0.6
|
|
322
|
+
? profile.confidence === "high"
|
|
323
|
+
? "high"
|
|
324
|
+
: "medium"
|
|
325
|
+
: workout.average_heart_rate
|
|
326
|
+
? "low"
|
|
327
|
+
: "unavailable";
|
|
328
|
+
const qualityFlags = [
|
|
329
|
+
...(hrSamples.length === 0 ? ["summary_hr_only"] : []),
|
|
330
|
+
...(sampleCoverage < 0.6 ? ["low_hr_sample_coverage"] : []),
|
|
331
|
+
...(routes.length === 0 ? ["no_route_points"] : [])
|
|
332
|
+
];
|
|
333
|
+
return {
|
|
334
|
+
zoneProfileId: profile.id,
|
|
335
|
+
confidence,
|
|
336
|
+
dataQuality: {
|
|
337
|
+
heartRateSampleCount: hrSamples.length,
|
|
338
|
+
sampleCoverage,
|
|
339
|
+
qualityFlags
|
|
340
|
+
},
|
|
341
|
+
zoneDurations,
|
|
342
|
+
hrSummary: {
|
|
343
|
+
averageHr: averageHr != null ? Number(averageHr.toFixed(1)) : null,
|
|
344
|
+
minHr: Number.isFinite(minHr) ? Number(minHr.toFixed(1)) : null,
|
|
345
|
+
maxHr: maxHr > 0 ? Number(maxHr.toFixed(1)) : workout.max_heart_rate,
|
|
346
|
+
restingHr,
|
|
347
|
+
maxHrForZones: profile.inferred_max_hr,
|
|
348
|
+
thresholds
|
|
349
|
+
},
|
|
350
|
+
load: {
|
|
351
|
+
trimp,
|
|
352
|
+
intensity,
|
|
353
|
+
durationSeconds
|
|
354
|
+
},
|
|
355
|
+
routeSummary: computeRouteSummary(routes)
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
export function upsertWorkoutTimeSeries(input) {
|
|
359
|
+
const db = getDatabase();
|
|
360
|
+
const now = nowIso();
|
|
361
|
+
const stmt = db.prepare(`INSERT INTO health_workout_time_series (
|
|
362
|
+
id, workout_id, user_id, source_sample_uid, series_index, metric_key,
|
|
363
|
+
label, category, unit, value, started_at, ended_at, source_device,
|
|
364
|
+
source_bundle_identifier, source_product_type, capture_method,
|
|
365
|
+
quality_flags_json, metadata_json, provenance_json, created_at, updated_at
|
|
366
|
+
)
|
|
367
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
368
|
+
ON CONFLICT(workout_id, metric_key, source_sample_uid, series_index)
|
|
369
|
+
DO UPDATE SET value = excluded.value, started_at = excluded.started_at,
|
|
370
|
+
ended_at = excluded.ended_at, source_device = excluded.source_device,
|
|
371
|
+
capture_method = excluded.capture_method,
|
|
372
|
+
quality_flags_json = excluded.quality_flags_json,
|
|
373
|
+
metadata_json = excluded.metadata_json,
|
|
374
|
+
provenance_json = excluded.provenance_json,
|
|
375
|
+
updated_at = excluded.updated_at`);
|
|
376
|
+
for (const sample of input.samples) {
|
|
377
|
+
stmt.run(id("hwts"), input.workoutId, input.userId, sample.sourceSampleUid, sample.seriesIndex, sample.metricKey, sample.label, sample.category, sample.unit, sample.value, sample.startedAt, sample.endedAt, sample.sourceDevice, sample.sourceBundleIdentifier ?? null, sample.sourceProductType ?? null, sample.captureMethod, JSON.stringify(sample.qualityFlags), JSON.stringify(sample.metadata), JSON.stringify(sample.provenance), now, now);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
export function upsertWorkoutRoutePoints(input) {
|
|
381
|
+
const db = getDatabase();
|
|
382
|
+
const now = nowIso();
|
|
383
|
+
const stmt = db.prepare(`INSERT INTO health_workout_routes (
|
|
384
|
+
id, workout_id, user_id, source_route_uid, point_index, recorded_at,
|
|
385
|
+
latitude, longitude, altitude_meters, horizontal_accuracy_meters,
|
|
386
|
+
vertical_accuracy_meters, speed_mps, course_degrees, metadata_json,
|
|
387
|
+
provenance_json, created_at, updated_at
|
|
388
|
+
)
|
|
389
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
390
|
+
ON CONFLICT(workout_id, source_route_uid, point_index)
|
|
391
|
+
DO UPDATE SET recorded_at = excluded.recorded_at,
|
|
392
|
+
latitude = excluded.latitude, longitude = excluded.longitude,
|
|
393
|
+
altitude_meters = excluded.altitude_meters,
|
|
394
|
+
horizontal_accuracy_meters = excluded.horizontal_accuracy_meters,
|
|
395
|
+
vertical_accuracy_meters = excluded.vertical_accuracy_meters,
|
|
396
|
+
speed_mps = excluded.speed_mps, course_degrees = excluded.course_degrees,
|
|
397
|
+
metadata_json = excluded.metadata_json, provenance_json = excluded.provenance_json,
|
|
398
|
+
updated_at = excluded.updated_at`);
|
|
399
|
+
for (const point of input.points) {
|
|
400
|
+
stmt.run(id("hwrt"), input.workoutId, input.userId, point.sourceRouteUid, point.pointIndex, point.recordedAt, point.latitude, point.longitude, point.altitudeMeters ?? null, point.horizontalAccuracyMeters ?? null, point.verticalAccuracyMeters ?? null, point.speedMps ?? null, point.courseDegrees ?? null, JSON.stringify(point.metadata), JSON.stringify(point.provenance), now, now);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
export function recomputeAndStoreWorkoutAnalytics(workout) {
|
|
404
|
+
const db = getDatabase();
|
|
405
|
+
const samples = db
|
|
406
|
+
.prepare(`SELECT *
|
|
407
|
+
FROM health_workout_time_series
|
|
408
|
+
WHERE workout_id = ?
|
|
409
|
+
ORDER BY started_at ASC, series_index ASC`)
|
|
410
|
+
.all(workout.id);
|
|
411
|
+
const routes = db
|
|
412
|
+
.prepare(`SELECT *
|
|
413
|
+
FROM health_workout_routes
|
|
414
|
+
WHERE workout_id = ?
|
|
415
|
+
ORDER BY point_index ASC`)
|
|
416
|
+
.all(workout.id);
|
|
417
|
+
const analytics = computeAnalytics(workout, samples, routes);
|
|
418
|
+
const now = nowIso();
|
|
419
|
+
db.prepare(`INSERT INTO health_workout_analytics (
|
|
420
|
+
id, workout_id, user_id, zone_profile_id, model_version, confidence,
|
|
421
|
+
data_quality_json, zone_durations_json, hr_summary_json, load_json,
|
|
422
|
+
route_summary_json, computed_at, created_at, updated_at
|
|
423
|
+
)
|
|
424
|
+
VALUES (?, ?, ?, ?, 'forge-hrr-v1', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
425
|
+
ON CONFLICT(workout_id, model_version)
|
|
426
|
+
DO UPDATE SET zone_profile_id = excluded.zone_profile_id,
|
|
427
|
+
confidence = excluded.confidence, data_quality_json = excluded.data_quality_json,
|
|
428
|
+
zone_durations_json = excluded.zone_durations_json,
|
|
429
|
+
hr_summary_json = excluded.hr_summary_json, load_json = excluded.load_json,
|
|
430
|
+
route_summary_json = excluded.route_summary_json, computed_at = excluded.computed_at,
|
|
431
|
+
updated_at = excluded.updated_at`).run(id("hwa"), workout.id, workout.user_id, analytics.zoneProfileId, analytics.confidence, JSON.stringify(analytics.dataQuality), JSON.stringify(analytics.zoneDurations), JSON.stringify(analytics.hrSummary), JSON.stringify(analytics.load), JSON.stringify(analytics.routeSummary), now, now, now);
|
|
432
|
+
return analytics;
|
|
433
|
+
}
|
|
434
|
+
export function getStoredWorkoutAnalytics(workout) {
|
|
435
|
+
const existing = getDatabase()
|
|
436
|
+
.prepare(`SELECT *
|
|
437
|
+
FROM health_workout_analytics
|
|
438
|
+
WHERE workout_id = ? AND model_version = 'forge-hrr-v1'`)
|
|
439
|
+
.get(workout.id);
|
|
440
|
+
if (!existing) {
|
|
441
|
+
return recomputeAndStoreWorkoutAnalytics(workout);
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
zoneProfileId: existing.zone_profile_id,
|
|
445
|
+
confidence: existing.confidence,
|
|
446
|
+
dataQuality: parseJson(existing.data_quality_json, {}),
|
|
447
|
+
zoneDurations: parseJson(existing.zone_durations_json, []),
|
|
448
|
+
hrSummary: parseJson(existing.hr_summary_json, {}),
|
|
449
|
+
load: parseJson(existing.load_json, {}),
|
|
450
|
+
routeSummary: parseJson(existing.route_summary_json, {}),
|
|
451
|
+
computedAt: existing.computed_at
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
export function getWorkoutRawEvidence(workout, resolution = "adaptive") {
|
|
455
|
+
const db = getDatabase();
|
|
456
|
+
const samples = db
|
|
457
|
+
.prepare(`SELECT *
|
|
458
|
+
FROM health_workout_time_series
|
|
459
|
+
WHERE workout_id = ?
|
|
460
|
+
ORDER BY started_at ASC, series_index ASC`)
|
|
461
|
+
.all(workout.id);
|
|
462
|
+
const routeLimit = resolution === "raw" ? 20000 : 1200;
|
|
463
|
+
const routePoints = db
|
|
464
|
+
.prepare(`SELECT *
|
|
465
|
+
FROM health_workout_routes
|
|
466
|
+
WHERE workout_id = ?
|
|
467
|
+
ORDER BY point_index ASC
|
|
468
|
+
LIMIT ?`)
|
|
469
|
+
.all(workout.id, routeLimit);
|
|
470
|
+
return {
|
|
471
|
+
timeSeries: downsampleSamples(samples, resolution === "raw" ? 50000 : 1500).map(mapSample),
|
|
472
|
+
routePoints: downsampleRoute(routePoints, resolution === "raw" ? 20000 : 1200).map(mapRoutePoint)
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function downsampleSamples(samples, limit) {
|
|
476
|
+
if (samples.length <= limit) {
|
|
477
|
+
return samples;
|
|
478
|
+
}
|
|
479
|
+
const stride = Math.ceil(samples.length / limit);
|
|
480
|
+
return samples.filter((_, index) => index % stride === 0);
|
|
481
|
+
}
|
|
482
|
+
function downsampleRoute(points, limit) {
|
|
483
|
+
if (points.length <= limit) {
|
|
484
|
+
return points;
|
|
485
|
+
}
|
|
486
|
+
const stride = Math.ceil(points.length / limit);
|
|
487
|
+
return points.filter((_, index) => index % stride === 0);
|
|
488
|
+
}
|
|
489
|
+
function mapSample(row) {
|
|
490
|
+
return {
|
|
491
|
+
id: row.id,
|
|
492
|
+
sourceSampleUid: row.source_sample_uid,
|
|
493
|
+
seriesIndex: row.series_index,
|
|
494
|
+
metricKey: row.metric_key,
|
|
495
|
+
label: row.label,
|
|
496
|
+
category: row.category,
|
|
497
|
+
unit: row.unit,
|
|
498
|
+
value: row.value,
|
|
499
|
+
startedAt: row.started_at,
|
|
500
|
+
endedAt: row.ended_at,
|
|
501
|
+
sourceDevice: row.source_device,
|
|
502
|
+
sourceBundleIdentifier: row.source_bundle_identifier,
|
|
503
|
+
sourceProductType: row.source_product_type,
|
|
504
|
+
captureMethod: row.capture_method,
|
|
505
|
+
qualityFlags: parseJson(row.quality_flags_json, []),
|
|
506
|
+
metadata: parseJson(row.metadata_json, {}),
|
|
507
|
+
provenance: parseJson(row.provenance_json, {})
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function mapRoutePoint(row) {
|
|
511
|
+
return {
|
|
512
|
+
id: row.id,
|
|
513
|
+
sourceRouteUid: row.source_route_uid,
|
|
514
|
+
pointIndex: row.point_index,
|
|
515
|
+
recordedAt: row.recorded_at,
|
|
516
|
+
latitude: row.latitude,
|
|
517
|
+
longitude: row.longitude,
|
|
518
|
+
altitudeMeters: row.altitude_meters,
|
|
519
|
+
horizontalAccuracyMeters: row.horizontal_accuracy_meters,
|
|
520
|
+
verticalAccuracyMeters: row.vertical_accuracy_meters,
|
|
521
|
+
speedMps: row.speed_mps,
|
|
522
|
+
courseDegrees: row.course_degrees,
|
|
523
|
+
metadata: parseJson(row.metadata_json, {}),
|
|
524
|
+
provenance: parseJson(row.provenance_json, {})
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
export function getHealthZoneProfile(userId) {
|
|
528
|
+
const profile = resolveZoneProfile(userId, new Date().toISOString());
|
|
529
|
+
return {
|
|
530
|
+
id: profile.id,
|
|
531
|
+
userId: profile.user_id,
|
|
532
|
+
modelVersion: profile.model_version,
|
|
533
|
+
birthYear: profile.birth_year,
|
|
534
|
+
sexAtBirth: profile.sex_at_birth,
|
|
535
|
+
knownMaxHr: profile.known_max_hr,
|
|
536
|
+
thresholdHr: profile.threshold_hr,
|
|
537
|
+
restingHrOverride: profile.resting_hr_override,
|
|
538
|
+
customZones: parseJson(profile.custom_zones_json, []),
|
|
539
|
+
inferredMaxHr: profile.inferred_max_hr,
|
|
540
|
+
inferredRestingHr: profile.inferred_resting_hr,
|
|
541
|
+
confidence: profile.confidence,
|
|
542
|
+
thresholds: parseJson(profile.thresholds_json, []),
|
|
543
|
+
metadata: parseJson(profile.metadata_json, {}),
|
|
544
|
+
createdAt: profile.created_at,
|
|
545
|
+
updatedAt: profile.updated_at
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
export function patchHealthZoneProfile(userId, patch) {
|
|
549
|
+
const parsed = healthZoneProfilePatchSchema.parse(patch);
|
|
550
|
+
const current = resolveZoneProfile(userId, new Date().toISOString());
|
|
551
|
+
const now = nowIso();
|
|
552
|
+
getDatabase()
|
|
553
|
+
.prepare(`UPDATE health_zone_profiles
|
|
554
|
+
SET birth_year = ?, sex_at_birth = ?, known_max_hr = ?, threshold_hr = ?,
|
|
555
|
+
resting_hr_override = ?, custom_zones_json = ?, metadata_json = ?,
|
|
556
|
+
updated_at = ?
|
|
557
|
+
WHERE id = ?`)
|
|
558
|
+
.run(parsed.birthYear === undefined ? current.birth_year : parsed.birthYear, parsed.sexAtBirth === undefined ? current.sex_at_birth : parsed.sexAtBirth, parsed.knownMaxHr === undefined ? current.known_max_hr : parsed.knownMaxHr, parsed.thresholdHr === undefined ? current.threshold_hr : parsed.thresholdHr, parsed.restingHrOverride === undefined
|
|
559
|
+
? current.resting_hr_override
|
|
560
|
+
: parsed.restingHrOverride, JSON.stringify(parsed.customZones === undefined
|
|
561
|
+
? parseJson(current.custom_zones_json, [])
|
|
562
|
+
: parsed.customZones), JSON.stringify(parsed.metadata === undefined
|
|
563
|
+
? parseJson(current.metadata_json, {})
|
|
564
|
+
: parsed.metadata), now, current.id);
|
|
565
|
+
const workouts = getDatabase()
|
|
566
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE user_id = ?`)
|
|
567
|
+
.all(userId);
|
|
568
|
+
for (const workout of workouts) {
|
|
569
|
+
recomputeAndStoreWorkoutAnalytics(workout);
|
|
570
|
+
}
|
|
571
|
+
return getHealthZoneProfile(userId);
|
|
572
|
+
}
|