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
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getDatabase, runInTransaction } from "./db.js";
|
|
4
4
|
import { HttpError } from "./errors.js";
|
|
5
5
|
import { buildWorkoutSessionPersistenceSeed, buildWorkoutSessionPresentation, workoutActivityDescriptorSchema, workoutDetailsSchema } from "./health-workout-adapters.js";
|
|
6
|
+
import { getHealthZoneProfile, getStoredWorkoutAnalytics, getWorkoutRawEvidence, healthZoneProfilePatchSchema, recomputeAndStoreWorkoutAnalytics, upsertWorkoutRoutePoints, upsertWorkoutTimeSeries, workoutCaptureQualitySchema, workoutRoutePointSchema, workoutTimeSeriesSampleSchema, patchHealthZoneProfile } from "./health-workout-analytics.js";
|
|
6
7
|
import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
|
|
7
8
|
import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
|
|
8
9
|
import { recordActivityEvent } from "./repositories/activity-events.js";
|
|
@@ -245,6 +246,10 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
245
246
|
sourceProductType: z.string().trim().optional(),
|
|
246
247
|
activity: workoutActivityDescriptorSchema.optional(),
|
|
247
248
|
details: workoutDetailsSchema.optional(),
|
|
249
|
+
timeSeriesSamples: z.array(workoutTimeSeriesSampleSchema).default([]),
|
|
250
|
+
routePoints: z.array(workoutRoutePointSchema).default([]),
|
|
251
|
+
captureQuality: workoutCaptureQualitySchema.optional(),
|
|
252
|
+
syncCursor: z.record(z.string(), z.unknown()).default({}),
|
|
248
253
|
links: z.array(healthLinkSchema).default([]),
|
|
249
254
|
annotations: workoutAnnotationSchema.partial().default({})
|
|
250
255
|
}))
|
|
@@ -254,6 +259,92 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
254
259
|
movement: movementSyncPayloadSchema.default({}),
|
|
255
260
|
screenTime: screenTimeSyncPayloadSchema.default({})
|
|
256
261
|
});
|
|
262
|
+
const HEALTH_MOBILE_SYNC_SCHEMA_VERSION = "healthkit-sync-v2";
|
|
263
|
+
const HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES = 512_000;
|
|
264
|
+
const HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES = 1_000_000;
|
|
265
|
+
const HEALTH_MOBILE_SYNC_SESSION_TTL_MS = 1000 * 60 * 60 * 24;
|
|
266
|
+
const mobileHealthSyncFamilySchema = z.enum([
|
|
267
|
+
"sleep_nights",
|
|
268
|
+
"sleep_segments",
|
|
269
|
+
"sleep_raw_records",
|
|
270
|
+
"workout_summaries",
|
|
271
|
+
"workout_time_series",
|
|
272
|
+
"workout_routes",
|
|
273
|
+
"workout_tombstones",
|
|
274
|
+
"vitals",
|
|
275
|
+
"movement",
|
|
276
|
+
"screen_time"
|
|
277
|
+
]);
|
|
278
|
+
const defaultMobileHealthSyncFamilies = mobileHealthSyncFamilySchema.options;
|
|
279
|
+
export const mobileHealthSyncSessionStartSchema = mobileHealthSyncSchema
|
|
280
|
+
.pick({
|
|
281
|
+
sessionId: true,
|
|
282
|
+
pairingToken: true,
|
|
283
|
+
device: true,
|
|
284
|
+
permissions: true,
|
|
285
|
+
sourceStates: true
|
|
286
|
+
})
|
|
287
|
+
.extend({
|
|
288
|
+
schemaVersion: z
|
|
289
|
+
.string()
|
|
290
|
+
.trim()
|
|
291
|
+
.default(HEALTH_MOBILE_SYNC_SCHEMA_VERSION),
|
|
292
|
+
requestedFamilies: z
|
|
293
|
+
.array(mobileHealthSyncFamilySchema)
|
|
294
|
+
.default(defaultMobileHealthSyncFamilies),
|
|
295
|
+
expectedCounts: z.record(z.string(), z.number().int().nonnegative()).default({}),
|
|
296
|
+
metadata: z.record(z.string(), z.unknown()).default({})
|
|
297
|
+
});
|
|
298
|
+
const workoutTimeSeriesChunkPayloadSchema = z.object({
|
|
299
|
+
workouts: z
|
|
300
|
+
.array(z.object({
|
|
301
|
+
externalUid: z.string().trim().min(1),
|
|
302
|
+
samples: z.array(workoutTimeSeriesSampleSchema).default([])
|
|
303
|
+
}))
|
|
304
|
+
.default([])
|
|
305
|
+
});
|
|
306
|
+
const workoutRouteChunkPayloadSchema = z.object({
|
|
307
|
+
workouts: z
|
|
308
|
+
.array(z.object({
|
|
309
|
+
externalUid: z.string().trim().min(1),
|
|
310
|
+
routePoints: z.array(workoutRoutePointSchema).default([])
|
|
311
|
+
}))
|
|
312
|
+
.default([])
|
|
313
|
+
});
|
|
314
|
+
const workoutTombstoneChunkPayloadSchema = z.object({
|
|
315
|
+
workouts: z
|
|
316
|
+
.array(z.object({
|
|
317
|
+
externalUid: z.string().trim().min(1),
|
|
318
|
+
deletedAt: z.string().datetime().nullable().optional(),
|
|
319
|
+
reason: z.string().trim().default("healthkit_deleted")
|
|
320
|
+
}))
|
|
321
|
+
.default([])
|
|
322
|
+
});
|
|
323
|
+
const mobileHealthSyncChunkPayloadSchema = z.object({
|
|
324
|
+
sleepNights: mobileHealthSyncSchema.shape.sleepNights.optional(),
|
|
325
|
+
sleepSegments: mobileHealthSyncSchema.shape.sleepSegments.optional(),
|
|
326
|
+
sleepRawRecords: mobileHealthSyncSchema.shape.sleepRawRecords.optional(),
|
|
327
|
+
workouts: mobileHealthSyncSchema.shape.workouts.optional(),
|
|
328
|
+
workoutTimeSeries: workoutTimeSeriesChunkPayloadSchema.shape.workouts.optional(),
|
|
329
|
+
workoutRoutes: workoutRouteChunkPayloadSchema.shape.workouts.optional(),
|
|
330
|
+
workoutTombstones: workoutTombstoneChunkPayloadSchema.shape.workouts.optional(),
|
|
331
|
+
vitals: vitalsSyncPayloadSchema.optional(),
|
|
332
|
+
movement: movementSyncPayloadSchema.optional(),
|
|
333
|
+
screenTime: screenTimeSyncPayloadSchema.optional()
|
|
334
|
+
});
|
|
335
|
+
export const mobileHealthSyncChunkSchema = z.object({
|
|
336
|
+
chunkId: z.string().trim().min(1),
|
|
337
|
+
sequence: z.number().int().nonnegative(),
|
|
338
|
+
family: mobileHealthSyncFamilySchema,
|
|
339
|
+
recordCount: z.number().int().nonnegative().default(0),
|
|
340
|
+
byteCount: z.number().int().nonnegative().default(0),
|
|
341
|
+
checksumSha256: z.string().trim().min(1),
|
|
342
|
+
payload: mobileHealthSyncChunkPayloadSchema.default({})
|
|
343
|
+
});
|
|
344
|
+
export const mobileHealthSyncSessionCompleteSchema = z.object({
|
|
345
|
+
finalCursor: z.record(z.string(), z.unknown()).default({}),
|
|
346
|
+
expectedCounts: z.record(z.string(), z.number().int().nonnegative()).default({})
|
|
347
|
+
});
|
|
257
348
|
export const verifyCompanionPairingSchema = z.object({
|
|
258
349
|
sessionId: z.string().trim().min(1),
|
|
259
350
|
pairingToken: z.string().trim().min(1),
|
|
@@ -788,6 +879,7 @@ function mapSleepRawLog(row) {
|
|
|
788
879
|
function mapWorkoutSession(row) {
|
|
789
880
|
const provenance = safeJsonParse(row.provenance_json, {});
|
|
790
881
|
const derived = safeJsonParse(row.derived_json, {});
|
|
882
|
+
const analytics = getStoredWorkoutAnalytics(row);
|
|
791
883
|
const presentation = buildWorkoutSessionPresentation({
|
|
792
884
|
source: row.source,
|
|
793
885
|
sourceType: row.source_type,
|
|
@@ -833,6 +925,7 @@ function mapWorkoutSession(row) {
|
|
|
833
925
|
annotations: safeJsonParse(row.annotations_json, {}),
|
|
834
926
|
provenance,
|
|
835
927
|
derived,
|
|
928
|
+
analytics,
|
|
836
929
|
generatedFromHabitId: row.generated_from_habit_id,
|
|
837
930
|
generatedFromCheckInId: row.generated_from_check_in_id,
|
|
838
931
|
reconciliationStatus: row.reconciliation_status,
|
|
@@ -1352,6 +1445,28 @@ export function getWorkoutSessionById(workoutId) {
|
|
|
1352
1445
|
.get(workoutId);
|
|
1353
1446
|
return row ? mapWorkoutSession(row) : undefined;
|
|
1354
1447
|
}
|
|
1448
|
+
export function getWorkoutSessionDetailById(workoutId, resolution = "adaptive") {
|
|
1449
|
+
const row = getDatabase()
|
|
1450
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
1451
|
+
.get(workoutId);
|
|
1452
|
+
if (!row) {
|
|
1453
|
+
return undefined;
|
|
1454
|
+
}
|
|
1455
|
+
const workout = mapWorkoutSession(row);
|
|
1456
|
+
return {
|
|
1457
|
+
workout,
|
|
1458
|
+
analytics: getStoredWorkoutAnalytics(row),
|
|
1459
|
+
evidence: getWorkoutRawEvidence(row, resolution),
|
|
1460
|
+
zoneProfile: getHealthZoneProfile(row.user_id)
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
export { healthZoneProfilePatchSchema };
|
|
1464
|
+
export function getHealthZoneProfileForUser(userId) {
|
|
1465
|
+
return getHealthZoneProfile(userId);
|
|
1466
|
+
}
|
|
1467
|
+
export function patchHealthZoneProfileForUser(userId, patch) {
|
|
1468
|
+
return patchHealthZoneProfile(userId, patch);
|
|
1469
|
+
}
|
|
1355
1470
|
function listPairingRows(userIds) {
|
|
1356
1471
|
const params = [];
|
|
1357
1472
|
const where = userIds && userIds.length > 0
|
|
@@ -2072,6 +2187,7 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
2072
2187
|
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
2073
2188
|
: null
|
|
2074
2189
|
}), matchedGenerated.generated_from_habit_id ? "merged" : "standalone", now, matchedGenerated.id);
|
|
2190
|
+
persistWorkoutEvidenceForInput(matchedGenerated.id, pairing.user_id, input);
|
|
2075
2191
|
return {
|
|
2076
2192
|
mode: matchedGenerated.generated_from_habit_id || matchedGenerated.source !== "apple_health"
|
|
2077
2193
|
? "merged"
|
|
@@ -2105,8 +2221,43 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
2105
2221
|
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
2106
2222
|
: null
|
|
2107
2223
|
}), now, now);
|
|
2224
|
+
persistWorkoutEvidenceForInput(id, pairing.user_id, input);
|
|
2108
2225
|
return { mode: "created", id };
|
|
2109
2226
|
}
|
|
2227
|
+
function persistWorkoutEvidenceForInput(workoutId, userId, input) {
|
|
2228
|
+
if (input.timeSeriesSamples.length > 0) {
|
|
2229
|
+
upsertWorkoutTimeSeries({
|
|
2230
|
+
workoutId,
|
|
2231
|
+
userId,
|
|
2232
|
+
samples: input.timeSeriesSamples
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
if (input.routePoints.length > 0) {
|
|
2236
|
+
upsertWorkoutRoutePoints({
|
|
2237
|
+
workoutId,
|
|
2238
|
+
userId,
|
|
2239
|
+
points: input.routePoints
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
const row = getDatabase()
|
|
2243
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
2244
|
+
.get(workoutId);
|
|
2245
|
+
if (row) {
|
|
2246
|
+
recomputeAndStoreWorkoutAnalytics(row);
|
|
2247
|
+
if (input.captureQuality) {
|
|
2248
|
+
const derived = safeJsonParse(row.derived_json, {});
|
|
2249
|
+
getDatabase()
|
|
2250
|
+
.prepare(`UPDATE health_workout_sessions
|
|
2251
|
+
SET derived_json = ?, updated_at = ?
|
|
2252
|
+
WHERE id = ?`)
|
|
2253
|
+
.run(JSON.stringify({
|
|
2254
|
+
...derived,
|
|
2255
|
+
captureQuality: input.captureQuality,
|
|
2256
|
+
syncCursor: input.syncCursor
|
|
2257
|
+
}), nowIso(), workoutId);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2110
2261
|
function clusterSleepRowsByGap(rows) {
|
|
2111
2262
|
const clusters = [];
|
|
2112
2263
|
let currentCluster = [];
|
|
@@ -2497,6 +2648,415 @@ function replaceHistoricalSleepSessionsForDate(userId, localDateKey, providerBac
|
|
|
2497
2648
|
.run(historical.id);
|
|
2498
2649
|
}
|
|
2499
2650
|
}
|
|
2651
|
+
function mobileSyncSessionId() {
|
|
2652
|
+
return `hms_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
2653
|
+
}
|
|
2654
|
+
function mobileSyncChunkRecordId() {
|
|
2655
|
+
return `hmsc_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
2656
|
+
}
|
|
2657
|
+
function expireStaleMobileSyncSessions() {
|
|
2658
|
+
const cutoff = new Date(Date.now() - HEALTH_MOBILE_SYNC_SESSION_TTL_MS).toISOString();
|
|
2659
|
+
const now = nowIso();
|
|
2660
|
+
getDatabase()
|
|
2661
|
+
.prepare(`UPDATE health_mobile_sync_sessions
|
|
2662
|
+
SET status = 'expired', expired_at = ?, updated_at = ?
|
|
2663
|
+
WHERE status = 'running' AND started_at < ?`)
|
|
2664
|
+
.run(now, now, cutoff);
|
|
2665
|
+
}
|
|
2666
|
+
function readMobileSyncSession(syncSessionId) {
|
|
2667
|
+
return getDatabase()
|
|
2668
|
+
.prepare(`SELECT * FROM health_mobile_sync_sessions WHERE id = ?`)
|
|
2669
|
+
.get(syncSessionId);
|
|
2670
|
+
}
|
|
2671
|
+
function ensureRunningMobileSyncSession(syncSessionId) {
|
|
2672
|
+
expireStaleMobileSyncSessions();
|
|
2673
|
+
const session = readMobileSyncSession(syncSessionId);
|
|
2674
|
+
if (!session) {
|
|
2675
|
+
throw new HttpError(404, "sync_session_not_found", "The HealthKit sync session does not exist.");
|
|
2676
|
+
}
|
|
2677
|
+
if (session.status !== "running") {
|
|
2678
|
+
throw new HttpError(409, "sync_session_closed", "The HealthKit sync session is no longer accepting chunks.", { status: session.status });
|
|
2679
|
+
}
|
|
2680
|
+
return session;
|
|
2681
|
+
}
|
|
2682
|
+
function chunkPayloadJson(payload) {
|
|
2683
|
+
return JSON.stringify(payload);
|
|
2684
|
+
}
|
|
2685
|
+
function chunkPayloadChecksum(payloadJson) {
|
|
2686
|
+
return createHash("sha256").update(payloadJson).digest("hex");
|
|
2687
|
+
}
|
|
2688
|
+
function summarizeChunkPayload(family, payload) {
|
|
2689
|
+
switch (family) {
|
|
2690
|
+
case "sleep_nights":
|
|
2691
|
+
return { sleepNights: payload.sleepNights?.length ?? 0 };
|
|
2692
|
+
case "sleep_segments":
|
|
2693
|
+
return { sleepSegments: payload.sleepSegments?.length ?? 0 };
|
|
2694
|
+
case "sleep_raw_records":
|
|
2695
|
+
return { sleepRawRecords: payload.sleepRawRecords?.length ?? 0 };
|
|
2696
|
+
case "workout_summaries":
|
|
2697
|
+
return { workouts: payload.workouts?.length ?? 0 };
|
|
2698
|
+
case "workout_time_series":
|
|
2699
|
+
return {
|
|
2700
|
+
workouts: payload.workoutTimeSeries?.length ?? 0,
|
|
2701
|
+
samples: payload.workoutTimeSeries?.reduce((sum, entry) => sum + entry.samples.length, 0) ?? 0
|
|
2702
|
+
};
|
|
2703
|
+
case "workout_routes":
|
|
2704
|
+
return {
|
|
2705
|
+
workouts: payload.workoutRoutes?.length ?? 0,
|
|
2706
|
+
routePoints: payload.workoutRoutes?.reduce((sum, entry) => sum + entry.routePoints.length, 0) ?? 0
|
|
2707
|
+
};
|
|
2708
|
+
case "workout_tombstones":
|
|
2709
|
+
return { workouts: payload.workoutTombstones?.length ?? 0 };
|
|
2710
|
+
case "vitals":
|
|
2711
|
+
return { daySummaries: payload.vitals?.daySummaries.length ?? 0 };
|
|
2712
|
+
case "movement":
|
|
2713
|
+
return {
|
|
2714
|
+
stays: payload.movement?.stays.length ?? 0,
|
|
2715
|
+
trips: payload.movement?.trips.length ?? 0
|
|
2716
|
+
};
|
|
2717
|
+
case "screen_time":
|
|
2718
|
+
return {
|
|
2719
|
+
daySummaries: payload.screenTime?.daySummaries.length ?? 0,
|
|
2720
|
+
hourlySegments: payload.screenTime?.hourlySegments.length ?? 0
|
|
2721
|
+
};
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
function updateMobileSyncSessionProgress(syncSessionId) {
|
|
2725
|
+
const chunks = getDatabase()
|
|
2726
|
+
.prepare(`SELECT family, record_count, byte_count
|
|
2727
|
+
FROM health_mobile_sync_chunks
|
|
2728
|
+
WHERE sync_session_id = ?`)
|
|
2729
|
+
.all(syncSessionId);
|
|
2730
|
+
const receivedCounts = {};
|
|
2731
|
+
const byteTotals = {};
|
|
2732
|
+
for (const chunk of chunks) {
|
|
2733
|
+
receivedCounts[chunk.family] =
|
|
2734
|
+
(receivedCounts[chunk.family] ?? 0) + chunk.record_count;
|
|
2735
|
+
byteTotals[chunk.family] =
|
|
2736
|
+
(byteTotals[chunk.family] ?? 0) + chunk.byte_count;
|
|
2737
|
+
}
|
|
2738
|
+
getDatabase()
|
|
2739
|
+
.prepare(`UPDATE health_mobile_sync_sessions
|
|
2740
|
+
SET received_counts_json = ?, byte_totals_json = ?, updated_at = ?
|
|
2741
|
+
WHERE id = ?`)
|
|
2742
|
+
.run(JSON.stringify(receivedCounts), JSON.stringify(byteTotals), nowIso(), syncSessionId);
|
|
2743
|
+
return {
|
|
2744
|
+
receivedCounts,
|
|
2745
|
+
byteTotals,
|
|
2746
|
+
chunkCount: chunks.length,
|
|
2747
|
+
receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
export function startMobileHealthSyncSession(payload) {
|
|
2751
|
+
const parsed = mobileHealthSyncSessionStartSchema.parse(payload);
|
|
2752
|
+
if (parsed.schemaVersion !== HEALTH_MOBILE_SYNC_SCHEMA_VERSION) {
|
|
2753
|
+
throw new HttpError(409, "schema_version_unsupported", "The companion HealthKit sync protocol is not supported by this Forge runtime.", {
|
|
2754
|
+
schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
|
|
2755
|
+
requestedSchemaVersion: parsed.schemaVersion
|
|
2756
|
+
});
|
|
2757
|
+
}
|
|
2758
|
+
expireStaleMobileSyncSessions();
|
|
2759
|
+
const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
|
|
2760
|
+
const resumeSyncSessionId = typeof parsed.metadata.resumeSyncSessionId === "string"
|
|
2761
|
+
? parsed.metadata.resumeSyncSessionId.trim()
|
|
2762
|
+
: "";
|
|
2763
|
+
if (resumeSyncSessionId.length > 0) {
|
|
2764
|
+
const existing = readMobileSyncSession(resumeSyncSessionId);
|
|
2765
|
+
if (existing &&
|
|
2766
|
+
existing.pairing_session_id === pairing.id &&
|
|
2767
|
+
existing.status === "running") {
|
|
2768
|
+
const receivedChunkIds = getDatabase()
|
|
2769
|
+
.prepare(`SELECT chunk_id
|
|
2770
|
+
FROM health_mobile_sync_chunks
|
|
2771
|
+
WHERE sync_session_id = ?
|
|
2772
|
+
ORDER BY sequence ASC`)
|
|
2773
|
+
.all(resumeSyncSessionId)
|
|
2774
|
+
.map((row) => row.chunk_id);
|
|
2775
|
+
return {
|
|
2776
|
+
syncSessionId: resumeSyncSessionId,
|
|
2777
|
+
schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
|
|
2778
|
+
chunkTargetBytes: HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES,
|
|
2779
|
+
chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
|
|
2780
|
+
supportsCompression: false,
|
|
2781
|
+
acceptedFamilies: safeJsonParse(existing.requested_families_json, parsed.requestedFamilies),
|
|
2782
|
+
receivedChunkIds
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
const now = nowIso();
|
|
2787
|
+
const syncSessionId = mobileSyncSessionId();
|
|
2788
|
+
getDatabase()
|
|
2789
|
+
.prepare(`INSERT INTO health_mobile_sync_sessions (
|
|
2790
|
+
id, pairing_session_id, user_id, status, schema_version,
|
|
2791
|
+
requested_families_json, source_metadata_json, expected_counts_json,
|
|
2792
|
+
received_counts_json, byte_totals_json, started_at, created_at, updated_at
|
|
2793
|
+
)
|
|
2794
|
+
VALUES (?, ?, ?, 'running', ?, ?, ?, ?, '{}', '{}', ?, ?, ?)`)
|
|
2795
|
+
.run(syncSessionId, pairing.id, pairing.user_id, HEALTH_MOBILE_SYNC_SCHEMA_VERSION, JSON.stringify(parsed.requestedFamilies), JSON.stringify({
|
|
2796
|
+
sessionId: parsed.sessionId,
|
|
2797
|
+
device: parsed.device,
|
|
2798
|
+
permissions: parsed.permissions,
|
|
2799
|
+
sourceStates: parsed.sourceStates,
|
|
2800
|
+
metadata: parsed.metadata
|
|
2801
|
+
}), JSON.stringify(parsed.expectedCounts), now, now, now);
|
|
2802
|
+
return {
|
|
2803
|
+
syncSessionId,
|
|
2804
|
+
schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
|
|
2805
|
+
chunkTargetBytes: HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES,
|
|
2806
|
+
chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
|
|
2807
|
+
supportsCompression: false,
|
|
2808
|
+
acceptedFamilies: parsed.requestedFamilies,
|
|
2809
|
+
receivedChunkIds: []
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJson) {
|
|
2813
|
+
const parsed = mobileHealthSyncChunkSchema.parse(payload);
|
|
2814
|
+
const session = ensureRunningMobileSyncSession(syncSessionId);
|
|
2815
|
+
const requestedFamilies = safeJsonParse(session.requested_families_json, []);
|
|
2816
|
+
if (requestedFamilies.length > 0 &&
|
|
2817
|
+
!requestedFamilies.includes(parsed.family)) {
|
|
2818
|
+
throw new HttpError(400, "unsupported_family", "This sync session did not request that HealthKit family.", { family: parsed.family });
|
|
2819
|
+
}
|
|
2820
|
+
const existing = getDatabase()
|
|
2821
|
+
.prepare(`SELECT * FROM health_mobile_sync_chunks
|
|
2822
|
+
WHERE sync_session_id = ? AND chunk_id = ?`)
|
|
2823
|
+
.get(syncSessionId, parsed.chunkId);
|
|
2824
|
+
if (existing) {
|
|
2825
|
+
if (existing.checksum_sha256 !== parsed.checksumSha256) {
|
|
2826
|
+
throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
|
|
2827
|
+
}
|
|
2828
|
+
const progress = updateMobileSyncSessionProgress(syncSessionId);
|
|
2829
|
+
return {
|
|
2830
|
+
accepted: true,
|
|
2831
|
+
duplicate: true,
|
|
2832
|
+
receivedCount: progress.chunkCount,
|
|
2833
|
+
receivedBytes: progress.receivedBytes,
|
|
2834
|
+
progress
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
const payloadJson = chunkPayloadJson(parsed.payload);
|
|
2838
|
+
const checksumPayloadJson = rawPayloadJson ?? payloadJson;
|
|
2839
|
+
const actualByteCount = Buffer.byteLength(checksumPayloadJson, "utf8");
|
|
2840
|
+
if (actualByteCount > HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES) {
|
|
2841
|
+
throw new HttpError(413, "chunk_too_large", "The HealthKit sync chunk is too large.", {
|
|
2842
|
+
maxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
|
|
2843
|
+
actualBytes: actualByteCount
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
const serverChecksum = chunkPayloadChecksum(checksumPayloadJson);
|
|
2847
|
+
if (parsed.checksumSha256 !== serverChecksum) {
|
|
2848
|
+
throw new HttpError(409, "chunk_checksum_mismatch", "The HealthKit sync chunk checksum does not match its payload.", { actualBytes: actualByteCount });
|
|
2849
|
+
}
|
|
2850
|
+
const now = nowIso();
|
|
2851
|
+
getDatabase()
|
|
2852
|
+
.prepare(`INSERT INTO health_mobile_sync_chunks (
|
|
2853
|
+
id, sync_session_id, chunk_id, sequence, family, checksum_sha256,
|
|
2854
|
+
record_count, byte_count, payload_json, payload_summary_json,
|
|
2855
|
+
received_at, applied_at, created_at, updated_at
|
|
2856
|
+
)
|
|
2857
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2858
|
+
.run(mobileSyncChunkRecordId(), syncSessionId, parsed.chunkId, parsed.sequence, parsed.family, serverChecksum, parsed.recordCount, parsed.byteCount || actualByteCount, payloadJson, JSON.stringify({
|
|
2859
|
+
...summarizeChunkPayload(parsed.family, parsed.payload),
|
|
2860
|
+
clientByteCount: parsed.byteCount,
|
|
2861
|
+
actualByteCount,
|
|
2862
|
+
serverChecksum
|
|
2863
|
+
}), now, now, now, now);
|
|
2864
|
+
const progress = updateMobileSyncSessionProgress(syncSessionId);
|
|
2865
|
+
return {
|
|
2866
|
+
accepted: true,
|
|
2867
|
+
duplicate: false,
|
|
2868
|
+
receivedCount: progress.chunkCount,
|
|
2869
|
+
receivedBytes: progress.receivedBytes,
|
|
2870
|
+
progress
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
function mergeMobileHealthSyncChunks(session, chunks) {
|
|
2874
|
+
const pairing = getDatabase()
|
|
2875
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
2876
|
+
.get(session.pairing_session_id);
|
|
2877
|
+
if (!pairing) {
|
|
2878
|
+
throw new HttpError(404, "pairing_invalid", "The sync pairing no longer exists.");
|
|
2879
|
+
}
|
|
2880
|
+
const metadata = safeJsonParse(session.source_metadata_json, {});
|
|
2881
|
+
const assembled = mobileHealthSyncSchema.parse({
|
|
2882
|
+
sessionId: metadata.sessionId ?? pairing.id,
|
|
2883
|
+
pairingToken: pairing.pairing_token,
|
|
2884
|
+
device: metadata.device ??
|
|
2885
|
+
{
|
|
2886
|
+
name: pairing.device_name ?? "iPhone",
|
|
2887
|
+
platform: pairing.platform ?? "ios",
|
|
2888
|
+
appVersion: pairing.app_version ?? "",
|
|
2889
|
+
sourceDevice: pairing.device_name ?? "iPhone"
|
|
2890
|
+
},
|
|
2891
|
+
permissions: metadata.permissions ??
|
|
2892
|
+
{
|
|
2893
|
+
healthKitAuthorized: false,
|
|
2894
|
+
backgroundRefreshEnabled: false,
|
|
2895
|
+
motionReady: false,
|
|
2896
|
+
locationReady: false,
|
|
2897
|
+
screenTimeReady: false
|
|
2898
|
+
},
|
|
2899
|
+
sourceStates: metadata.sourceStates,
|
|
2900
|
+
sleepSessions: [],
|
|
2901
|
+
sleepNights: [],
|
|
2902
|
+
sleepSegments: [],
|
|
2903
|
+
sleepRawRecords: [],
|
|
2904
|
+
workouts: [],
|
|
2905
|
+
vitals: { daySummaries: [] },
|
|
2906
|
+
movement: {},
|
|
2907
|
+
screenTime: {}
|
|
2908
|
+
});
|
|
2909
|
+
const workoutsByExternalUid = new Map();
|
|
2910
|
+
const tombstones = [];
|
|
2911
|
+
for (const chunk of chunks.sort((left, right) => left.sequence - right.sequence)) {
|
|
2912
|
+
const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
|
|
2913
|
+
if (payload.sleepNights) {
|
|
2914
|
+
assembled.sleepNights.push(...payload.sleepNights);
|
|
2915
|
+
}
|
|
2916
|
+
if (payload.sleepSegments) {
|
|
2917
|
+
assembled.sleepSegments.push(...payload.sleepSegments);
|
|
2918
|
+
}
|
|
2919
|
+
if (payload.sleepRawRecords) {
|
|
2920
|
+
assembled.sleepRawRecords.push(...payload.sleepRawRecords);
|
|
2921
|
+
}
|
|
2922
|
+
if (payload.workouts) {
|
|
2923
|
+
for (const workout of payload.workouts) {
|
|
2924
|
+
const previous = workoutsByExternalUid.get(workout.externalUid);
|
|
2925
|
+
workoutsByExternalUid.set(workout.externalUid, {
|
|
2926
|
+
...(previous ?? workout),
|
|
2927
|
+
...workout,
|
|
2928
|
+
timeSeriesSamples: [
|
|
2929
|
+
...(previous?.timeSeriesSamples ?? []),
|
|
2930
|
+
...workout.timeSeriesSamples
|
|
2931
|
+
],
|
|
2932
|
+
routePoints: [
|
|
2933
|
+
...(previous?.routePoints ?? []),
|
|
2934
|
+
...workout.routePoints
|
|
2935
|
+
]
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
if (payload.workoutTimeSeries) {
|
|
2940
|
+
for (const entry of payload.workoutTimeSeries) {
|
|
2941
|
+
const workout = workoutsByExternalUid.get(entry.externalUid);
|
|
2942
|
+
if (!workout) {
|
|
2943
|
+
throw new HttpError(409, "missing_required_chunks", "A workout time-series chunk arrived without a matching workout summary.", { workoutExternalUid: entry.externalUid });
|
|
2944
|
+
}
|
|
2945
|
+
workout.timeSeriesSamples.push(...entry.samples);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
if (payload.workoutRoutes) {
|
|
2949
|
+
for (const entry of payload.workoutRoutes) {
|
|
2950
|
+
const workout = workoutsByExternalUid.get(entry.externalUid);
|
|
2951
|
+
if (!workout) {
|
|
2952
|
+
throw new HttpError(409, "missing_required_chunks", "A workout route chunk arrived without a matching workout summary.", { workoutExternalUid: entry.externalUid });
|
|
2953
|
+
}
|
|
2954
|
+
workout.routePoints.push(...entry.routePoints);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (payload.workoutTombstones) {
|
|
2958
|
+
tombstones.push(...payload.workoutTombstones);
|
|
2959
|
+
}
|
|
2960
|
+
if (payload.vitals) {
|
|
2961
|
+
assembled.vitals.daySummaries.push(...payload.vitals.daySummaries);
|
|
2962
|
+
}
|
|
2963
|
+
if (payload.movement) {
|
|
2964
|
+
assembled.movement.settings = payload.movement.settings;
|
|
2965
|
+
assembled.movement.knownPlaces.push(...payload.movement.knownPlaces);
|
|
2966
|
+
assembled.movement.stays.push(...payload.movement.stays);
|
|
2967
|
+
assembled.movement.trips.push(...payload.movement.trips);
|
|
2968
|
+
}
|
|
2969
|
+
if (payload.screenTime) {
|
|
2970
|
+
assembled.screenTime.settings = payload.screenTime.settings;
|
|
2971
|
+
assembled.screenTime.daySummaries.push(...payload.screenTime.daySummaries);
|
|
2972
|
+
assembled.screenTime.hourlySegments.push(...payload.screenTime.hourlySegments);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
assembled.workouts = [...workoutsByExternalUid.values()].sort((left, right) => right.startedAt.localeCompare(left.startedAt));
|
|
2976
|
+
return { assembled, tombstones };
|
|
2977
|
+
}
|
|
2978
|
+
function applyWorkoutTombstones(pairing, tombstones) {
|
|
2979
|
+
if (tombstones.length === 0) {
|
|
2980
|
+
return 0;
|
|
2981
|
+
}
|
|
2982
|
+
const deleteStmt = getDatabase().prepare(`DELETE FROM health_workout_sessions
|
|
2983
|
+
WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`);
|
|
2984
|
+
let deleted = 0;
|
|
2985
|
+
for (const tombstone of tombstones) {
|
|
2986
|
+
const result = deleteStmt.run(pairing.user_id, tombstone.externalUid);
|
|
2987
|
+
deleted += Number(result.changes ?? 0);
|
|
2988
|
+
}
|
|
2989
|
+
return deleted;
|
|
2990
|
+
}
|
|
2991
|
+
function upsertMobileSyncFamilyCursors(pairing, finalCursor) {
|
|
2992
|
+
const now = nowIso();
|
|
2993
|
+
const stmt = getDatabase().prepare(`INSERT INTO health_mobile_sync_family_cursors (
|
|
2994
|
+
id, pairing_session_id, user_id, family, cursor_json, updated_at
|
|
2995
|
+
)
|
|
2996
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2997
|
+
ON CONFLICT(pairing_session_id, family)
|
|
2998
|
+
DO UPDATE SET cursor_json = excluded.cursor_json, updated_at = excluded.updated_at`);
|
|
2999
|
+
for (const [family, cursor] of Object.entries(finalCursor)) {
|
|
3000
|
+
stmt.run(`hmscur_${randomUUID().replaceAll("-", "").slice(0, 10)}`, pairing.id, pairing.user_id, family, JSON.stringify(cursor), now);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
export function completeMobileHealthSyncSession(syncSessionId, payload) {
|
|
3004
|
+
const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
|
|
3005
|
+
const session = ensureRunningMobileSyncSession(syncSessionId);
|
|
3006
|
+
const chunks = getDatabase()
|
|
3007
|
+
.prepare(`SELECT * FROM health_mobile_sync_chunks
|
|
3008
|
+
WHERE sync_session_id = ?
|
|
3009
|
+
ORDER BY sequence ASC`)
|
|
3010
|
+
.all(syncSessionId);
|
|
3011
|
+
if (chunks.length === 0) {
|
|
3012
|
+
throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
|
|
3013
|
+
}
|
|
3014
|
+
const pairing = getDatabase()
|
|
3015
|
+
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
3016
|
+
.get(session.pairing_session_id);
|
|
3017
|
+
try {
|
|
3018
|
+
const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, chunks);
|
|
3019
|
+
const sync = ingestMobileHealthSync(assembled);
|
|
3020
|
+
const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
|
|
3021
|
+
upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
|
|
3022
|
+
const now = nowIso();
|
|
3023
|
+
getDatabase()
|
|
3024
|
+
.prepare(`UPDATE health_mobile_sync_sessions
|
|
3025
|
+
SET status = 'completed', expected_counts_json = ?, completed_at = ?,
|
|
3026
|
+
updated_at = ?
|
|
3027
|
+
WHERE id = ?`)
|
|
3028
|
+
.run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
|
|
3029
|
+
return {
|
|
3030
|
+
...sync,
|
|
3031
|
+
upload: {
|
|
3032
|
+
syncSessionId,
|
|
3033
|
+
chunks: chunks.length,
|
|
3034
|
+
deletedWorkoutCount
|
|
3035
|
+
}
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
catch (error) {
|
|
3039
|
+
const now = nowIso();
|
|
3040
|
+
getDatabase()
|
|
3041
|
+
.prepare(`UPDATE health_mobile_sync_sessions
|
|
3042
|
+
SET status = 'failed', failed_at = ?, error_json = ?, updated_at = ?
|
|
3043
|
+
WHERE id = ?`)
|
|
3044
|
+
.run(now, JSON.stringify({
|
|
3045
|
+
message: error instanceof Error ? error.message : String(error)
|
|
3046
|
+
}), now, syncSessionId);
|
|
3047
|
+
throw error;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
export function abortMobileHealthSyncSession(syncSessionId) {
|
|
3051
|
+
const session = ensureRunningMobileSyncSession(syncSessionId);
|
|
3052
|
+
const now = nowIso();
|
|
3053
|
+
getDatabase()
|
|
3054
|
+
.prepare(`UPDATE health_mobile_sync_sessions
|
|
3055
|
+
SET status = 'aborted', aborted_at = ?, updated_at = ?
|
|
3056
|
+
WHERE id = ?`)
|
|
3057
|
+
.run(now, now, session.id);
|
|
3058
|
+
return { syncSessionId, status: "aborted" };
|
|
3059
|
+
}
|
|
2500
3060
|
export function ingestMobileHealthSync(payload) {
|
|
2501
3061
|
const parsed = mobileHealthSyncSchema.parse(payload);
|
|
2502
3062
|
ensureLegacyAppleSleepHistoryRepaired();
|
|
@@ -2920,6 +3480,42 @@ export function getFitnessViewData(userIds) {
|
|
|
2920
3480
|
workoutTypeBreakdown.set(session.workoutType, current);
|
|
2921
3481
|
}
|
|
2922
3482
|
const orderedWorkoutTypes = [...workoutTypeBreakdown.entries()].sort((left, right) => right[1].totalMinutes - left[1].totalMinutes);
|
|
3483
|
+
const zoneTotals = new Map();
|
|
3484
|
+
let totalZoneSeconds = 0;
|
|
3485
|
+
let heartRateCoverageSum = 0;
|
|
3486
|
+
let heartRateCoverageCount = 0;
|
|
3487
|
+
let totalTrainingLoad = 0;
|
|
3488
|
+
let routeWorkoutCount = 0;
|
|
3489
|
+
for (const session of recent) {
|
|
3490
|
+
const analytics = session.analytics;
|
|
3491
|
+
for (const zone of analytics?.zoneDurations ?? []) {
|
|
3492
|
+
const current = zoneTotals.get(zone.key) ?? {
|
|
3493
|
+
label: zone.label,
|
|
3494
|
+
seconds: 0
|
|
3495
|
+
};
|
|
3496
|
+
current.seconds += zone.seconds;
|
|
3497
|
+
totalZoneSeconds += zone.seconds;
|
|
3498
|
+
zoneTotals.set(zone.key, current);
|
|
3499
|
+
}
|
|
3500
|
+
if (typeof analytics?.dataQuality?.sampleCoverage === "number") {
|
|
3501
|
+
heartRateCoverageSum += analytics.dataQuality.sampleCoverage;
|
|
3502
|
+
heartRateCoverageCount += 1;
|
|
3503
|
+
}
|
|
3504
|
+
if (typeof analytics?.load?.trimp === "number") {
|
|
3505
|
+
totalTrainingLoad += analytics.load.trimp;
|
|
3506
|
+
}
|
|
3507
|
+
if (analytics?.routeSummary?.hasRoute) {
|
|
3508
|
+
routeWorkoutCount += 1;
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
const zoneMix = [...zoneTotals.entries()].map(([key, value]) => ({
|
|
3512
|
+
key,
|
|
3513
|
+
label: value.label,
|
|
3514
|
+
seconds: Math.round(value.seconds),
|
|
3515
|
+
percentage: totalZoneSeconds > 0
|
|
3516
|
+
? Number((value.seconds / totalZoneSeconds).toFixed(4))
|
|
3517
|
+
: 0
|
|
3518
|
+
}));
|
|
2923
3519
|
return {
|
|
2924
3520
|
summary: {
|
|
2925
3521
|
workoutCount: weekly.length,
|
|
@@ -2940,7 +3536,13 @@ export function getFitnessViewData(userIds) {
|
|
|
2940
3536
|
topWorkoutType: orderedWorkoutTypes[0]?.[0] ?? null,
|
|
2941
3537
|
topWorkoutTypeLabel: recent.find((session) => session.workoutType === orderedWorkoutTypes[0]?.[0])
|
|
2942
3538
|
?.workoutTypeLabel ?? null,
|
|
2943
|
-
streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length
|
|
3539
|
+
streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length,
|
|
3540
|
+
averageHeartRateCoverage: heartRateCoverageCount > 0
|
|
3541
|
+
? Number((heartRateCoverageSum / heartRateCoverageCount).toFixed(3))
|
|
3542
|
+
: 0,
|
|
3543
|
+
totalTrainingLoad: Number(totalTrainingLoad.toFixed(1)),
|
|
3544
|
+
routeWorkoutCount,
|
|
3545
|
+
zoneMix
|
|
2944
3546
|
},
|
|
2945
3547
|
weeklyTrend: weekly
|
|
2946
3548
|
.map((session) => ({
|
|
@@ -2951,7 +3553,12 @@ export function getFitnessViewData(userIds) {
|
|
|
2951
3553
|
activityFamily: session.activityFamily,
|
|
2952
3554
|
activityFamilyLabel: session.activityFamilyLabel,
|
|
2953
3555
|
durationMinutes: Math.round(session.durationSeconds / 60),
|
|
2954
|
-
energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0)
|
|
3556
|
+
energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0),
|
|
3557
|
+
zoneDurations: session.analytics
|
|
3558
|
+
?.zoneDurations ?? [],
|
|
3559
|
+
trainingLoad: (session.analytics
|
|
3560
|
+
?.load?.trimp ?? null),
|
|
3561
|
+
heartRateCoverage: (session.analytics?.dataQuality?.sampleCoverage ?? 0)
|
|
2955
3562
|
}))
|
|
2956
3563
|
.reverse(),
|
|
2957
3564
|
typeBreakdown: orderedWorkoutTypes.map(([workoutType, metrics]) => ({
|
|
@@ -3482,6 +4089,7 @@ export function deleteWorkoutSession(workoutId, activity) {
|
|
|
3482
4089
|
if (!current) {
|
|
3483
4090
|
return undefined;
|
|
3484
4091
|
}
|
|
4092
|
+
const deletedWorkout = mapWorkoutSession(current);
|
|
3485
4093
|
getDatabase()
|
|
3486
4094
|
.prepare(`DELETE FROM health_workout_sessions WHERE id = ?`)
|
|
3487
4095
|
.run(workoutId);
|
|
@@ -3499,7 +4107,7 @@ export function deleteWorkoutSession(workoutId, activity) {
|
|
|
3499
4107
|
startedAt: current.started_at
|
|
3500
4108
|
}
|
|
3501
4109
|
});
|
|
3502
|
-
return
|
|
4110
|
+
return deletedWorkout;
|
|
3503
4111
|
}
|
|
3504
4112
|
export function updateWorkoutMetadata(workoutId, patch, activity) {
|
|
3505
4113
|
const parsed = updateWorkoutMetadataSchema.parse(patch);
|