forge-openclaw-plugin 0.3.4 → 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-CMGr_Uv_.js → action-bar-CsDQF9d4.js} +1 -1
- package/dist/assets/{activity-page-DwGKkJtr.js → activity-page-CACf1Sd5.js} +1 -1
- package/dist/assets/{ai-surface-workspace-UtnoNmjq.js → ai-surface-workspace-Vn5fqCAT.js} +1 -1
- package/dist/assets/{atlas-panel-f1kc-LkD.js → atlas-panel-GdK1oyxo.js} +1 -1
- package/dist/assets/{board-CLOHbg6t.js → board-CuxQRKPJ.js} +1 -1
- package/dist/assets/{calendar-page-C4YqeVrC.js → calendar-page-CcVGbvfY.js} +1 -1
- package/dist/assets/{calendar-rules-BQppCPiN.js → calendar-rules-Dlq0KT93.js} +1 -1
- package/dist/assets/{calendar-week-toolbar-DdPuHUwd.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-N3cR33GS.js → companion-sync-lab-page-Dgvtdc4i.js} +1 -1
- package/dist/assets/{daily-metrics-dashboard-CXnAtN2T.js → daily-metrics-dashboard-ChQVbNJI.js} +1 -1
- package/dist/assets/date-keys-BnZV4PNO.js +1 -0
- package/dist/assets/{define-workbench-box-DkffygDU.js → define-workbench-box-DWAqqjvH.js} +1 -1
- package/dist/assets/{entity-link-multiselect-wuyJ8BbW.js → entity-link-multiselect-Do5DTju7.js} +1 -1
- package/dist/assets/{entity-note-count-link-CEtKyMAl.js → entity-note-count-link-CV3eOv66.js} +1 -1
- package/dist/assets/{entity-notes-surface-iKNiierJ.js → entity-notes-surface-CiUgAVl7.js} +1 -1
- package/dist/assets/{execution-board-DesFZ1f5.js → execution-board-ClaXVQ0H.js} +1 -1
- package/dist/assets/{faceted-token-search-D4Okr-Kr.js → faceted-token-search-DoSsb7qT.js} +1 -1
- package/dist/assets/{flagship-signal-deck-CW91n6u9.js → flagship-signal-deck-B2s7TrIN.js} +1 -1
- package/dist/assets/{floating-action-menu-GCeuOdYZ.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-COhU_Wm3.js → generic-node-view-CgLkbts-.js} +1 -1
- package/dist/assets/{goal-detail-page-Bd8JU3IK.js → goal-detail-page-JRcEYpeA.js} +1 -1
- package/dist/assets/{goal-dialog-CLz5fYhO.js → goal-dialog-6R2uYWPQ.js} +1 -1
- package/dist/assets/{goals-page-CZHNVyY-.js → goals-page-CYestXkp.js} +1 -1
- package/dist/assets/{graph-Cd5WF3lw.js → graph-BF4IsheG.js} +1 -1
- package/dist/assets/{habits-page-CXiBB7_N.js → habits-page-B27lnyKu.js} +1 -1
- package/dist/assets/{health-boxes-BUcsxDLh.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-CcRDO3pd.js → inline-note-fields-CJ9ukqFp.js} +1 -1
- package/dist/assets/{insight-flow-dialog-DUBk2MR8.js → insight-flow-dialog-BgaJRYGX.js} +1 -1
- package/dist/assets/{insights-page-8fLhCYsM.js → insights-page-Dc5bilGE.js} +1 -1
- package/dist/assets/{kanban-boxes-CH7plRk1.js → kanban-boxes-qIwmQlRq.js} +1 -1
- package/dist/assets/{kanban-page-Bq-aYwUL.js → kanban-page-BGnVLHeN.js} +1 -1
- package/dist/assets/{knowledge-graph-page-DYFfWh4Y.js → knowledge-graph-page-B7IsVVO_.js} +1 -1
- package/dist/assets/{life-force-page-D80rgBBR.js → life-force-page-B5N4DknK.js} +1 -1
- package/dist/assets/{life-force-workspace-CcmZRRfU.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-DysHMgM3.js → metric-tile-BVuj7mc3.js} +1 -1
- package/dist/assets/{motion-DwjmC9aq.js → motion-DcgUnXhY.js} +1 -1
- package/dist/assets/{movement-boxes-BH3botKt.js → movement-boxes-BZrdIh8b.js} +1 -1
- package/dist/assets/{movement-page-D5KLMKS9.js → movement-page-CY2XLgp_.js} +1 -1
- package/dist/assets/{note-markdown-Byd_vNkS.js → note-markdown-BZHBOkEd.js} +1 -1
- package/dist/assets/{note-tags-input-DYAFDq8Q.js → note-tags-input-DucocvNH.js} +1 -1
- package/dist/assets/{notes-boxes-CPiPps1e.js → notes-boxes-DAQ6KBkJ.js} +1 -1
- package/dist/assets/{notes-page-DehVrOOb.js → notes-page-CSCfdDJl.js} +1 -1
- package/dist/assets/{open-in-graph-button-L21MKsb5.js → open-in-graph-button-ObO3m8yd.js} +1 -1
- package/dist/assets/{orbit-map-ChJOQ8CN.js → orbit-map-Bteg-ola.js} +1 -1
- package/dist/assets/overview-page-ckfN_sLZ.js +1 -0
- package/dist/assets/{page-hero-DlHEJ0Yt.js → page-hero-BkrRTu-t.js} +1 -1
- package/dist/assets/pill-cluster-COzv8VgR.js +1 -0
- package/dist/assets/{preference-entity-handoff-button-ugHU2us9.js → preference-entity-handoff-button-BpVAeFmY.js} +1 -1
- package/dist/assets/{preferences-page-BTTXeSDR.js → preferences-page-DseOh9AP.js} +1 -1
- package/dist/assets/{project-collections-USb6PUe0.js → project-collections-C7yU5PSi.js} +1 -1
- package/dist/assets/{project-detail-page-1tD6tUsB.js → project-detail-page-Crt46y_7.js} +1 -1
- package/dist/assets/{project-dialog-BaHhJqP6.js → project-dialog-YYxtlqg8.js} +1 -1
- package/dist/assets/{project-management-hierarchy-page-D4HV8QXs.js → project-management-hierarchy-page-DnojKHzy.js} +1 -1
- package/dist/assets/{project-management-section-nav-9OV6_ifH.js → project-management-section-nav-Ctjd348W.js} +1 -1
- package/dist/assets/{projects-boxes-AyudWls3.js → projects-boxes-D8lLFGBC.js} +1 -1
- package/dist/assets/{projects-page-DizVVzbt.js → projects-page-CV_2em8d.js} +1 -1
- package/dist/assets/{psyche-behaviors-page-CX5MJjWj.js → psyche-behaviors-page-CYCNk0rz.js} +1 -1
- package/dist/assets/{psyche-flashcards-page-DzCGy-FV.js → psyche-flashcards-page-DwmjBixN.js} +1 -1
- package/dist/assets/{psyche-goal-map-page-BcKR792E.js → psyche-goal-map-page-CdMhyBKh.js} +1 -1
- package/dist/assets/{psyche-graph-BNllZR67.js → psyche-graph-BcrZcJrq.js} +1 -1
- package/dist/assets/{psyche-metrics-page-CpEKlZLO.js → psyche-metrics-page-D5bX1mNX.js} +1 -1
- package/dist/assets/{psyche-mode-guide-page-DXKSE2o7.js → psyche-mode-guide-page-BmX9EOOB.js} +1 -1
- package/dist/assets/{psyche-modes-page-Bh6eeNm1.js → psyche-modes-page-CTHFDyLE.js} +1 -1
- package/dist/assets/{psyche-page-B44RJrrC.js → psyche-page-DRR3Fjv-.js} +1 -1
- package/dist/assets/{psyche-patterns-page-Bm7T5FMJ.js → psyche-patterns-page-BAS38xYf.js} +1 -1
- package/dist/assets/{psyche-questionnaire-builder-page-BNtMNjY8.js → psyche-questionnaire-builder-page-DpSi-zg3.js} +1 -1
- package/dist/assets/{psyche-questionnaire-detail-page-BnMXErRK.js → psyche-questionnaire-detail-page-BcXIiV4E.js} +1 -1
- package/dist/assets/{psyche-questionnaire-run-detail-page-CWKxbdwZ.js → psyche-questionnaire-run-detail-page-DvccwzWO.js} +1 -1
- package/dist/assets/{psyche-questionnaire-run-page-DwH7Sk52.js → psyche-questionnaire-run-page-vcyon8IZ.js} +1 -1
- package/dist/assets/{psyche-questionnaires-page-B1t_a8QU.js → psyche-questionnaires-page-B0DDTner.js} +1 -1
- package/dist/assets/{psyche-report-detail-page-x2AtC99e.js → psyche-report-detail-page-BgBA3xIO.js} +1 -1
- package/dist/assets/{psyche-reports-page-KiwCgWPa.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-CIEoAJAp.js → psyche-schemas-beliefs-page-D-IHHohY.js} +1 -1
- package/dist/assets/{psyche-screen-time-page-DO6UycL9.js → psyche-screen-time-page-DOKW3m8B.js} +1 -1
- package/dist/assets/{psyche-self-observation-page-CPcpUSdm.js → psyche-self-observation-page-j7Sk6Ns1.js} +1 -1
- package/dist/assets/{psyche-values-page-BAgNeo4L.js → psyche-values-page-NfzdWUyb.js} +1 -1
- package/dist/assets/{question-flow-dialog-BByFV8Yw.js → question-flow-dialog-CLHVmFON.js} +1 -1
- package/dist/assets/{report-chain-fields-BpRWMp7h.js → report-chain-fields-C9MuyBha.js} +1 -1
- package/dist/assets/{rewards-page-kIiX8SHn.js → rewards-page-CVxOBP6m.js} +1 -1
- package/dist/assets/{scheduling-rules-editor-oQXGcL5l.js → scheduling-rules-editor-BmbOHH_R.js} +1 -1
- package/dist/assets/{schema-badge-BoNjw3h4.js → schema-badge-BAiS_OAp.js} +1 -1
- package/dist/assets/{schemas-IBbtdPzn.js → schemas-B0AXfuOr.js} +1 -1
- package/dist/assets/{select-menu-U-YryCwP.js → select-menu-Bl5MILOj.js} +1 -1
- package/dist/assets/{settings-agents-page-CbHeMq3e.js → settings-agents-page-BIRP02mt.js} +1 -1
- package/dist/assets/{settings-bin-page-LxS2YPsA.js → settings-bin-page-DxJKeM28.js} +1 -1
- package/dist/assets/{settings-calendar-page-CZc-I-HK.js → settings-calendar-page-aMBtwvO_.js} +1 -1
- package/dist/assets/{settings-data-page-m-aBRMR9.js → settings-data-page-nHNergsh.js} +1 -1
- package/dist/assets/{settings-logs-page-BUEGWlQu.js → settings-logs-page-CFotPoFy.js} +1 -1
- package/dist/assets/{settings-mobile-page-C_5CHjHl.js → settings-mobile-page-Bf_1D-bN.js} +1 -1
- package/dist/assets/{settings-models-page-B4HQu8kL.js → settings-models-page-BbFNmnik.js} +1 -1
- package/dist/assets/{settings-page-CY8N-fNV.js → settings-page-CalbNbcg.js} +1 -1
- package/dist/assets/{settings-rewards-page-C0XQoWjb.js → settings-rewards-page-ByDfCzP5.js} +1 -1
- package/dist/assets/{settings-section-nav-CkqqtM0F.js → settings-section-nav-x_JXfzL9.js} +1 -1
- package/dist/assets/{settings-users-page-COCBOq7d.js → settings-users-page-N1jl9hWV.js} +1 -1
- package/dist/assets/{settings-wiki-page-Dy1d7-z-.js → settings-wiki-page-Bn2Qr94L.js} +1 -1
- package/dist/assets/{sleep-page-1kKicdFq.js → sleep-page-DyU635jb.js} +1 -1
- package/dist/assets/{sports-page-DJErRii_.js → sports-page-DbK4yGrR.js} +1 -1
- package/dist/assets/{state-BtwEvpO6.js → state-Bpe5dF3T.js} +1 -1
- package/dist/assets/{strategies-page-CWNFb9vv.js → strategies-page-AUBlFCZ9.js} +1 -1
- package/dist/assets/{strategy-detail-page-D9rFVXRS.js → strategy-detail-page-DCF8mVaL.js} +1 -1
- package/dist/assets/{strategy-dialog-BIVHfUR6.js → strategy-dialog-50xKlqcz.js} +1 -1
- package/dist/assets/{surface-B3oXO1No.js → surface-BjT1dIAF.js} +1 -1
- package/dist/assets/{table-BuONJH1s.js → table-U7otr5go.js} +1 -1
- package/dist/assets/{task-detail-page-r8ipOpAF.js → task-detail-page-Dsll2LCX.js} +1 -1
- package/dist/assets/{task-dialog-C3kkWtcT.js → task-dialog-BfPkbIPE.js} +1 -1
- package/dist/assets/{timebox-planning-dialog-BadS5tEr.js → timebox-planning-dialog-BTKUS6Jj.js} +1 -1
- package/dist/assets/{today-boxes-CiccN0Gw.js → today-boxes-BPi9bDHM.js} +1 -1
- package/dist/assets/{today-page-DvQredbl.js → today-page-BSXSA-Ts.js} +1 -1
- package/dist/assets/{training-load-page-CkX-nSoe.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-BKzwGZDd.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-DvxKA6dI.js → utility-widgets-Cq508sqJ.js} +1 -1
- package/dist/assets/{vendor-Cpmju3nw.js → vendor-BwL6m4SE.js} +216 -211
- package/dist/assets/{vitals-page-CRIsr_C7.js → vitals-page-BnXk8OzN.js} +1 -1
- package/dist/assets/{weekly-review-page-CNqxLRCd.js → weekly-review-page-CnmSGnEC.js} +1 -1
- package/dist/assets/weight-loss-page-Bn6lAXNf.js +5 -0
- package/dist/assets/{wiki-article-markdown-C9VKineE.js → wiki-article-markdown-BDnkdNDC.js} +1 -1
- package/dist/assets/{wiki-editor-page-DxGsKvF7.js → wiki-editor-page-CHEETB0G.js} +1 -1
- package/dist/assets/{wiki-ingest-history-page-CFmP7K1F.js → wiki-ingest-history-page-CisvtAzm.js} +1 -1
- package/dist/assets/{wiki-ingest-modal-BLw2PSWP.js → wiki-ingest-modal-C2faIjzj.js} +1 -1
- package/dist/assets/{wiki-page-BiQ-jAuz.js → wiki-page-CELe_6qL.js} +1 -1
- package/dist/assets/{workbench-flow-page-D960hXu_.js → workbench-flow-page-JBVim3L0.js} +1 -1
- package/dist/assets/{workbench-page-BWlY-FiL.js → workbench-page-q7Z3sQtz.js} +1 -1
- package/dist/assets/{workout-detail-page-G32yJoE9.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 +139 -14
- package/dist/server/server/src/health-weight-loss.js +48 -6
- package/dist/server/server/src/health.js +348 -112
- 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 +8 -1
- 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-BejvcRGb.js +0 -2
- package/dist/assets/overview-page-D_ewyeWF.js +0 -1
- package/dist/assets/pill-cluster-DhOR4m7X.js +0 -1
- package/dist/assets/weight-loss-page-SSsCp1Yw.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
|
|
@@ -2716,25 +2750,93 @@ function readMobileSyncSession(syncSessionId) {
|
|
|
2716
2750
|
.prepare(`SELECT * FROM health_mobile_sync_sessions WHERE id = ?`)
|
|
2717
2751
|
.get(syncSessionId);
|
|
2718
2752
|
}
|
|
2753
|
+
function mobileSyncSessionReceivedChunkIds(syncSessionId) {
|
|
2754
|
+
return getDatabase()
|
|
2755
|
+
.prepare(`SELECT chunk_id
|
|
2756
|
+
FROM health_mobile_sync_chunks
|
|
2757
|
+
WHERE sync_session_id = ?
|
|
2758
|
+
ORDER BY sequence ASC`)
|
|
2759
|
+
.all(syncSessionId)
|
|
2760
|
+
.map((row) => row.chunk_id);
|
|
2761
|
+
}
|
|
2762
|
+
function mobileSyncSessionCanResume(session, requestedFamilies) {
|
|
2763
|
+
const existingFamilies = safeJsonParse(session.requested_families_json, []);
|
|
2764
|
+
return (session.schema_version === HEALTH_MOBILE_SYNC_SCHEMA_VERSION &&
|
|
2765
|
+
requestedFamilies.every((family) => existingFamilies.includes(family)));
|
|
2766
|
+
}
|
|
2767
|
+
function findResumableMobileSyncSessionForPairing(pairingId, requestedFamilies) {
|
|
2768
|
+
const rows = getDatabase()
|
|
2769
|
+
.prepare(`SELECT s.*
|
|
2770
|
+
FROM health_mobile_sync_sessions s
|
|
2771
|
+
WHERE s.pairing_session_id = ?
|
|
2772
|
+
AND s.status = 'running'
|
|
2773
|
+
AND EXISTS (
|
|
2774
|
+
SELECT 1
|
|
2775
|
+
FROM health_mobile_sync_chunks c
|
|
2776
|
+
WHERE c.sync_session_id = s.id
|
|
2777
|
+
)
|
|
2778
|
+
ORDER BY s.updated_at DESC, s.started_at DESC
|
|
2779
|
+
LIMIT 8`)
|
|
2780
|
+
.all(pairingId);
|
|
2781
|
+
return (rows.find((session) => mobileSyncSessionCanResume(session, requestedFamilies)) ?? null);
|
|
2782
|
+
}
|
|
2719
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) {
|
|
2720
2791
|
const chunks = getDatabase()
|
|
2721
2792
|
.prepare(`SELECT family, record_count, byte_count
|
|
2722
2793
|
FROM health_mobile_sync_chunks
|
|
2723
2794
|
WHERE sync_session_id = ?`)
|
|
2724
2795
|
.all(syncSessionId);
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
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);
|
|
2732
2813
|
}
|
|
2733
2814
|
return {
|
|
2734
2815
|
receivedCounts,
|
|
2735
2816
|
byteTotals,
|
|
2736
|
-
chunkCount
|
|
2737
|
-
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)
|
|
2738
2840
|
};
|
|
2739
2841
|
}
|
|
2740
2842
|
function finiteNumberFromUnknown(value) {
|
|
@@ -2787,40 +2889,57 @@ function expectedWorkoutEvidenceCounts(derived) {
|
|
|
2787
2889
|
captureRoutePointCount !== null
|
|
2788
2890
|
};
|
|
2789
2891
|
}
|
|
2790
|
-
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];
|
|
2791
2916
|
const rows = getDatabase()
|
|
2792
|
-
.prepare(`
|
|
2793
|
-
SELECT workout_id, COUNT(*) AS time_series_count
|
|
2794
|
-
FROM health_workout_time_series
|
|
2795
|
-
GROUP BY workout_id
|
|
2796
|
-
),
|
|
2797
|
-
heart_rate_counts AS (
|
|
2798
|
-
SELECT workout_id, COUNT(*) AS heart_rate_count
|
|
2799
|
-
FROM health_workout_time_series
|
|
2800
|
-
WHERE metric_key = 'heart_rate'
|
|
2801
|
-
GROUP BY workout_id
|
|
2802
|
-
),
|
|
2803
|
-
route_counts AS (
|
|
2804
|
-
SELECT workout_id, COUNT(*) AS route_point_count
|
|
2805
|
-
FROM health_workout_routes
|
|
2806
|
-
GROUP BY workout_id
|
|
2807
|
-
)
|
|
2808
|
-
SELECT
|
|
2917
|
+
.prepare(`SELECT
|
|
2809
2918
|
w.external_uid,
|
|
2810
2919
|
w.derived_json,
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
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
|
|
2814
2935
|
FROM health_workout_sessions w
|
|
2815
|
-
LEFT JOIN time_series_counts ON time_series_counts.workout_id = w.id
|
|
2816
|
-
LEFT JOIN heart_rate_counts ON heart_rate_counts.workout_id = w.id
|
|
2817
|
-
LEFT JOIN route_counts ON route_counts.workout_id = w.id
|
|
2818
2936
|
WHERE w.user_id = ?
|
|
2819
2937
|
AND w.source = 'apple_health'
|
|
2820
2938
|
AND w.external_uid IS NOT NULL
|
|
2821
2939
|
AND w.external_uid <> ''
|
|
2940
|
+
${startedAtFilter}
|
|
2822
2941
|
ORDER BY w.started_at DESC`)
|
|
2823
|
-
.all(
|
|
2942
|
+
.all(...params);
|
|
2824
2943
|
const alreadyUploadedWorkoutExternalUids = [];
|
|
2825
2944
|
const incompleteWorkoutExternalUids = [];
|
|
2826
2945
|
let incompleteWorkoutCount = 0;
|
|
@@ -2840,7 +2959,9 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2840
2959
|
actualRoutePointCount >= evidenceCounts.expectedRoutePoints
|
|
2841
2960
|
: false;
|
|
2842
2961
|
if (evidenceCountsComplete) {
|
|
2843
|
-
|
|
2962
|
+
if (includeExternalUids) {
|
|
2963
|
+
alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
|
|
2964
|
+
}
|
|
2844
2965
|
timeSeriesSampleCount += actualTimeSeriesCount;
|
|
2845
2966
|
heartRateSampleCount += actualHeartRateCount;
|
|
2846
2967
|
routePointCount += actualRoutePointCount;
|
|
@@ -2850,13 +2971,15 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2850
2971
|
}
|
|
2851
2972
|
else {
|
|
2852
2973
|
incompleteWorkoutCount += 1;
|
|
2853
|
-
|
|
2974
|
+
if (includeExternalUids) {
|
|
2975
|
+
incompleteWorkoutExternalUids.push(row.external_uid.toLowerCase());
|
|
2976
|
+
}
|
|
2854
2977
|
}
|
|
2855
2978
|
}
|
|
2856
2979
|
return {
|
|
2857
2980
|
alreadyUploadedWorkoutExternalUids,
|
|
2858
2981
|
incompleteWorkoutExternalUids,
|
|
2859
|
-
alreadyUploadedWorkoutCount:
|
|
2982
|
+
alreadyUploadedWorkoutCount: rows.length - incompleteWorkoutCount,
|
|
2860
2983
|
existingWorkoutCount: rows.length,
|
|
2861
2984
|
incompleteWorkoutCount,
|
|
2862
2985
|
staleEvidenceVersionWorkoutCount,
|
|
@@ -2866,7 +2989,7 @@ function mobileHealthWorkoutImportState(userId) {
|
|
|
2866
2989
|
capturedAt: nowIso()
|
|
2867
2990
|
};
|
|
2868
2991
|
}
|
|
2869
|
-
function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
|
|
2992
|
+
function mobileSyncSessionUploadPayload(session, receivedChunkIds, options = {}) {
|
|
2870
2993
|
return {
|
|
2871
2994
|
syncSessionId: session.id,
|
|
2872
2995
|
schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
|
|
@@ -2878,8 +3001,15 @@ function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
|
|
|
2878
3001
|
supportsCompression: true,
|
|
2879
3002
|
acceptedFamilies: safeJsonParse(session.requested_families_json, []),
|
|
2880
3003
|
receivedChunkIds,
|
|
2881
|
-
|
|
2882
|
-
|
|
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)
|
|
2883
3013
|
};
|
|
2884
3014
|
}
|
|
2885
3015
|
export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
|
|
@@ -2888,14 +3018,19 @@ export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
|
|
|
2888
3018
|
if (!session || session.pairing_session_id !== pairing.id) {
|
|
2889
3019
|
throw new HttpError(404, "sync_session_not_found", "The HealthKit sync session does not exist.");
|
|
2890
3020
|
}
|
|
2891
|
-
const receivedChunkIds =
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
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
|
+
});
|
|
2899
3034
|
}
|
|
2900
3035
|
function ensureRunningMobileSyncSession(syncSessionId) {
|
|
2901
3036
|
expireStaleMobileSyncSessions();
|
|
@@ -3133,6 +3268,7 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
|
|
|
3133
3268
|
let createdCount = 0;
|
|
3134
3269
|
let updatedCount = 0;
|
|
3135
3270
|
let mergedCount = 0;
|
|
3271
|
+
const dateKeysToSummarize = new Set();
|
|
3136
3272
|
getDatabase()
|
|
3137
3273
|
.prepare(`INSERT INTO health_import_runs (
|
|
3138
3274
|
id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
|
|
@@ -3151,7 +3287,10 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
|
|
|
3151
3287
|
else {
|
|
3152
3288
|
updatedCount += 1;
|
|
3153
3289
|
}
|
|
3154
|
-
|
|
3290
|
+
dateKeysToSummarize.add(dayKey(workout.startedAt));
|
|
3291
|
+
}
|
|
3292
|
+
for (const dateKeyValue of dateKeysToSummarize) {
|
|
3293
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
3155
3294
|
}
|
|
3156
3295
|
getDatabase()
|
|
3157
3296
|
.prepare(`UPDATE companion_pairing_sessions
|
|
@@ -3174,6 +3313,29 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
|
|
|
3174
3313
|
}), workouts.length, createdCount, updatedCount, mergedCount, now, now, runId);
|
|
3175
3314
|
});
|
|
3176
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
|
+
}
|
|
3177
3339
|
function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
3178
3340
|
const timeSeries = payload.workoutTimeSeries ?? [];
|
|
3179
3341
|
const routes = payload.workoutRoutes ?? [];
|
|
@@ -3183,12 +3345,16 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
|
3183
3345
|
const pairing = mobileSyncSessionPairing(session);
|
|
3184
3346
|
runInTransaction(() => {
|
|
3185
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
|
+
]);
|
|
3186
3356
|
for (const entry of timeSeries) {
|
|
3187
|
-
const row =
|
|
3188
|
-
.prepare(`SELECT *
|
|
3189
|
-
FROM health_workout_sessions
|
|
3190
|
-
WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
|
|
3191
|
-
.get(pairing.user_id, entry.externalUid);
|
|
3357
|
+
const row = rowsByExternalUid.get(entry.externalUid);
|
|
3192
3358
|
if (!row || entry.samples.length === 0) {
|
|
3193
3359
|
continue;
|
|
3194
3360
|
}
|
|
@@ -3200,11 +3366,7 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
|
3200
3366
|
rowsToRecompute.set(row.id, row);
|
|
3201
3367
|
}
|
|
3202
3368
|
for (const entry of routes) {
|
|
3203
|
-
const row =
|
|
3204
|
-
.prepare(`SELECT *
|
|
3205
|
-
FROM health_workout_sessions
|
|
3206
|
-
WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
|
|
3207
|
-
.get(pairing.user_id, entry.externalUid);
|
|
3369
|
+
const row = rowsByExternalUid.get(entry.externalUid);
|
|
3208
3370
|
if (!row || entry.routePoints.length === 0) {
|
|
3209
3371
|
continue;
|
|
3210
3372
|
}
|
|
@@ -3217,7 +3379,9 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
|
|
|
3217
3379
|
}
|
|
3218
3380
|
for (const row of rowsToRecompute.values()) {
|
|
3219
3381
|
recomputeAndStoreWorkoutAnalytics(row);
|
|
3220
|
-
|
|
3382
|
+
}
|
|
3383
|
+
for (const dateKeyValue of new Set([...rowsToRecompute.values()].map((row) => dayKey(row.started_at)))) {
|
|
3384
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
3221
3385
|
}
|
|
3222
3386
|
});
|
|
3223
3387
|
}
|
|
@@ -3235,6 +3399,9 @@ function markMobileHealthSyncChunkApplied(input) {
|
|
|
3235
3399
|
}), now, input.syncSessionId, input.chunkId);
|
|
3236
3400
|
}
|
|
3237
3401
|
function mobileHealthSyncChunkWasImmediatelyApplied(chunk) {
|
|
3402
|
+
if (chunk.applied_at) {
|
|
3403
|
+
return true;
|
|
3404
|
+
}
|
|
3238
3405
|
const summary = safeJsonParse(chunk.payload_summary_json, {});
|
|
3239
3406
|
return summary.immediateApplied === true;
|
|
3240
3407
|
}
|
|
@@ -3291,8 +3458,7 @@ function applyMobileHealthSyncChunkImmediately(session, family, payload) {
|
|
|
3291
3458
|
return null;
|
|
3292
3459
|
}
|
|
3293
3460
|
}
|
|
3294
|
-
function updateMobileSyncSessionProgress(syncSessionId) {
|
|
3295
|
-
const progress = mobileSyncSessionProgress(syncSessionId);
|
|
3461
|
+
function updateMobileSyncSessionProgress(syncSessionId, progress = mobileSyncSessionProgress(syncSessionId)) {
|
|
3296
3462
|
getDatabase()
|
|
3297
3463
|
.prepare(`UPDATE health_mobile_sync_sessions
|
|
3298
3464
|
SET received_counts_json = ?, byte_totals_json = ?, updated_at = ?
|
|
@@ -3318,18 +3484,8 @@ export function startMobileHealthSyncSession(payload) {
|
|
|
3318
3484
|
if (existing &&
|
|
3319
3485
|
existing.pairing_session_id === pairing.id &&
|
|
3320
3486
|
existing.status === "running") {
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
parsed.requestedFamilies.every((family) => existingFamilies.includes(family));
|
|
3324
|
-
if (canResume) {
|
|
3325
|
-
const receivedChunkIds = getDatabase()
|
|
3326
|
-
.prepare(`SELECT chunk_id
|
|
3327
|
-
FROM health_mobile_sync_chunks
|
|
3328
|
-
WHERE sync_session_id = ?
|
|
3329
|
-
ORDER BY sequence ASC`)
|
|
3330
|
-
.all(resumeSyncSessionId)
|
|
3331
|
-
.map((row) => row.chunk_id);
|
|
3332
|
-
return mobileSyncSessionUploadPayload(existing, receivedChunkIds);
|
|
3487
|
+
if (mobileSyncSessionCanResume(existing, parsed.requestedFamilies)) {
|
|
3488
|
+
return mobileSyncSessionUploadPayload(existing, mobileSyncSessionReceivedChunkIds(existing.id));
|
|
3333
3489
|
}
|
|
3334
3490
|
getDatabase()
|
|
3335
3491
|
.prepare(`UPDATE health_mobile_sync_sessions
|
|
@@ -3338,6 +3494,10 @@ export function startMobileHealthSyncSession(payload) {
|
|
|
3338
3494
|
.run(nowIso(), nowIso(), existing.id);
|
|
3339
3495
|
}
|
|
3340
3496
|
}
|
|
3497
|
+
const implicitResumeSession = findResumableMobileSyncSessionForPairing(pairing.id, parsed.requestedFamilies);
|
|
3498
|
+
if (implicitResumeSession) {
|
|
3499
|
+
return mobileSyncSessionUploadPayload(implicitResumeSession, mobileSyncSessionReceivedChunkIds(implicitResumeSession.id));
|
|
3500
|
+
}
|
|
3341
3501
|
const now = nowIso();
|
|
3342
3502
|
const syncSessionId = mobileSyncSessionId();
|
|
3343
3503
|
getDatabase()
|
|
@@ -3377,7 +3537,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
3377
3537
|
if (existing.checksum_sha256 !== clientChecksum) {
|
|
3378
3538
|
throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
|
|
3379
3539
|
}
|
|
3380
|
-
const progress =
|
|
3540
|
+
const progress = mobileSyncSessionProgressFromStoredSession(session);
|
|
3381
3541
|
return {
|
|
3382
3542
|
accepted: true,
|
|
3383
3543
|
duplicate: true,
|
|
@@ -3459,6 +3619,12 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
3459
3619
|
}
|
|
3460
3620
|
return runInTransaction(() => {
|
|
3461
3621
|
const now = nowIso();
|
|
3622
|
+
const progress = mobileSyncSessionProgressAfterAcceptedChunk({
|
|
3623
|
+
session,
|
|
3624
|
+
family: parsed.family,
|
|
3625
|
+
recordCount: parsed.recordCount,
|
|
3626
|
+
byteCount: actualByteCount
|
|
3627
|
+
});
|
|
3462
3628
|
const payloadSummary = {
|
|
3463
3629
|
...summarizeChunkPayload(parsed.family, wirePayload.payload),
|
|
3464
3630
|
clientByteCount: parsed.byteCount,
|
|
@@ -3484,7 +3650,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
|
|
|
3484
3650
|
mode: appliedMode
|
|
3485
3651
|
});
|
|
3486
3652
|
}
|
|
3487
|
-
|
|
3653
|
+
updateMobileSyncSessionProgress(syncSessionId, progress);
|
|
3488
3654
|
return {
|
|
3489
3655
|
accepted: true,
|
|
3490
3656
|
duplicate: false,
|
|
@@ -3533,18 +3699,12 @@ function mergeMobileHealthSyncChunks(session, chunks, options = {}) {
|
|
|
3533
3699
|
const workoutsByExternalUid = new Map();
|
|
3534
3700
|
const tombstones = [];
|
|
3535
3701
|
for (const chunk of chunks.sort((left, right) => left.sequence - right.sequence)) {
|
|
3536
|
-
const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
|
|
3537
3702
|
const skipRecords = options.skipImmediatelyApplied === true &&
|
|
3538
3703
|
mobileHealthSyncChunkWasImmediatelyApplied(chunk);
|
|
3539
3704
|
if (skipRecords) {
|
|
3540
|
-
if (payload.movement?.settings) {
|
|
3541
|
-
assembled.movement.settings = payload.movement.settings;
|
|
3542
|
-
}
|
|
3543
|
-
if (payload.screenTime?.settings) {
|
|
3544
|
-
assembled.screenTime.settings = payload.screenTime.settings;
|
|
3545
|
-
}
|
|
3546
3705
|
continue;
|
|
3547
3706
|
}
|
|
3707
|
+
const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
|
|
3548
3708
|
if (payload.sleepNights) {
|
|
3549
3709
|
assembled.sleepNights.push(...payload.sleepNights);
|
|
3550
3710
|
}
|
|
@@ -3635,12 +3795,14 @@ function upsertMobileSyncFamilyCursors(pairing, finalCursor) {
|
|
|
3635
3795
|
stmt.run(`hmscur_${randomUUID().replaceAll("-", "").slice(0, 10)}`, pairing.id, pairing.user_id, family, JSON.stringify(cursor), now);
|
|
3636
3796
|
}
|
|
3637
3797
|
}
|
|
3638
|
-
function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
|
|
3798
|
+
function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts, chunks) {
|
|
3639
3799
|
const expectedEntries = Object.entries(expectedCounts).filter(([, expected]) => Number.isFinite(expected) && expected > 0);
|
|
3640
3800
|
if (expectedEntries.length === 0) {
|
|
3641
3801
|
return;
|
|
3642
3802
|
}
|
|
3643
|
-
const progress =
|
|
3803
|
+
const progress = chunks
|
|
3804
|
+
? mobileSyncChunkProgressFromRows(chunks)
|
|
3805
|
+
: updateMobileSyncSessionProgress(syncSessionId);
|
|
3644
3806
|
const missingFamilies = expectedEntries
|
|
3645
3807
|
.map(([family, expected]) => ({
|
|
3646
3808
|
family,
|
|
@@ -3652,6 +3814,45 @@ function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
|
|
|
3652
3814
|
throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session is missing required chunks.", { families: missingFamilies });
|
|
3653
3815
|
}
|
|
3654
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
|
+
}
|
|
3655
3856
|
function aggregateMobileSyncChunkCounts(chunks) {
|
|
3656
3857
|
const counts = {
|
|
3657
3858
|
sleepNights: 0,
|
|
@@ -3719,6 +3920,30 @@ function syncReceiptWithChunkCounts(sync, chunks) {
|
|
|
3719
3920
|
}
|
|
3720
3921
|
};
|
|
3721
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
|
+
}
|
|
3722
3947
|
function markMobileSyncSessionFailed(session, error) {
|
|
3723
3948
|
const now = nowIso();
|
|
3724
3949
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
@@ -3738,21 +3963,18 @@ function markMobileSyncSessionFailed(session, error) {
|
|
|
3738
3963
|
export function completeMobileHealthSyncSession(syncSessionId, payload) {
|
|
3739
3964
|
const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
|
|
3740
3965
|
const session = ensureRunningMobileSyncSession(syncSessionId);
|
|
3741
|
-
const chunks =
|
|
3742
|
-
.prepare(`SELECT * FROM health_mobile_sync_chunks
|
|
3743
|
-
WHERE sync_session_id = ?
|
|
3744
|
-
ORDER BY sequence ASC`)
|
|
3745
|
-
.all(syncSessionId);
|
|
3966
|
+
const chunks = listMobileSyncCompletionChunks(syncSessionId);
|
|
3746
3967
|
if (chunks.length === 0) {
|
|
3747
3968
|
throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
|
|
3748
3969
|
}
|
|
3970
|
+
const completionChunks = dedupeMobileSyncChunksForCompletion(chunks);
|
|
3749
3971
|
const pairing = getDatabase()
|
|
3750
3972
|
.prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
|
|
3751
3973
|
.get(session.pairing_session_id);
|
|
3752
3974
|
try {
|
|
3753
3975
|
return runInTransaction(() => {
|
|
3754
|
-
validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts);
|
|
3755
|
-
const { assembled, tombstones } = mergeMobileHealthSyncChunks(session,
|
|
3976
|
+
validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts, completionChunks);
|
|
3977
|
+
const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, completionChunks, { skipImmediatelyApplied: true });
|
|
3756
3978
|
const sync = ingestMobileHealthSync(assembled);
|
|
3757
3979
|
const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
|
|
3758
3980
|
upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
|
|
@@ -3764,10 +3986,12 @@ export function completeMobileHealthSyncSession(syncSessionId, payload) {
|
|
|
3764
3986
|
WHERE id = ?`)
|
|
3765
3987
|
.run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
|
|
3766
3988
|
return {
|
|
3767
|
-
...syncReceiptWithChunkCounts(sync,
|
|
3989
|
+
...syncReceiptWithChunkCounts(sync, completionChunks),
|
|
3768
3990
|
upload: {
|
|
3769
3991
|
syncSessionId,
|
|
3770
3992
|
chunks: chunks.length,
|
|
3993
|
+
effectiveChunks: completionChunks.length,
|
|
3994
|
+
supersededChunks: chunks.length - completionChunks.length,
|
|
3771
3995
|
deletedWorkoutCount
|
|
3772
3996
|
}
|
|
3773
3997
|
};
|
|
@@ -3814,6 +4038,8 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3814
4038
|
const screenTimeSync = ingestScreenTimeSync(pairing, parsed.screenTime, parsed.device.sourceDevice);
|
|
3815
4039
|
const sleepSessionsByLocalDate = new Map();
|
|
3816
4040
|
const sourceRecordIdsByExternalUid = new Map();
|
|
4041
|
+
const sleepDateKeysToSummarize = new Set();
|
|
4042
|
+
const workoutDateKeysToSummarize = new Set();
|
|
3817
4043
|
for (const sleep of normalizedSleepNights) {
|
|
3818
4044
|
const result = insertOrUpdateSleepSession(pairing, sleep);
|
|
3819
4045
|
if (result.mode === "created") {
|
|
@@ -3829,7 +4055,7 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3829
4055
|
endedAt: sleep.endedAt
|
|
3830
4056
|
});
|
|
3831
4057
|
sleepSessionsByLocalDate.set(sleep.localDateKey, group);
|
|
3832
|
-
|
|
4058
|
+
sleepDateKeysToSummarize.add(sleep.localDateKey);
|
|
3833
4059
|
}
|
|
3834
4060
|
for (const rawRecord of normalizedSleepRawRecords) {
|
|
3835
4061
|
const candidateSessions = sleepSessionsByLocalDate.get(rawRecord.localDateKey) ?? [];
|
|
@@ -3885,6 +4111,9 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3885
4111
|
replaceHistoricalSleepSessionsForDate(pairing.user_id, sleep.localDateKey, targetSessionId);
|
|
3886
4112
|
}
|
|
3887
4113
|
}
|
|
4114
|
+
for (const dateKeyValue of sleepDateKeysToSummarize) {
|
|
4115
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
4116
|
+
}
|
|
3888
4117
|
for (const workout of parsed.workouts) {
|
|
3889
4118
|
const result = insertOrUpdateWorkoutSession(pairing, workout);
|
|
3890
4119
|
if (result.mode === "created") {
|
|
@@ -3896,7 +4125,10 @@ export function ingestMobileHealthSync(payload) {
|
|
|
3896
4125
|
else {
|
|
3897
4126
|
updatedCount += 1;
|
|
3898
4127
|
}
|
|
3899
|
-
|
|
4128
|
+
workoutDateKeysToSummarize.add(dayKey(workout.startedAt));
|
|
4129
|
+
}
|
|
4130
|
+
for (const dateKeyValue of workoutDateKeysToSummarize) {
|
|
4131
|
+
summarizeUserHealthDay(pairing.user_id, dateKeyValue);
|
|
3900
4132
|
}
|
|
3901
4133
|
for (const daySummary of parsed.vitals.daySummaries) {
|
|
3902
4134
|
upsertVitalDaySummary(pairing.user_id, daySummary);
|
|
@@ -4999,17 +5231,21 @@ export function getTrainingLoadViewData(userIds) {
|
|
|
4999
5231
|
}
|
|
5000
5232
|
};
|
|
5001
5233
|
}
|
|
5002
|
-
export function getFitnessViewData(userIds) {
|
|
5234
|
+
export function getFitnessViewData(userIds, options = {}) {
|
|
5003
5235
|
const workoutRows = listWorkoutRows(userIds);
|
|
5004
5236
|
const recent = workoutRows
|
|
5005
5237
|
.slice(0, 40)
|
|
5006
5238
|
.map((row) => mapWorkoutSession(row, { includeAnalytics: true }));
|
|
5007
|
-
const browserSessions =
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5239
|
+
const browserSessions = options.compact
|
|
5240
|
+
? []
|
|
5241
|
+
: workoutRows
|
|
5242
|
+
.slice(0, 2000)
|
|
5243
|
+
.map((row, index) => mapWorkoutSession(row, { includeAnalytics: index < 40 }));
|
|
5244
|
+
const analysisSessions = options.compact
|
|
5245
|
+
? []
|
|
5246
|
+
: workoutRows
|
|
5247
|
+
.slice(0, 500)
|
|
5248
|
+
.map((row) => mapWorkoutSession(row, { includeAnalytics: true }));
|
|
5013
5249
|
const vitalsTrend = buildFitnessVitalsTrend(listDailySummaryRows("vitals", userIds));
|
|
5014
5250
|
const weekly = recent.filter((session) => Date.now() - Date.parse(session.startedAt) <= 7 * 24 * 60 * 60 * 1000);
|
|
5015
5251
|
const weeklyVolumeSeconds = weekly.reduce((sum, session) => sum + session.durationSeconds, 0);
|