forge-openclaw-plugin 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/dist/assets/{action-bar-DBZ38L_6.js → action-bar-CsDQF9d4.js} +1 -1
  2. package/dist/assets/{activity-page-BJxUR9bD.js → activity-page-CACf1Sd5.js} +1 -1
  3. package/dist/assets/{ai-surface-workspace-ehzBjTsW.js → ai-surface-workspace-Vn5fqCAT.js} +1 -1
  4. package/dist/assets/{atlas-panel-BFUeVT69.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-Bg8W-YQ-.js → calendar-page-CcVGbvfY.js} +1 -1
  7. package/dist/assets/{calendar-rules-9XQLoI96.js → calendar-rules-Dlq0KT93.js} +1 -1
  8. package/dist/assets/{calendar-week-toolbar-BeYnx1pE.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-BRKYkIrs.js → companion-sync-lab-page-Dgvtdc4i.js} +1 -1
  11. package/dist/assets/{daily-metrics-dashboard-Jt0nAHU5.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-5h-dyvJY.js → define-workbench-box-DWAqqjvH.js} +1 -1
  14. package/dist/assets/{entity-link-multiselect-Bb4De_e3.js → entity-link-multiselect-Do5DTju7.js} +1 -1
  15. package/dist/assets/{entity-note-count-link-BzWnsOmI.js → entity-note-count-link-CV3eOv66.js} +1 -1
  16. package/dist/assets/{entity-notes-surface-CagOwdng.js → entity-notes-surface-CiUgAVl7.js} +1 -1
  17. package/dist/assets/{execution-board-DTmAtmqZ.js → execution-board-ClaXVQ0H.js} +1 -1
  18. package/dist/assets/{faceted-token-search-DJPRm9AY.js → faceted-token-search-DoSsb7qT.js} +1 -1
  19. package/dist/assets/{flagship-signal-deck-CJbGZF7I.js → flagship-signal-deck-B2s7TrIN.js} +1 -1
  20. package/dist/assets/{floating-action-menu-K77jh8XZ.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-p5ePSFuG.js → generic-node-view-CgLkbts-.js} +1 -1
  23. package/dist/assets/{goal-detail-page-DGu_khEK.js → goal-detail-page-JRcEYpeA.js} +1 -1
  24. package/dist/assets/{goal-dialog-BWD7cOv9.js → goal-dialog-6R2uYWPQ.js} +1 -1
  25. package/dist/assets/{goals-page-Bgj3sj0f.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-Ctut1tuX.js → habits-page-B27lnyKu.js} +1 -1
  28. package/dist/assets/{health-boxes-Ie3horXx.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-DE6WM9uM.js → inline-note-fields-CJ9ukqFp.js} +1 -1
  32. package/dist/assets/{insight-flow-dialog-XL8I74eJ.js → insight-flow-dialog-BgaJRYGX.js} +1 -1
  33. package/dist/assets/{insights-page-Bz1FLbp5.js → insights-page-Dc5bilGE.js} +1 -1
  34. package/dist/assets/{kanban-boxes-BBziel7P.js → kanban-boxes-qIwmQlRq.js} +1 -1
  35. package/dist/assets/{kanban-page-DdQP73I3.js → kanban-page-BGnVLHeN.js} +1 -1
  36. package/dist/assets/{knowledge-graph-page-DdHWj4Dj.js → knowledge-graph-page-B7IsVVO_.js} +1 -1
  37. package/dist/assets/{life-force-page-BRN-93cI.js → life-force-page-B5N4DknK.js} +1 -1
  38. package/dist/assets/{life-force-workspace-BowvP5R4.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-B6aJueRo.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-5cjWjIdR.js → movement-boxes-BZrdIh8b.js} +1 -1
  43. package/dist/assets/{movement-page-Dy53RWtB.js → movement-page-CY2XLgp_.js} +1 -1
  44. package/dist/assets/{note-markdown-CvEQCfoi.js → note-markdown-BZHBOkEd.js} +1 -1
  45. package/dist/assets/{note-tags-input-Cg5zBXos.js → note-tags-input-DucocvNH.js} +1 -1
  46. package/dist/assets/{notes-boxes-CgZyf7mV.js → notes-boxes-DAQ6KBkJ.js} +1 -1
  47. package/dist/assets/{notes-page-Cbdcv0Ej.js → notes-page-CSCfdDJl.js} +1 -1
  48. package/dist/assets/{open-in-graph-button-D31ZOEhA.js → open-in-graph-button-ObO3m8yd.js} +1 -1
  49. package/dist/assets/{orbit-map-8eNDWek8.js → orbit-map-Bteg-ola.js} +1 -1
  50. package/dist/assets/{overview-page-CZ6q52Qj.js → overview-page-ckfN_sLZ.js} +1 -1
  51. package/dist/assets/{page-hero-C0MpI3MM.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-CJhr5AXl.js → preference-entity-handoff-button-BpVAeFmY.js} +1 -1
  54. package/dist/assets/{preferences-page-8vszDNFX.js → preferences-page-DseOh9AP.js} +1 -1
  55. package/dist/assets/{project-collections-sC7eAAhS.js → project-collections-C7yU5PSi.js} +1 -1
  56. package/dist/assets/{project-detail-page-BKdnMjJy.js → project-detail-page-Crt46y_7.js} +1 -1
  57. package/dist/assets/{project-dialog-BiHZpOo1.js → project-dialog-YYxtlqg8.js} +1 -1
  58. package/dist/assets/{project-management-hierarchy-page-VW-hykAI.js → project-management-hierarchy-page-DnojKHzy.js} +1 -1
  59. package/dist/assets/{project-management-section-nav-BEZ5zihs.js → project-management-section-nav-Ctjd348W.js} +1 -1
  60. package/dist/assets/{projects-boxes-Ca6rpYeE.js → projects-boxes-D8lLFGBC.js} +1 -1
  61. package/dist/assets/{projects-page-D86pjpTf.js → projects-page-CV_2em8d.js} +1 -1
  62. package/dist/assets/{psyche-behaviors-page-YLJB6CRU.js → psyche-behaviors-page-CYCNk0rz.js} +1 -1
  63. package/dist/assets/{psyche-flashcards-page-D3gdHLUw.js → psyche-flashcards-page-DwmjBixN.js} +1 -1
  64. package/dist/assets/{psyche-goal-map-page-DJiqSiCx.js → psyche-goal-map-page-CdMhyBKh.js} +1 -1
  65. package/dist/assets/{psyche-graph-Tke0qFdt.js → psyche-graph-BcrZcJrq.js} +1 -1
  66. package/dist/assets/{psyche-metrics-page-DoLnvmC2.js → psyche-metrics-page-D5bX1mNX.js} +1 -1
  67. package/dist/assets/{psyche-mode-guide-page-_Zbvg_HL.js → psyche-mode-guide-page-BmX9EOOB.js} +1 -1
  68. package/dist/assets/{psyche-modes-page-eAkaAzJc.js → psyche-modes-page-CTHFDyLE.js} +1 -1
  69. package/dist/assets/{psyche-page-CwmuBVjA.js → psyche-page-DRR3Fjv-.js} +1 -1
  70. package/dist/assets/{psyche-patterns-page-D7B4Ykjq.js → psyche-patterns-page-BAS38xYf.js} +1 -1
  71. package/dist/assets/{psyche-questionnaire-builder-page-CHJlvCzX.js → psyche-questionnaire-builder-page-DpSi-zg3.js} +1 -1
  72. package/dist/assets/{psyche-questionnaire-detail-page-Bg3ll-D4.js → psyche-questionnaire-detail-page-BcXIiV4E.js} +1 -1
  73. package/dist/assets/{psyche-questionnaire-run-detail-page-CIEerczF.js → psyche-questionnaire-run-detail-page-DvccwzWO.js} +1 -1
  74. package/dist/assets/{psyche-questionnaire-run-page-DyxIk6Qx.js → psyche-questionnaire-run-page-vcyon8IZ.js} +1 -1
  75. package/dist/assets/{psyche-questionnaires-page-ZlqUxuIl.js → psyche-questionnaires-page-B0DDTner.js} +1 -1
  76. package/dist/assets/{psyche-report-detail-page-B2jgYnQ5.js → psyche-report-detail-page-BgBA3xIO.js} +1 -1
  77. package/dist/assets/{psyche-reports-page-DlXr52yr.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-BhrTZN9B.js → psyche-schemas-beliefs-page-D-IHHohY.js} +1 -1
  80. package/dist/assets/{psyche-screen-time-page-WNGcdusx.js → psyche-screen-time-page-DOKW3m8B.js} +1 -1
  81. package/dist/assets/{psyche-self-observation-page-XsCOglo4.js → psyche-self-observation-page-j7Sk6Ns1.js} +1 -1
  82. package/dist/assets/{psyche-values-page-NvHI6zWK.js → psyche-values-page-NfzdWUyb.js} +1 -1
  83. package/dist/assets/{question-flow-dialog-Bj4PxpjS.js → question-flow-dialog-CLHVmFON.js} +1 -1
  84. package/dist/assets/{report-chain-fields-mWFikZzT.js → report-chain-fields-C9MuyBha.js} +1 -1
  85. package/dist/assets/{rewards-page-DyjwXsQN.js → rewards-page-CVxOBP6m.js} +1 -1
  86. package/dist/assets/{scheduling-rules-editor-C6FEYOxd.js → scheduling-rules-editor-BmbOHH_R.js} +1 -1
  87. package/dist/assets/{schema-badge-CCe4zkSN.js → schema-badge-BAiS_OAp.js} +1 -1
  88. package/dist/assets/{schemas-Cjwn6ooR.js → schemas-B0AXfuOr.js} +1 -1
  89. package/dist/assets/{select-menu-DeJhCsd8.js → select-menu-Bl5MILOj.js} +1 -1
  90. package/dist/assets/{settings-agents-page-DYDxyKu-.js → settings-agents-page-BIRP02mt.js} +1 -1
  91. package/dist/assets/{settings-bin-page-BWLfKVW1.js → settings-bin-page-DxJKeM28.js} +1 -1
  92. package/dist/assets/{settings-calendar-page-Ci-wXhCv.js → settings-calendar-page-aMBtwvO_.js} +1 -1
  93. package/dist/assets/{settings-data-page-DxIw-fWn.js → settings-data-page-nHNergsh.js} +1 -1
  94. package/dist/assets/{settings-logs-page-inpyIdu6.js → settings-logs-page-CFotPoFy.js} +1 -1
  95. package/dist/assets/{settings-mobile-page-BcJGLax8.js → settings-mobile-page-Bf_1D-bN.js} +1 -1
  96. package/dist/assets/{settings-models-page-OWTRhfkj.js → settings-models-page-BbFNmnik.js} +1 -1
  97. package/dist/assets/{settings-page-DXxF3qK2.js → settings-page-CalbNbcg.js} +1 -1
  98. package/dist/assets/{settings-rewards-page-CCXin1n_.js → settings-rewards-page-ByDfCzP5.js} +1 -1
  99. package/dist/assets/{settings-section-nav-CwSDNC3W.js → settings-section-nav-x_JXfzL9.js} +1 -1
  100. package/dist/assets/{settings-users-page-B4NEACwR.js → settings-users-page-N1jl9hWV.js} +1 -1
  101. package/dist/assets/{settings-wiki-page-BNRK1SYc.js → settings-wiki-page-Bn2Qr94L.js} +1 -1
  102. package/dist/assets/{sleep-page-Bara54nB.js → sleep-page-DyU635jb.js} +1 -1
  103. package/dist/assets/{sports-page-BawekFKD.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-BEl3NGAU.js → strategies-page-AUBlFCZ9.js} +1 -1
  106. package/dist/assets/{strategy-detail-page-DPPI_8Ub.js → strategy-detail-page-DCF8mVaL.js} +1 -1
  107. package/dist/assets/{strategy-dialog-DU6wbBkQ.js → strategy-dialog-50xKlqcz.js} +1 -1
  108. package/dist/assets/{surface-BVYp-Wq9.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-BsMVAsbb.js → task-detail-page-Dsll2LCX.js} +1 -1
  111. package/dist/assets/{task-dialog-BGzPc6TW.js → task-dialog-BfPkbIPE.js} +1 -1
  112. package/dist/assets/{timebox-planning-dialog-4_XWuqkw.js → timebox-planning-dialog-BTKUS6Jj.js} +1 -1
  113. package/dist/assets/{today-boxes-5pCvh5zS.js → today-boxes-BPi9bDHM.js} +1 -1
  114. package/dist/assets/{today-page-BGurICpl.js → today-page-BSXSA-Ts.js} +1 -1
  115. package/dist/assets/{training-load-page-BkoYKZ9_.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-DfDv87j7.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-CbYj32he.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-DlxMk-L7.js → vitals-page-BnXk8OzN.js} +1 -1
  124. package/dist/assets/{weekly-review-page-VrEvAl1T.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-DxTkiQRy.js → wiki-article-markdown-BDnkdNDC.js} +1 -1
  127. package/dist/assets/{wiki-editor-page-Beu92YnZ.js → wiki-editor-page-CHEETB0G.js} +1 -1
  128. package/dist/assets/{wiki-ingest-history-page-Cq_soygm.js → wiki-ingest-history-page-CisvtAzm.js} +1 -1
  129. package/dist/assets/{wiki-ingest-modal-D6xs2sEn.js → wiki-ingest-modal-C2faIjzj.js} +1 -1
  130. package/dist/assets/{wiki-page-yF3Flgtg.js → wiki-page-CELe_6qL.js} +1 -1
  131. package/dist/assets/{workbench-flow-page-CZZGg3a8.js → workbench-flow-page-JBVim3L0.js} +1 -1
  132. package/dist/assets/{workbench-page-DKENZ5Nz.js → workbench-page-q7Z3sQtz.js} +1 -1
  133. package/dist/assets/{workout-detail-page-CeN4QiYQ.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 +135 -13
  137. package/dist/server/server/src/health-weight-loss.js +48 -6
  138. package/dist/server/server/src/health.js +301 -93
  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 +4 -0
  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-BaiwtAgo.js +0 -2
  154. package/dist/assets/pill-cluster-DGwKZQKF.js +0 -1
  155. package/dist/assets/weight-loss-page-DJDnjxKF.js +0 -5
@@ -2045,20 +2045,54 @@ function upsertVitalDaySummary(userId, input) {
2045
2045
  metricCount: input.metrics.length
2046
2046
  });
