forge-openclaw-plugin 0.2.67 → 0.2.69

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 (29) hide show
  1. package/dist/assets/{board-DFNV9VAZ.js → board-BfqxFNiQ.js} +1 -1
  2. package/dist/assets/index-BfLQnCNZ.js +91 -0
  3. package/dist/assets/index-DIapFz9v.css +1 -0
  4. package/dist/assets/{motion-CXdn34ih.js → motion-C0ALlgho.js} +1 -1
  5. package/dist/assets/{table-CEq3bTDv.js → table-WcMjnJll.js} +1 -1
  6. package/dist/assets/{ui-g7FaEglG.js → ui-B5I-3U91.js} +1 -1
  7. package/dist/assets/vendor-B-Lq_OG3.css +1 -0
  8. package/dist/assets/vendor-C56o26_3.js +2163 -0
  9. package/dist/index.html +8 -8
  10. package/dist/server/server/migrations/060_psyche_devrage_metrics.sql +40 -0
  11. package/dist/server/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  12. package/dist/server/server/src/app.js +94 -12
  13. package/dist/server/server/src/health-workout-analytics.js +572 -0
  14. package/dist/server/server/src/health.js +116 -3
  15. package/dist/server/server/src/openapi.js +238 -0
  16. package/dist/server/server/src/psyche-types.js +90 -0
  17. package/dist/server/server/src/services/devrage.js +412 -0
  18. package/dist/server/server/src/services/psyche.js +3 -0
  19. package/dist/server/src/lib/api.js +13 -0
  20. package/openclaw.plugin.json +1 -1
  21. package/package.json +1 -1
  22. package/server/migrations/060_psyche_devrage_metrics.sql +40 -0
  23. package/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  24. package/skills/forge-openclaw/SKILL.md +25 -1
  25. package/skills/forge-openclaw/entity_conversation_playbooks.md +46 -14
  26. package/dist/assets/index-B9IVt8VN.js +0 -90
  27. package/dist/assets/index-DJlo9Tsp.css +0 -1
  28. package/dist/assets/vendor-BcOHGipZ.js +0 -1341
  29. 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
+ }