forge-openclaw-plugin 0.3.5 → 0.3.6
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/{action-bar-DBZ38L_6.js → action-bar-CsDQF9d4.js} +1 -1
- package/dist/assets/{activity-page-BJxUR9bD.js → activity-page-CACf1Sd5.js} +1 -1
- package/dist/assets/{ai-surface-workspace-ehzBjTsW.js → ai-surface-workspace-Vn5fqCAT.js} +1 -1
- package/dist/assets/{atlas-panel-BFUeVT69.js → atlas-panel-GdK1oyxo.js} +1 -1
- package/dist/assets/{board-CLOHbg6t.js → board-CuxQRKPJ.js} +1 -1
- package/dist/assets/{calendar-page-Bg8W-YQ-.js → calendar-page-CcVGbvfY.js} +1 -1
- package/dist/assets/{calendar-rules-9XQLoI96.js → calendar-rules-Dlq0KT93.js} +1 -1
- package/dist/assets/{calendar-week-toolbar-BeYnx1pE.js → calendar-week-toolbar-BVFb1_2G.js} +1 -1
- package/dist/assets/{charts-DwTguE_x.js → charts-BzT4pUPg.js} +1 -1
- package/dist/assets/{companion-sync-lab-page-BRKYkIrs.js → companion-sync-lab-page-Dgvtdc4i.js} +1 -1
- package/dist/assets/{daily-metrics-dashboard-Jt0nAHU5.js → daily-metrics-dashboard-ChQVbNJI.js} +1 -1
- package/dist/assets/date-keys-BnZV4PNO.js +1 -0
- package/dist/assets/{define-workbench-box-5h-dyvJY.js → define-workbench-box-DWAqqjvH.js} +1 -1
- package/dist/assets/{entity-link-multiselect-Bb4De_e3.js → entity-link-multiselect-Do5DTju7.js} +1 -1
- package/dist/assets/{entity-note-count-link-BzWnsOmI.js → entity-note-count-link-CV3eOv66.js} +1 -1
- package/dist/assets/{entity-notes-surface-CagOwdng.js → entity-notes-surface-CiUgAVl7.js} +1 -1
- package/dist/assets/{execution-board-DTmAtmqZ.js → execution-board-ClaXVQ0H.js} +1 -1
- package/dist/assets/{faceted-token-search-DJPRm9AY.js → faceted-token-search-DoSsb7qT.js} +1 -1
- package/dist/assets/{flagship-signal-deck-CJbGZF7I.js → flagship-signal-deck-B2s7TrIN.js} +1 -1
- package/dist/assets/{floating-action-menu-K77jh8XZ.js → floating-action-menu-DGkHCmvI.js} +1 -1
- package/dist/assets/{forms-C5d5hTf2.js → forms-D1qJ3oOP.js} +1 -1
- package/dist/assets/{generic-node-view-p5ePSFuG.js → generic-node-view-CgLkbts-.js} +1 -1
- package/dist/assets/{goal-detail-page-DGu_khEK.js → goal-detail-page-JRcEYpeA.js} +1 -1
- package/dist/assets/{goal-dialog-BWD7cOv9.js → goal-dialog-6R2uYWPQ.js} +1 -1
- package/dist/assets/{goals-page-Bgj3sj0f.js → goals-page-CYestXkp.js} +1 -1
- package/dist/assets/{graph-Cd5WF3lw.js → graph-BF4IsheG.js} +1 -1
- package/dist/assets/{habits-page-Ctut1tuX.js → habits-page-B27lnyKu.js} +1 -1
- package/dist/assets/{health-boxes-Ie3horXx.js → health-boxes-CeqSGyZ3.js} +1 -1
- package/dist/assets/index-DGYIFHgo.js +2 -0
- package/dist/assets/{index-CEgIwgk9.css → index-UzsVTD1W.css} +1 -1
- package/dist/assets/{inline-note-fields-DE6WM9uM.js → inline-note-fields-CJ9ukqFp.js} +1 -1
- package/dist/assets/{insight-flow-dialog-XL8I74eJ.js → insight-flow-dialog-BgaJRYGX.js} +1 -1
- package/dist/assets/{insights-page-Bz1FLbp5.js → insights-page-Dc5bilGE.js} +1 -1
- package/dist/assets/{kanban-boxes-BBziel7P.js → kanban-boxes-qIwmQlRq.js} +1 -1
- package/dist/assets/{kanban-page-DdQP73I3.js → kanban-page-BGnVLHeN.js} +1 -1
- package/dist/assets/{knowledge-graph-page-DdHWj4Dj.js → knowledge-graph-page-B7IsVVO_.js} +1 -1
- package/dist/assets/{life-force-page-BRN-93cI.js → life-force-page-B5N4DknK.js} +1 -1
- package/dist/assets/{life-force-workspace-BowvP5R4.js → life-force-workspace-Da-dmoRX.js} +1 -1
- package/dist/assets/{maps-D0Mm6WPG.js → maps-BTVHALP8.js} +1 -1
- package/dist/assets/{metric-tile-B6aJueRo.js → metric-tile-BVuj7mc3.js} +1 -1
- package/dist/assets/{motion-DwjmC9aq.js → motion-DcgUnXhY.js} +1 -1
- package/dist/assets/{movement-boxes-5cjWjIdR.js → movement-boxes-BZrdIh8b.js} +1 -1
- package/dist/assets/{movement-page-Dy53RWtB.js → movement-page-CY2XLgp_.js} +1 -1
- package/dist/assets/{note-markdown-CvEQCfoi.js → note-markdown-BZHBOkEd.js} +1 -1
- package/dist/assets/{note-tags-input-Cg5zBXos.js → note-tags-input-DucocvNH.js} +1 -1
- package/dist/assets/{notes-boxes-CgZyf7mV.js → notes-boxes-DAQ6KBkJ.js} +1 -1
- package/dist/assets/{notes-page-Cbdcv0Ej.js → notes-page-CSCfdDJl.js} +1 -1
- package/dist/assets/{open-in-graph-button-D31ZOEhA.js → open-in-graph-button-ObO3m8yd.js} +1 -1
- package/dist/assets/{orbit-map-8eNDWek8.js → orbit-map-Bteg-ola.js} +1 -1
- package/dist/assets/{overview-page-CZ6q52Qj.js → overview-page-ckfN_sLZ.js} +1 -1
- package/dist/assets/{page-hero-C0MpI3MM.js → page-hero-BkrRTu-t.js} +1 -1
- package/dist/assets/pill-cluster-COzv8VgR.js +1 -0
- package/dist/assets/{preference-entity-handoff-button-CJhr5AXl.js → preference-entity-handoff-button-BpVAeFmY.js} +1 -1
- package/dist/assets/{preferences-page-8vszDNFX.js → preferences-page-DseOh9AP.js} +1 -1
- package/dist/assets/{project-collections-sC7eAAhS.js → project-collections-C7yU5PSi.js} +1 -1
- package/dist/assets/{project-detail-page-BKdnMjJy.js → project-detail-page-Crt46y_7.js} +1 -1
- package/dist/assets/{project-dialog-BiHZpOo1.js → project-dialog-YYxtlqg8.js} +1 -1
- package/dist/assets/{project-management-hierarchy-page-VW-hykAI.js → project-management-hierarchy-page-DnojKHzy.js} +1 -1
- package/dist/assets/{project-management-section-nav-BEZ5zihs.js → project-management-section-nav-Ctjd348W.js} +1 -1
- package/dist/assets/{projects-boxes-Ca6rpYeE.js → projects-boxes-D8lLFGBC.js} +1 -1
- package/dist/assets/{projects-page-D86pjpTf.js → projects-page-CV_2em8d.js} +1 -1
- package/dist/assets/{psyche-behaviors-page-YLJB6CRU.js → psyche-behaviors-page-CYCNk0rz.js} +1 -1
- package/dist/assets/{psyche-flashcards-page-D3gdHLUw.js → psyche-flashcards-page-DwmjBixN.js} +1 -1
- package/dist/assets/{psyche-goal-map-page-DJiqSiCx.js → psyche-goal-map-page-CdMhyBKh.js} +1 -1
- package/dist/assets/{psyche-graph-Tke0qFdt.js → psyche-graph-BcrZcJrq.js} +1 -1
- package/dist/assets/{psyche-metrics-page-DoLnvmC2.js → psyche-metrics-page-D5bX1mNX.js} +1 -1
- package/dist/assets/{psyche-mode-guide-page-_Zbvg_HL.js → psyche-mode-guide-page-BmX9EOOB.js} +1 -1
- package/dist/assets/{psyche-modes-page-eAkaAzJc.js → psyche-modes-page-CTHFDyLE.js} +1 -1
- package/dist/assets/{psyche-page-CwmuBVjA.js → psyche-page-DRR3Fjv-.js} +1 -1
- package/dist/assets/{psyche-patterns-page-D7B4Ykjq.js → psyche-patterns-page-BAS38xYf.js} +1 -1
- package/dist/assets/{psyche-questionnaire-builder-page-CHJlvCzX.js → psyche-questionnaire-builder-page-DpSi-zg3.js} +1 -1
- package/dist/assets/{psyche-questionnaire-detail-page-Bg3ll-D4.js → psyche-questionnaire-detail-page-BcXIiV4E.js} +1 -1
- package/dist/assets/{psyche-questionnaire-run-detail-page-CIEerczF.js → psyche-questionnaire-run-detail-page-DvccwzWO.js} +1 -1
- package/dist/assets/{psyche-questionnaire-run-page-DyxIk6Qx.js → psyche-questionnaire-run-page-vcyon8IZ.js} +1 -1
- package/dist/assets/{psyche-questionnaires-page-ZlqUxuIl.js → psyche-questionnaires-page-B0DDTner.js} +1 -1
- package/dist/assets/{psyche-report-detail-page-B2jgYnQ5.js → psyche-report-detail-page-BgBA3xIO.js} +1 -1
- package/dist/assets/{psyche-reports-page-DlXr52yr.js → psyche-reports-page-Ce1vEEqW.js} +1 -1
- package/dist/assets/{psyche-schemas-Dtskzvv1.js → psyche-schemas-DDol0j-g.js} +1 -1
- package/dist/assets/{psyche-schemas-beliefs-page-BhrTZN9B.js → psyche-schemas-beliefs-page-D-IHHohY.js} +1 -1
- package/dist/assets/{psyche-screen-time-page-WNGcdusx.js → psyche-screen-time-page-DOKW3m8B.js} +1 -1
- package/dist/assets/{psyche-self-observation-page-XsCOglo4.js → psyche-self-observation-page-j7Sk6Ns1.js} +1 -1
- package/dist/assets/{psyche-values-page-NvHI6zWK.js → psyche-values-page-NfzdWUyb.js} +1 -1
- package/dist/assets/{question-flow-dialog-Bj4PxpjS.js → question-flow-dialog-CLHVmFON.js} +1 -1
- package/dist/assets/{report-chain-fields-mWFikZzT.js → report-chain-fields-C9MuyBha.js} +1 -1
- package/dist/assets/{rewards-page-DyjwXsQN.js → rewards-page-CVxOBP6m.js} +1 -1
- package/dist/assets/{scheduling-rules-editor-C6FEYOxd.js → scheduling-rules-editor-BmbOHH_R.js} +1 -1
- package/dist/assets/{schema-badge-CCe4zkSN.js → schema-badge-BAiS_OAp.js} +1 -1
- package/dist/assets/{schemas-Cjwn6ooR.js → schemas-B0AXfuOr.js} +1 -1
- package/dist/assets/{select-menu-DeJhCsd8.js → select-menu-Bl5MILOj.js} +1 -1
- package/dist/assets/{settings-agents-page-DYDxyKu-.js → settings-agents-page-BIRP02mt.js} +1 -1
- package/dist/assets/{settings-bin-page-BWLfKVW1.js → settings-bin-page-DxJKeM28.js} +1 -1
- package/dist/assets/{settings-calendar-page-Ci-wXhCv.js → settings-calendar-page-aMBtwvO_.js} +1 -1
- package/dist/assets/{settings-data-page-DxIw-fWn.js → settings-data-page-nHNergsh.js} +1 -1
- package/dist/assets/{settings-logs-page-inpyIdu6.js → settings-logs-page-CFotPoFy.js} +1 -1
- package/dist/assets/{settings-mobile-page-BcJGLax8.js → settings-mobile-page-Bf_1D-bN.js} +1 -1
- package/dist/assets/{settings-models-page-OWTRhfkj.js → settings-models-page-BbFNmnik.js} +1 -1
- package/dist/assets/{settings-page-DXxF3qK2.js → settings-page-CalbNbcg.js} +1 -1
- package/dist/assets/{settings-rewards-page-CCXin1n_.js → settings-rewards-page-ByDfCzP5.js} +1 -1
- package/dist/assets/{settings-section-nav-CwSDNC3W.js → settings-section-nav-x_JXfzL9.js} +1 -1
- package/dist/assets/{settings-users-page-B4NEACwR.js → settings-users-page-N1jl9hWV.js} +1 -1
- package/dist/assets/{settings-wiki-page-BNRK1SYc.js → settings-wiki-page-Bn2Qr94L.js} +1 -1
- package/dist/assets/{sleep-page-Bara54nB.js → sleep-page-DyU635jb.js} +1 -1
- package/dist/assets/{sports-page-BawekFKD.js → sports-page-DbK4yGrR.js} +1 -1
- package/dist/assets/{state-BtwEvpO6.js → state-Bpe5dF3T.js} +1 -1
- package/dist/assets/{strategies-page-BEl3NGAU.js → strategies-page-AUBlFCZ9.js} +1 -1
- package/dist/assets/{strategy-detail-page-DPPI_8Ub.js → strategy-detail-page-DCF8mVaL.js} +1 -1
- package/dist/assets/{strategy-dialog-DU6wbBkQ.js → strategy-dialog-50xKlqcz.js} +1 -1
- package/dist/assets/{surface-BVYp-Wq9.js → surface-BjT1dIAF.js} +1 -1
- package/dist/assets/{table-BuONJH1s.js → table-U7otr5go.js} +1 -1
- package/dist/assets/{task-detail-page-BsMVAsbb.js → task-detail-page-Dsll2LCX.js} +1 -1
- package/dist/assets/{task-dialog-BGzPc6TW.js → task-dialog-BfPkbIPE.js} +1 -1
- package/dist/assets/{timebox-planning-dialog-4_XWuqkw.js → timebox-planning-dialog-BTKUS6Jj.js} +1 -1
- package/dist/assets/{today-boxes-5pCvh5zS.js → today-boxes-BPi9bDHM.js} +1 -1
- package/dist/assets/{today-page-BGurICpl.js → today-page-BSXSA-Ts.js} +1 -1
- package/dist/assets/{training-load-page-BkoYKZ9_.js → training-load-page-BD0s8-Zm.js} +1 -1
- package/dist/assets/{ui-B9O-eUim.js → ui-B9TWEtCx.js} +1 -1
- package/dist/assets/{use-anchored-overlay-position-BrQ4cqKn.js → use-anchored-overlay-position-BY4kNzPj.js} +1 -1
- package/dist/assets/{use-psyche-focus-target-Cuxni3SK.js → use-psyche-focus-target-BhNedCZB.js} +1 -1
- package/dist/assets/{user-badge-DfDv87j7.js → user-badge-ap1PvOxd.js} +1 -1
- package/dist/assets/{user-select-field-BNqv8-wd.js → user-select-field-fx129Uh6.js} +1 -1
- package/dist/assets/{utility-widgets-CbYj32he.js → utility-widgets-Cq508sqJ.js} +1 -1
- package/dist/assets/{vendor-Cpmju3nw.js → vendor-BwL6m4SE.js} +216 -211
- package/dist/assets/{vitals-page-DlxMk-L7.js → vitals-page-BnXk8OzN.js} +1 -1
- package/dist/assets/{weekly-review-page-VrEvAl1T.js → weekly-review-page-CnmSGnEC.js} +1 -1
- package/dist/assets/weight-loss-page-Bn6lAXNf.js +5 -0
- package/dist/assets/{wiki-article-markdown-DxTkiQRy.js → wiki-article-markdown-BDnkdNDC.js} +1 -1
- package/dist/assets/{wiki-editor-page-Beu92YnZ.js → wiki-editor-page-CHEETB0G.js} +1 -1
- package/dist/assets/{wiki-ingest-history-page-Cq_soygm.js → wiki-ingest-history-page-CisvtAzm.js} +1 -1
- package/dist/assets/{wiki-ingest-modal-D6xs2sEn.js → wiki-ingest-modal-C2faIjzj.js} +1 -1
- package/dist/assets/{wiki-page-yF3Flgtg.js → wiki-page-CELe_6qL.js} +1 -1
- package/dist/assets/{workbench-flow-page-CZZGg3a8.js → workbench-flow-page-JBVim3L0.js} +1 -1
- package/dist/assets/{workbench-page-DKENZ5Nz.js → workbench-page-q7Z3sQtz.js} +1 -1
- package/dist/assets/{workout-detail-page-CeN4QiYQ.js → workout-detail-page-9I_3DPYU.js} +2 -2
- package/dist/index.html +8 -8
- package/dist/server/server/migrations/070_health_mobile_sync_completion_index.sql +16 -0
- package/dist/server/server/src/app.js +135 -13
- package/dist/server/server/src/health-weight-loss.js +48 -6
- package/dist/server/server/src/health.js +301 -93
- package/dist/server/server/src/openapi.js +31 -0
- package/dist/server/server/src/repositories/gamification.js +25 -0
- package/dist/server/server/src/repositories/tasks.js +82 -17
- package/dist/server/server/src/services/dashboard.js +5 -4
- package/dist/server/server/src/services/gamification.js +47 -19
- package/dist/server/server/src/services/life-force.js +99 -0
- package/dist/server/src/lib/api.js +4 -0
- package/dist/server/src/lib/snapshot-normalizer.js +42 -23
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/070_health_mobile_sync_completion_index.sql +16 -0
- package/skills/forge-openclaw/SKILL.md +1 -0
- package/skills/forge-openclaw/psyche_entity_playbooks.md +8 -0
- package/dist/assets/date-keys-Cj1G3TOn.js +0 -1
- package/dist/assets/index-BaiwtAgo.js +0 -2
- package/dist/assets/pill-cluster-DGwKZQKF.js +0 -1
- package/dist/assets/weight-loss-page-DJDnjxKF.js +0 -5
|
@@ -2045,20 +2045,54 @@ function upsertVitalDaySummary(userId, input) {
|
|
|
2045
2045
|
metricCount: input.metrics.length
|
|
2046
2046
|
});
|
|
2047
2047
|
}
|
|
2048
|
-
function
|
|
2049
|
-
|
|
2048
|
+
function nextUtcDateKey(dateKeyValue) {
|
|
2049
|
+
return addUtcDaysToDateKey(dateKeyValue, 1);
|
|
2050
|
+
}
|
|
2051
|
+
function addUtcDaysToDateKey(dateKeyValue, days) {
|
|
2052
|
+
const date = new Date(`${dateKeyValue}T00:00:00.000Z`);
|
|
2053
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
2054
|
+
return date.toISOString().slice(0, 10);
|
|
2055
|
+
}
|
|
2056
|
+
function workoutDaySummaryMetrics(userId, dateKeyValue) {
|
|
2057
|
+
const nextDateKey = nextUtcDateKey(dateKeyValue);
|
|
2058
|
+
return getDatabase()
|
|
2059
|
+
.prepare(`SELECT COALESCE(SUM(duration_seconds), 0) AS totalWorkoutSeconds,
|
|
2060
|
+
COALESCE(SUM(COALESCE(exercise_minutes, duration_seconds / 60.0)), 0) AS totalExerciseMinutes,
|
|
2061
|
+
COALESCE(SUM(COALESCE(total_energy_kcal, active_energy_kcal, 0)), 0) AS totalEnergyKcal,
|
|
2062
|
+
COUNT(*) AS workoutCount
|
|
2063
|
+
FROM health_workout_sessions
|
|
2064
|
+
WHERE user_id = ?
|
|
2065
|
+
AND started_at >= ?
|
|
2066
|
+
AND started_at < ?`)
|
|
2067
|
+
.get(userId, `${dateKeyValue}T00:00:00`, `${nextDateKey}T00:00:00`);
|
|
2068
|
+
}
|
|
2069
|
+
function sleepRowsForHealthDay(userId, dateKeyValue) {
|
|
2070
|
+
const previousDateKey = addUtcDaysToDateKey(dateKeyValue, -1);
|
|
2071
|
+
const nextDateKey = addUtcDaysToDateKey(dateKeyValue, 1);
|
|
2072
|
+
const followingDateKey = addUtcDaysToDateKey(dateKeyValue, 2);
|
|
2073
|
+
return getDatabase()
|
|
2074
|
+
.prepare(`SELECT *
|
|
2075
|
+
FROM health_sleep_sessions
|
|
2076
|
+
WHERE user_id = ?
|
|
2077
|
+
AND (
|
|
2078
|
+
local_date_key = ?
|
|
2079
|
+
OR (local_date_key = '' AND ended_at >= ? AND ended_at < ?)
|
|
2080
|
+
OR (started_at >= ? AND started_at < ?)
|
|
2081
|
+
)
|
|
2082
|
+
ORDER BY ended_at DESC`)
|
|
2083
|
+
.all(userId, dateKeyValue, `${dateKeyValue}T00:00:00`, `${nextDateKey}T00:00:00`, `${previousDateKey}T00:00:00`, `${followingDateKey}T00:00:00`).filter((row) => sleepSessionDateKey(row) === dateKeyValue ||
|
|
2050
2084
|
localDateKeyForTimezone(row.started_at, resolveTimeZone(row.source_timezone)) === dateKeyValue);
|
|
2051
|
-
|
|
2085
|
+
}
|
|
2086
|
+
function summarizeUserHealthDay(userId, dateKeyValue) {
|
|
2087
|
+
const sleeps = sleepRowsForHealthDay(userId, dateKeyValue);
|
|
2088
|
+
const workoutSummary = workoutDaySummaryMetrics(userId, dateKeyValue);
|
|
2052
2089
|
const totalSleepSeconds = sleeps.reduce((sum, row) => sum + row.asleep_seconds, 0);
|
|
2053
|
-
const totalWorkoutSeconds = workouts.reduce((sum, row) => sum + row.duration_seconds, 0);
|
|
2054
|
-
const totalExerciseMinutes = workouts.reduce((sum, row) => sum + (row.exercise_minutes ?? row.duration_seconds / 60), 0);
|
|
2055
|
-
const totalEnergyKcal = workouts.reduce((sum, row) => sum + (row.total_energy_kcal ?? row.active_energy_kcal ?? 0), 0);
|
|
2056
2090
|
upsertDailySummary(userId, dateKeyValue, "health", {
|
|
2057
2091
|
totalSleepSeconds,
|
|
2058
|
-
totalWorkoutSeconds,
|
|
2059
|
-
totalExerciseMinutes,
|
|
2060
|
-
totalEnergyKcal,
|
|
2061
|
-
workoutCount:
|
|
2092
|
+
totalWorkoutSeconds: workoutSummary.totalWorkoutSeconds,
|
|
2093
|
+
totalExerciseMinutes: workoutSummary.totalExerciseMinutes,
|
|
2094
|
+
totalEnergyKcal: workoutSummary.totalEnergyKcal,
|
|
2095
|
+
workoutCount: workoutSummary.workoutCount,
|
|
2062
2096
|
sleepSessionCount: sleeps.length
|
|
2063
2097
|
}, {
|
|
2064
2098
|
recoveryState: totalSleepSeconds >= 7 * 3600
|
|
@@ -2747,24 +2781,62 @@ function findResumableMobileSyncSessionForPairing(pairingId, requestedFamilies)
|
|
|
2747
2781
|
return (rows.find((session) => mobileSyncSessionCanResume(session, requestedFamilies)) ?? null);
|
|
2748
2782
|
}
|
|
2749
2783
|
function mobileSyncSessionProgress(syncSessionId) {
|
|
2784
|
+
const session = readMobileSyncSession(syncSessionId);
|
|
2785
|
+
if (session) {
|
|
2786
|
+
return mobileSyncSessionProgressFromStoredSession(session);
|
|
2787
|
+
}
|
|
2788
|
+
return mobileSyncSessionProgressFromChunkRows(syncSessionId);
|
|
2789
|
+
}
|
|
2790
|
+
function mobileSyncSessionProgressFromChunkRows(syncSessionId) {
|
|
2750
2791
|
const chunks = getDatabase()
|
|
2751
2792
|
.prepare(`SELECT family, record_count, byte_count
|
|
2752
2793
|
FROM health_mobile_sync_chunks
|
|
2753
2794
|
WHERE sync_session_id = ?`)
|
|
2754
2795
|
.all(syncSessionId);
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2796
|
+
return mobileSyncChunkProgressFromRows(chunks);
|
|
2797
|
+
}
|
|
2798
|
+
function mobileSyncSessionChunkCount(syncSessionId) {
|
|
2799
|
+
const row = getDatabase()
|
|
2800
|
+
.prepare(`SELECT COUNT(*) AS chunk_count
|
|
2801
|
+
FROM health_mobile_sync_chunks
|
|
2802
|
+
WHERE sync_session_id = ?`)
|
|
2803
|
+
.get(syncSessionId);
|
|
2804
|
+
return row?.chunk_count ?? 0;
|
|
2805
|
+
}
|
|
2806
|
+
function mobileSyncSessionProgressFromStoredSession(session, chunkCount = mobileSyncSessionChunkCount(session.id)) {
|
|
2807
|
+
const receivedCounts = safeJsonParse(session.received_counts_json, {});
|
|
2808
|
+
const byteTotals = safeJsonParse(session.byte_totals_json, {});
|
|
2809
|
+
if (chunkCount > 0 &&
|
|
2810
|
+
Object.keys(receivedCounts).length === 0 &&
|
|
2811
|
+
Object.keys(byteTotals).length === 0) {
|
|
2812
|
+
return mobileSyncSessionProgressFromChunkRows(session.id);
|
|
2762
2813
|
}
|
|
2763
2814
|
return {
|
|
2764
2815
|
receivedCounts,
|
|
2765
2816
|
byteTotals,
|
|
2766
|
-
chunkCount
|
|
2767
|
-
receivedBytes:
|
|
2817
|
+
chunkCount,
|
|
2818
|
+
receivedBytes: Object.values(byteTotals).reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0)
|
|
2819
|
+
};
|
|
2820
|
+
}
|
|
2821
|
+
function mobileSyncSessionProgressAfterAcceptedChunk(input) {
|
|
2822
|
+
const currentChunkCount = mobileSyncSessionChunkCount(input.session.id);
|
|
2823
|
+
let receivedCounts = safeJsonParse(input.session.received_counts_json, {});
|
|
2824
|
+
let byteTotals = safeJsonParse(input.session.byte_totals_json, {});
|
|
2825
|
+
if (currentChunkCount > 0 &&
|
|
2826
|
+
Object.keys(receivedCounts).length === 0 &&
|
|
2827
|
+
Object.keys(byteTotals).length === 0) {
|
|
2828
|
+
const currentProgress = mobileSyncSessionProgressFromChunkRows(input.session.id);
|
|
2829
|
+
receivedCounts = currentProgress.receivedCounts;
|
|
2830
|
+
byteTotals = currentProgress.byteTotals;
|
|
2831
|
+
}
|
|
2832
|
+
receivedCounts[input.family] =
|
|
2833
|
+
(receivedCounts[input.family] ?? 0) + input.recordCount;
|
|
2834
|
+
byteTotals[input.family] = (byteTotals[input.family] ?? 0) + input.byteCount;
|
|
2835
|
+
return {
|
|
2836
|
+
receivedCounts,
|
|
2837
|
+
byteTotals,
|
|
2838
|
+
chunkCount: currentChunkCount + 1,
|
|
2839
|
+
receivedBytes: Object.values(byteTotals).reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0)
|
|
2768
2840
|
};
|
|
2769
2841
|
}
|
|
2770
2842
|
function finiteNumberFromUnknown(value) {
|
|
@@ -2817,40 +2889,57 @@ function expectedWorkoutEvidenceCounts(derived) {
|
|
|
2817
2889
|
captureRoutePointCount !== null
|
|
2818
2890
|
};
|
|
2819
2891
|
}
|
|
2820
|
-
function
|
|
2892
|
+
function mobileSyncSessionWorkoutImportOptions(session) {
|
|
2893
|
+
const metadata = mobileSyncSessionMetadata(session).metadata ?? {};
|
|
2894
|
+
const startedAfter = typeof metadata.workoutImportStartedAfter === "string"
|
|
2895
|
+
? metadata.workoutImportStartedAfter.trim()
|
|
2896
|
+
: "";
|
|
2897
|
+
if (startedAfter.length === 0) {
|
|
2898
|
+
return {};
|
|
2899
|
+
}
|
|
2900
|
+
const parsedStartedAfter = new Date(startedAfter);
|
|
2901
|
+
if (Number.isNaN(parsedStartedAfter.getTime())) {
|
|
2902
|
+
return {};
|
|
2903
|
+
}
|
|
2904
|
+
return {
|
|
2905
|
+
startedAtOrAfter: parsedStartedAfter.toISOString()
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2908
|
+
function mobileHealthWorkoutImportState(userId, options = {}) {
|
|
2909
|
+
const includeExternalUids = options.includeExternalUids !== false;
|
|
2910
|
+
const startedAtFilter = options.startedAtOrAfter
|
|
2911
|
+
? "AND w.started_at >= ?"
|
|
2912
|
+
: "";
|
|
2913
|
+
const params = options.startedAtOrAfter
|
|
2914
|
+
? [userId, options.startedAtOrAfter]
|
|
2915
|
+
: [userId];
|
|
2821
2916
|
const rows = getDatabase()
|
|
2822
|
-
.prepare(`
|
|
2823
|
-
SELECT workout_id, COUNT(*) AS time_series_count
|
|
2824
|
-
FROM health_workout_time_series
|
|
2825
|
-
GROUP BY workout_id
|
|
2826
|
-
),
|
|
2827
|
-
heart_rate_counts AS (
|
|
2828
|
-
SELECT workout_id, COUNT(*) AS heart_rate_count
|
|
2829
|
-
FROM health_workout_time_series
|
|
2830
|
-
WHERE metric_key = 'heart_rate'
|
|
2831
|
-
GROUP BY workout_id
|
|
2832
|
-
),
|
|
2833
|
-
route_counts AS (
|
|
2834
|
-
SELECT workout_id, COUNT(*) AS route_point_count
|
|
2835
|
-
FROM health_workout_routes
|
|
2836
|
-
GROUP BY workout_id
|
|
2837
|
-
)
|
|
2838
|
-
SELECT
|
|
2917
|
+
.prepare(`SELECT
|
|
2839
2918
|
w.external_uid,
|
|
2840
2919
|
w.derived_json,
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2920
|
+
(
|
|
2921
|
+
SELECT COUNT(*)
|
|
2922
|
+
FROM health_workout_time_series ts
|
|
2923
|
+
WHERE ts.workout_id = w.id
|
|
2924
|
+
) AS time_series_count,
|
|
2925
|
+
(
|
|
2926
|
+
SELECT COUNT(*)
|
|
2927
|
+
FROM health_workout_time_series ts
|
|
2928
|
+
WHERE ts.workout_id = w.id AND ts.metric_key = 'heart_rate'
|
|
2929
|
+
) AS heart_rate_count,
|
|
2930
|
+
(
|
|
2931
|
+
SELECT COUNT(*)
|
|
2932
|
+
FROM health_workout_routes r
|
|
2933
|
+
WHERE r.workout_id = w.id
|
|
2934
|
+
) AS route_point_count
|
|
2844
2935
|
FROM health_workout_sessions w
|
|
2845
|
-
LEFT JOIN time_series_counts ON time_series_counts.workout_id = w.id
|
|
2846
|
-
LEFT JOIN heart_rate_counts ON heart_rate_counts.workout_id = w.id
|
|
2847
|
-
LEFT JOIN route_counts ON route_counts.workout_id = w.id
|
|
2848
2936
|
WHERE w.user_id = ?
|
|
2849
2937
|
AND w.source = 'apple_health'
|
|
2850
2938
|
AND w.external_uid IS NOT NULL
|
|
2851
2939
|
AND w.external_uid <> ''
|
|
2940
|
+
${startedAtFilter}
|
|
2852
2941
|
ORDER BY w.started_at DESC`)
|
|
2853
|
-
.all(
|
|
2942
|
+
.all(...params);
|
|
2854
2943
|
const alreadyUploadedWorkoutExternalUids = [];
|
|
2855
2944
|
const incompleteWorkoutExternalUids = [];
|
|
2856
2945
|
let incompleteWorkoutCount = 0;
|
|
@@ -2870,7 +2959,9 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2870
2959
|
actualRoutePointCount >= evidenceCounts.expectedRoutePoints
|
|
2871
2960
|
: false;
|
|
2872
2961
|
if (evidenceCountsComplete) {
|
|
2873
|
-
|
|
2962
|
+
if (includeExternalUids) {
|
|
2963
|
+
alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
|
|
2964
|
+
}
|
|
2874
2965
|
timeSeriesSampleCount += actualTimeSeriesCount;
|
|
2875
2966
|
heartRateSampleCount += actualHeartRateCount;
|
|
2876
2967
|
routePointCount += actualRoutePointCount;
|
|
@@ -2880,13 +2971,15 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2880
2971
|
}
|
|
2881
2972
|
else {
|
|
2882
2973
|
incompleteWorkoutCount += 1;
|
|
2883
|
-
|
|
2974
|
+
if (includeExternalUids) {
|
|
2975
|
+
incompleteWorkoutExternalUids.push(row.external_uid.toLowerCase());
|
|
2976
|
+
}
|
|
2884
2977
|
}
|
|
2885
2978
|
}
|
|
2886
2979
|
return {
|
|
2887
2980
|
alreadyUploadedWorkoutExternalUids,
|
|
2888
2981
|
incompleteWorkoutExternalUids,
|
|
2889
|
-
alreadyUploadedWorkoutCount:
|
|
2982
|
+
alreadyUploadedWorkoutCount: rows.length - incompleteWorkoutCount,
|
|
2890
2983
|
existingWorkoutCount: rows.length,
|
|
2891
2984
|
incompleteWorkoutCount,
|
|
2892
2985
|
staleEvidenceVersionWorkoutCount,
|
|
@@ -2896,7 +2989,7 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2896
2989
|
capturedAt: nowIso()
|
|
2897
2990
|
};
|
|
2898
2991
|
}
|
|
2899
|
-
function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
|
|
2992
|
+
function mobileSyncSessionUploadPayload(session, receivedChunkIds, options = {}) {
|
|
2900
2993
|
return {
|
|
2901
2994
|
syncSessionId: session.id,
|
|
2902
2995
|
schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
|
|
@@ -2908,8 +3001,15 @@ function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
|
|
|
2908
3001
|
supportsCompression: true,
|
|
2909
3002
|
acceptedFamilies: safeJsonParse(session.requested_families_json, []),
|
|
2910
3003
|
receivedChunkIds,
|
|
2911
|
-
|
|
2912
|
-
|
|
3004
|
+
...(options.includeWorkoutImportState === false
|
|
3005
|
+
? {}
|
|
3006
|
+
: {
|
|
3007
|
+
workoutImportState: mobileHealthWorkoutImportState(session.user_id, {
|
|
3008
|
+
...mobileSyncSessionWorkoutImportOptions(session),
|
|
3009
|
+
includeExternalUids: options.includeWorkoutImportExternalUids
|
|
3010
|
+
})
|
|
3011
|
+
}),
|
|
3012
|
+
progress: mobileSyncSessionProgressFromStoredSession(session)
|
|
2913
3013
|
};
|
|
2914
3014
|
}
|
|
2915
3015
|
export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
|
|
@@ -2918,14 +3018,19 @@ export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
|
|
|
2918
3018
|
if (!session || session.pairing_session_id !== pairing.id) {
|
|
2919
3019
|
throw new HttpError(404, "sync_session_not_found", "The HealthKit sync session does not exist.");
|
|
2920
3020
|
}
|
|
2921
|
-
const receivedChunkIds =
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
3021
|
+
const receivedChunkIds = payload.includeReceivedChunkIds === false
|
|
3022
|
+
? []
|
|
3023
|
+
: getDatabase()
|
|
3024
|
+
.prepare(`SELECT chunk_id
|
|
3025
|
+
FROM health_mobile_sync_chunks
|
|
3026
|
+
WHERE sync_session_id = ?
|
|
3027
|
+
ORDER BY sequence ASC`)
|
|
3028
|
+
.all(syncSessionId)
|
|
3029
|
+
.map((row) => row.chunk_id);
|
|
3030
|
+
return mobileSyncSessionUploadPayload(session, receivedChunkIds, {
|
|
3031
|
+
includeWorkoutImportState: payload.includeWorkoutImportState,
|
|
3032
|
+
includeWorkoutImportExternalUids: payload.includeWorkoutImportExternalUids
|
|
3033
|
+
});
|
|
2929
3034
|
}
|
|
2930
3035
|
function ensureRunningMobileSyncSession(syncSessionId) {
|
|
2931
3036
|
expireStaleMobileSyncSessions();
|
|
@@ -3163,6 +3268,7 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
|
|
|
3163
3268
|
let createdCount = 0;
|
|
3164
3269
|
let updatedCount = 0;
|
|
3165
3270
|
let mergedCount = 0;
|
|
3271
|
+
const dateKeysToSummarize = new Set();
|
|
3166
3272
|
getDatabase()
|
|
3167
3273
|
.prepare(`INSERT INTO health_import_runs (
|
|
3168
3274
|
id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
|
|
@@ -3181,7 +3287,10 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
|
|
|
3181
3287
|
else {
|
|
3182
3288
|
updatedCount += 1;
|
|
3183
3289
|
}
|
|
3184
|
-
|
|
3290
|
+
dateKeysToSummarize.add(dayKey(workout.startedAt));
|
|
3291
|
+
}
|
|
3292
|
+
for (const dateKeyValue of dateKeysToSummarize) {
|
|
3293
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
3185
3294
|
}
|
|
3186
3295
|
getDatabase()
|
|
3187
3296
|
.prepare(`UPDATE companion_pairing_sessions
|
|
@@ -3204,6 +3313,29 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
|
|
|
3204
3313
|
}), workouts.length, createdCount, updatedCount, mergedCount, now, now, runId);
|
|
3205
3314
|
});
|
|
3206
3315
|
}
|
|
3316
|
+
function mobileSyncWorkoutRowsByExternalUid(userId, externalUids) {
|
|
3317
|
+
const uniqueExternalUids = [...new Set(externalUids.filter(Boolean))];
|
|
3318
|
+
const rowsByExternalUid = new Map();
|
|
3319
|
+
const chunkSize = 500;
|
|
3320
|
+
for (let lowerBound = 0; lowerBound < uniqueExternalUids.length; lowerBound += chunkSize) {
|
|
3321
|
+
const chunk = uniqueExternalUids.slice(lowerBound, lowerBound + chunkSize);
|
|
3322
|
+
if (chunk.length === 0) {
|
|
3323
|
+
continue;
|
|
3324
|
+
}
|
|
3325
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
3326
|
+
const rows = getDatabase()
|
|
3327
|
+
.prepare(`SELECT *
|
|
3328
|
+
FROM health_workout_sessions
|
|
3329
|
+
WHERE user_id = ?
|
|
3330
|
+
AND source = 'apple_health'
|
|
3331
|
+
AND external_uid IN (${placeholders})`)
|
|
3332
|
+
.all(userId, ...chunk);
|
|
3333
|
+
for (const row of rows) {
|
|
3334
|
+
rowsByExternalUid.set(row.external_uid, row);
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
return rowsByExternalUid;
|
|
3338
|
+
}
|
|
3207
3339
|
function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
3208
3340
|
const timeSeries = payload.workoutTimeSeries ?? [];
|
|
3209
3341
|
const routes = payload.workoutRoutes ?? [];
|
|
@@ -3213,12 +3345,16 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
|
3213
3345
|
const pairing = mobileSyncSessionPairing(session);
|
|
3214
3346
|
runInTransaction(() => {
|
|
3215
3347
|
const rowsToRecompute = new Map();
|
|
3348
|
+
const rowsByExternalUid = mobileSyncWorkoutRowsByExternalUid(pairing.user_id, [
|
|
3349
|
+
...timeSeries
|
|
3350
|
+
.filter((entry) => entry.samples.length > 0)
|
|
3351
|
+
.map((entry) => entry.externalUid),
|
|
3352
|
+
...routes
|
|
3353
|
+
.filter((entry) => entry.routePoints.length > 0)
|
|
3354
|
+
.map((entry) => entry.externalUid)
|
|
3355
|
+
]);
|
|
3216
3356
|
for (const entry of timeSeries) {
|
|
3217
|
-
const row =
|
|
3218
|
-
.prepare(`SELECT *
|
|
3219
|
-
FROM health_workout_sessions
|
|
3220
|
-
WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
|
|
3221
|
-
.get(pairing.user_id, entry.externalUid);
|
|
3357
|
+
const row = rowsByExternalUid.get(entry.externalUid);
|
|
3222
3358
|
if (!row || entry.samples.length === 0) {
|
|
3223
3359
|
continue;
|
|
3224
3360
|
}
|
|
@@ -3230,11 +3366,7 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
|
3230
3366
|
rowsToRecompute.set(row.id, row);
|
|
3231
3367
|
}
|
|
3232
3368
|
for (const entry of routes) {
|
|
3233
|
-
const row =
|
|
3234
|
-
.prepare(`SELECT *
|
|
3235
|
-
FROM health_workout_sessions
|
|
3236
|
-
WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
|
|
3237
|
-
.get(pairing.user_id, entry.externalUid);
|
|
3369
|
+
const row = rowsByExternalUid.get(entry.externalUid);
|
|
3238
3370
|
if (!row || entry.routePoints.length === 0) {
|
|
3239
3371
|
continue;
|
|
3240
3372
|
}
|
|
@@ -3247,7 +3379,9 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
|
3247
3379
|
}
|
|
3248
3380
|
for (const row of rowsToRecompute.values()) {
|
|
3249
3381
|
recomputeAndStoreWorkoutAnalytics(row);
|
|
3250
|
-
|
|
3382
|
+
}
|
|
3383
|
+
for (const dateKeyValue of new Set([...rowsToRecompute.values()].map((row) => dayKey(row.started_at)))) {
|
|
3384
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
3251
3385
|
}
|
|
3252
3386
|
});
|
|
3253
3387
|
}
|
|
@@ -3265,6 +3399,9 @@ function markMobileHealthSyncChunkApplied(input) {
|
|
|
3265
3399
|
}), now, input.syncSessionId, input.chunkId);
|
|
3266
3400
|
}
|
|
3267
3401
|
function mobileHealthSyncChunkWasImmediatelyApplied(chunk) {
|
|
3402
|
+
if (chunk.applied_at) {
|
|
3403
|
+
return true;
|
|
3404
|
+
}
|
|
3268
3405
|
const summary = safeJsonParse(chunk.payload_summary_json, {});
|
|
3269
3406
|
return summary.immediateApplied === true;
|
|
3270
3407
|
}
|
|
@@ -3321,8 +3458,7 @@ function applyMobileHealthSyncChunkImmediately(session, family, payload) {
|
|
|
3321
3458
|
return null;
|
|
3322
3459
|
}
|
|
3323
3460
|
}
|
|
3324
|
-
function updateMobileSyncSessionProgress(syncSessionId) {
|
|
3325
|
-
const progress = mobileSyncSessionProgress(syncSessionId);
|
|
3461
|
+
function updateMobileSyncSessionProgress(syncSessionId, progress = mobileSyncSessionProgress(syncSessionId)) {
|
|
3326
3462
|
getDatabase()
|
|
3327
3463
|
.prepare(`UPDATE health_mobile_sync_sessions
|
|
3328
3464
|
SET received_counts_json = ?, byte_totals_json = ?, updated_at = ?
|
|
@@ -3401,7 +3537,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
3401
3537
|
if (existing.checksum_sha256 !== clientChecksum) {
|
|
3402
3538
|
throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
|
|
3403
3539
|
}
|
|
3404
|
-
const progress =
|
|
3540
|
+
const progress = mobileSyncSessionProgressFromStoredSession(session);
|
|
3405
3541
|
return {
|
|
3406
3542
|
accepted: true,
|
|
3407
3543
|
duplicate: true,
|
|
@@ -3483,6 +3619,12 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
3483
3619
|
}
|
|
3484
3620
|
return runInTransaction(() => {
|
|
3485
3621
|
const now = nowIso();
|
|
3622
|
+
const progress = mobileSyncSessionProgressAfterAcceptedChunk({
|
|
3623
|
+
session,
|
|
3624
|
+
family: parsed.family,
|
|
3625
|
+
recordCount: parsed.recordCount,
|
|
3626
|
+
byteCount: actualByteCount
|
|
3627
|
+
});
|
|
3486
3628
|
const payloadSummary = {
|
|
3487
3629
|
...summarizeChunkPayload(parsed.family, wirePayload.payload),
|
|
3488
3630
|
clientByteCount: parsed.byteCount,
|
|
@@ -3508,7 +3650,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
3508
3650
|
mode: appliedMode
|
|
3509
3651
|
});
|
|
3510
3652
|
}
|
|
3511
|
-
|
|
3653
|
+
updateMobileSyncSessionProgress(syncSessionId, progress);
|
|
3512
3654
|
return {
|
|
3513
3655
|
accepted: true,
|
|
3514
3656
|
duplicate: false,
|
|
@@ -3557,18 +3699,12 @@ function mergeMobileHealthSyncChunks(session, chunks, options = {}) {
|
|
|
3557
3699
|
const workoutsByExternalUid = new Map();
|
|
3558
3700
|
const tombstones = [];
|
|
3559
3701
|
for (const chunk of chunks.sort((left, right) => left.sequence - right.sequence)) {
|
|
3560
|
-
const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
|
|
3561
3702
|
const skipRecords = options.skipImmediatelyApplied === true &&
|
|
3562
3703
|
mobileHealthSyncChunkWasImmediatelyApplied(chunk);
|
|
3563
3704
|
if (skipRecords) {
|
|
3564
|
-
if (payload.movement?.settings) {
|
|
3565
|
-
assembled.movement.settings = payload.movement.settings;
|
|
3566
|
-
}
|
|
3567
|
-
if (payload.screenTime?.settings) {
|
|
3568
|
-
assembled.screenTime.settings = payload.screenTime.settings;
|
|
3569
|
-
}
|
|
3570
3705
|
continue;
|
|
3571
3706
|
}
|
|
3707
|
+
const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
|
|
3572
3708
|
if (payload.sleepNights) {
|
|
3573
3709
|
assembled.sleepNights.push(...payload.sleepNights);
|
|
3574
3710
|
}
|
|
@@ -3659,12 +3795,14 @@ function upsertMobileSyncFamilyCursors(pairing, finalCursor) {
|
|
|
3659
3795
|
stmt.run(`hmscur_${randomUUID().replaceAll("-", "").slice(0, 10)}`, pairing.id, pairing.user_id, family, JSON.stringify(cursor), now);
|
|
3660
3796
|
}
|
|
3661
3797
|
}
|
|
3662
|
-
function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
|
|
3798
|
+
function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts, chunks) {
|
|
3663
3799
|
const expectedEntries = Object.entries(expectedCounts).filter(([, expected]) => Number.isFinite(expected) && expected > 0);
|
|
3664
3800
|
if (expectedEntries.length === 0) {
|
|
3665
3801
|
return;
|
|
3666
3802
|
}
|
|
3667
|
-
const progress =
|
|
3803
|
+
const progress = chunks
|
|
3804
|
+
? mobileSyncChunkProgressFromRows(chunks)
|
|
3805
|
+
: updateMobileSyncSessionProgress(syncSessionId);
|
|
3668
3806
|
const missingFamilies = expectedEntries
|
|
3669
3807
|
.map(([family, expected]) => ({
|
|
3670
3808
|
family,
|
|
@@ -3676,6 +3814,45 @@ function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
|
|
|
3676
3814
|
throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session is missing required chunks.", { families: missingFamilies });
|
|
3677
3815
|
}
|
|
3678
3816
|
}
|
|
3817
|
+
function mobileSyncChunkProgressFromRows(chunks) {
|
|
3818
|
+
const receivedCounts = {};
|
|
3819
|
+
const byteTotals = {};
|
|
3820
|
+
for (const chunk of chunks) {
|
|
3821
|
+
receivedCounts[chunk.family] =
|
|
3822
|
+
(receivedCounts[chunk.family] ?? 0) + chunk.record_count;
|
|
3823
|
+
byteTotals[chunk.family] =
|
|
3824
|
+
(byteTotals[chunk.family] ?? 0) + chunk.byte_count;
|
|
3825
|
+
}
|
|
3826
|
+
return {
|
|
3827
|
+
receivedCounts,
|
|
3828
|
+
byteTotals,
|
|
3829
|
+
chunkCount: chunks.length,
|
|
3830
|
+
receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
|
|
3831
|
+
};
|
|
3832
|
+
}
|
|
3833
|
+
function dedupeMobileSyncChunksForCompletion(chunks) {
|
|
3834
|
+
const latestBySlot = new Map();
|
|
3835
|
+
for (const chunk of chunks) {
|
|
3836
|
+
const slotKey = `${chunk.family}:${chunk.sequence}`;
|
|
3837
|
+
const previous = latestBySlot.get(slotKey);
|
|
3838
|
+
if (!previous) {
|
|
3839
|
+
latestBySlot.set(slotKey, chunk);
|
|
3840
|
+
continue;
|
|
3841
|
+
}
|
|
3842
|
+
const chunkReceivedAt = Date.parse(chunk.received_at);
|
|
3843
|
+
const previousReceivedAt = Date.parse(previous.received_at);
|
|
3844
|
+
if (chunkReceivedAt > previousReceivedAt ||
|
|
3845
|
+
(chunkReceivedAt === previousReceivedAt && chunk.id > previous.id)) {
|
|
3846
|
+
latestBySlot.set(slotKey, chunk);
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
return Array.from(latestBySlot.values()).sort((left, right) => {
|
|
3850
|
+
if (left.sequence !== right.sequence) {
|
|
3851
|
+
return left.sequence - right.sequence;
|
|
3852
|
+
}
|
|
3853
|
+
return left.family.localeCompare(right.family);
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3679
3856
|
function aggregateMobileSyncChunkCounts(chunks) {
|
|
3680
3857
|
const counts = {
|
|
3681
3858
|
sleepNights: 0,
|
|
@@ -3743,6 +3920,30 @@ function syncReceiptWithChunkCounts(sync, chunks) {
|
|
|
3743
3920
|
}
|
|
3744
3921
|
};
|
|
3745
3922
|
}
|
|
3923
|
+
function listMobileSyncCompletionChunks(syncSessionId) {
|
|
3924
|
+
const chunks = getDatabase()
|
|
3925
|
+
.prepare(`SELECT id, sync_session_id, chunk_id, sequence, family, checksum_sha256,
|
|
3926
|
+
record_count, byte_count, '{}' AS payload_json,
|
|
3927
|
+
payload_summary_json, received_at, applied_at, created_at, updated_at
|
|
3928
|
+
FROM health_mobile_sync_chunks
|
|
3929
|
+
WHERE sync_session_id = ?
|
|
3930
|
+
ORDER BY sequence ASC`)
|
|
3931
|
+
.all(syncSessionId);
|
|
3932
|
+
const payloadRows = getDatabase()
|
|
3933
|
+
.prepare(`SELECT id, payload_json
|
|
3934
|
+
FROM health_mobile_sync_chunks
|
|
3935
|
+
WHERE sync_session_id = ?
|
|
3936
|
+
AND applied_at IS NULL`)
|
|
3937
|
+
.all(syncSessionId);
|
|
3938
|
+
if (payloadRows.length === 0) {
|
|
3939
|
+
return chunks;
|
|
3940
|
+
}
|
|
3941
|
+
const payloadById = new Map(payloadRows.map((row) => [row.id, row.payload_json]));
|
|
3942
|
+
for (const chunk of chunks) {
|
|
3943
|
+
chunk.payload_json = payloadById.get(chunk.id) ?? "{}";
|
|
3944
|
+
}
|
|
3945
|
+
return chunks;
|
|
3946
|
+
}
|
|
3746
3947
|
function markMobileSyncSessionFailed(session, error) {
|
|
3747
3948
|
const now = nowIso();
|
|
3748
3949
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -3762,21 +3963,18 @@ function markMobileSyncSessionFailed(session, error) {
|
|
|
3762
3963
|
export function completeMobileHealthSyncSession(syncSessionId, payload) {
|
|
3763
3964
|
const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
|
|
3764
3965
|
const session = ensureRunningMobileSyncSession(syncSessionId);
|
|
3765
|
-
const chunks =
|
|
3766
|
-
.prepare(`SELECT * FROM health_mobile_sync_chunks
|
|
3767
|
-
WHERE sync_session_id = ?
|
|
3768
|
-
ORDER BY sequence ASC`)
|
|
3769
|
-
.all(syncSessionId);
|
|
3966
|
+
const chunks = listMobileSyncCompletionChunks(syncSessionId);
|
|
3770
3967
|
if (chunks.length === 0) {
|
|
3771
3968
|
throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
|
|
3772
3969
|
}
|
|
3970
|
+
const completionChunks = dedupeMobileSyncChunksForCompletion(chunks);
|
|
3773
3971
|
const pairing = getDatabase()
|
|
3774
3972
|
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
3775
3973
|
.get(session.pairing_session_id);
|
|
3776
3974
|
try {
|
|
3777
3975
|
return runInTransaction(() => {
|
|
3778
|
-
validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts);
|
|
3779
|
-
const { assembled, tombstones } = mergeMobileHealthSyncChunks(session,
|
|
3976
|
+
validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts, completionChunks);
|
|
3977
|
+
const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, completionChunks, { skipImmediatelyApplied: true });
|
|
3780
3978
|
const sync = ingestMobileHealthSync(assembled);
|
|
3781
3979
|
const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
|
|
3782
3980
|
upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
|
|
@@ -3788,10 +3986,12 @@ export function completeMobileHealthSyncSession(syncSessionId, payload) {
|
|
|
3788
3986
|
WHERE id = ?`)
|
|
3789
3987
|
.run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
|
|
3790
3988
|
return {
|
|
3791
|
-
...syncReceiptWithChunkCounts(sync,
|
|
3989
|
+
...syncReceiptWithChunkCounts(sync, completionChunks),
|
|
3792
3990
|
upload: {
|
|
3793
3991
|
syncSessionId,
|
|
3794
3992
|
chunks: chunks.length,
|
|
3993
|
+
effectiveChunks: completionChunks.length,
|
|
3994
|
+
supersededChunks: chunks.length - completionChunks.length,
|
|
3795
3995
|
deletedWorkoutCount
|
|
3796
3996
|
}
|
|
3797
3997
|
};
|
|
@@ -3838,6 +4038,8 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3838
4038
|
const screenTimeSync = ingestScreenTimeSync(pairing, parsed.screenTime, parsed.device.sourceDevice);
|
|
3839
4039
|
const sleepSessionsByLocalDate = new Map();
|
|
3840
4040
|
const sourceRecordIdsByExternalUid = new Map();
|
|
4041
|
+
const sleepDateKeysToSummarize = new Set();
|
|
4042
|
+
const workoutDateKeysToSummarize = new Set();
|
|
3841
4043
|
for (const sleep of normalizedSleepNights) {
|
|
3842
4044
|
const result = insertOrUpdateSleepSession(pairing, sleep);
|
|
3843
4045
|
if (result.mode === "created") {
|
|
@@ -3853,7 +4055,7 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3853
4055
|
endedAt: sleep.endedAt
|
|
3854
4056
|
});
|
|
3855
4057
|
sleepSessionsByLocalDate.set(sleep.localDateKey, group);
|
|
3856
|
-
|
|
4058
|
+
sleepDateKeysToSummarize.add(sleep.localDateKey);
|
|
3857
4059
|
}
|
|
3858
4060
|
for (const rawRecord of normalizedSleepRawRecords) {
|
|
3859
4061
|
const candidateSessions = sleepSessionsByLocalDate.get(rawRecord.localDateKey) ?? [];
|
|
@@ -3909,6 +4111,9 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3909
4111
|
replaceHistoricalSleepSessionsForDate(pairing.user_id, sleep.localDateKey, targetSessionId);
|
|
3910
4112
|
}
|
|
3911
4113
|
}
|
|
4114
|
+
for (const dateKeyValue of sleepDateKeysToSummarize) {
|
|
4115
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
4116
|
+
}
|
|
3912
4117
|
for (const workout of parsed.workouts) {
|
|
3913
4118
|
const result = insertOrUpdateWorkoutSession(pairing, workout);
|
|
3914
4119
|
if (result.mode === "created") {
|
|
@@ -3920,7 +4125,10 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3920
4125
|
else {
|
|
3921
4126
|
updatedCount += 1;
|
|
3922
4127
|
}
|
|
3923
|
-
|
|
4128
|
+
workoutDateKeysToSummarize.add(dayKey(workout.startedAt));
|
|
4129
|
+
}
|
|
4130
|
+
for (const dateKeyValue of workoutDateKeysToSummarize) {
|
|
4131
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
3924
4132
|
}
|
|
3925
4133
|
for (const daySummary of parsed.vitals.daySummaries) {
|
|
3926
4134
|
upsertVitalDaySummary(pairing.user_id, daySummary);
|