2047
2047
  }
2048
- function 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
@@ -2747,24 +2781,62 @@ function findResumableMobileSyncSessionForPairing(pairingId, requestedFamilies)
2747
2781
  return (rows.find((session) => mobileSyncSessionCanResume(session, requestedFamilies)) ?? null);
2748
2782
  }
2749
2783
  function mobileSyncSessionProgress(syncSessionId) {
2784
+ const session = readMobileSyncSession(syncSessionId);
2785
+ if (session) {
2786
+ return mobileSyncSessionProgressFromStoredSession(session);
2787
+ }
2788
+ return mobileSyncSessionProgressFromChunkRows(syncSessionId);
2789
+ }
2790
+ function mobileSyncSessionProgressFromChunkRows(syncSessionId) {
2750
2791
  const chunks = getDatabase()
2751
2792
  .prepare(`SELECT family, record_count, byte_count
2752
2793
  FROM health_mobile_sync_chunks
2753
2794
  WHERE sync_session_id = ?`)
2754
2795
  .all(syncSessionId);
2755
- const receivedCounts = {};
2756
- const byteTotals = {};
2757
- for (const chunk of chunks) {
2758
- receivedCounts[chunk.family] =
2759
- (receivedCounts[chunk.family] ?? 0) + chunk.record_count;
2760
- byteTotals[chunk.family] =
2761
- (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);
2762
2813
  }
2763
2814
  return {
2764
2815
  receivedCounts,
2765
2816
  byteTotals,
2766
- chunkCount: chunks.length,
2767
- 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)
2768
2840
  };
