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.
Files changed (156) hide show
  1. package/dist/assets/{action-bar-CMGr_Uv_.js → action-bar-CsDQF9d4.js} +1 -1
  2. package/dist/assets/{activity-page-DwGKkJtr.js → activity-page-CACf1Sd5.js} +1 -1
  3. package/dist/assets/{ai-surface-workspace-UtnoNmjq.js → ai-surface-workspace-Vn5fqCAT.js} +1 -1
  4. package/dist/assets/{atlas-panel-f1kc-LkD.js → atlas-panel-GdK1oyxo.js} +1 -1
  5. package/dist/assets/{board-CLOHbg6t.js → board-CuxQRKPJ.js} +1 -1
  6. package/dist/assets/{calendar-page-C4YqeVrC.js → calendar-page-CcVGbvfY.js} +1 -1
  7. package/dist/assets/{calendar-rules-BQppCPiN.js → calendar-rules-Dlq0KT93.js} +1 -1
  8. package/dist/assets/{calendar-week-toolbar-DdPuHUwd.js → calendar-week-toolbar-BVFb1_2G.js} +1 -1
  9. package/dist/assets/{charts-DwTguE_x.js → charts-BzT4pUPg.js} +1 -1
  10. package/dist/assets/{companion-sync-lab-page-N3cR33GS.js → companion-sync-lab-page-Dgvtdc4i.js} +1 -1
  11. package/dist/assets/{daily-metrics-dashboard-CXnAtN2T.js → daily-metrics-dashboard-ChQVbNJI.js} +1 -1
  12. package/dist/assets/date-keys-BnZV4PNO.js +1 -0
  13. package/dist/assets/{define-workbench-box-DkffygDU.js → define-workbench-box-DWAqqjvH.js} +1 -1
  14. package/dist/assets/{entity-link-multiselect-wuyJ8BbW.js → entity-link-multiselect-Do5DTju7.js} +1 -1
  15. package/dist/assets/{entity-note-count-link-CEtKyMAl.js → entity-note-count-link-CV3eOv66.js} +1 -1
  16. package/dist/assets/{entity-notes-surface-iKNiierJ.js → entity-notes-surface-CiUgAVl7.js} +1 -1
  17. package/dist/assets/{execution-board-DesFZ1f5.js → execution-board-ClaXVQ0H.js} +1 -1
  18. package/dist/assets/{faceted-token-search-D4Okr-Kr.js → faceted-token-search-DoSsb7qT.js} +1 -1
  19. package/dist/assets/{flagship-signal-deck-CW91n6u9.js → flagship-signal-deck-B2s7TrIN.js} +1 -1
  20. package/dist/assets/{floating-action-menu-GCeuOdYZ.js → floating-action-menu-DGkHCmvI.js} +1 -1
  21. package/dist/assets/{forms-C5d5hTf2.js → forms-D1qJ3oOP.js} +1 -1
  22. package/dist/assets/{generic-node-view-COhU_Wm3.js → generic-node-view-CgLkbts-.js} +1 -1
  23. package/dist/assets/{goal-detail-page-Bd8JU3IK.js → goal-detail-page-JRcEYpeA.js} +1 -1
  24. package/dist/assets/{goal-dialog-CLz5fYhO.js → goal-dialog-6R2uYWPQ.js} +1 -1
  25. package/dist/assets/{goals-page-CZHNVyY-.js → goals-page-CYestXkp.js} +1 -1
  26. package/dist/assets/{graph-Cd5WF3lw.js → graph-BF4IsheG.js} +1 -1
  27. package/dist/assets/{habits-page-CXiBB7_N.js → habits-page-B27lnyKu.js} +1 -1
  28. package/dist/assets/{health-boxes-BUcsxDLh.js → health-boxes-CeqSGyZ3.js} +1 -1
  29. package/dist/assets/index-DGYIFHgo.js +2 -0
  30. package/dist/assets/{index-CEgIwgk9.css → index-UzsVTD1W.css} +1 -1
  31. package/dist/assets/{inline-note-fields-CcRDO3pd.js → inline-note-fields-CJ9ukqFp.js} +1 -1
  32. package/dist/assets/{insight-flow-dialog-DUBk2MR8.js → insight-flow-dialog-BgaJRYGX.js} +1 -1
  33. package/dist/assets/{insights-page-8fLhCYsM.js → insights-page-Dc5bilGE.js} +1 -1
  34. package/dist/assets/{kanban-boxes-CH7plRk1.js → kanban-boxes-qIwmQlRq.js} +1 -1
  35. package/dist/assets/{kanban-page-Bq-aYwUL.js → kanban-page-BGnVLHeN.js} +1 -1
  36. package/dist/assets/{knowledge-graph-page-DYFfWh4Y.js → knowledge-graph-page-B7IsVVO_.js} +1 -1
  37. package/dist/assets/{life-force-page-D80rgBBR.js → life-force-page-B5N4DknK.js} +1 -1
  38. package/dist/assets/{life-force-workspace-CcmZRRfU.js → life-force-workspace-Da-dmoRX.js} +1 -1
  39. package/dist/assets/{maps-D0Mm6WPG.js → maps-BTVHALP8.js} +1 -1
  40. package/dist/assets/{metric-tile-DysHMgM3.js → metric-tile-BVuj7mc3.js} +1 -1
  41. package/dist/assets/{motion-DwjmC9aq.js → motion-DcgUnXhY.js} +1 -1
  42. package/dist/assets/{movement-boxes-BH3botKt.js → movement-boxes-BZrdIh8b.js} +1 -1
  43. package/dist/assets/{movement-page-D5KLMKS9.js → movement-page-CY2XLgp_.js} +1 -1
  44. package/dist/assets/{note-markdown-Byd_vNkS.js → note-markdown-BZHBOkEd.js} +1 -1
  45. package/dist/assets/{note-tags-input-DYAFDq8Q.js → note-tags-input-DucocvNH.js} +1 -1
  46. package/dist/assets/{notes-boxes-CPiPps1e.js → notes-boxes-DAQ6KBkJ.js} +1 -1
  47. package/dist/assets/{notes-page-DehVrOOb.js → notes-page-CSCfdDJl.js} +1 -1
  48. package/dist/assets/{open-in-graph-button-L21MKsb5.js → open-in-graph-button-ObO3m8yd.js} +1 -1
  49. package/dist/assets/{orbit-map-ChJOQ8CN.js → orbit-map-Bteg-ola.js} +1 -1
  50. package/dist/assets/overview-page-ckfN_sLZ.js +1 -0
  51. package/dist/assets/{page-hero-DlHEJ0Yt.js → page-hero-BkrRTu-t.js} +1 -1
  52. package/dist/assets/pill-cluster-COzv8VgR.js +1 -0
  53. package/dist/assets/{preference-entity-handoff-button-ugHU2us9.js → preference-entity-handoff-button-BpVAeFmY.js} +1 -1
  54. package/dist/assets/{preferences-page-BTTXeSDR.js → preferences-page-DseOh9AP.js} +1 -1
  55. package/dist/assets/{project-collections-USb6PUe0.js → project-collections-C7yU5PSi.js} +1 -1
  56. package/dist/assets/{project-detail-page-1tD6tUsB.js → project-detail-page-Crt46y_7.js} +1 -1
  57. package/dist/assets/{project-dialog-BaHhJqP6.js → project-dialog-YYxtlqg8.js} +1 -1
  58. package/dist/assets/{project-management-hierarchy-page-D4HV8QXs.js → project-management-hierarchy-page-DnojKHzy.js} +1 -1
  59. package/dist/assets/{project-management-section-nav-9OV6_ifH.js → project-management-section-nav-Ctjd348W.js} +1 -1
  60. package/dist/assets/{projects-boxes-AyudWls3.js → projects-boxes-D8lLFGBC.js} +1 -1
  61. package/dist/assets/{projects-page-DizVVzbt.js → projects-page-CV_2em8d.js} +1 -1
  62. package/dist/assets/{psyche-behaviors-page-CX5MJjWj.js → psyche-behaviors-page-CYCNk0rz.js} +1 -1
  63. package/dist/assets/{psyche-flashcards-page-DzCGy-FV.js → psyche-flashcards-page-DwmjBixN.js} +1 -1
  64. package/dist/assets/{psyche-goal-map-page-BcKR792E.js → psyche-goal-map-page-CdMhyBKh.js} +1 -1
  65. package/dist/assets/{psyche-graph-BNllZR67.js → psyche-graph-BcrZcJrq.js} +1 -1
  66. package/dist/assets/{psyche-metrics-page-CpEKlZLO.js → psyche-metrics-page-D5bX1mNX.js} +1 -1
  67. package/dist/assets/{psyche-mode-guide-page-DXKSE2o7.js → psyche-mode-guide-page-BmX9EOOB.js} +1 -1
  68. package/dist/assets/{psyche-modes-page-Bh6eeNm1.js → psyche-modes-page-CTHFDyLE.js} +1 -1
  69. package/dist/assets/{psyche-page-B44RJrrC.js → psyche-page-DRR3Fjv-.js} +1 -1
  70. package/dist/assets/{psyche-patterns-page-Bm7T5FMJ.js → psyche-patterns-page-BAS38xYf.js} +1 -1
  71. package/dist/assets/{psyche-questionnaire-builder-page-BNtMNjY8.js → psyche-questionnaire-builder-page-DpSi-zg3.js} +1 -1
  72. package/dist/assets/{psyche-questionnaire-detail-page-BnMXErRK.js → psyche-questionnaire-detail-page-BcXIiV4E.js} +1 -1
  73. package/dist/assets/{psyche-questionnaire-run-detail-page-CWKxbdwZ.js → psyche-questionnaire-run-detail-page-DvccwzWO.js} +1 -1
  74. package/dist/assets/{psyche-questionnaire-run-page-DwH7Sk52.js → psyche-questionnaire-run-page-vcyon8IZ.js} +1 -1
  75. package/dist/assets/{psyche-questionnaires-page-B1t_a8QU.js → psyche-questionnaires-page-B0DDTner.js} +1 -1
  76. package/dist/assets/{psyche-report-detail-page-x2AtC99e.js → psyche-report-detail-page-BgBA3xIO.js} +1 -1
  77. package/dist/assets/{psyche-reports-page-KiwCgWPa.js → psyche-reports-page-Ce1vEEqW.js} +1 -1
  78. package/dist/assets/{psyche-schemas-Dtskzvv1.js → psyche-schemas-DDol0j-g.js} +1 -1
  79. package/dist/assets/{psyche-schemas-beliefs-page-CIEoAJAp.js → psyche-schemas-beliefs-page-D-IHHohY.js} +1 -1
  80. package/dist/assets/{psyche-screen-time-page-DO6UycL9.js → psyche-screen-time-page-DOKW3m8B.js} +1 -1
  81. package/dist/assets/{psyche-self-observation-page-CPcpUSdm.js → psyche-self-observation-page-j7Sk6Ns1.js} +1 -1
  82. package/dist/assets/{psyche-values-page-BAgNeo4L.js → psyche-values-page-NfzdWUyb.js} +1 -1
  83. package/dist/assets/{question-flow-dialog-BByFV8Yw.js → question-flow-dialog-CLHVmFON.js} +1 -1
  84. package/dist/assets/{report-chain-fields-BpRWMp7h.js → report-chain-fields-C9MuyBha.js} +1 -1
  85. package/dist/assets/{rewards-page-kIiX8SHn.js → rewards-page-CVxOBP6m.js} +1 -1
  86. package/dist/assets/{scheduling-rules-editor-oQXGcL5l.js → scheduling-rules-editor-BmbOHH_R.js} +1 -1
  87. package/dist/assets/{schema-badge-BoNjw3h4.js → schema-badge-BAiS_OAp.js} +1 -1
  88. package/dist/assets/{schemas-IBbtdPzn.js → schemas-B0AXfuOr.js} +1 -1
  89. package/dist/assets/{select-menu-U-YryCwP.js → select-menu-Bl5MILOj.js} +1 -1
  90. package/dist/assets/{settings-agents-page-CbHeMq3e.js → settings-agents-page-BIRP02mt.js} +1 -1
  91. package/dist/assets/{settings-bin-page-LxS2YPsA.js → settings-bin-page-DxJKeM28.js} +1 -1
  92. package/dist/assets/{settings-calendar-page-CZc-I-HK.js → settings-calendar-page-aMBtwvO_.js} +1 -1
  93. package/dist/assets/{settings-data-page-m-aBRMR9.js → settings-data-page-nHNergsh.js} +1 -1
  94. package/dist/assets/{settings-logs-page-BUEGWlQu.js → settings-logs-page-CFotPoFy.js} +1 -1
  95. package/dist/assets/{settings-mobile-page-C_5CHjHl.js → settings-mobile-page-Bf_1D-bN.js} +1 -1
  96. package/dist/assets/{settings-models-page-B4HQu8kL.js → settings-models-page-BbFNmnik.js} +1 -1
  97. package/dist/assets/{settings-page-CY8N-fNV.js → settings-page-CalbNbcg.js} +1 -1
  98. package/dist/assets/{settings-rewards-page-C0XQoWjb.js → settings-rewards-page-ByDfCzP5.js} +1 -1
  99. package/dist/assets/{settings-section-nav-CkqqtM0F.js → settings-section-nav-x_JXfzL9.js} +1 -1
  100. package/dist/assets/{settings-users-page-COCBOq7d.js → settings-users-page-N1jl9hWV.js} +1 -1
  101. package/dist/assets/{settings-wiki-page-Dy1d7-z-.js → settings-wiki-page-Bn2Qr94L.js} +1 -1
  102. package/dist/assets/{sleep-page-1kKicdFq.js → sleep-page-DyU635jb.js} +1 -1
  103. package/dist/assets/{sports-page-DJErRii_.js → sports-page-DbK4yGrR.js} +1 -1
  104. package/dist/assets/{state-BtwEvpO6.js → state-Bpe5dF3T.js} +1 -1
  105. package/dist/assets/{strategies-page-CWNFb9vv.js → strategies-page-AUBlFCZ9.js} +1 -1
  106. package/dist/assets/{strategy-detail-page-D9rFVXRS.js → strategy-detail-page-DCF8mVaL.js} +1 -1
  107. package/dist/assets/{strategy-dialog-BIVHfUR6.js → strategy-dialog-50xKlqcz.js} +1 -1
  108. package/dist/assets/{surface-B3oXO1No.js → surface-BjT1dIAF.js} +1 -1
  109. package/dist/assets/{table-BuONJH1s.js → table-U7otr5go.js} +1 -1
  110. package/dist/assets/{task-detail-page-r8ipOpAF.js → task-detail-page-Dsll2LCX.js} +1 -1
  111. package/dist/assets/{task-dialog-C3kkWtcT.js → task-dialog-BfPkbIPE.js} +1 -1
  112. package/dist/assets/{timebox-planning-dialog-BadS5tEr.js → timebox-planning-dialog-BTKUS6Jj.js} +1 -1
  113. package/dist/assets/{today-boxes-CiccN0Gw.js → today-boxes-BPi9bDHM.js} +1 -1
  114. package/dist/assets/{today-page-DvQredbl.js → today-page-BSXSA-Ts.js} +1 -1
  115. package/dist/assets/{training-load-page-CkX-nSoe.js → training-load-page-BD0s8-Zm.js} +1 -1
  116. package/dist/assets/{ui-B9O-eUim.js → ui-B9TWEtCx.js} +1 -1
  117. package/dist/assets/{use-anchored-overlay-position-BrQ4cqKn.js → use-anchored-overlay-position-BY4kNzPj.js} +1 -1
  118. package/dist/assets/{use-psyche-focus-target-Cuxni3SK.js → use-psyche-focus-target-BhNedCZB.js} +1 -1
  119. package/dist/assets/{user-badge-BKzwGZDd.js → user-badge-ap1PvOxd.js} +1 -1
  120. package/dist/assets/{user-select-field-BNqv8-wd.js → user-select-field-fx129Uh6.js} +1 -1
  121. package/dist/assets/{utility-widgets-DvxKA6dI.js → utility-widgets-Cq508sqJ.js} +1 -1
  122. package/dist/assets/{vendor-Cpmju3nw.js → vendor-BwL6m4SE.js} +216 -211
  123. package/dist/assets/{vitals-page-CRIsr_C7.js → vitals-page-BnXk8OzN.js} +1 -1
  124. package/dist/assets/{weekly-review-page-CNqxLRCd.js → weekly-review-page-CnmSGnEC.js} +1 -1
  125. package/dist/assets/weight-loss-page-Bn6lAXNf.js +5 -0
  126. package/dist/assets/{wiki-article-markdown-C9VKineE.js → wiki-article-markdown-BDnkdNDC.js} +1 -1
  127. package/dist/assets/{wiki-editor-page-DxGsKvF7.js → wiki-editor-page-CHEETB0G.js} +1 -1
  128. package/dist/assets/{wiki-ingest-history-page-CFmP7K1F.js → wiki-ingest-history-page-CisvtAzm.js} +1 -1
  129. package/dist/assets/{wiki-ingest-modal-BLw2PSWP.js → wiki-ingest-modal-C2faIjzj.js} +1 -1
  130. package/dist/assets/{wiki-page-BiQ-jAuz.js → wiki-page-CELe_6qL.js} +1 -1
  131. package/dist/assets/{workbench-flow-page-D960hXu_.js → workbench-flow-page-JBVim3L0.js} +1 -1
  132. package/dist/assets/{workbench-page-BWlY-FiL.js → workbench-page-q7Z3sQtz.js} +1 -1
  133. package/dist/assets/{workout-detail-page-G32yJoE9.js → workout-detail-page-9I_3DPYU.js} +2 -2
  134. package/dist/index.html +8 -8
  135. package/dist/server/server/migrations/070_health_mobile_sync_completion_index.sql +16 -0
  136. package/dist/server/server/src/app.js +139 -14
  137. package/dist/server/server/src/health-weight-loss.js +48 -6
  138. package/dist/server/server/src/health.js +348 -112
  139. package/dist/server/server/src/openapi.js +31 -0
  140. package/dist/server/server/src/repositories/gamification.js +25 -0
  141. package/dist/server/server/src/repositories/tasks.js +82 -17
  142. package/dist/server/server/src/services/dashboard.js +5 -4
  143. package/dist/server/server/src/services/gamification.js +47 -19
  144. package/dist/server/server/src/services/life-force.js +99 -0
  145. package/dist/server/src/lib/api.js +8 -1
  146. package/dist/server/src/lib/snapshot-normalizer.js +42 -23
  147. package/openclaw.plugin.json +1 -1
  148. package/package.json +1 -1
  149. package/server/migrations/070_health_mobile_sync_completion_index.sql +16 -0
  150. package/skills/forge-openclaw/SKILL.md +1 -0
  151. package/skills/forge-openclaw/psyche_entity_playbooks.md +8 -0
  152. package/dist/assets/date-keys-Cj1G3TOn.js +0 -1
  153. package/dist/assets/index-BejvcRGb.js +0 -2
  154. package/dist/assets/overview-page-D_ewyeWF.js +0 -1
  155. package/dist/assets/pill-cluster-DhOR4m7X.js +0 -1
  156. 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 summarizeUserHealthDay(userId, dateKeyValue) {
2049
- const sleeps = listSleepRows([userId]).filter((row) => sleepSessionDateKey(row) === dateKeyValue ||
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
- const workouts = listWorkoutRows([userId]).filter((row) => dayKey(row.started_at) === dateKeyValue);
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: workouts.length,
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
- const receivedCounts = {};
2726
- const byteTotals = {};
2727
- for (const chunk of chunks) {
2728
- receivedCounts[chunk.family] =
2729
- (receivedCounts[chunk.family] ?? 0) + chunk.record_count;
2730
- byteTotals[chunk.family] =
2731
- (byteTotals[chunk.family] ?? 0) + chunk.byte_count;
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: chunks.length,
2737
- receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
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 mobileHealthWorkoutImportState(userId) {
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(`WITH time_series_counts AS (
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
- COALESCE(time_series_counts.time_series_count, 0) AS time_series_count,
2812
- COALESCE(heart_rate_counts.heart_rate_count, 0) AS heart_rate_count,
2813
- COALESCE(route_counts.route_point_count, 0) AS route_point_count
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(userId);
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
- alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
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
- incompleteWorkoutExternalUids.push(row.external_uid.toLowerCase());
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: alreadyUploadedWorkoutExternalUids.length,
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
- workoutImportState: mobileHealthWorkoutImportState(session.user_id),
2882
- progress: mobileSyncSessionProgress(session.id)
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 = getDatabase()
2892
- .prepare(`SELECT chunk_id
2893
- FROM health_mobile_sync_chunks
2894
- WHERE sync_session_id = ?
2895
- ORDER BY sequence ASC`)
2896
- .all(syncSessionId)
2897
- .map((row) => row.chunk_id);
2898
- return mobileSyncSessionUploadPayload(session, receivedChunkIds);
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
- summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
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 = getDatabase()
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 = getDatabase()
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
- summarizeUserHealthDay(pairing.user_id, dayKey(row.started_at));
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
- const existingFamilies = safeJsonParse(existing.requested_families_json, []);
3322
- const canResume = existing.schema_version === HEALTH_MOBILE_SYNC_SCHEMA_VERSION &&
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 = updateMobileSyncSessionProgress(syncSessionId);
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
- const progress = updateMobileSyncSessionProgress(syncSessionId);
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 = updateMobileSyncSessionProgress(syncSessionId);
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 = getDatabase()
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, chunks, { skipImmediatelyApplied: true });
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, chunks),
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
- summarizeUserHealthDay(pairing.user_id, sleep.localDateKey);
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
- summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
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 = workoutRows
5008
- .slice(0, 2000)
5009
- .map((row, index) => mapWorkoutSession(row, { includeAnalytics: index < 40 }));
5010
- const analysisSessions = workoutRows
5011
- .slice(0, 500)
5012
- .map((row) => mapWorkoutSession(row, { includeAnalytics: true }));
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);