forge-openclaw-plugin 0.3.5 → 0.3.8

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 (157) hide show
  1. package/dist/assets/{action-bar-DBZ38L_6.js → action-bar-BFjWjRIM.js} +1 -1
  2. package/dist/assets/{activity-page-BJxUR9bD.js → activity-page-D-6yBWuZ.js} +1 -1
  3. package/dist/assets/{ai-surface-workspace-ehzBjTsW.js → ai-surface-workspace-BEfo9bRO.js} +1 -1
  4. package/dist/assets/{atlas-panel-BFUeVT69.js → atlas-panel-BfMyJXxQ.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-D4tQNJ2V.js} +1 -1
  7. package/dist/assets/{calendar-rules-9XQLoI96.js → calendar-rules-C-6O_uGU.js} +1 -1
  8. package/dist/assets/{calendar-week-toolbar-BeYnx1pE.js → calendar-week-toolbar-_NzeKsYx.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-D1Oqsf6M.js} +1 -1
  11. package/dist/assets/{daily-metrics-dashboard-Jt0nAHU5.js → daily-metrics-dashboard-DtE3pVOl.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-D-32C8nM.js} +1 -1
  14. package/dist/assets/{entity-link-multiselect-Bb4De_e3.js → entity-link-multiselect-DcCvkesQ.js} +1 -1
  15. package/dist/assets/{entity-note-count-link-BzWnsOmI.js → entity-note-count-link-Cjsk5oT2.js} +1 -1
  16. package/dist/assets/{entity-notes-surface-CagOwdng.js → entity-notes-surface-DQQPLjxd.js} +1 -1
  17. package/dist/assets/{execution-board-DTmAtmqZ.js → execution-board-agWQbN-y.js} +1 -1
  18. package/dist/assets/{faceted-token-search-DJPRm9AY.js → faceted-token-search-DldM3-ru.js} +1 -1
  19. package/dist/assets/{flagship-signal-deck-CJbGZF7I.js → flagship-signal-deck-BlLYW9Kz.js} +1 -1
  20. package/dist/assets/{floating-action-menu-K77jh8XZ.js → floating-action-menu-D9-psbha.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-CcepUVhP.js} +1 -1
  23. package/dist/assets/{goal-detail-page-DGu_khEK.js → goal-detail-page-DP1n5-Hk.js} +1 -1
  24. package/dist/assets/{goal-dialog-BWD7cOv9.js → goal-dialog-Oxx8WqbZ.js} +1 -1
  25. package/dist/assets/{goals-page-Bgj3sj0f.js → goals-page-CUt1a4Y2.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-5REbWAlo.js} +1 -1
  28. package/dist/assets/{health-boxes-Ie3horXx.js → health-boxes-sHNML3tm.js} +1 -1
  29. package/dist/assets/index-CQ5r7ZUz.js +2 -0
  30. package/dist/assets/index-FxgNSuZX.css +1 -0
  31. package/dist/assets/{inline-note-fields-DE6WM9uM.js → inline-note-fields-Bql_KfR9.js} +1 -1
  32. package/dist/assets/{insight-flow-dialog-XL8I74eJ.js → insight-flow-dialog-CN-CMSB7.js} +1 -1
  33. package/dist/assets/{insights-page-Bz1FLbp5.js → insights-page-B1u6ONtQ.js} +1 -1
  34. package/dist/assets/{kanban-boxes-BBziel7P.js → kanban-boxes-DB1kuUlY.js} +1 -1
  35. package/dist/assets/{kanban-page-DdQP73I3.js → kanban-page-BBW9_vMu.js} +1 -1
  36. package/dist/assets/{knowledge-graph-page-DdHWj4Dj.js → knowledge-graph-page-CoJaydZb.js} +1 -1
  37. package/dist/assets/{life-force-page-BRN-93cI.js → life-force-page-DBYbA1GF.js} +1 -1
  38. package/dist/assets/{life-force-workspace-BowvP5R4.js → life-force-workspace-BiD9xOEt.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-CuP9DOYm.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-DZg_qPPg.js} +1 -1
  43. package/dist/assets/{movement-page-Dy53RWtB.js → movement-page-CmwsQGR_.js} +1 -1
  44. package/dist/assets/{note-markdown-CvEQCfoi.js → note-markdown-B82ncnFt.js} +1 -1
  45. package/dist/assets/{note-tags-input-Cg5zBXos.js → note-tags-input-C_x5WdK5.js} +1 -1
  46. package/dist/assets/{notes-boxes-CgZyf7mV.js → notes-boxes-BEFlp9yd.js} +1 -1
  47. package/dist/assets/{notes-page-Cbdcv0Ej.js → notes-page-B6Vl-GPf.js} +1 -1
  48. package/dist/assets/{open-in-graph-button-D31ZOEhA.js → open-in-graph-button-C-bJekoH.js} +1 -1
  49. package/dist/assets/{orbit-map-8eNDWek8.js → orbit-map-DHeTM15g.js} +1 -1
  50. package/dist/assets/{overview-page-CZ6q52Qj.js → overview-page-BRWje1F9.js} +1 -1
  51. package/dist/assets/{page-hero-C0MpI3MM.js → page-hero-8bITsx_x.js} +1 -1
  52. package/dist/assets/pill-cluster-XQjm-wPc.js +1 -0
  53. package/dist/assets/{preference-entity-handoff-button-CJhr5AXl.js → preference-entity-handoff-button-DwYF_5i3.js} +1 -1
  54. package/dist/assets/{preferences-page-8vszDNFX.js → preferences-page-C7DBPpNb.js} +1 -1
  55. package/dist/assets/{project-collections-sC7eAAhS.js → project-collections-mtxanSMf.js} +1 -1
  56. package/dist/assets/{project-detail-page-BKdnMjJy.js → project-detail-page-BT87Goqc.js} +1 -1
  57. package/dist/assets/{project-dialog-BiHZpOo1.js → project-dialog-DnZe757y.js} +1 -1
  58. package/dist/assets/{project-management-hierarchy-page-VW-hykAI.js → project-management-hierarchy-page-B3R2lNFI.js} +1 -1
  59. package/dist/assets/{project-management-section-nav-BEZ5zihs.js → project-management-section-nav-DyBWxHbe.js} +1 -1
  60. package/dist/assets/{projects-boxes-Ca6rpYeE.js → projects-boxes-CxZj3P29.js} +1 -1
  61. package/dist/assets/{projects-page-D86pjpTf.js → projects-page-Bec11c0x.js} +1 -1
  62. package/dist/assets/{psyche-behaviors-page-YLJB6CRU.js → psyche-behaviors-page-DWRpYvl1.js} +1 -1
  63. package/dist/assets/{psyche-flashcards-page-D3gdHLUw.js → psyche-flashcards-page-CX4rcsXZ.js} +1 -1
  64. package/dist/assets/{psyche-goal-map-page-DJiqSiCx.js → psyche-goal-map-page-Y6b3lCvV.js} +1 -1
  65. package/dist/assets/{psyche-graph-Tke0qFdt.js → psyche-graph-CQuCWKIp.js} +1 -1
  66. package/dist/assets/{psyche-metrics-page-DoLnvmC2.js → psyche-metrics-page-DadDJOnm.js} +1 -1
  67. package/dist/assets/{psyche-mode-guide-page-_Zbvg_HL.js → psyche-mode-guide-page-B1nz0uCg.js} +1 -1
  68. package/dist/assets/{psyche-modes-page-eAkaAzJc.js → psyche-modes-page-i3uSuhKA.js} +1 -1
  69. package/dist/assets/{psyche-page-CwmuBVjA.js → psyche-page-Y_s-BE2m.js} +1 -1
  70. package/dist/assets/{psyche-patterns-page-D7B4Ykjq.js → psyche-patterns-page-DaaOLIlN.js} +1 -1
  71. package/dist/assets/{psyche-questionnaire-builder-page-CHJlvCzX.js → psyche-questionnaire-builder-page-CuF7rXOv.js} +1 -1
  72. package/dist/assets/{psyche-questionnaire-detail-page-Bg3ll-D4.js → psyche-questionnaire-detail-page-BfFEMkRY.js} +1 -1
  73. package/dist/assets/{psyche-questionnaire-run-detail-page-CIEerczF.js → psyche-questionnaire-run-detail-page-BoQTvd7Q.js} +1 -1
  74. package/dist/assets/{psyche-questionnaire-run-page-DyxIk6Qx.js → psyche-questionnaire-run-page-C0qKiNZN.js} +1 -1
  75. package/dist/assets/{psyche-questionnaires-page-ZlqUxuIl.js → psyche-questionnaires-page-B6hfD448.js} +1 -1
  76. package/dist/assets/{psyche-report-detail-page-B2jgYnQ5.js → psyche-report-detail-page-BlFL8moM.js} +1 -1
  77. package/dist/assets/{psyche-reports-page-DlXr52yr.js → psyche-reports-page-DAAcYENp.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-CsxKSrKM.js} +1 -1
  80. package/dist/assets/{psyche-screen-time-page-WNGcdusx.js → psyche-screen-time-page-n4b0e58x.js} +1 -1
  81. package/dist/assets/{psyche-self-observation-page-XsCOglo4.js → psyche-self-observation-page-BaxEQ2-3.js} +1 -1
  82. package/dist/assets/{psyche-values-page-NvHI6zWK.js → psyche-values-page-DTv5NMSU.js} +1 -1
  83. package/dist/assets/{question-flow-dialog-Bj4PxpjS.js → question-flow-dialog-CskCt5NZ.js} +1 -1
  84. package/dist/assets/{report-chain-fields-mWFikZzT.js → report-chain-fields-D132-EMh.js} +1 -1
  85. package/dist/assets/{rewards-page-DyjwXsQN.js → rewards-page-C533lVP-.js} +1 -1
  86. package/dist/assets/{scheduling-rules-editor-C6FEYOxd.js → scheduling-rules-editor-CDpontGp.js} +1 -1
  87. package/dist/assets/{schema-badge-CCe4zkSN.js → schema-badge-B3DiMnjB.js} +1 -1
  88. package/dist/assets/{schemas-Cjwn6ooR.js → schemas-CH1_ngUX.js} +1 -1
  89. package/dist/assets/{select-menu-DeJhCsd8.js → select-menu-BOF-k4Ln.js} +1 -1
  90. package/dist/assets/{settings-agents-page-DYDxyKu-.js → settings-agents-page-B5OQtlZX.js} +1 -1
  91. package/dist/assets/{settings-bin-page-BWLfKVW1.js → settings-bin-page-D_bk3Kcu.js} +1 -1
  92. package/dist/assets/{settings-calendar-page-Ci-wXhCv.js → settings-calendar-page-PuSj9_kM.js} +1 -1
  93. package/dist/assets/{settings-data-page-DxIw-fWn.js → settings-data-page-EwFMaeq6.js} +1 -1
  94. package/dist/assets/{settings-logs-page-inpyIdu6.js → settings-logs-page-BKkse0DX.js} +1 -1
  95. package/dist/assets/{settings-mobile-page-BcJGLax8.js → settings-mobile-page-BrIVmdeB.js} +1 -1
  96. package/dist/assets/{settings-models-page-OWTRhfkj.js → settings-models-page-Mg84D_0K.js} +1 -1
  97. package/dist/assets/{settings-page-DXxF3qK2.js → settings-page-D-kul92f.js} +1 -1
  98. package/dist/assets/{settings-rewards-page-CCXin1n_.js → settings-rewards-page-waNyCcX_.js} +1 -1
  99. package/dist/assets/{settings-section-nav-CwSDNC3W.js → settings-section-nav-BmJWnrYk.js} +1 -1
  100. package/dist/assets/{settings-users-page-B4NEACwR.js → settings-users-page-DBgC6y56.js} +1 -1
  101. package/dist/assets/{settings-wiki-page-BNRK1SYc.js → settings-wiki-page-CM0te9dI.js} +1 -1
  102. package/dist/assets/sleep-page-C_krRE59.js +1 -0
  103. package/dist/assets/{sports-page-BawekFKD.js → sports-page-eg5Rfc_E.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-D4AqvFNW.js} +1 -1
  106. package/dist/assets/{strategy-detail-page-DPPI_8Ub.js → strategy-detail-page-BlYVkXaW.js} +1 -1
  107. package/dist/assets/{strategy-dialog-DU6wbBkQ.js → strategy-dialog-FO9Oa0dB.js} +1 -1
  108. package/dist/assets/{surface-BVYp-Wq9.js → surface-MVeeZGKB.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-Dy-aRyY6.js} +1 -1
  111. package/dist/assets/{task-dialog-BGzPc6TW.js → task-dialog-DrA9pba7.js} +1 -1
  112. package/dist/assets/{timebox-planning-dialog-4_XWuqkw.js → timebox-planning-dialog-C_gnfxFx.js} +1 -1
  113. package/dist/assets/{today-boxes-5pCvh5zS.js → today-boxes-BFOws_iC.js} +1 -1
  114. package/dist/assets/{today-page-BGurICpl.js → today-page-BoPj6a6q.js} +1 -1
  115. package/dist/assets/{training-load-page-BkoYKZ9_.js → training-load-page-DGU40Zfl.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-CZWtYeMw.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-B3wWGxQc.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-BXRZEP_8.js} +1 -1
  124. package/dist/assets/{weekly-review-page-VrEvAl1T.js → weekly-review-page-D5cebA5x.js} +1 -1
  125. package/dist/assets/weight-loss-page-BuUdFh9z.js +5 -0
  126. package/dist/assets/{wiki-article-markdown-DxTkiQRy.js → wiki-article-markdown-CvaCjg_t.js} +1 -1
  127. package/dist/assets/{wiki-editor-page-Beu92YnZ.js → wiki-editor-page-Dco79SLY.js} +1 -1
  128. package/dist/assets/{wiki-ingest-history-page-Cq_soygm.js → wiki-ingest-history-page-uvKRkRDF.js} +1 -1
  129. package/dist/assets/{wiki-ingest-modal-D6xs2sEn.js → wiki-ingest-modal-BW6AJPea.js} +1 -1
  130. package/dist/assets/{wiki-page-yF3Flgtg.js → wiki-page-BIL5zMqv.js} +1 -1
  131. package/dist/assets/{workbench-flow-page-CZZGg3a8.js → workbench-flow-page-Dx_nq8R_.js} +1 -1
  132. package/dist/assets/{workbench-page-DKENZ5Nz.js → workbench-page-BJnt_Zzf.js} +1 -1
  133. package/dist/assets/{workout-detail-page-CeN4QiYQ.js → workout-detail-page-CKgqyB-y.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 -13
  137. package/dist/server/server/src/health-weight-loss.js +48 -6
  138. package/dist/server/server/src/health.js +345 -96
  139. package/dist/server/server/src/openapi.js +62 -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/index-CEgIwgk9.css +0 -1
  155. package/dist/assets/pill-cluster-DGwKZQKF.js +0 -1
  156. package/dist/assets/sleep-page-Bara54nB.js +0 -1
  157. package/dist/assets/weight-loss-page-DJDnjxKF.js +0 -5
@@ -637,6 +637,13 @@ function localDateKeyForTimezone(value, timeZone) {
637
637
  const parts = getTimeZoneParts(value, timeZone);
638
638
  return `${parts.year}-${parts.month}-${parts.day}`;
639
639
  }
640
+ function compareDateKeys(left, right) {
641
+ const normalizedLeft = left?.trim();
642
+ if (!normalizedLeft) {
643
+ return -1;
644
+ }
645
+ return normalizedLeft.localeCompare(right);
646
+ }
640
647
  function mergeStringLists(...groups) {
641
648
  return [
642
649
  ...new Set(groups
@@ -1311,7 +1318,34 @@ function pickDisplaySleepSessions(sessions) {
1311
1318
  byDateKey.set(key, session);
1312
1319
  }
1313
1320
  }
1314
- return [...byDateKey.values()].sort((left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt));
1321
+ return [...byDateKey.values()].sort((left, right) => {
1322
+ const dateComparison = compareDateKeys(right.localDateKey || dayKey(right.endedAt), left.localDateKey || dayKey(left.endedAt));
1323
+ if (dateComparison !== 0) {
1324
+ return dateComparison;
1325
+ }
1326
+ return Date.parse(right.endedAt) - Date.parse(left.endedAt);
1327
+ });
1328
+ }
1329
+ function buildLatestSleepNightFreshness(latestNight, now = new Date()) {
1330
+ const sourceTimezone = resolveTimeZone(latestNight?.sourceTimezone || defaultSleepTimeZone());
1331
+ const expectedDateKey = localDateKeyForTimezone(now.toISOString(), sourceTimezone);
1332
+ const actualDateKey = latestNight?.localDateKey || null;
1333
+ const comparison = compareDateKeys(actualDateKey, expectedDateKey);
1334
+ const status = actualDateKey === null
1335
+ ? "empty"
1336
+ : comparison === 0
1337
+ ? "current"
1338
+ : comparison > 0
1339
+ ? "future"
1340
+ : "stale";
1341
+ return {
1342
+ status,
1343
+ isCurrent: status === "current",
1344
+ expectedDateKey,
1345
+ actualDateKey,
1346
+ sourceTimezone,
1347
+ missingDateKeys: status === "stale" ? [expectedDateKey] : []
1348
+ };
1315
1349
  }
1316
1350
  function normalizeTimelineStage(stage, bucket) {
1317
1351
  const normalized = stage
@@ -2045,20 +2079,54 @@ function upsertVitalDaySummary(userId, input) {
2045
2079
  metricCount: input.metrics.length
2046
2080
  });
2047
2081
  }
2048
- function summarizeUserHealthDay(userId, dateKeyValue) {
2049
- const sleeps = listSleepRows([userId]).filter((row) => sleepSessionDateKey(row) === dateKeyValue ||
2082
+ function nextUtcDateKey(dateKeyValue) {
2083
+ return addUtcDaysToDateKey(dateKeyValue, 1);
2084
+ }
2085
+ function addUtcDaysToDateKey(dateKeyValue, days) {
2086
+ const date = new Date(`${dateKeyValue}T00:00:00.000Z`);
2087
+ date.setUTCDate(date.getUTCDate() + days);
2088
+ return date.toISOString().slice(0, 10);
2089
+ }
2090
+ function workoutDaySummaryMetrics(userId, dateKeyValue) {
2091
+ const nextDateKey = nextUtcDateKey(dateKeyValue);
2092
+ return getDatabase()
2093
+ .prepare(`SELECT COALESCE(SUM(duration_seconds), 0) AS totalWorkoutSeconds,
2094
+ COALESCE(SUM(COALESCE(exercise_minutes, duration_seconds / 60.0)), 0) AS totalExerciseMinutes,
2095
+ COALESCE(SUM(COALESCE(total_energy_kcal, active_energy_kcal, 0)), 0) AS totalEnergyKcal,
2096
+ COUNT(*) AS workoutCount
2097
+ FROM health_workout_sessions
2098
+ WHERE user_id = ?
2099
+ AND started_at >= ?
2100
+ AND started_at < ?`)
2101
+ .get(userId, `${dateKeyValue}T00:00:00`, `${nextDateKey}T00:00:00`);
2102
+ }
2103
+ function sleepRowsForHealthDay(userId, dateKeyValue) {
2104
+ const previousDateKey = addUtcDaysToDateKey(dateKeyValue, -1);
2105
+ const nextDateKey = addUtcDaysToDateKey(dateKeyValue, 1);
2106
+ const followingDateKey = addUtcDaysToDateKey(dateKeyValue, 2);
2107
+ return getDatabase()
2108
+ .prepare(`SELECT *
2109
+ FROM health_sleep_sessions
2110
+ WHERE user_id = ?
2111
+ AND (
2112
+ local_date_key = ?
2113
+ OR (local_date_key = '' AND ended_at >= ? AND ended_at < ?)
2114
+ OR (started_at >= ? AND started_at < ?)
2115
+ )
2116
+ ORDER BY ended_at DESC`)
2117
+ .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
2118
  localDateKeyForTimezone(row.started_at, resolveTimeZone(row.source_timezone)) === dateKeyValue);
2051
- const workouts = listWorkoutRows([userId]).filter((row) => dayKey(row.started_at) === dateKeyValue);
2119
+ }
2120
+ function summarizeUserHealthDay(userId, dateKeyValue) {
2121
+ const sleeps = sleepRowsForHealthDay(userId, dateKeyValue);
2122
+ const workoutSummary = workoutDaySummaryMetrics(userId, dateKeyValue);
2052
2123
  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
2124
  upsertDailySummary(userId, dateKeyValue, "health", {
2057
2125
  totalSleepSeconds,
2058
- totalWorkoutSeconds,
2059
- totalExerciseMinutes,
2060
- totalEnergyKcal,
2061
- workoutCount: workouts.length,
2126
+ totalWorkoutSeconds: workoutSummary.totalWorkoutSeconds,
2127
+ totalExerciseMinutes: workoutSummary.totalExerciseMinutes,
2128
+ totalEnergyKcal: workoutSummary.totalEnergyKcal,
2129
+ workoutCount: workoutSummary.workoutCount,
2062
2130
  sleepSessionCount: sleeps.length
2063
2131
  }, {
2064
2132
  recoveryState: totalSleepSeconds >= 7 * 3600
@@ -2747,24 +2815,62 @@ function findResumableMobileSyncSessionForPairing(pairingId, requestedFamilies)
2747
2815
  return (rows.find((session) => mobileSyncSessionCanResume(session, requestedFamilies)) ?? null);
2748
2816
  }
2749
2817
  function mobileSyncSessionProgress(syncSessionId) {
2818
+ const session = readMobileSyncSession(syncSessionId);
2819
+ if (session) {
2820
+ return mobileSyncSessionProgressFromStoredSession(session);
2821
+ }
2822
+ return mobileSyncSessionProgressFromChunkRows(syncSessionId);
2823
+ }
2824
+ function mobileSyncSessionProgressFromChunkRows(syncSessionId) {
2750
2825
  const chunks = getDatabase()
2751
2826
  .prepare(`SELECT family, record_count, byte_count
2752
2827
  FROM health_mobile_sync_chunks
2753
2828
  WHERE sync_session_id = ?`)
2754
2829
  .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;
2830
+ return mobileSyncChunkProgressFromRows(chunks);
2831
+ }
2832
+ function mobileSyncSessionChunkCount(syncSessionId) {
2833
+ const row = getDatabase()
2834
+ .prepare(`SELECT COUNT(*) AS chunk_count
2835
+ FROM health_mobile_sync_chunks
2836
+ WHERE sync_session_id = ?`)
2837
+ .get(syncSessionId);
2838
+ return row?.chunk_count ?? 0;
2839
+ }
2840
+ function mobileSyncSessionProgressFromStoredSession(session, chunkCount = mobileSyncSessionChunkCount(session.id)) {
2841
+ const receivedCounts = safeJsonParse(session.received_counts_json, {});
2842
+ const byteTotals = safeJsonParse(session.byte_totals_json, {});
2843
+ if (chunkCount > 0 &&
2844
+ Object.keys(receivedCounts).length === 0 &&
2845
+ Object.keys(byteTotals).length === 0) {
2846
+ return mobileSyncSessionProgressFromChunkRows(session.id);
2762
2847
  }
2763
2848
  return {
2764
2849
  receivedCounts,
2765
2850
  byteTotals,
2766
- chunkCount: chunks.length,
2767
- receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
2851
+ chunkCount,
2852
+ receivedBytes: Object.values(byteTotals).reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0)
2853
+ };
2854
+ }
2855
+ function mobileSyncSessionProgressAfterAcceptedChunk(input) {
2856
+ const currentChunkCount = mobileSyncSessionChunkCount(input.session.id);
2857
+ let receivedCounts = safeJsonParse(input.session.received_counts_json, {});
2858
+ let byteTotals = safeJsonParse(input.session.byte_totals_json, {});
2859
+ if (currentChunkCount > 0 &&
2860
+ Object.keys(receivedCounts).length === 0 &&
2861
+ Object.keys(byteTotals).length === 0) {
2862
+ const currentProgress = mobileSyncSessionProgressFromChunkRows(input.session.id);
2863
+ receivedCounts = currentProgress.receivedCounts;
2864
+ byteTotals = currentProgress.byteTotals;
2865
+ }
2866
+ receivedCounts[input.family] =
2867
+ (receivedCounts[input.family] ?? 0) + input.recordCount;
2868
+ byteTotals[input.family] = (byteTotals[input.family] ?? 0) + input.byteCount;
2869
+ return {
2870
+ receivedCounts,
2871
+ byteTotals,
2872
+ chunkCount: currentChunkCount + 1,
2873
+ receivedBytes: Object.values(byteTotals).reduce((sum, value) => sum + (Number.isFinite(value) ? value : 0), 0)
2768
2874
  };
2769
2875
  }
2770
2876
  function finiteNumberFromUnknown(value) {
@@ -2817,40 +2923,57 @@ function expectedWorkoutEvidenceCounts(derived) {
2817
2923
  captureRoutePointCount !== null
2818
2924
  };
2819
2925
  }
2820
- function mobileHealthWorkoutImportState(userId) {
2926
+ function mobileSyncSessionWorkoutImportOptions(session) {
2927
+ const metadata = mobileSyncSessionMetadata(session).metadata ?? {};
2928
+ const startedAfter = typeof metadata.workoutImportStartedAfter === "string"
2929
+ ? metadata.workoutImportStartedAfter.trim()
2930
+ : "";
2931
+ if (startedAfter.length === 0) {
2932
+ return {};
2933
+ }
2934
+ const parsedStartedAfter = new Date(startedAfter);
2935
+ if (Number.isNaN(parsedStartedAfter.getTime())) {
2936
+ return {};
2937
+ }
2938
+ return {
2939
+ startedAtOrAfter: parsedStartedAfter.toISOString()
2940
+ };
2941
+ }
2942
+ function mobileHealthWorkoutImportState(userId, options = {}) {
2943
+ const includeExternalUids = options.includeExternalUids !== false;
2944
+ const startedAtFilter = options.startedAtOrAfter
2945
+ ? "AND w.started_at >= ?"
2946
+ : "";
2947
+ const params = options.startedAtOrAfter
2948
+ ? [userId, options.startedAtOrAfter]
2949
+ : [userId];
2821
2950
  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
2951
+ .prepare(`SELECT
2839
2952
  w.external_uid,
2840
2953
  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
2954
+ (
2955
+ SELECT COUNT(*)
2956
+ FROM health_workout_time_series ts
2957
+ WHERE ts.workout_id = w.id
2958
+ ) AS time_series_count,
2959
+ (
2960
+ SELECT COUNT(*)
2961
+ FROM health_workout_time_series ts
2962
+ WHERE ts.workout_id = w.id AND ts.metric_key = 'heart_rate'
2963
+ ) AS heart_rate_count,
2964
+ (
2965
+ SELECT COUNT(*)
2966
+ FROM health_workout_routes r
2967
+ WHERE r.workout_id = w.id
2968
+ ) AS route_point_count
2844
2969
  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
2970
  WHERE w.user_id = ?
2849
2971
  AND w.source = 'apple_health'
2850
2972
  AND w.external_uid IS NOT NULL
2851
2973
  AND w.external_uid <> ''
2974
+ ${startedAtFilter}
2852
2975
  ORDER BY w.started_at DESC`)
2853
- .all(userId);
2976
+ .all(...params);
2854
2977
  const alreadyUploadedWorkoutExternalUids = [];
2855
2978
  const incompleteWorkoutExternalUids = [];
2856
2979
  let incompleteWorkoutCount = 0;
@@ -2870,7 +2993,9 @@ function mobileHealthWorkoutImportState(userId) {
2870
2993
  actualRoutePointCount >= evidenceCounts.expectedRoutePoints
2871
2994
  : false;
2872
2995
  if (evidenceCountsComplete) {
2873
- alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
2996
+ if (includeExternalUids) {
2997
+ alreadyUploadedWorkoutExternalUids.push(row.external_uid.toLowerCase());
2998
+ }
2874
2999
  timeSeriesSampleCount += actualTimeSeriesCount;
2875
3000
  heartRateSampleCount += actualHeartRateCount;
2876
3001
  routePointCount += actualRoutePointCount;
@@ -2880,13 +3005,15 @@ function mobileHealthWorkoutImportState(userId) {
2880
3005
  }
2881
3006
  else {
2882
3007
  incompleteWorkoutCount += 1;
2883
- incompleteWorkoutExternalUids.push(row.external_uid.toLowerCase());
3008
+ if (includeExternalUids) {
3009
+ incompleteWorkoutExternalUids.push(row.external_uid.toLowerCase());
3010
+ }
2884
3011
  }
2885
3012
  }
2886
3013
  return {
2887
3014
  alreadyUploadedWorkoutExternalUids,
2888
3015
  incompleteWorkoutExternalUids,
2889
- alreadyUploadedWorkoutCount: alreadyUploadedWorkoutExternalUids.length,
3016
+ alreadyUploadedWorkoutCount: rows.length - incompleteWorkoutCount,
2890
3017
  existingWorkoutCount: rows.length,
2891
3018
  incompleteWorkoutCount,
2892
3019
  staleEvidenceVersionWorkoutCount,
@@ -2896,7 +3023,7 @@ function mobileHealthWorkoutImportState(userId) {
2896
3023
  capturedAt: nowIso()
2897
3024
  };
2898
3025
  }
2899
- function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
3026
+ function mobileSyncSessionUploadPayload(session, receivedChunkIds, options = {}) {
2900
3027
  return {
2901
3028
  syncSessionId: session.id,
2902
3029
  schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
@@ -2908,8 +3035,15 @@ function mobileSyncSessionUploadPayload(session, receivedChunkIds) {
2908
3035
  supportsCompression: true,
2909
3036
  acceptedFamilies: safeJsonParse(session.requested_families_json, []),
2910
3037
  receivedChunkIds,
2911
- workoutImportState: mobileHealthWorkoutImportState(session.user_id),
2912
- progress: mobileSyncSessionProgress(session.id)
3038
+ ...(options.includeWorkoutImportState === false
3039
+ ? {}
3040
+ : {
3041
+ workoutImportState: mobileHealthWorkoutImportState(session.user_id, {
3042
+ ...mobileSyncSessionWorkoutImportOptions(session),
3043
+ includeExternalUids: options.includeWorkoutImportExternalUids
3044
+ })
3045
+ }),
3046
+ progress: mobileSyncSessionProgressFromStoredSession(session)
2913
3047
  };
2914
3048
  }
2915
3049
  export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
@@ -2918,14 +3052,19 @@ export function getMobileHealthSyncSessionStatus(syncSessionId, payload) {
2918
3052
  if (!session || session.pairing_session_id !== pairing.id) {
2919
3053
  throw new HttpError(404, "sync_session_not_found", "The HealthKit sync session does not exist.");
2920
3054
  }
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);
3055
+ const receivedChunkIds = payload.includeReceivedChunkIds === false
3056
+ ? []
3057
+ : getDatabase()
3058
+ .prepare(`SELECT chunk_id
3059
+ FROM health_mobile_sync_chunks
3060
+ WHERE sync_session_id = ?
3061
+ ORDER BY sequence ASC`)
3062
+ .all(syncSessionId)
3063
+ .map((row) => row.chunk_id);
3064
+ return mobileSyncSessionUploadPayload(session, receivedChunkIds, {
3065
+ includeWorkoutImportState: payload.includeWorkoutImportState,
3066
+ includeWorkoutImportExternalUids: payload.includeWorkoutImportExternalUids
3067
+ });
2929
3068
  }
2930
3069
  function ensureRunningMobileSyncSession(syncSessionId) {
2931
3070
  expireStaleMobileSyncSessions();
@@ -3163,6 +3302,7 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
3163
3302
  let createdCount = 0;
3164
3303
  let updatedCount = 0;
3165
3304
  let mergedCount = 0;
3305
+ const dateKeysToSummarize = new Set();
3166
3306
  getDatabase()
3167
3307
  .prepare(`INSERT INTO health_import_runs (
3168
3308
  id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
@@ -3181,7 +3321,10 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
3181
3321
  else {
3182
3322
  updatedCount += 1;
3183
3323
  }
3184
- summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
3324
+ dateKeysToSummarize.add(dayKey(workout.startedAt));
3325
+ }
3326
+ for (const dateKeyValue of dateKeysToSummarize) {
3327
+ summarizeUserHealthDay(pairing.user_id, dateKeyValue);
3185
3328
  }
3186
3329
  getDatabase()
3187
3330
  .prepare(`UPDATE companion_pairing_sessions
@@ -3204,6 +3347,29 @@ function applyWorkoutChunkImmediately(session, family, workouts) {
3204
3347
  }), workouts.length, createdCount, updatedCount, mergedCount, now, now, runId);
3205
3348
  });
3206
3349
  }
3350
+ function mobileSyncWorkoutRowsByExternalUid(userId, externalUids) {
3351
+ const uniqueExternalUids = [...new Set(externalUids.filter(Boolean))];
3352
+ const rowsByExternalUid = new Map();
3353
+ const chunkSize = 500;
3354
+ for (let lowerBound = 0; lowerBound < uniqueExternalUids.length; lowerBound += chunkSize) {
3355
+ const chunk = uniqueExternalUids.slice(lowerBound, lowerBound + chunkSize);
3356
+ if (chunk.length === 0) {
3357
+ continue;
3358
+ }
3359
+ const placeholders = chunk.map(() => "?").join(", ");
3360
+ const rows = getDatabase()
3361
+ .prepare(`SELECT *
3362
+ FROM health_workout_sessions
3363
+ WHERE user_id = ?
3364
+ AND source = 'apple_health'
3365
+ AND external_uid IN (${placeholders})`)
3366
+ .all(userId, ...chunk);
3367
+ for (const row of rows) {
3368
+ rowsByExternalUid.set(row.external_uid, row);
3369
+ }
3370
+ }
3371
+ return rowsByExternalUid;
3372
+ }
3207
3373
  function applyWorkoutEvidenceChunkImmediately(session, payload) {
3208
3374
  const timeSeries = payload.workoutTimeSeries ?? [];
3209
3375
  const routes = payload.workoutRoutes ?? [];
@@ -3213,12 +3379,16 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
3213
3379
  const pairing = mobileSyncSessionPairing(session);
3214
3380
  runInTransaction(() => {
3215
3381
  const rowsToRecompute = new Map();
3382
+ const rowsByExternalUid = mobileSyncWorkoutRowsByExternalUid(pairing.user_id, [
3383
+ ...timeSeries
3384
+ .filter((entry) => entry.samples.length > 0)
3385
+ .map((entry) => entry.externalUid),
3386
+ ...routes
3387
+ .filter((entry) => entry.routePoints.length > 0)
3388
+ .map((entry) => entry.externalUid)
3389
+ ]);
3216
3390
  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);
3391
+ const row = rowsByExternalUid.get(entry.externalUid);
3222
3392
  if (!row || entry.samples.length === 0) {
3223
3393
  continue;
3224
3394
  }
@@ -3230,11 +3400,7 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
3230
3400
  rowsToRecompute.set(row.id, row);
3231
3401
  }
3232
3402
  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);
3403
+ const row = rowsByExternalUid.get(entry.externalUid);
3238
3404
  if (!row || entry.routePoints.length === 0) {
3239
3405
  continue;
3240
3406
  }
@@ -3247,7 +3413,9 @@ function applyWorkoutEvidenceChunkImmediately(session, payload) {
3247
3413
  }
3248
3414
  for (const row of rowsToRecompute.values()) {
3249
3415
  recomputeAndStoreWorkoutAnalytics(row);
3250
- summarizeUserHealthDay(pairing.user_id, dayKey(row.started_at));
3416
+ }
3417
+ for (const dateKeyValue of new Set([...rowsToRecompute.values()].map((row) => dayKey(row.started_at)))) {
3418
+ summarizeUserHealthDay(pairing.user_id, dateKeyValue);
3251
3419
  }
3252
3420
  });
3253
3421
  }