2769
2841
  }
2770
2842
  function finiteNumberFromUnknown(value) {
@@ -2817,40 +2889,57 @@ function expectedWorkoutEvidenceCounts(derived) {
2817
2889
  captureRoutePointCount !== null
2818
2890
  };
2819
2891
  }
2820
- function 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];
2821
2916
  const rows = getDatabase()
2822
- .prepare(`WITH time_series_counts AS (
2823
- SELECT workout_id, COUNT(*) AS time_series_count
2824
- FROM health_workout_time_series
2825
- GROUP BY workout_id
2826
- ),
2827
- heart_rate_counts AS (
2828
- SELECT workout_id, COUNT(*) AS heart_rate_count
2829
- FROM health_workout_time_series
2830
- WHERE metric_key = 'heart_rate'
2831
- GROUP BY workout_id
2832
- ),
2833
- route_counts AS (
2834
- SELECT workout_id, COUNT(*) AS route_point_count
2835
- FROM health_workout_routes
2836
- GROUP BY workout_id
2837
- )
2838
- SELECT
2917
+ .prepare(`SELECT
2839
2918
  w.external_uid,
2840
2919
  w.derived_json,
2841
- COALESCE(time_series_counts.time_series_count, 0) AS time_series_count,
2842
- COALESCE(heart_rate_counts.heart_rate_count, 0) AS heart_rate_count,
2843
- 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
2844
2935
  FROM health_workout_sessions w
2845
- LEFT JOIN time_series_counts ON time_series_counts.workout_id = w.id
2846
- LEFT JOIN heart_rate_counts ON heart_rate_counts.workout_id = w.id
2847
- LEFT JOIN route_counts ON route_counts.workout_id = w.id
2848
2936
  WHERE w.user_id = ?
2849
2937
  AND w.source = 'apple_health'
2850
2938
  AND w.external_uid IS NOT NULL
2851
2939
  AND w.external_uid <> ''
2940
+ ${startedAtFilter}
2852
2941
  ORDER BY w.started_at DESC`)
2853
- .all(userId);
2942
+ .all(...params);
2854
2943
  const alreadyUploadedWorkoutExternalUids = [];
2855
2944
  const incompleteWorkoutExternalUids = [];
2856
2945
  let incompleteWorkoutCount = 0;
@@ -2870,7 +2959,9 @@ function mobileHealthWorkoutImportState(userId) {
2870
2959
  actualRoutePointCount >= evidenceCounts.expectedRoutePoints
2871
2960
  : false;
2872
2961
  if (evidenceCountsComplete) {
2873
- alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
2962
+ if (includeExternalUids) {
2963
+ alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
2964
+ }
2874
2965
  timeSeriesSampleCount += actualTimeSeriesCount;
2875
2966
  heartRateSampleCount += actualHeartRateCount;
2876
2967
  routePointCount += actualRoutePointCount;
@@ -2880,13 +2971,15 @@ function mobileHealthWorkoutImportState(userId) {
2880
2971
  }
2881
2972
  else {
2882
2973
  incompleteWorkoutCount += 1;
2883
- incompleteWorkoutExternalUids.push(row.external_uid.toLowerCase());
2974
+ if (includeExternalUids) {
2975
+ incompleteWorkoutExternalUids.push(row.external_uid.toLowerCase());
2976
+ }
2884
2977
  }
2885
2978
  }
2886
2979
  return {
2887
2980
  alreadyUploadedWorkoutExternalUids,
2888
2981
  incompleteWorkoutExternalUids,
2889
- alreadyUploadedWorkoutCount: alreadyUploadedWorkoutExternalUids.length,
2982
+ alreadyUploadedWorkoutCount: rows.length - incompleteWorkoutCount,
2890
2983
  existingWorkoutCount: rows.length,
2891
2984
  incompleteWorkoutCount,
2892
2985
  staleEvidenceVersionWorkoutCount,
@@ -2896,7 +2989,7 @@ function mobileHealthWorkoutImportState(userId) {
2896
2989
  capturedAt: nowIso()
2897
2990
  };
2898
2991
  }
2899
- function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
2992
+ function mobileSyncSessionUploadPayload(session, receivedChunkIds, options = {}) {
2900
2993
  return {
2901
2994
  syncSessionId: session.id,
2902
2995
  schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
@@ -2908,8 +3001,15 @@ function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
2908
3001
  supportsCompression: true,
2909
3002
  acceptedFamilies: safeJsonParse(session.requested_families_json, []),
2910
3003
  receivedChunkIds,
2911
- workoutImportState: mobileHealthWorkoutImportState(session.user_id),
2912
- 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)
2913
3013
  };
2914
3014
  }
2915
3015
  export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
@@ -2918,14 +3018,19 @@ export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
2918
3018
  if (!session || session.pairing_session_id !== pairing.id) {
2919
3019
  throw new HttpError(404, "sync_session_not_found", "The HealthKit sync session does not exist.");
2920
3020
  }
2921
- const receivedChunkIds = getDatabase()
2922
- .prepare(`SELECT chunk_id
2923
- FROM health_mobile_sync_chunks
2924
- WHERE sync_session_id = ?
2925
- ORDER BY sequence ASC`)
2926
- .all(syncSessionId)
2927
- .map((row) => row.chunk_id);
2928
- 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
+ });
2929
3034
  }
2930
3035
  function ensureRunningMobileSyncSession(syncSessionId) {
2931
3036
  expireStaleMobileSyncSessions();
@@ -3163,6 +3268,7 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
3163
3268
  let createdCount = 0;
3164
3269
  let updatedCount = 0;
3165
3270
  let mergedCount = 0;
3271
+ const dateKeysToSummarize = new Set();
3166
3272
  getDatabase()
3167
3273
  .prepare(`INSERT INTO health_import_runs (
3168
3274
  id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
@@ -3181,7 +3287,10 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
3181
3287
  else {
3182
3288
  updatedCount += 1;
3183
3289
  }
3184
- 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);
3185
3294
  }
