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.
- package/dist/assets/{action-bar-DBZ38L_6.js → action-bar-BFjWjRIM.js} +1 -1
- package/dist/assets/{activity-page-BJxUR9bD.js → activity-page-D-6yBWuZ.js} +1 -1
- package/dist/assets/{ai-surface-workspace-ehzBjTsW.js → ai-surface-workspace-BEfo9bRO.js} +1 -1
- package/dist/assets/{atlas-panel-BFUeVT69.js → atlas-panel-BfMyJXxQ.js} +1 -1
- package/dist/assets/{board-CLOHbg6t.js → board-CuxQRKPJ.js} +1 -1
- package/dist/assets/{calendar-page-Bg8W-YQ-.js → calendar-page-D4tQNJ2V.js} +1 -1
- package/dist/assets/{calendar-rules-9XQLoI96.js → calendar-rules-C-6O_uGU.js} +1 -1
- package/dist/assets/{calendar-week-toolbar-BeYnx1pE.js → calendar-week-toolbar-_NzeKsYx.js} +1 -1
- package/dist/assets/{charts-DwTguE_x.js → charts-BzT4pUPg.js} +1 -1
- package/dist/assets/{companion-sync-lab-page-BRKYkIrs.js → companion-sync-lab-page-D1Oqsf6M.js} +1 -1
- package/dist/assets/{daily-metrics-dashboard-Jt0nAHU5.js → daily-metrics-dashboard-DtE3pVOl.js} +1 -1
- package/dist/assets/date-keys-BnZV4PNO.js +1 -0
- package/dist/assets/{define-workbench-box-5h-dyvJY.js → define-workbench-box-D-32C8nM.js} +1 -1
- package/dist/assets/{entity-link-multiselect-Bb4De_e3.js → entity-link-multiselect-DcCvkesQ.js} +1 -1
- package/dist/assets/{entity-note-count-link-BzWnsOmI.js → entity-note-count-link-Cjsk5oT2.js} +1 -1
- package/dist/assets/{entity-notes-surface-CagOwdng.js → entity-notes-surface-DQQPLjxd.js} +1 -1
- package/dist/assets/{execution-board-DTmAtmqZ.js → execution-board-agWQbN-y.js} +1 -1
- package/dist/assets/{faceted-token-search-DJPRm9AY.js → faceted-token-search-DldM3-ru.js} +1 -1
- package/dist/assets/{flagship-signal-deck-CJbGZF7I.js → flagship-signal-deck-BlLYW9Kz.js} +1 -1
- package/dist/assets/{floating-action-menu-K77jh8XZ.js → floating-action-menu-D9-psbha.js} +1 -1
- package/dist/assets/{forms-C5d5hTf2.js → forms-D1qJ3oOP.js} +1 -1
- package/dist/assets/{generic-node-view-p5ePSFuG.js → generic-node-view-CcepUVhP.js} +1 -1
- package/dist/assets/{goal-detail-page-DGu_khEK.js → goal-detail-page-DP1n5-Hk.js} +1 -1
- package/dist/assets/{goal-dialog-BWD7cOv9.js → goal-dialog-Oxx8WqbZ.js} +1 -1
- package/dist/assets/{goals-page-Bgj3sj0f.js → goals-page-CUt1a4Y2.js} +1 -1
- package/dist/assets/{graph-Cd5WF3lw.js → graph-BF4IsheG.js} +1 -1
- package/dist/assets/{habits-page-Ctut1tuX.js → habits-page-5REbWAlo.js} +1 -1
- package/dist/assets/{health-boxes-Ie3horXx.js → health-boxes-sHNML3tm.js} +1 -1
- package/dist/assets/index-CQ5r7ZUz.js +2 -0
- package/dist/assets/index-FxgNSuZX.css +1 -0
- package/dist/assets/{inline-note-fields-DE6WM9uM.js → inline-note-fields-Bql_KfR9.js} +1 -1
- package/dist/assets/{insight-flow-dialog-XL8I74eJ.js → insight-flow-dialog-CN-CMSB7.js} +1 -1
- package/dist/assets/{insights-page-Bz1FLbp5.js → insights-page-B1u6ONtQ.js} +1 -1
- package/dist/assets/{kanban-boxes-BBziel7P.js → kanban-boxes-DB1kuUlY.js} +1 -1
- package/dist/assets/{kanban-page-DdQP73I3.js → kanban-page-BBW9_vMu.js} +1 -1
- package/dist/assets/{knowledge-graph-page-DdHWj4Dj.js → knowledge-graph-page-CoJaydZb.js} +1 -1
- package/dist/assets/{life-force-page-BRN-93cI.js → life-force-page-DBYbA1GF.js} +1 -1
- package/dist/assets/{life-force-workspace-BowvP5R4.js → life-force-workspace-BiD9xOEt.js} +1 -1
- package/dist/assets/{maps-D0Mm6WPG.js → maps-BTVHALP8.js} +1 -1
- package/dist/assets/{metric-tile-B6aJueRo.js → metric-tile-CuP9DOYm.js} +1 -1
- package/dist/assets/{motion-DwjmC9aq.js → motion-DcgUnXhY.js} +1 -1
- package/dist/assets/{movement-boxes-5cjWjIdR.js → movement-boxes-DZg_qPPg.js} +1 -1
- package/dist/assets/{movement-page-Dy53RWtB.js → movement-page-CmwsQGR_.js} +1 -1
- package/dist/assets/{note-markdown-CvEQCfoi.js → note-markdown-B82ncnFt.js} +1 -1
- package/dist/assets/{note-tags-input-Cg5zBXos.js → note-tags-input-C_x5WdK5.js} +1 -1
- package/dist/assets/{notes-boxes-CgZyf7mV.js → notes-boxes-BEFlp9yd.js} +1 -1
- package/dist/assets/{notes-page-Cbdcv0Ej.js → notes-page-B6Vl-GPf.js} +1 -1
- package/dist/assets/{open-in-graph-button-D31ZOEhA.js → open-in-graph-button-C-bJekoH.js} +1 -1
- package/dist/assets/{orbit-map-8eNDWek8.js → orbit-map-DHeTM15g.js} +1 -1
- package/dist/assets/{overview-page-CZ6q52Qj.js → overview-page-BRWje1F9.js} +1 -1
- package/dist/assets/{page-hero-C0MpI3MM.js → page-hero-8bITsx_x.js} +1 -1
- package/dist/assets/pill-cluster-XQjm-wPc.js +1 -0
- package/dist/assets/{preference-entity-handoff-button-CJhr5AXl.js → preference-entity-handoff-button-DwYF_5i3.js} +1 -1
- package/dist/assets/{preferences-page-8vszDNFX.js → preferences-page-C7DBPpNb.js} +1 -1
- package/dist/assets/{project-collections-sC7eAAhS.js → project-collections-mtxanSMf.js} +1 -1
- package/dist/assets/{project-detail-page-BKdnMjJy.js → project-detail-page-BT87Goqc.js} +1 -1
- package/dist/assets/{project-dialog-BiHZpOo1.js → project-dialog-DnZe757y.js} +1 -1
- package/dist/assets/{project-management-hierarchy-page-VW-hykAI.js → project-management-hierarchy-page-B3R2lNFI.js} +1 -1
- package/dist/assets/{project-management-section-nav-BEZ5zihs.js → project-management-section-nav-DyBWxHbe.js} +1 -1
- package/dist/assets/{projects-boxes-Ca6rpYeE.js → projects-boxes-CxZj3P29.js} +1 -1
- package/dist/assets/{projects-page-D86pjpTf.js → projects-page-Bec11c0x.js} +1 -1
- package/dist/assets/{psyche-behaviors-page-YLJB6CRU.js → psyche-behaviors-page-DWRpYvl1.js} +1 -1
- package/dist/assets/{psyche-flashcards-page-D3gdHLUw.js → psyche-flashcards-page-CX4rcsXZ.js} +1 -1
- package/dist/assets/{psyche-goal-map-page-DJiqSiCx.js → psyche-goal-map-page-Y6b3lCvV.js} +1 -1
- package/dist/assets/{psyche-graph-Tke0qFdt.js → psyche-graph-CQuCWKIp.js} +1 -1
- package/dist/assets/{psyche-metrics-page-DoLnvmC2.js → psyche-metrics-page-DadDJOnm.js} +1 -1
- package/dist/assets/{psyche-mode-guide-page-_Zbvg_HL.js → psyche-mode-guide-page-B1nz0uCg.js} +1 -1
- package/dist/assets/{psyche-modes-page-eAkaAzJc.js → psyche-modes-page-i3uSuhKA.js} +1 -1
- package/dist/assets/{psyche-page-CwmuBVjA.js → psyche-page-Y_s-BE2m.js} +1 -1
- package/dist/assets/{psyche-patterns-page-D7B4Ykjq.js → psyche-patterns-page-DaaOLIlN.js} +1 -1
- package/dist/assets/{psyche-questionnaire-builder-page-CHJlvCzX.js → psyche-questionnaire-builder-page-CuF7rXOv.js} +1 -1
- package/dist/assets/{psyche-questionnaire-detail-page-Bg3ll-D4.js → psyche-questionnaire-detail-page-BfFEMkRY.js} +1 -1
- package/dist/assets/{psyche-questionnaire-run-detail-page-CIEerczF.js → psyche-questionnaire-run-detail-page-BoQTvd7Q.js} +1 -1
- package/dist/assets/{psyche-questionnaire-run-page-DyxIk6Qx.js → psyche-questionnaire-run-page-C0qKiNZN.js} +1 -1
- package/dist/assets/{psyche-questionnaires-page-ZlqUxuIl.js → psyche-questionnaires-page-B6hfD448.js} +1 -1
- package/dist/assets/{psyche-report-detail-page-B2jgYnQ5.js → psyche-report-detail-page-BlFL8moM.js} +1 -1
- package/dist/assets/{psyche-reports-page-DlXr52yr.js → psyche-reports-page-DAAcYENp.js} +1 -1
- package/dist/assets/{psyche-schemas-Dtskzvv1.js → psyche-schemas-DDol0j-g.js} +1 -1
- package/dist/assets/{psyche-schemas-beliefs-page-BhrTZN9B.js → psyche-schemas-beliefs-page-CsxKSrKM.js} +1 -1
- package/dist/assets/{psyche-screen-time-page-WNGcdusx.js → psyche-screen-time-page-n4b0e58x.js} +1 -1
- package/dist/assets/{psyche-self-observation-page-XsCOglo4.js → psyche-self-observation-page-BaxEQ2-3.js} +1 -1
- package/dist/assets/{psyche-values-page-NvHI6zWK.js → psyche-values-page-DTv5NMSU.js} +1 -1
- package/dist/assets/{question-flow-dialog-Bj4PxpjS.js → question-flow-dialog-CskCt5NZ.js} +1 -1
- package/dist/assets/{report-chain-fields-mWFikZzT.js → report-chain-fields-D132-EMh.js} +1 -1
- package/dist/assets/{rewards-page-DyjwXsQN.js → rewards-page-C533lVP-.js} +1 -1
- package/dist/assets/{scheduling-rules-editor-C6FEYOxd.js → scheduling-rules-editor-CDpontGp.js} +1 -1
- package/dist/assets/{schema-badge-CCe4zkSN.js → schema-badge-B3DiMnjB.js} +1 -1
- package/dist/assets/{schemas-Cjwn6ooR.js → schemas-CH1_ngUX.js} +1 -1
- package/dist/assets/{select-menu-DeJhCsd8.js → select-menu-BOF-k4Ln.js} +1 -1
- package/dist/assets/{settings-agents-page-DYDxyKu-.js → settings-agents-page-B5OQtlZX.js} +1 -1
- package/dist/assets/{settings-bin-page-BWLfKVW1.js → settings-bin-page-D_bk3Kcu.js} +1 -1
- package/dist/assets/{settings-calendar-page-Ci-wXhCv.js → settings-calendar-page-PuSj9_kM.js} +1 -1
- package/dist/assets/{settings-data-page-DxIw-fWn.js → settings-data-page-EwFMaeq6.js} +1 -1
- package/dist/assets/{settings-logs-page-inpyIdu6.js → settings-logs-page-BKkse0DX.js} +1 -1
- package/dist/assets/{settings-mobile-page-BcJGLax8.js → settings-mobile-page-BrIVmdeB.js} +1 -1
- package/dist/assets/{settings-models-page-OWTRhfkj.js → settings-models-page-Mg84D_0K.js} +1 -1
- package/dist/assets/{settings-page-DXxF3qK2.js → settings-page-D-kul92f.js} +1 -1
- package/dist/assets/{settings-rewards-page-CCXin1n_.js → settings-rewards-page-waNyCcX_.js} +1 -1
- package/dist/assets/{settings-section-nav-CwSDNC3W.js → settings-section-nav-BmJWnrYk.js} +1 -1
- package/dist/assets/{settings-users-page-B4NEACwR.js → settings-users-page-DBgC6y56.js} +1 -1
- package/dist/assets/{settings-wiki-page-BNRK1SYc.js → settings-wiki-page-CM0te9dI.js} +1 -1
- package/dist/assets/sleep-page-C_krRE59.js +1 -0
- package/dist/assets/{sports-page-BawekFKD.js → sports-page-eg5Rfc_E.js} +1 -1
- package/dist/assets/{state-BtwEvpO6.js → state-Bpe5dF3T.js} +1 -1
- package/dist/assets/{strategies-page-BEl3NGAU.js → strategies-page-D4AqvFNW.js} +1 -1
- package/dist/assets/{strategy-detail-page-DPPI_8Ub.js → strategy-detail-page-BlYVkXaW.js} +1 -1
- package/dist/assets/{strategy-dialog-DU6wbBkQ.js → strategy-dialog-FO9Oa0dB.js} +1 -1
- package/dist/assets/{surface-BVYp-Wq9.js → surface-MVeeZGKB.js} +1 -1
- package/dist/assets/{table-BuONJH1s.js → table-U7otr5go.js} +1 -1
- package/dist/assets/{task-detail-page-BsMVAsbb.js → task-detail-page-Dy-aRyY6.js} +1 -1
- package/dist/assets/{task-dialog-BGzPc6TW.js → task-dialog-DrA9pba7.js} +1 -1
- package/dist/assets/{timebox-planning-dialog-4_XWuqkw.js → timebox-planning-dialog-C_gnfxFx.js} +1 -1
- package/dist/assets/{today-boxes-5pCvh5zS.js → today-boxes-BFOws_iC.js} +1 -1
- package/dist/assets/{today-page-BGurICpl.js → today-page-BoPj6a6q.js} +1 -1
- package/dist/assets/{training-load-page-BkoYKZ9_.js → training-load-page-DGU40Zfl.js} +1 -1
- package/dist/assets/{ui-B9O-eUim.js → ui-B9TWEtCx.js} +1 -1
- package/dist/assets/{use-anchored-overlay-position-BrQ4cqKn.js → use-anchored-overlay-position-BY4kNzPj.js} +1 -1
- package/dist/assets/{use-psyche-focus-target-Cuxni3SK.js → use-psyche-focus-target-BhNedCZB.js} +1 -1
- package/dist/assets/{user-badge-DfDv87j7.js → user-badge-CZWtYeMw.js} +1 -1
- package/dist/assets/{user-select-field-BNqv8-wd.js → user-select-field-fx129Uh6.js} +1 -1
- package/dist/assets/{utility-widgets-CbYj32he.js → utility-widgets-B3wWGxQc.js} +1 -1
- package/dist/assets/{vendor-Cpmju3nw.js → vendor-BwL6m4SE.js} +216 -211
- package/dist/assets/{vitals-page-DlxMk-L7.js → vitals-page-BXRZEP_8.js} +1 -1
- package/dist/assets/{weekly-review-page-VrEvAl1T.js → weekly-review-page-D5cebA5x.js} +1 -1
- package/dist/assets/weight-loss-page-BuUdFh9z.js +5 -0
- package/dist/assets/{wiki-article-markdown-DxTkiQRy.js → wiki-article-markdown-CvaCjg_t.js} +1 -1
- package/dist/assets/{wiki-editor-page-Beu92YnZ.js → wiki-editor-page-Dco79SLY.js} +1 -1
- package/dist/assets/{wiki-ingest-history-page-Cq_soygm.js → wiki-ingest-history-page-uvKRkRDF.js} +1 -1
- package/dist/assets/{wiki-ingest-modal-D6xs2sEn.js → wiki-ingest-modal-BW6AJPea.js} +1 -1
- package/dist/assets/{wiki-page-yF3Flgtg.js → wiki-page-BIL5zMqv.js} +1 -1
- package/dist/assets/{workbench-flow-page-CZZGg3a8.js → workbench-flow-page-Dx_nq8R_.js} +1 -1
- package/dist/assets/{workbench-page-DKENZ5Nz.js → workbench-page-BJnt_Zzf.js} +1 -1
- package/dist/assets/{workout-detail-page-CeN4QiYQ.js → workout-detail-page-CKgqyB-y.js} +2 -2
- package/dist/index.html +8 -8
- package/dist/server/server/migrations/070_health_mobile_sync_completion_index.sql +16 -0
- package/dist/server/server/src/app.js +139 -13
- package/dist/server/server/src/health-weight-loss.js +48 -6
- package/dist/server/server/src/health.js +345 -96
- package/dist/server/server/src/openapi.js +62 -0
- package/dist/server/server/src/repositories/gamification.js +25 -0
- package/dist/server/server/src/repositories/tasks.js +82 -17
- package/dist/server/server/src/services/dashboard.js +5 -4
- package/dist/server/server/src/services/gamification.js +47 -19
- package/dist/server/server/src/services/life-force.js +99 -0
- package/dist/server/src/lib/api.js +4 -0
- package/dist/server/src/lib/snapshot-normalizer.js +42 -23
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/070_health_mobile_sync_completion_index.sql +16 -0
- package/skills/forge-openclaw/SKILL.md +1 -0
- package/skills/forge-openclaw/psyche_entity_playbooks.md +8 -0
- package/dist/assets/date-keys-Cj1G3TOn.js +0 -1
- package/dist/assets/index-BaiwtAgo.js +0 -2
- package/dist/assets/index-CEgIwgk9.css +0 -1
- package/dist/assets/pill-cluster-DGwKZQKF.js +0 -1
- package/dist/assets/sleep-page-Bara54nB.js +0 -1
- 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) =>
|
|
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
|
|
2049
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
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
|
|
2767
|
-
receivedBytes:
|
|
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
|
|
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(`
|
|
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
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
2912
|
-
|
|
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 =
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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))
|