@@ -3265,6 +3433,9 @@ function markMobileHealthSyncChunkApplied(input) {
3265
3433
  }), now, input.syncSessionId, input.chunkId);
3266
3434
  }
3267
3435
  function mobileHealthSyncChunkWasImmediatelyApplied(chunk) {
3436
+ if (chunk.applied_at) {
3437
+ return true;
3438
+ }
3268
3439
  const summary = safeJsonParse(chunk.payload_summary_json, {});
3269
3440
  return summary.immediateApplied === true;
3270
3441
  }
@@ -3321,8 +3492,7 @@ function applyMobileHealthSyncChunkImmediately(session, family, payload) {
3321
3492
  return null;
3322
3493
  }
3323
3494
  }
3324
- function updateMobileSyncSessionProgress(syncSessionId) {
3325
- const progress = mobileSyncSessionProgress(syncSessionId);
3495
+ function updateMobileSyncSessionProgress(syncSessionId, progress = mobileSyncSessionProgress(syncSessionId)) {
3326
3496
  getDatabase()
3327
3497
  .prepare(`UPDATE health_mobile_sync_sessions
3328
3498
  SET received_counts_json = ?, byte_totals_json = ?, updated_at = ?
@@ -3401,7 +3571,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
3401
3571
  if (existing.checksum_sha256 !== clientChecksum) {
3402
3572
  throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
3403
3573
  }
3404
- const progress = updateMobileSyncSessionProgress(syncSessionId);
3574
+ const progress = mobileSyncSessionProgressFromStoredSession(session);
3405
3575
  return {
3406
3576
  accepted: true,
3407
3577
  duplicate: true,
@@ -3483,6 +3653,12 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
3483
3653
  }
3484
3654
  return runInTransaction(() => {
3485
3655
  const now = nowIso();
3656
+ const progress = mobileSyncSessionProgressAfterAcceptedChunk({
3657
+ session,
3658
+ family: parsed.family,
3659
+ recordCount: parsed.recordCount,
3660
+ byteCount: actualByteCount
3661
+ });
3486
3662
  const payloadSummary = {
3487
3663
  ...summarizeChunkPayload(parsed.family, wirePayload.payload),
3488
3664
  clientByteCount: parsed.byteCount,
@@ -3508,7 +3684,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
3508
3684
  mode: appliedMode
3509
3685
  });
3510
3686
  }
3511
- const progress = updateMobileSyncSessionProgress(syncSessionId);
3687
+ updateMobileSyncSessionProgress(syncSessionId, progress);
3512
3688
  return {
3513
3689
  accepted: true,
3514
3690
  duplicate: false,
@@ -3557,18 +3733,12 @@ function mergeMobileHealthSyncChunks(session, chunks, options = {}) {
3557
3733
  const workoutsByExternalUid = new Map();
3558
3734
  const tombstones = [];
3559
3735
  for (const chunk of chunks.sort((left, right) => left.sequence - right.sequence)) {
3560
- const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
3561
3736
  const skipRecords = options.skipImmediatelyApplied === true &&
3562
3737
  mobileHealthSyncChunkWasImmediatelyApplied(chunk);
3563
3738
  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
3739
  continue;
3571
3740
  }
3741
+ const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
3572
3742
  if (payload.sleepNights) {
3573
3743
  assembled.sleepNights.push(...payload.sleepNights);
3574
3744
  }
@@ -3659,12 +3829,14 @@ function upsertMobileSyncFamilyCursors(pairing, finalCursor) {
3659
3829
  stmt.run(`hmscur_${randomUUID().replaceAll("-", "").slice(0, 10)}`, pairing.id, pairing.user_id, family, JSON.stringify(cursor), now);
3660
3830
  }
3661
3831
  }
3662
- function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
3832
+ function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts, chunks) {
3663
3833
  const expectedEntries = Object.entries(expectedCounts).filter(([, expected]) => Number.isFinite(expected) && expected > 0);
3664
3834
  if (expectedEntries.length === 0) {
3665
3835
  return;
3666
3836
  }
3667
- const progress = updateMobileSyncSessionProgress(syncSessionId);
3837
+ const progress = chunks
3838
+ ? mobileSyncChunkProgressFromRows(chunks)
3839
+ : updateMobileSyncSessionProgress(syncSessionId);
3668
3840
  const missingFamilies = expectedEntries
3669
3841
  .map(([family, expected]) => ({
3670
3842
  family,
@@ -3676,6 +3848,45 @@ function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
3676
3848
  throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session is missing required chunks.", { families: missingFamilies });
3677
3849
  }
3678
3850
  }
3851
+ function mobileSyncChunkProgressFromRows(chunks) {
3852
+ const receivedCounts = {};
3853
+ const byteTotals = {};
3854
+ for (const chunk of chunks) {
3855
+ receivedCounts[chunk.family] =
3856
+ (receivedCounts[chunk.family] ?? 0) + chunk.record_count;
3857
+ byteTotals[chunk.family] =
3858
+ (byteTotals[chunk.family] ?? 0) + chunk.byte_count;
3859
+ }
3860
+ return {
3861
+ receivedCounts,
3862
+ byteTotals,
3863
+ chunkCount: chunks.length,
3864
+ receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
3865
+ };
3866
+ }
3867
+ function dedupeMobileSyncChunksForCompletion(chunks) {
3868
+ const latestBySlot = new Map();
3869
+ for (const chunk of chunks) {
3870
+ const slotKey = `${chunk.family}:${chunk.sequence}`;
3871
+ const previous = latestBySlot.get(slotKey);
3872
+ if (!previous) {
3873
+ latestBySlot.set(slotKey, chunk);
3874
+ continue;
3875
+ }
3876
+ const chunkReceivedAt = Date.parse(chunk.received_at);
3877
+ const previousReceivedAt = Date.parse(previous.received_at);
3878
+ if (chunkReceivedAt > previousReceivedAt ||
3879
+ (chunkReceivedAt === previousReceivedAt && chunk.id > previous.id)) {
3880
+ latestBySlot.set(slotKey, chunk);
3881
+ }
3882
+ }
3883
+ return Array.from(latestBySlot.values()).sort((left, right) => {
3884
+ if (left.sequence !== right.sequence) {
3885
+ return left.sequence - right.sequence;
3886
+ }
3887
+ return left.family.localeCompare(right.family);
3888
+ });
3889
+ }
3679
3890
  function aggregateMobileSyncChunkCounts(chunks) {
3680
3891
  const counts = {
3681
3892
  sleepNights: 0,
@@ -3743,6 +3954,30 @@ function syncReceiptWithChunkCounts(sync, chunks) {
3743
3954
  }
3744
3955
  };
3745
3956
  }
3957
+ function listMobileSyncCompletionChunks(syncSessionId) {
3958
+ const chunks = getDatabase()
3959
+ .prepare(`SELECT id, sync_session_id, chunk_id, sequence, family, checksum_sha256,
3960
+ record_count, byte_count, '{}' AS payload_json,
3961
+ payload_summary_json, received_at, applied_at, created_at, updated_at
3962
+ FROM health_mobile_sync_chunks
3963
+ WHERE sync_session_id = ?
3964
+ ORDER BY sequence ASC`)
3965
+ .all(syncSessionId);
3966
+ const payloadRows = getDatabase()
3967
+ .prepare(`SELECT id, payload_json
3968
+ FROM health_mobile_sync_chunks
3969
+ WHERE sync_session_id = ?
3970
+ AND applied_at IS NULL`)
3971
+ .all(syncSessionId);
3972
+ if (payloadRows.length === 0) {
3973
+ return chunks;
3974
+ }
3975
+ const payloadById = new Map(payloadRows.map((row) => [row.id, row.payload_json]));
3976
+ for (const chunk of chunks) {
3977
+ chunk.payload_json = payloadById.get(chunk.id) ?? "{}";
3978
+ }
3979
+ return chunks;
3980
+ }
3746
3981
  function markMobileSyncSessionFailed(session, error) {
3747
3982
  const now = nowIso();
3748
3983
  const errorMessage = error instanceof Error ? error.message : String(error);
@@ -3762,21 +3997,18 @@ function markMobileSyncSessionFailed(session, error) {
3762
3997
  export function completeMobileHealthSyncSession(syncSessionId, payload) {
3763
3998
  const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
3764
3999
  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);
4000
+ const chunks = listMobileSyncCompletionChunks(syncSessionId);
3770
4001
  if (chunks.length === 0) {
3771
4002
  throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
3772
4003
  }
4004
+ const completionChunks = dedupeMobileSyncChunksForCompletion(chunks);
3773
4005
  const pairing = getDatabase()
3774
4006
  .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
3775
4007
  .get(session.pairing_session_id);
3776
4008
  try {
3777
4009
  return runInTransaction(() => {
3778
- validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts);
3779
- const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, chunks, { skipImmediatelyApplied: true });
4010
+ validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts, completionChunks);
4011
+ const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, completionChunks, { skipImmediatelyApplied: true });
3780
4012
  const sync = ingestMobileHealthSync(assembled);
3781
4013
  const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
3782
4014
  upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
@@ -3788,10 +4020,12 @@ export function completeMobileHealthSyncSession(syncSessionId, payload) {
3788
4020
  WHERE id = ?`)
3789
4021
  .run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
3790
4022
  return {
3791
- ...syncReceiptWithChunkCounts(sync, chunks),
4023
+ ...syncReceiptWithChunkCounts(sync, completionChunks),
3792
4024
  upload: {
3793
4025
  syncSessionId,
3794
4026
  chunks: chunks.length,
4027
+ effectiveChunks: completionChunks.length,
4028
+ supersededChunks: chunks.length - completionChunks.length,
3795
4029
  deletedWorkoutCount
3796
4030
  }
3797
4031
  };
@@ -3838,6 +4072,8 @@ export function ingestMobileHealthSync(payload) {
3838
4072
  const screenTimeSync = ingestScreenTimeSync(pairing, parsed.screenTime, parsed.device.sourceDevice);
3839
4073
  const sleepSessionsByLocalDate = new Map();
3840
4074
  const sourceRecordIdsByExternalUid = new Map();
4075
+ const sleepDateKeysToSummarize = new Set();
4076
+ const workoutDateKeysToSummarize = new Set();
3841
4077
  for (const sleep of normalizedSleepNights) {
3842
4078
  const result = insertOrUpdateSleepSession(pairing, sleep);
3843
4079
  if (result.mode === "created") {
@@ -3853,7 +4089,7 @@ export function ingestMobileHealthSync(payload) {
3853
4089
  endedAt: sleep.endedAt
3854
4090
  });
3855
4091
  sleepSessionsByLocalDate.set(sleep.localDateKey, group);
3856
- summarizeUserHealthDay(pairing.user_id, sleep.localDateKey);
4092
+ sleepDateKeysToSummarize.add(sleep.localDateKey);
3857
4093
  }
3858
4094
  for (const rawRecord of normalizedSleepRawRecords) {
3859
4095
  const candidateSessions = sleepSessionsByLocalDate.get(rawRecord.localDateKey) ?? [];
@@ -3909,6 +4145,9 @@ export function ingestMobileHealthSync(payload) {
3909
4145
  replaceHistoricalSleepSessionsForDate(pairing.user_id, sleep.localDateKey, targetSessionId);
3910
4146
  }
3911
4147
  }
4148
+ for (const dateKeyValue of sleepDateKeysToSummarize) {
4149
+ summarizeUserHealthDay(pairing.user_id, dateKeyValue);
4150
+ }
3912
4151
  for (const workout of parsed.workouts) {
3913
4152
  const result = insertOrUpdateWorkoutSession(pairing, workout);
3914
4153
  if (result.mode === "created") {
@@ -3920,7 +4159,10 @@ export function ingestMobileHealthSync(payload) {
3920
4159
  else {
3921
4160
  updatedCount += 1;
3922
4161
  }
3923
- summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
4162
+ workoutDateKeysToSummarize.add(dayKey(workout.startedAt));
4163
+ }
4164
+ for (const dateKeyValue of workoutDateKeysToSummarize) {
4165
+ summarizeUserHealthDay(pairing.user_id, dateKeyValue);
3924
4166
  }
3925
4167
  for (const daySummary of parsed.vitals.daySummaries) {
3926
4168
  upsertVitalDaySummary(pairing.user_id, daySummary);
@@ -4134,7 +4376,7 @@ export function getCompanionOverview(userIds) {
4134
4376
  }
4135
4377
  };
4136
4378
  }
4137
- export function getSleepViewData(userIds) {
4379
+ export function getSleepViewData(userIds, options = {}) {
4138
4380
  const sessions = listSleepRows(userIds).map(mapSleepSession);
4139
4381
  const displaySessions = pickDisplaySleepSessions(sessions);
4140
4382
  const recentDisplay = displaySessions.slice(0, 30);
@@ -4142,6 +4384,7 @@ export function getSleepViewData(userIds) {
4142
4384
  const monthly = recentDisplay.slice(0, 30);
4143
4385
  const calendarWindow = displaySessions.slice(0, 84);
4144
4386
  const latestNight = recentDisplay[0] ?? null;
4387
+ const latestNightFreshness = buildLatestSleepNightFreshness(latestNight, options.now);
4145
4388
  const weeklyBaseline = weekly.length > 1
4146
4389
  ? Math.round(average(weekly.slice(1).map((session) => session.asleepSeconds)))
4147
4390
  : Math.round(average(weekly.map((session) => session.asleepSeconds)));
@@ -4186,8 +4429,14 @@ export function getSleepViewData(userIds) {
4186
4429
  latestBedtime: latestNight?.startedAt ?? null,
4187
4430
  latestWakeTime: latestNight?.endedAt ?? null
4188
4431
  },
4432
+ latestNightFreshness,
4189
4433
  latestNight: latestNight
4190
- ? buildSleepSurfaceNight(latestNight, weeklyBaseline)
4434
+ ? {
4435
+ ...buildSleepSurfaceNight(latestNight, weeklyBaseline),
4436
+ expectedDateKey: latestNightFreshness.expectedDateKey,
4437
+ isExpectedLastNight: latestNightFreshness.isCurrent,
4438
+ freshnessStatus: latestNightFreshness.status
4439
+ }
4191
4440
  : null,
4192
4441
  calendarDays: calendarWindow
4193
4442
  .map((session) => buildSleepCalendarDay(session))