3186
3295
  getDatabase()
3187
3296
  .prepare(`UPDATE companion_pairing_sessions
@@ -3204,6 +3313,29 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
3204
3313
  }), workouts.length, createdCount, updatedCount, mergedCount, now, now, runId);
3205
3314
  });
3206
3315
  }
3316
+ function mobileSyncWorkoutRowsByExternalUid(userId, externalUids) {
3317
+ const uniqueExternalUids = [...new Set(externalUids.filter(Boolean))];
3318
+ const rowsByExternalUid = new Map();
3319
+ const chunkSize = 500;
3320
+ for (let lowerBound = 0; lowerBound < uniqueExternalUids.length; lowerBound += chunkSize) {
3321
+ const chunk = uniqueExternalUids.slice(lowerBound, lowerBound + chunkSize);
3322
+ if (chunk.length === 0) {
3323
+ continue;
3324
+ }
3325
+ const placeholders = chunk.map(() => "?").join(", ");
3326
+ const rows = getDatabase()
3327
+ .prepare(`SELECT *
3328
+ FROM health_workout_sessions
3329
+ WHERE user_id = ?
3330
+ AND source = 'apple_health'
3331
+ AND external_uid IN (${placeholders})`)
3332
+ .all(userId, ...chunk);
3333
+ for (const row of rows) {
3334
+ rowsByExternalUid.set(row.external_uid, row);
3335
+ }
3336
+ }
3337
+ return rowsByExternalUid;
3338
+ }
3207
3339
  function applyWorkoutEvidenceChunkImmediately(session, payload) {
3208
3340
  const timeSeries = payload.workoutTimeSeries ?? [];
3209
3341
  const routes = payload.workoutRoutes ?? [];
@@ -3213,12 +3345,16 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
3213
3345
  const pairing = mobileSyncSessionPairing(session);
3214
3346
  runInTransaction(() => {
3215
3347
  const rowsToRecompute = new Map();
3348
+ const rowsByExternalUid = mobileSyncWorkoutRowsByExternalUid(pairing.user_id, [
3349
+ ...timeSeries
3350
+ .filter((entry) => entry.samples.length > 0)
3351
+ .map((entry) => entry.externalUid),
3352
+ ...routes
3353
+ .filter((entry) => entry.routePoints.length > 0)
3354
+ .map((entry) => entry.externalUid)
3355
+ ]);
3216
3356
  for (const entry of timeSeries) {
3217
- const row = getDatabase()
3218
- .prepare(`SELECT *
3219
- FROM health_workout_sessions
3220
- WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
3221
- .get(pairing.user_id, entry.externalUid);
3357
+ const row = rowsByExternalUid.get(entry.externalUid);
3222
3358
  if (!row || entry.samples.length === 0) {
3223
3359
  continue;
3224
3360
  }
@@ -3230,11 +3366,7 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
3230
3366
  rowsToRecompute.set(row.id, row);
3231
3367
  }
3232
3368
  for (const entry of routes) {
3233
- const row = getDatabase()
3234
- .prepare(`SELECT *
3235
- FROM health_workout_sessions
3236
- WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
3237
- .get(pairing.user_id, entry.externalUid);
3369
+ const row = rowsByExternalUid.get(entry.externalUid);
3238
3370
  if (!row || entry.routePoints.length === 0) {
3239
3371
  continue;
3240
3372
  }
@@ -3247,7 +3379,9 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
3247
3379
  }
3248
3380
  for (const row of rowsToRecompute.values()) {
3249
3381
  recomputeAndStoreWorkoutAnalytics(row);
3250
- 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);
3251
3385
  }
3252
3386
  });
3253
3387
  }
@@ -3265,6 +3399,9 @@ function markMobileHealthSyncChunkApplied(input) {
3265
3399
  }), now, input.syncSessionId, input.chunkId);
3266
3400
  }
3267
3401
  function mobileHealthSyncChunkWasImmediatelyApplied(chunk) {
3402
+ if (chunk.applied_at) {
3403
+ return true;
3404
+ }
3268
3405
  const summary = safeJsonParse(chunk.payload_summary_json, {});
3269
3406
  return summary.immediateApplied === true;
3270
3407
  }
@@ -3321,8 +3458,7 @@ function applyMobileHealthSyncChunkImmediately(session, family, payload) {
3321
3458
  return null;
3322
3459
  }
3323
3460
  }
3324
- function updateMobileSyncSessionProgress(syncSessionId) {
3325
- const progress = mobileSyncSessionProgress(syncSessionId);
3461
+ function updateMobileSyncSessionProgress(syncSessionId, progress = mobileSyncSessionProgress(syncSessionId)) {
3326
3462
  getDatabase()
3327
3463
  .prepare(`UPDATE health_mobile_sync_sessions
3328
3464
  SET received_counts_json = ?, byte_totals_json = ?, updated_at = ?
@@ -3401,7 +3537,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
3401
3537
  if (existing.checksum_sha256 !== clientChecksum) {
3402
3538
  throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
3403
3539
  }
3404
- const progress = updateMobileSyncSessionProgress(syncSessionId);
3540
+ const progress = mobileSyncSessionProgressFromStoredSession(session);
3405
3541
  return {
3406
3542
  accepted: true,
3407
3543
  duplicate: true,
@@ -3483,6 +3619,12 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
3483
3619
  }
3484
3620
  return runInTransaction(() => {
3485
3621
  const now = nowIso();
3622
+ const progress = mobileSyncSessionProgressAfterAcceptedChunk({
3623
+ session,
3624
+ family: parsed.family,
3625
+ recordCount: parsed.recordCount,
3626
+ byteCount: actualByteCount
3627
+ });
3486
3628
  const payloadSummary = {
3487
3629
  ...summarizeChunkPayload(parsed.family, wirePayload.payload),
3488
3630
  clientByteCount: parsed.byteCount,
@@ -3508,7 +3650,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
3508
3650
  mode: appliedMode
3509
3651
  });
3510
3652
  }
3511
- const progress = updateMobileSyncSessionProgress(syncSessionId);
3653
+ updateMobileSyncSessionProgress(syncSessionId, progress);
3512
3654
  return {
3513
3655
  accepted: true,
3514
3656
  duplicate: false,
@@ -3557,18 +3699,12 @@ function mergeMobileHealthSyncChunks(session, chunks, options = {}) {
3557
3699
  const workoutsByExternalUid = new Map();
3558
3700
  const tombstones = [];
3559
3701
  for (const chunk of chunks.sort((left, right) => left.sequence - right.sequence)) {
3560
- const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
3561
3702
  const skipRecords = options.skipImmediatelyApplied === true &&
3562
3703
  mobileHealthSyncChunkWasImmediatelyApplied(chunk);
3563
3704
  if (skipRecords) {
3564
- if (payload.movement?.settings) {
3565
- assembled.movement.settings = payload.movement.settings;
3566
- }
3567
- if (payload.screenTime?.settings) {
3568
- assembled.screenTime.settings = payload.screenTime.settings;
3569
- }
3570
3705
  continue;
3571
3706
  }
3707
+ const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
3572
3708
  if (payload.sleepNights) {
3573
3709
  assembled.sleepNights.push(...payload.sleepNights);
3574
3710
  }
@@ -3659,12 +3795,14 @@ function upsertMobileSyncFamilyCursors(pairing, finalCursor) {
3659
3795
  stmt.run(`hmscur_${randomUUID().replaceAll("-", "").slice(0, 10)}`, pairing.id, pairing.user_id, family, JSON.stringify(cursor), now);
3660
3796
  }
3661
3797
  }
3662
- function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
3798
+ function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts, chunks) {
3663
3799
  const expectedEntries = Object.entries(expectedCounts).filter(([, expected]) => Number.isFinite(expected) && expected > 0);
3664
3800
  if (expectedEntries.length === 0) {
3665
3801
  return;
3666
3802
  }
3667
- const progress = updateMobileSyncSessionProgress(syncSessionId);
3803
+ const progress = chunks
3804
+ ? mobileSyncChunkProgressFromRows(chunks)
3805
+ : updateMobileSyncSessionProgress(syncSessionId);
3668
3806
  const missingFamilies = expectedEntries
3669
3807
  .map(([family, expected]) => ({
3670
3808
  family,
@@ -3676,6 +3814,45 @@ function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
3676
3814
  throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session is missing required chunks.", { families: missingFamilies });
3677
3815
  }
3678
3816
  }
3817
+ function mobileSyncChunkProgressFromRows(chunks) {
3818
+ const receivedCounts = {};
3819
+ const byteTotals = {};
3820
+ for (const chunk of chunks) {
3821
+ receivedCounts[chunk.family] =
3822
+ (receivedCounts[chunk.family] ?? 0) + chunk.record_count;
3823
+ byteTotals[chunk.family] =
3824
+ (byteTotals[chunk.family] ?? 0) + chunk.byte_count;
3825
+ }
3826
+ return {
3827
+ receivedCounts,
3828
+ byteTotals,
3829
+ chunkCount: chunks.length,
3830
+ receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
3831
+ };
3832
+ }
3833
+ function dedupeMobileSyncChunksForCompletion(chunks) {
3834
+ const latestBySlot = new Map();
3835
+ for (const chunk of chunks) {
3836
+ const slotKey = `${chunk.family}:${chunk.sequence}`;
3837
+ const previous = latestBySlot.get(slotKey);
3838
+ if (!previous) {
3839
+ latestBySlot.set(slotKey, chunk);
3840
+ continue;
3841
+ }
3842
+ const chunkReceivedAt = Date.parse(chunk.received_at);
3843
+ const previousReceivedAt = Date.parse(previous.received_at);
3844
+ if (chunkReceivedAt > previousReceivedAt ||
3845
+ (chunkReceivedAt === previousReceivedAt && chunk.id > previous.id)) {
3846
+ latestBySlot.set(slotKey, chunk);
3847
+ }
3848
+ }
3849
+ return Array.from(latestBySlot.values()).sort((left, right) => {
3850
+ if (left.sequence !== right.sequence) {
3851
+ return left.sequence - right.sequence;
3852
+ }
3853
+ return left.family.localeCompare(right.family);
3854
+ });
3855
+ }
3679
3856
  function aggregateMobileSyncChunkCounts(chunks) {
3680
3857
  const counts = {
3681
3858
  sleepNights: 0,
@@ -3743,6 +3920,30 @@ function syncReceiptWithChunkCounts(sync, chunks) {
3743
3920
  }
3744
3921
  };
3745
3922
  }
3923
+ function listMobileSyncCompletionChunks(syncSessionId) {
3924
+ const chunks = getDatabase()
3925
+ .prepare(`SELECT id, sync_session_id, chunk_id, sequence, family, checksum_sha256,
3926
+ record_count, byte_count, '{}' AS payload_json,
3927
+ payload_summary_json, received_at, applied_at, created_at, updated_at
3928
+ FROM health_mobile_sync_chunks
3929
+ WHERE sync_session_id = ?
3930
+ ORDER BY sequence ASC`)
3931
+ .all(syncSessionId);
3932
+ const payloadRows = getDatabase()
3933
+ .prepare(`SELECT id, payload_json
3934
+ FROM health_mobile_sync_chunks
3935
+ WHERE sync_session_id = ?
3936
+ AND applied_at IS NULL`)
3937
+ .all(syncSessionId);
3938
+ if (payloadRows.length === 0) {
3939
+ return chunks;
3940
+ }
3941
+ const payloadById = new Map(payloadRows.map((row) => [row.id, row.payload_json]));
3942
+ for (const chunk of chunks) {
3943
+ chunk.payload_json = payloadById.get(chunk.id) ?? "{}";
3944
+ }
3945
+ return chunks;
3946
+ }
3746
3947
  function markMobileSyncSessionFailed(session, error) {
3747
3948
  const now = nowIso();
3748
3949
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -3762,21 +3963,18 @@ function markMobileSyncSessionFailed(session, error) {
3762
3963
  export function completeMobileHealthSyncSession(syncSessionId, payload) {
3763
3964
  const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
3764
3965
  const session = ensureRunningMobileSyncSession(syncSessionId);
3765
- const chunks = getDatabase()
3766
- .prepare(`SELECT * FROM health_mobile_sync_chunks
3767
- WHERE sync_session_id = ?
3768
- ORDER BY sequence ASC`)
3769
- .all(syncSessionId);
3966
+ const chunks = listMobileSyncCompletionChunks(syncSessionId);
3770
3967
  if (chunks.length === 0) {
3771
3968
  throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
3772
3969
  }
3970
+ const completionChunks = dedupeMobileSyncChunksForCompletion(chunks);
3773
3971
  const pairing = getDatabase()
3774
3972
  .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
3775
3973
  .get(session.pairing_session_id);
3776
3974
  try {
3777
3975
  return runInTransaction(() => {
3778
- validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts);
3779
- const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, chunks, { skipImmediatelyApplied: true });
3976
+ validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts, completionChunks);
3977
+ const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, completionChunks, { skipImmediatelyApplied: true });
3780
3978
  const sync = ingestMobileHealthSync(assembled);
3781
3979
  const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
3782
3980
  upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
@@ -3788,10 +3986,12 @@ export function completeMobileHealthSyncSession(syncSessionId, payload) {
3788
3986
  WHERE id = ?`)
3789
3987
  .run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
3790
3988
  return {
3791
- ...syncReceiptWithChunkCounts(sync, chunks),
3989
+ ...syncReceiptWithChunkCounts(sync, completionChunks),
3792
3990
  upload: {
3793
3991
  syncSessionId,
3794
3992
  chunks: chunks.length,
3993
+ effectiveChunks: completionChunks.length,
3994
+ supersededChunks: chunks.length - completionChunks.length,
3795
3995
  deletedWorkoutCount
3796
3996
  }
3797
3997
  };
@@ -3838,6 +4038,8 @@ export function ingestMobileHealthSync(payload) {
3838
4038
  const screenTimeSync = ingestScreenTimeSync(pairing, parsed.screenTime, parsed.device.sourceDevice);
3839
4039
  const sleepSessionsByLocalDate = new Map();
3840
4040
  const sourceRecordIdsByExternalUid = new Map();
4041
+ const sleepDateKeysToSummarize = new Set();
4042
+ const workoutDateKeysToSummarize = new Set();
3841
4043
  for (const sleep of normalizedSleepNights) {
3842
4044
  const result = insertOrUpdateSleepSession(pairing, sleep);
3843
4045
  if (result.mode === "created") {
@@ -3853,7 +4055,7 @@ export function ingestMobileHealthSync(payload) {
3853
4055
  endedAt: sleep.endedAt
3854
4056
  });
3855
4057
  sleepSessionsByLocalDate.set(sleep.localDateKey, group);
3856
- summarizeUserHealthDay(pairing.user_id, sleep.localDateKey);
4058
+ sleepDateKeysToSummarize.add(sleep.localDateKey);
3857
4059
  }
3858
4060
  for (const rawRecord of normalizedSleepRawRecords) {
3859
4061
  const candidateSessions = sleepSessionsByLocalDate.get(rawRecord.localDateKey) ?? [];
@@ -3909,6 +4111,9 @@ export function ingestMobileHealthSync(payload) {
3909
4111
  replaceHistoricalSleepSessionsForDate(pairing.user_id, sleep.localDateKey, targetSessionId);
3910
4112
  }
3911
4113
  }
4114
+ for (const dateKeyValue of sleepDateKeysToSummarize) {
4115
+ summarizeUserHealthDay(pairing.user_id, dateKeyValue);
4116
+ }
3912
4117
  for (const workout of parsed.workouts) {
3913
4118
  const result = insertOrUpdateWorkoutSession(pairing, workout);
3914
4119
  if (result.mode === "created") {
@@ -3920,7 +4125,10 @@ export function ingestMobileHealthSync(payload) {
3920
4125
  else {
3921
4126
  updatedCount += 1;
3922
4127
  }
3923
- 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);
3924
4132
  }
3925
4133
  for (const daySummary of parsed.vitals.daySummaries) {
3926
4134
  upsertVitalDaySummary(pairing.user_id, daySummary);